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
8 changes: 7 additions & 1 deletion src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,12 @@ export abstract class APIClient {
controller: AbortController,
): Promise<Response> {
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);

Expand All @@ -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);
})
);
}
Expand Down
46 changes: 45 additions & 1 deletion tests/index.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<Promise<Response>, [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<Response> => {
Expand Down