Skip to content

Commit e5828e2

Browse files
fix: resolve request hang with large API responses (#437)
* fix: resolve request hang with `pageSize` and `fetchLinks` Replace `response.clone()` with a memoized response wrapper that caches promises for `text()`, `json()`, and `blob()` reads. This allows multiple callers sharing a deduplicated request to safely read from the same response without stream/body handling issues. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: prevent body read conflicts in memoized responses Clone the response before reading text/blob to avoid "body already read" errors when both methods are called on the same memoized response. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: buffer response body immediately to avoid backpressure Buffers the response body as a blob immediately instead of using response.clone(). This fixes hanging requests in Node.js 22+ where backpressure blocks the cloned stream when the original is not consumed. See: node-fetch/node-fetch#139 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: simplify memoizeResponse to reduce complexity Replace verbose wrapper with lazy caching variables with a compact implementation that eagerly buffers the blob and derives text/json from it on each call. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: return fresh Response per mock call to match real fetch mockResolvedValue reuses one Response object across calls, but real fetch always returns a fresh Response. Use mockImplementation so each call creates a new Response, avoiding "Body has already been read". Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: clarify memoizeResponse comment Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 826b079 commit e5828e2

File tree

3 files changed

+31
-9
lines changed

3 files changed

+31
-9
lines changed

src/lib/request.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,26 @@ export interface HeadersLike {
104104
get(name: string): string | null
105105
}
106106

107+
async function memoizeResponse(response: ResponseLike): Promise<ResponseLike> {
108+
// Deduplicated responses are shared across multiple callers. Calling
109+
// response.clone() on a shared response can cause backpressure hangs
110+
// in Node.js, so we buffer the body as a blob upfront instead.
111+
const blob = await response.blob()
112+
113+
const memoized: ResponseLike = {
114+
ok: response.ok,
115+
status: response.status,
116+
headers: response.headers,
117+
url: response.url,
118+
text: async () => blob.text(),
119+
json: async () => JSON.parse(await blob.text()),
120+
blob: async () => blob,
121+
clone: () => memoized,
122+
}
123+
124+
return memoized
125+
}
126+
107127
/**
108128
* Makes an HTTP request with automatic retry for rate limits and request
109129
* deduplication.
@@ -137,12 +157,14 @@ export async function request(
137157
if (existingJob) {
138158
job = existingJob
139159
} else {
140-
job = fetchFn(stringURL, init).finally(() => {
141-
DEDUPLICATED_JOBS[stringURL]?.delete(init?.signal)
142-
if (DEDUPLICATED_JOBS[stringURL]?.size === 0) {
143-
delete DEDUPLICATED_JOBS[stringURL]
144-
}
145-
})
160+
job = fetchFn(stringURL, init)
161+
.then(memoizeResponse)
162+
.finally(() => {
163+
DEDUPLICATED_JOBS[stringURL]?.delete(init?.signal)
164+
if (DEDUPLICATED_JOBS[stringURL]?.size === 0) {
165+
delete DEDUPLICATED_JOBS[stringURL]
166+
}
167+
})
146168
const map = (DEDUPLICATED_JOBS[stringURL] ||= new Map())
147169
map.set(init?.signal, job)
148170
}
@@ -162,5 +184,5 @@ export async function request(
162184
return request(url, init, fetchFn)
163185
}
164186

165-
return response.clone()
187+
return response
166188
}

test/client-dangerouslyGetAll.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ it("throttles invalid ref logs", async ({ expect, client, response }) => {
172172
.mockResolvedValueOnce(response.repository("invalid"))
173173
.mockResolvedValueOnce(response.refNotFound("invalid"))
174174
.mockResolvedValueOnce(response.repository("invalid"))
175-
.mockResolvedValue(response.refNotFound("invalid"))
175+
.mockImplementation(() => Promise.resolve(response.refNotFound("invalid")))
176176
await expect(() => client.dangerouslyGetAll()).rejects.toThrow(
177177
RefNotFoundError,
178178
)

test/client.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ describe.for(queryCases)("$name", async ({ fn }) => {
233233
.mockResolvedValueOnce(response.repository("invalid"))
234234
.mockResolvedValueOnce(response.refNotFound("invalid"))
235235
.mockResolvedValueOnce(response.repository("invalid"))
236-
.mockResolvedValue(response.refNotFound("invalid"))
236+
.mockImplementation(() => Promise.resolve(response.refNotFound("invalid")))
237237
await expect(() => fn({ client, docs })).rejects.toThrow(RefNotFoundError)
238238
expect(console.warn).toHaveBeenCalledTimes(1)
239239
})

0 commit comments

Comments
 (0)