Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 78 additions & 4 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -949,7 +949,28 @@ export class OpenAI {
const abort = this._makeAbort(controller);
if (signal) signal.addEventListener('abort', abort, { once: true });

const timeout = setTimeout(abort, ms);
let timeout: ReturnType<typeof setTimeout> | undefined = setTimeout(abort, ms);
let timeoutCleared = false;
// Re-arm the timer so that the timeout applies to inactivity during the
// body-read phase as well, not just to header arrival. A stalled body read
// (headers received, then the server stops sending) is aborted after `ms`
// of inactivity instead of hanging forever, while a steadily-streaming
// response keeps resetting the timer and is never cut off.
// See https://github.com/openai/openai-node/issues/1825
const rearmTimeout = () => {
if (timeoutCleared) return;
if (timeout) clearTimeout(timeout);
timeout = setTimeout(abort, ms);
};
const clearCurrentTimeout = () => {
if (timeout) clearTimeout(timeout);
timeout = undefined;
};
const clearTimeoutOnce = () => {
if (timeoutCleared) return;
timeoutCleared = true;
clearCurrentTimeout();
};

const isReadableBody =
((globalThis as any).ReadableStream && options.body instanceof (globalThis as any).ReadableStream) ||
Expand All @@ -967,12 +988,65 @@ export class OpenAI {
fetchOptions.method = method.toUpperCase();
}

let response: Response;
try {
// use undefined this binding; fetch errors if bound to something else in browser/cloudflare
return await this.fetch.call(undefined, url, fetchOptions);
} finally {
clearTimeout(timeout);
response = await this.fetch.call(undefined, url, fetchOptions);
} catch (err) {
clearTimeoutOnce();
throw err;
}

const ReadableStreamCtor = (globalThis as any).ReadableStream;
if (!response.body || !ReadableStreamCtor) {
clearTimeoutOnce();
return response;
}

// Headers have arrived; wait until the body is actually read before starting
// the body-read timeout, so callers that only inspect the Response don't
// leave an active timer behind.
clearCurrentTimeout();

const reader = response.body.getReader();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard custom fetch bodies before calling getReader

In Node 20+ globalThis.ReadableStream exists, so any custom fetch that returns a node-fetch-style response with a Node Readable body reaches this path, but those bodies do not implement getReader(). Previously such responses could still be parsed via their own Response.text()/json() methods; now the request throws TypeError: response.body.getReader is not a function before returning, so custom fetch implementations with non-WHATWG bodies need to be left unwrapped or checked for getReader first.

Useful? React with 👍 / 👎.

const releaseReader = () => {
try {
reader.releaseLock();
} catch {}
};
const monitoredBody = new ReadableStreamCtor({
async pull(streamController: ReadableStreamDefaultController) {
try {
rearmTimeout();
const { done, value } = await reader.read();
Comment on lines +1020 to +1021

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Route body timeouts through retry handling

When the server sends headers and then stalls the body, the new timer now fires from this pull() after makeRequest() has already returned a successful APIResponseProps, so the existing timeout classification/retry path around fetchWithAuth() is bypassed. With the default maxRetries, a stalled JSON or SSE body will surface as the raw abort/read error and will not be retried or normalized as APIConnectionTimeoutError, even though the client documents request timeouts as retryable.

Useful? React with 👍 / 👎.

if (done) {
clearTimeoutOnce();
releaseReader();
streamController.close();
return;
}
clearCurrentTimeout();
streamController.enqueue(value);
} catch (err) {
clearTimeoutOnce();
releaseReader();
streamController.error(err);
}
},
cancel(reason: any) {
clearTimeoutOnce();
return reader.cancel(reason).finally(releaseReader);
},
});

const monitoredResponse = new Response(monitoredBody as any, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
// `Response` does not let us pass `url` through the constructor, so preserve it.
Object.defineProperty(monitoredResponse, 'url', { value: response.url, configurable: true });
return monitoredResponse;
}

private async shouldRetry(response: Response): Promise<boolean> {
Expand Down
31 changes: 31 additions & 0 deletions tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,37 @@ describe('retries', () => {
expect(count).toEqual(3);
});

test('timeout applies to the response body read phase', async () => {
// Server sends headers promptly, then stalls mid-body (never finishes the
// body). The timeout must still fire instead of hanging forever.
const testFetch = async (
_url: string | URL | Request,
{ signal }: RequestInit = {},
): Promise<Response> => {
const body = new ReadableStream({
start(controller) {
signal?.addEventListener('abort', () => controller.error(new Error('aborted')));
// intentionally never enqueue or close: the body read stalls
},
});
return new Response(body, { headers: { 'Content-Type': 'application/json' } });
};

const client = new OpenAI({
apiKey: 'My API Key',
adminAPIKey: 'My Admin API Key',
timeout: 50,
maxRetries: 0,
fetch: testFetch,
});

const started = Date.now();
await expect(client.request({ path: '/foo', method: 'get' })).rejects.toThrow();
const elapsed = Date.now() - started;
// Should abort shortly after the 50ms timeout, not hang.
expect(elapsed).toBeLessThan(2000);
});

test('retry count header', async () => {
let count = 0;
let capturedRequest: RequestInit | undefined;
Expand Down