Skip to content

fix(client): remove abort listener on success in fetchWithTimeout#1893

Open
LeSingh1 wants to merge 1 commit into
openai:mainfrom
LeSingh1:fix/remove-abort-listener-on-success
Open

fix(client): remove abort listener on success in fetchWithTimeout#1893
LeSingh1 wants to merge 1 commit into
openai:mainfrom
LeSingh1:fix/remove-abort-listener-on-success

Conversation

@LeSingh1

Copy link
Copy Markdown

Fixes #1811.

fetchWithTimeout attaches an abort listener to the caller's signal that forwards to its internal controller. The listener is added with { once: true }, so it self-removes when the signal actually fires, but it is never removed when the request completes successfully.

On Node this is harmless because AbortSignal.timeout() keeps its timer unref'd regardless of listeners. On Deno, attaching a listener to a signal returned by AbortSignal.timeout() refs the underlying timer. The orphaned listener keeps that timer alive for the full timeout duration, so a request that completes in 500ms blocks process exit for the remainder of a caller's 30s timeout.

The fix removes the listener in the existing finally block alongside clearTimeout. The abort reference is already hoisted into the enclosing scope, so no other changes are needed.

Repro (from the linked issue)

import OpenAI from "openai";

const client = new OpenAI();
const signal = AbortSignal.timeout(30000);

const response = await client.chat.completions.create(
  { model: "gpt-4o", messages: [{ role: "user", content: "Say hi" }], max_tokens: 10 },
  { signal },
);

console.log("Result:", response.choices[0].message.content);
// Before: Deno hangs ~30s after logging. After: exits immediately.

Tests

Added a regression test in tests/index.test.ts that spies on addEventListener / removeEventListener of a caller-supplied AbortSignal and asserts the same listener reference is both added and removed once the request resolves. Full tests/index.test.ts suite (61 tests) passes against the mock server.

When a caller passes a signal option, fetchWithTimeout adds an abort
listener that forwards to its internal controller but never removes it
on successful completion. The once: true option only self-removes when
the signal actually fires.

On Deno this matters because attaching a listener to a signal returned
by AbortSignal.timeout() refs the underlying timer (Node keeps it
unref'd regardless). The orphaned listener keeps that timer alive for
the full timeout duration, so a process that finishes a request in
500ms can hang for the remainder of the caller's 30s timeout before
exiting.

Remove the listener in the finally block so the signal is no longer
referenced once the request resolves.

Fixes openai#1811
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fetchWithTimeout does not remove abort event listener on successful completion, preventing process exit on Deno

1 participant