diff --git a/open-sse/utils/proxyFetch.ts b/open-sse/utils/proxyFetch.ts index 134aaf8311..62ed9356be 100644 --- a/open-sse/utils/proxyFetch.ts +++ b/open-sse/utils/proxyFetch.ts @@ -107,6 +107,29 @@ export function describeFetchCause(err: unknown): string { return parts.join(" | ") || String(err); } + +function isStreamLikeBody(body: unknown): boolean { + return ( + body !== null && + body !== undefined && + typeof body === "object" && + (typeof (body as Record).getReader === "function" || + typeof (body as Record).stream === "function") + ); +} + +function requestHasNonReplayableBody( + input: RequestInfo | URL, + options: FetchWithDispatcherOptions +): boolean { + if (isStreamLikeBody(options.body as unknown)) return true; + if (typeof Request !== "undefined" && input instanceof Request) { + if (input.bodyUsed) return true; + if (input.body !== null) return true; + } + return false; +} + /** Injectable dependencies for testability (Approach B DI). */ export type ProxyFetchDeps = { undiciFetch?: FetchWithDispatcher; @@ -436,17 +459,13 @@ async function patchedFetch( // Falls back to original native fetch if dispatcher initialization fails (#1054). // Retries once on transient dispatcher errors before falling back (fix: proxyfetch-undici-retry). // - // ReadableStream/Blob body guard: if the body is non-replayable, skip the retry because - // the first attempt drains the stream; a second attempt would silently send an empty body. - // ReadableStream check: cast through unknown to avoid explicit-any budget (T11). - const _bodyUnknown = options.body as unknown; - const bodyIsStream = - _bodyUnknown !== null && - _bodyUnknown !== undefined && - typeof _bodyUnknown === "object" && - (typeof (_bodyUnknown as Record).getReader === "function" || // ReadableStream - typeof (_bodyUnknown as Record).stream === "function"); // Blob - const maxAttempts = bodyIsStream ? 1 : 2; + // Non-replayable body guard: if the body is stream-like (ReadableStream/Blob) + // or the input is a Request that carries a body, the first dispatcher attempt + // owns that body. Retrying or falling back to native fetch would replay a + // consumed/locked body and can mask the original transport error with + // "Response body object should not be disturbed or locked". + const hasNonReplayableBody = requestHasNonReplayableBody(input, options); + const maxAttempts = hasNonReplayableBody ? 1 : 2; const _undiciDirect = deps.undiciFetch ?? (undiciFetch as unknown as (...args: unknown[]) => Promise); const _nativeFallback = @@ -492,6 +511,17 @@ async function patchedFetch( await new Promise((r) => setTimeout(r, 25 + Math.random() * 50)); continue; } + if (hasNonReplayableBody) { + const detail = `dispatcher=[${describeFetchCause(dispatcherError)}] native=[skipped: non-replayable request body]`; + console.warn( + `[ProxyFetch] skipping native fetch fallback for non-replayable body: ${detail}` + ); + if (dispatcherError instanceof Error) { + (dispatcherError as Error & { proxyFetchDetail?: string }).proxyFetchDetail = detail; + } + throw dispatcherError; + } + // All attempts exhausted — try proxy fallback before native fetch if (source === "direct" && isFeatureFlagEnabled("PROXY_AUTO_SELECT_ENABLED")) { let targetHostname = ""; diff --git a/tests/unit/proxyfetch-undici-retry.test.ts b/tests/unit/proxyfetch-undici-retry.test.ts index a7d3a57c96..4acd765271 100644 --- a/tests/unit/proxyfetch-undici-retry.test.ts +++ b/tests/unit/proxyfetch-undici-retry.test.ts @@ -119,13 +119,14 @@ test("#2463 — undici error with a NON-STRING code must not crash on errCode.st assert.equal(await res.text(), "native-fallback-body"); }); -test("does not retry when body is a ReadableStream (non-replayable body)", async () => { +test("does not retry or native-fallback when body is a ReadableStream (non-replayable body)", async () => { let undiciCalls = 0; let nativeCalls = 0; + const dispatcherError = makeUndiciError("fetch failed"); const mockUndici = async (_input: RequestInfo | URL, _init?: RequestInit): Promise => { undiciCalls++; - throw makeUndiciError("fetch failed"); + throw dispatcherError; }; const mockNative = async (_input: RequestInfo | URL, _init?: RequestInit): Promise => { @@ -140,10 +141,17 @@ test("does not retry when body is a ReadableStream (non-replayable body)", async }, }); - const res = await proxyFetch( - "https://example.invalid/test", - { method: "POST", body: stream }, - { undiciFetch: mockUndici, nativeFetch: mockNative } + await assert.rejects( + proxyFetch( + "https://example.invalid/test", + { method: "POST", body: stream }, + { undiciFetch: mockUndici, nativeFetch: mockNative } + ), + (err: Error & { proxyFetchDetail?: string }) => { + assert.equal(err, dispatcherError, "must rethrow the original dispatcher error"); + assert.match(err.proxyFetchDetail ?? "", /native=\[skipped: non-replayable request body\]/); + return true; + } ); assert.equal( @@ -151,8 +159,40 @@ test("does not retry when body is a ReadableStream (non-replayable body)", async 1, "undici must NOT retry when body is a ReadableStream (called exactly once)" ); - assert.equal(nativeCalls, 1, "native fallback fires after single undici attempt"); - assert.equal(await res.text(), "native-stream-fallback"); + assert.equal(nativeCalls, 0, "native fallback must NOT consume a non-replayable body"); +}); + +test("does not retry or native-fallback when input is a Request with a body", async () => { + let undiciCalls = 0; + let nativeCalls = 0; + + const dispatcherError = makeUndiciError("fetch failed"); + const mockUndici = async (_input: RequestInfo | URL, _init?: RequestInit): Promise => { + undiciCalls++; + throw dispatcherError; + }; + + const mockNative = async (_input: RequestInfo | URL, _init?: RequestInit): Promise => { + nativeCalls++; + return new Response("native-request-fallback", { status: 200 }); + }; + + const request = new Request("https://example.invalid/test", { + method: "POST", + body: "request-body", + }); + + await assert.rejects( + proxyFetch(request, {}, { undiciFetch: mockUndici, nativeFetch: mockNative }), + (err: Error & { proxyFetchDetail?: string }) => { + assert.equal(err, dispatcherError, "must rethrow the original dispatcher error"); + assert.match(err.proxyFetchDetail ?? "", /native=\[skipped: non-replayable request body\]/); + return true; + } + ); + + assert.equal(undiciCalls, 1, "undici must NOT retry a Request body"); + assert.equal(nativeCalls, 0, "native fallback must NOT replay a Request body"); }); // ── #4252: surface err.cause so silent "fetch failed" dispatcher bursts are diagnosable ──