From 32f6df7a7e73897bd1049f2e2c37f6d17666db20 Mon Sep 17 00:00:00 2001 From: kigland Date: Mon, 1 Jun 2026 09:08:10 +0800 Subject: [PATCH 1/2] fix: remove abort listener after fetch completion --- src/client.ts | 1 + tests/index.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/client.ts b/src/client.ts index c7df965cd..6db061d19 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1022,6 +1022,7 @@ export class OpenAI { return await this.fetch.call(undefined, url, fetchOptions); } finally { clearTimeout(timeout); + if (signal) signal.removeEventListener('abort', abort); } } diff --git a/tests/index.test.ts b/tests/index.test.ts index e19e33b98..9ff60a68e 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -677,6 +677,29 @@ 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 }); + expect(removeEventListener).toHaveBeenCalledWith('abort', addEventListener.mock.calls[0][1]); + }); + test('retry on timeout', async () => { let count = 0; const testFetch = async ( From 38d50a08fd7c46399b51d21c595fc72f7a164e39 Mon Sep 17 00:00:00 2001 From: kigland Date: Mon, 1 Jun 2026 11:05:07 +0800 Subject: [PATCH 2/2] fix: preserve stream abort cancellation --- src/client.ts | 10 +++++++--- tests/index.test.ts | 32 +++++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/client.ts b/src/client.ts index 6db061d19..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,7 +1026,7 @@ export class OpenAI { return await this.fetch.call(undefined, url, fetchOptions); } finally { clearTimeout(timeout); - if (signal) signal.removeEventListener('abort', abort); + if (signal && !keepAbortListener) signal.removeEventListener('abort', abort); } } diff --git a/tests/index.test.ts b/tests/index.test.ts index 9ff60a68e..2b49caf20 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -697,7 +697,37 @@ describe('retries', () => { ); expect(addEventListener).toHaveBeenCalledWith('abort', expect.any(Function), { once: true }); - expect(removeEventListener).toHaveBeenCalledWith('abort', addEventListener.mock.calls[0][1]); + 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 () => {