From 27c8f891b0f14f9dd1f109b9a08a6b3ba17fed27 Mon Sep 17 00:00:00 2001 From: pouya shahrdami <131306412+pouyashahrdami@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:44:06 -0400 Subject: [PATCH] fix: clone Request inputs so retries do not reuse a consumed body fetchWithRetry sent the same Request instance on every attempt, but the first attempt consumes the body stream, so every retry of a POST/PUT with a body failed with "Body has already been used". This broke retries for all execute-tool API calls proxied through GlobalOutbound: a 429 slept through the backoff delays and returned the original 429 anyway, and a network error surfaced a misleading TypeError instead of retrying. Send a clone on every attempt that can still be retried and let the final attempt consume the original. --- src/utils/fetch-retry.ts | 5 ++- tests/fetch-retry.test.ts | 77 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/utils/fetch-retry.ts b/src/utils/fetch-retry.ts index 47f993d..762de3f 100644 --- a/src/utils/fetch-retry.ts +++ b/src/utils/fetch-retry.ts @@ -48,7 +48,10 @@ export async function fetchWithRetry( for (let attempt = 0; attempt <= opts.maxRetries; attempt++) { try { - const response = await fetch(input, init) + // Sending a Request consumes its body, so retryable attempts get a clone + const attemptInput = + input instanceof Request && attempt < opts.maxRetries ? input.clone() : input + const response = await fetch(attemptInput, init) if (response.status !== 429) { return response diff --git a/tests/fetch-retry.test.ts b/tests/fetch-retry.test.ts index b1a6d21..4003d36 100644 --- a/tests/fetch-retry.test.ts +++ b/tests/fetch-retry.test.ts @@ -203,6 +203,83 @@ describe('fetchWithRetry', () => { expect(mock).toHaveBeenCalledTimes(2) }) + it('retries a Request with a body after a 429 without consuming it', async () => { + const bodies: string[] = [] + const mock = vi.fn().mockImplementation(async (input: Request) => { + bodies.push(await input.text()) + return bodies.length === 1 + ? new Response('rate limited', { status: 429 }) + : new Response('ok', { status: 200 }) + }) + globalThis.fetch = mock + + const request = new Request('https://api.example.com/test', { + method: 'POST', + body: '{"key":"value"}' + }) + const result = await fetchWithRetry(request, undefined, { + maxRetries: 3, + baseDelayMs: 1, + jitter: false + }) + + expect(result.status).toBe(200) + expect(bodies).toEqual(['{"key":"value"}', '{"key":"value"}']) + }) + + it('retries a Request with a body after a network error', async () => { + const bodies: string[] = [] + const mock = vi + .fn() + // Real fetch can consume the body before the connection fails. + .mockImplementationOnce(async (input: Request) => { + await input.text() + throw new Error('network failure') + }) + .mockImplementation(async (input: Request) => { + bodies.push(await input.text()) + return new Response('ok', { status: 200 }) + }) + globalThis.fetch = mock + + const request = new Request('https://api.example.com/test', { + method: 'POST', + body: '{"key":"value"}' + }) + const result = await fetchWithRetry(request, undefined, { + maxRetries: 1, + baseDelayMs: 1, + jitter: false + }) + + expect(result.status).toBe(200) + expect(bodies).toEqual(['{"key":"value"}']) + }) + + it('sends the original Request on the final attempt', async () => { + const request = new Request('https://api.example.com/test', { + method: 'POST', + body: '{"key":"value"}' + }) + const seen: Request[] = [] + const mock = vi.fn().mockImplementation(async (input: Request) => { + seen.push(input) + return new Response('rate limited', { status: 429 }) + }) + globalThis.fetch = mock + + const result = await fetchWithRetry(request, undefined, { + maxRetries: 1, + baseDelayMs: 1, + jitter: false + }) + + expect(result.status).toBe(429) + expect(seen).toHaveLength(2) + expect(seen[0]).not.toBe(request) + expect(seen[1]).toBe(request) + }) + it('passes through request init options', async () => { const mockResponse = new Response('ok', { status: 200 }) globalThis.fetch = vi.fn().mockResolvedValue(mockResponse)