From bd9e8a59c86be9a2ebeb8e60730c14a7f8cc962e Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Singh Date: Sat, 15 Aug 2026 14:36:53 +0530 Subject: [PATCH] fix: clean up abort signal listeners --- src/core.ts | 8 +++++++- tests/index.test.ts | 46 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/core.ts b/src/core.ts index 10b9d3f..c544035 100644 --- a/src/core.ts +++ b/src/core.ts @@ -547,7 +547,12 @@ export abstract class APIClient { controller: AbortController, ): Promise { const { signal, ...options } = init || {}; - if (signal) signal.addEventListener('abort', () => controller.abort()); + const abortHandler = () => controller.abort(); + if (signal?.aborted) { + controller.abort(); + } else if (signal) { + signal.addEventListener('abort', abortHandler); + } const timeout = setTimeout(() => controller.abort(), ms); @@ -565,6 +570,7 @@ export abstract class APIClient { // use undefined this binding; fetch errors if bound to something else in browser/cloudflare this.fetch.call(undefined, url, fetchOptions).finally(() => { clearTimeout(timeout); + signal?.removeEventListener('abort', abortHandler); }) ); } diff --git a/tests/index.test.ts b/tests/index.test.ts index 7639b23..2d1ea13 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,7 +1,7 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. import Browserbase from '@browserbasehq/sdk'; -import { APIUserAbortError } from '@browserbasehq/sdk'; +import { APIConnectionError, APIUserAbortError } from '@browserbasehq/sdk'; import { Headers } from '@browserbasehq/sdk/core'; import defaultFetch, { Response, type RequestInit, type RequestInfo } from 'node-fetch'; @@ -131,6 +131,50 @@ describe('instantiate client', () => { expect(spy).toHaveBeenCalledTimes(1); }); + test('cleans up custom signal listeners after requests settle', async () => { + const testFetch = jest + .fn, [RequestInfo, RequestInit?]>() + .mockResolvedValueOnce( + new Response(JSON.stringify({}), { headers: { 'Content-Type': 'application/json' } }), + ) + .mockRejectedValueOnce(new Error('network failure')); + const client = new Browserbase({ apiKey: 'My API Key', fetch: testFetch, maxRetries: 0 }); + const controller = new AbortController(); + const addEventListener = jest.spyOn(controller.signal, 'addEventListener'); + const removeEventListener = jest.spyOn(controller.signal, 'removeEventListener'); + + await client.get('/success', { signal: controller.signal }); + await expect(client.get('/failure', { signal: controller.signal })).rejects.toThrow(APIConnectionError); + + expect(addEventListener).toHaveBeenCalledTimes(2); + expect(removeEventListener).toHaveBeenCalledTimes(2); + expect(removeEventListener.mock.calls.map((call) => call[1])).toEqual( + addEventListener.mock.calls.map((call) => call[1]), + ); + }); + + test('propagates a signal that is already aborted', async () => { + let capturedSignal: RequestInit['signal']; + const client = new Browserbase({ + apiKey: 'My API Key', + fetch: async (_url, init) => { + capturedSignal = init?.signal; + return new Response(JSON.stringify({}), { headers: { 'Content-Type': 'application/json' } }); + }, + }); + const inputController = new AbortController(); + inputController.abort(); + + await client.fetchWithTimeout( + 'https://example.com', + { signal: inputController.signal as RequestInit['signal'] }, + 100, + new AbortController(), + ); + + expect(capturedSignal?.aborted).toBe(true); + }); + test('normalized method', async () => { let capturedRequest: RequestInit | undefined; const testFetch = async (url: RequestInfo, init: RequestInit = {}): Promise => {