diff --git a/.changeset/cold-tigers-count.md b/.changeset/cold-tigers-count.md new file mode 100644 index 0000000..29ed24f --- /dev/null +++ b/.changeset/cold-tigers-count.md @@ -0,0 +1,7 @@ +--- +'@fetchkit/ffetch': patch +--- + +Fixed + +- Clone request on each retry attempt to prevent body-already-used error when retrying POST requests with a body diff --git a/src/client.ts b/src/client.ts index 9eea312..371ee50 100644 --- a/src/client.ts +++ b/src/client.ts @@ -247,7 +247,7 @@ export function createClient< ) } } - const reqWithSignal = new Request(requestForAttempt, { + const reqWithSignal = new Request(requestForAttempt.clone(), { signal: dispatchSignal, }) try { diff --git a/test/core/client.test.ts b/test/core/client.test.ts index e026dc9..c880072 100644 --- a/test/core/client.test.ts +++ b/test/core/client.test.ts @@ -319,6 +319,48 @@ describe('retry', () => { await expect(f('https://example.com')).rejects.toThrow('boom') expect(global.fetch).toHaveBeenCalledTimes(3) }) + + it('retries a POST request with a JSON body — succeeds on second attempt', async () => { + // Regression: without the fix, the retry throws + // "Cannot construct a Request with a Request object that has already been used." + // because the body of the original Request is consumed on the first attempt. + let calls = 0 + global.fetch = vi.fn().mockImplementation(async () => { + calls++ + if (calls === 1) return new Response('upstream error', { status: 500 }) + return new Response('ok', { status: 200 }) + }) + + const f = createClient({ retries: 1 }) + const res = await f('https://example.com/api', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key: 'value' }), + }) + + expect(res.status).toBe(200) + expect(calls).toBe(2) + }) + + it('retries a POST request with a JSON body — all attempts return 500, no TypeError thrown', async () => { + // Regression: without the fix, the second attempt throws TypeError instead of + // returning the upstream 500 response. + let calls = 0 + global.fetch = vi.fn().mockImplementation(async () => { + calls++ + return new Response('upstream error', { status: 500 }) + }) + + const f = createClient({ retries: 2 }) + const res = await f('https://example.com/api', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key: 'value' }), + }) + + expect(res.status).toBe(500) + expect(calls).toBe(3) + }) }) describe('retry with shouldRetry', () => { diff --git a/test/integration/retry-post-body.integration.test.ts b/test/integration/retry-post-body.integration.test.ts new file mode 100644 index 0000000..b0d7ddd --- /dev/null +++ b/test/integration/retry-post-body.integration.test.ts @@ -0,0 +1,79 @@ +// @vitest-environment node +// This test MUST run in the Node.js environment (not happy-dom) because only +// Node.js/undici enforces the Fetch API spec rule that constructing a new Request +// from an already-consumed Request throws: +// "Cannot construct a Request with a Request object that has already been used." +// happy-dom silently copies the body, masking the regression. + +import { createServer } from 'node:http' +import type { Server, IncomingMessage, ServerResponse } from 'node:http' +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { createClient } from '../../src/client.js' + +let server: Server +let baseUrl: string +let requestHandler: (req: IncomingMessage, res: ServerResponse) => void + +beforeAll( + () => + new Promise((resolve) => { + server = createServer((req, res) => requestHandler(req, res)) + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as { port: number } + baseUrl = `http://127.0.0.1:${addr.port}` + resolve() + }) + }) +) + +afterAll( + () => + new Promise((resolve) => { + server.close(() => resolve()) + }) +) + +describe('retry POST with body — Node.js native fetch (undici)', () => { + it('succeeds on retry after 500 without throwing body-already-used error', async () => { + let callCount = 0 + requestHandler = (_req, res) => { + callCount++ + if (callCount === 1) { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'upstream error' })) + } else { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ ok: true })) + } + } + + const f = createClient({ retries: 1 }) + const res = await f(`${baseUrl}/api`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key: 'value' }), + }) + + expect(res.status).toBe(200) + expect(callCount).toBe(2) + }) + + it('exhausts all retries with body without throwing body-already-used error', async () => { + let callCount = 0 + requestHandler = (_req, res) => { + callCount++ + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'upstream error' })) + } + + const f = createClient({ retries: 2 }) + const res = await f(`${baseUrl}/api`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key: 'value' }), + }) + + expect(res.status).toBe(500) + expect(callCount).toBe(3) + }) +})