Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/cold-tigers-count.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ export function createClient<
)
}
}
const reqWithSignal = new Request(requestForAttempt, {
const reqWithSignal = new Request(requestForAttempt.clone(), {
signal: dispatchSignal,
})
try {
Expand Down
42 changes: 42 additions & 0 deletions test/core/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
79 changes: 79 additions & 0 deletions test/integration/retry-post-body.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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<void>((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)
})
})