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
5 changes: 4 additions & 1 deletion src/utils/fetch-retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 77 additions & 0 deletions tests/fetch-retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down