diff --git a/src/core.ts b/src/core.ts index 10b9d3f..b6b5959 100644 --- a/src/core.ts +++ b/src/core.ts @@ -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); } @@ -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 => { + 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`); diff --git a/tests/index.test.ts b/tests/index.test.ts index 7639b23..14a1d8d 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -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 => { + 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 => { + 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 => { + 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 => {