diff --git a/src/client.ts b/src/client.ts index c7df965cd..7199a0be8 100644 --- a/src/client.ts +++ b/src/client.ts @@ -791,7 +791,9 @@ export class OpenAI { const security = options.__security ?? { bearerAuth: true }; const controller = new AbortController(); - const response = await this.fetchWithAuth(url, req, timeout, controller, security).catch(castToError); + const response = await this.fetchWithAuth(url, req, timeout, controller, security, options.stream).catch( + castToError, + ); const headersTime = Date.now(); if (response instanceof globalThis.Error) { @@ -974,6 +976,7 @@ export class OpenAI { bearerAuth: true, adminAPIKeyAuth: true, }, + keepAbortListener = false, ): Promise { if (this._workloadIdentityAuth && schemes.bearerAuth) { const headers = init.headers as Headers; @@ -984,7 +987,7 @@ export class OpenAI { } } - const response = await this.fetchWithTimeout(url, init, timeout, controller); + const response = await this.fetchWithTimeout(url, init, timeout, controller, keepAbortListener); return response; } @@ -994,6 +997,7 @@ export class OpenAI { init: RequestInit | undefined, ms: number, controller: AbortController, + keepAbortListener = false, ): Promise { const { signal, method, ...options } = init || {}; const abort = this._makeAbort(controller); @@ -1022,6 +1026,7 @@ export class OpenAI { return await this.fetch.call(undefined, url, fetchOptions); } finally { clearTimeout(timeout); + if (signal && !keepAbortListener) signal.removeEventListener('abort', abort); } } diff --git a/tests/index.test.ts b/tests/index.test.ts index e19e33b98..2b49caf20 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -677,6 +677,59 @@ describe('default encoder', () => { }); describe('retries', () => { + test('removes abort listener after successful fetch', async () => { + const client = new OpenAI({ + apiKey: 'My API Key', + adminAPIKey: 'My Admin API Key', + fetch: async () => + new Response(JSON.stringify({ a: 1 }), { headers: { 'Content-Type': 'application/json' } }), + }); + + const externalController = new AbortController(); + const addEventListener = jest.spyOn(externalController.signal, 'addEventListener'); + const removeEventListener = jest.spyOn(externalController.signal, 'removeEventListener'); + + await client.fetchWithTimeout( + 'http://localhost/foo', + { signal: externalController.signal }, + 1000, + new AbortController(), + ); + + expect(addEventListener).toHaveBeenCalledWith('abort', expect.any(Function), { once: true }); + const abortListener = addEventListener.mock.calls[0]?.[1]; + expect(abortListener).toEqual(expect.any(Function)); + expect(removeEventListener).toHaveBeenCalledWith('abort', abortListener); + }); + + test('keeps abort listener after fetch resolves for streaming responses', async () => { + const client = new OpenAI({ + apiKey: 'My API Key', + adminAPIKey: 'My Admin API Key', + fetch: async () => new Response('data: {"a":1}\n\n'), + }); + + const externalController = new AbortController(); + const requestController = new AbortController(); + const addEventListener = jest.spyOn(externalController.signal, 'addEventListener'); + const removeEventListener = jest.spyOn(externalController.signal, 'removeEventListener'); + + await client.fetchWithTimeout( + 'http://localhost/foo', + { signal: externalController.signal }, + 1000, + requestController, + true, + ); + + expect(addEventListener).toHaveBeenCalledWith('abort', expect.any(Function), { once: true }); + expect(removeEventListener).not.toHaveBeenCalled(); + + externalController.abort(); + + expect(requestController.signal.aborted).toBe(true); + }); + test('retry on timeout', async () => { let count = 0; const testFetch = async (