Skip to content

Commit f3be91f

Browse files
authored
fix(dedupe): clone response per waiter to prevent body-already-used errors (#48)
1 parent 12f9c19 commit f3be91f

6 files changed

Lines changed: 97 additions & 96 deletions

File tree

.changeset/neat-hounds-bathe.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@fetchkit/ffetch': minor
3+
---
4+
5+
Fixed
6+
7+
- dedupePlugin: each deduplicated caller now receives an independent Response clone, preventing "body already used" errors when multiple concurrent callers consume the response body
8+
9+
Documentation
10+
11+
- deduplication: explained response cloning behaviour and auth header considerations for custom hashFn
12+
- advanced: clarified that timeout acts as total duration cap including retry wait periods

CHANGELOG.md

Lines changed: 10 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,24 @@
11
# ffetch
22

3-
## 5.4.13
3+
## 5.4.9 – 5.4.13
44

55
### Patch Changes
66

7-
- 9dc55ee: Fixed: use CHANGESETS_TOKEN PAT to trigger CI on version PRs
7+
Internal CI/CD infrastructure releases. No functional changes.
88

9-
## 5.4.12
9+
- ci: automated publishing pipeline setup (Node 24, npm Trusted Publishing, OIDC)
10+
- ci: GitHub Actions pinned to commit SHAs
11+
- ci: CodeQL, Dependabot, OpenSSF Scorecard, SBOM generation configured
12+
- ci: fine-grained PAT for changeset version PRs to trigger CI
13+
- docs: README updated with security section and OpenSSF Scorecard badge
1014

11-
### Patch Changes
12-
13-
- aab0230: test: trigger release
14-
15-
## 5.4.11
16-
17-
### Patch Changes
18-
19-
- 36a70ef: docs: readme updated with security section
20-
21-
## 5.4.10
22-
23-
### Patch Changes
24-
25-
- b1ecd57: docs: readme updated with security section
26-
27-
## 5.4.9
28-
29-
### Patch Changes
30-
31-
- a808121: Fixed: Discord announcement is inlined in publish.yml action
32-
33-
## 5.4.8
34-
35-
### Patch Changes
36-
37-
- 8da944a: fix: discord announcement trigger
38-
39-
## 5.4.7
15+
## 5.4.3 – 5.4.8
4016

4117
### Patch Changes
4218

43-
- d084935: chore: changeset created
44-
45-
## 5.4.6
46-
47-
### Patch Changes
48-
49-
- 285f68c: ci: fix action versions and registry-url placement
50-
51-
## 5.4.5
52-
53-
### Patch Changes
54-
55-
- d67f74a: Trigger retry with Node24 fix
56-
57-
## 5.4.4
58-
59-
### Patch Changes
60-
61-
- 5cc69e6: Retry npm publish with corrected repository url
62-
63-
## 5.4.3
64-
65-
### Patch Changes
19+
Internal CI/CD infrastructure releases. No functional changes.
6620

67-
- f523b31: test: verify automated publishing setup
68-
- 70bcea4: Test automated publishing workflow
21+
- ci: automated publishing pipeline initial setup and stabilisation
6922

7023
## 5.4.2
7124

docs/advanced.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,8 @@ If you override retry behavior, make sure your custom logic still handles `Retry
189189
// ffetch will wait until that date/time before retrying
190190
```
191191

192+
> **Note**: The `timeout` option acts as a total duration cap across the entire request lifecycle, including retry wait periods. If a server responds with a large `Retry-After` value and the combined wait would exceed the configured `timeout`, the timeout fires and the request throws a `TimeoutError` — the retry wait is interrupted immediately. To allow indefinite waits, set `timeout: 0`.
193+
192194
## Circuit Breaker Pattern
193195

194196
The circuit breaker pattern protects your service from repeated failures by temporarily blocking requests after a threshold of consecutive errors. This helps prevent cascading failures and allows your system to recover gracefully.

docs/deduplication.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,14 @@ const client = createClient({
5656
- TTL eviction does not reject already in-flight request promises.
5757
- Stream/FormData request bodies are skipped by the default hash strategy.
5858

59+
## Response Body Consumption
60+
61+
Each deduplicated caller receives an independent `Response` — the first caller gets the original, and each additional concurrent caller receives a clone. This means every caller can consume the response body (`.json()`, `.text()`, `.arrayBuffer()`, etc.) independently without conflict.
62+
63+
Note: `response.url` and `response.redirected` are not preserved on cloned responses (they are read-only on constructed `Response` objects). This is a browser/runtime constraint, not specific to ffetch. In practice, API responses rarely require these properties.
64+
65+
If you need auth headers (e.g. `Authorization`, `Cookie`) to be part of the dedupe key, provide a custom `hashFn` that includes them — the default hash uses method, URL, and body only.
66+
5967
## Defaults
6068

6169
- `hashFn`: `dedupeRequestHash`

src/plugins/dedupe.ts

Lines changed: 25 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@ import {
44
type DedupeHashParams,
55
} from '../dedupeRequestHash.js'
66

7+
type Waiter = {
8+
resolve: (value: Response) => void
9+
reject: (reason?: unknown) => void
10+
}
11+
712
type DedupeEntry = {
813
promise: Promise<Response>
9-
resolve: (value: Response | PromiseLike<Response>) => void
10-
reject: (reason?: unknown) => void
14+
waiters: Waiter[]
1115
createdAt: number
1216
}
1317

@@ -80,51 +84,33 @@ export function dedupePlugin(options: DedupePluginOptions = {}): ClientPlugin {
8084

8185
const existing = inFlight.get(key)
8286
if (existing) {
83-
return existing.promise
87+
return new Promise<Response>((resolve, reject) => {
88+
existing.waiters.push({ resolve, reject })
89+
})
8490
}
8591

86-
let settled = false
87-
let resolveFn: (value: Response | PromiseLike<Response>) => void
88-
let rejectFn: (reason?: unknown) => void
89-
90-
const placeholder = new Promise<Response>((resolve, reject) => {
91-
resolveFn = (value) => {
92-
if (!settled) {
93-
settled = true
94-
resolve(value)
95-
}
96-
}
97-
rejectFn = (reason) => {
98-
if (!settled) {
99-
settled = true
100-
reject(reason)
101-
}
102-
}
103-
})
104-
// Internal placeholder can reject before a consumer attaches handlers.
105-
// Mark it observed to avoid unhandled-rejection noise.
106-
placeholder.catch(() => undefined)
92+
const waiters: Waiter[] = []
93+
const actualPromise = next(ctx)
10794

10895
inFlight.set(key, {
109-
promise: placeholder,
110-
resolve: resolveFn!,
111-
reject: rejectFn!,
96+
promise: actualPromise,
97+
waiters,
11298
createdAt: Date.now(),
11399
})
114100
startSweeper()
115101

116-
const actualPromise = next(ctx)
117-
const entry = inFlight.get(key)
118-
if (entry) {
119-
actualPromise.then(
120-
(result) => entry.resolve(result),
121-
(error) => entry.reject(error)
122-
)
123-
inFlight.set(key, {
124-
...entry,
125-
promise: actualPromise,
126-
})
127-
}
102+
actualPromise.then(
103+
(result) => {
104+
for (const waiter of waiters) {
105+
waiter.resolve(result.clone())
106+
}
107+
},
108+
(error) => {
109+
for (const waiter of waiters) {
110+
waiter.reject(error)
111+
}
112+
}
113+
)
128114

129115
return actualPromise.finally(() => {
130116
const current = inFlight.get(key)

test/plugins/dedupe.plugin.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,46 @@ describe('dedupe plugin parity', () => {
170170
expect(seenSignal).toBe(controller.signal)
171171
})
172172

173+
it('allows all deduped callers to consume the response body independently', async () => {
174+
const deferred = createDeferred<Response>()
175+
global.fetch = vi.fn().mockReturnValue(deferred.promise)
176+
177+
const client = createClient({ plugins: [dedupePlugin()] })
178+
179+
const p1 = client('https://example.com/body')
180+
const p2 = client('https://example.com/body')
181+
182+
deferred.resolve(new Response(JSON.stringify({ value: 42 }), { status: 200 }))
183+
184+
const [r1, r2] = await Promise.all([p1, p2])
185+
const [d1, d2] = await Promise.all([r1.json(), r2.json()])
186+
187+
expect(d1).toEqual({ value: 42 })
188+
expect(d2).toEqual({ value: 42 })
189+
expect(global.fetch).toHaveBeenCalledTimes(1)
190+
})
191+
192+
it('allows three deduped callers to consume the response body independently', async () => {
193+
const deferred = createDeferred<Response>()
194+
global.fetch = vi.fn().mockReturnValue(deferred.promise)
195+
196+
const client = createClient({ plugins: [dedupePlugin()] })
197+
198+
const p1 = client('https://example.com/body3')
199+
const p2 = client('https://example.com/body3')
200+
const p3 = client('https://example.com/body3')
201+
202+
deferred.resolve(new Response(JSON.stringify({ value: 99 }), { status: 200 }))
203+
204+
const [r1, r2, r3] = await Promise.all([p1, p2, p3])
205+
const [d1, d2, d3] = await Promise.all([r1.json(), r2.json(), r3.json()])
206+
207+
expect(d1).toEqual({ value: 99 })
208+
expect(d2).toEqual({ value: 99 })
209+
expect(d3).toEqual({ value: 99 })
210+
expect(global.fetch).toHaveBeenCalledTimes(1)
211+
})
212+
173213
it('propagates one underlying failure to all deduped callers', async () => {
174214
const deferred = createDeferred<Response>()
175215
global.fetch = vi.fn().mockReturnValue(deferred.promise)

0 commit comments

Comments
 (0)