diff --git a/src/client.ts b/src/client.ts index ac082a5e1..2c1e3580c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -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 | 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) || @@ -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(); + const releaseReader = () => { + try { + reader.releaseLock(); + } catch {} + }; + const monitoredBody = new ReadableStreamCtor({ + async pull(streamController: ReadableStreamDefaultController) { + try { + rearmTimeout(); + const { done, value } = await reader.read(); + 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 { diff --git a/tests/index.test.ts b/tests/index.test.ts index 028eccb17..302e599bb 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -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 => { + 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;