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
16 changes: 16 additions & 0 deletions src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,7 @@ export abstract class APIClient {
if (!response.ok) {
if (retriesRemaining && this.shouldRetry(response)) {
const retryMessage = `retrying, ${retriesRemaining} attempts remaining`;
await cancelResponseBody(response.body);
debug(`response (error; ${retryMessage})`, response.status, url, responseHeaders);
return this.retryRequest(options, retriesRemaining, responseHeaders);
}
Expand Down Expand Up @@ -1019,6 +1020,21 @@ const isAbsoluteURL = (url: string): boolean => {

export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

/** Release a response body that will not be consumed before retrying. */
const cancelResponseBody = async (body: any): Promise<void> => {
if (body === null || typeof body !== 'object') return;

if (body[Symbol.asyncIterator]) {
await body[Symbol.asyncIterator]().return?.();
return;
}

const reader = body.getReader();
const cancelPromise = reader.cancel();
reader.releaseLock();
await cancelPromise;
};

const validatePositiveInteger = (name: string, n: unknown): number => {
if (typeof n !== 'number' || !Number.isInteger(n)) {
throw new BrowserbaseError(`${name} must be an integer`);
Expand Down
50 changes: 50 additions & 0 deletions tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,56 @@ describe('request building', () => {
});

describe('retries', () => {
test('cancels a web response body before retrying', async () => {
let count = 0;
const cancel = jest.fn();
const testFetch = async (): Promise<Response> => {
if (count++ === 0) {
const body = new ReadableStream({ cancel });
return new globalThis.Response(body, { status: 500 }) as unknown as Response;
}
return new Response(JSON.stringify({ a: 1 }), { headers: { 'Content-Type': 'application/json' } });
};
const client = new Browserbase({ apiKey: 'My API Key', fetch: testFetch, maxRetries: 1 });

await expect(client.get('/foo')).resolves.toEqual({ a: 1 });

expect(cancel).toHaveBeenCalledTimes(1);
});

test('releases a node-style async iterable body before retrying', async () => {
let count = 0;
const returnBody = jest.fn();
const retryResponse = {
body: { [Symbol.asyncIterator]: () => ({ return: returnBody }) },
headers: new Response().headers,
ok: false,
status: 500,
url: 'https://example.com/retry',
} as unknown as Response;
const testFetch = async (): Promise<Response> => {
if (count++ === 0) return retryResponse;
return new Response(JSON.stringify({ a: 1 }), { headers: { 'Content-Type': 'application/json' } });
};
const client = new Browserbase({ apiKey: 'My API Key', fetch: testFetch, maxRetries: 1 });

await expect(client.get('/foo')).resolves.toEqual({ a: 1 });

expect(returnBody).toHaveBeenCalledTimes(1);
});

test('retries responses without bodies', async () => {
let count = 0;
const testFetch = async (): Promise<Response> => {
if (count++ === 0) return new Response(undefined, { status: 500 });
return new Response(JSON.stringify({ a: 1 }), { headers: { 'Content-Type': 'application/json' } });
};
const client = new Browserbase({ apiKey: 'My API Key', fetch: testFetch, maxRetries: 1 });

await expect(client.get('/foo')).resolves.toEqual({ a: 1 });
expect(count).toBe(2);
});

test('retry on timeout', async () => {
let count = 0;
const testFetch = async (url: RequestInfo, { signal }: RequestInit = {}): Promise<Response> => {
Expand Down