diff --git a/clients/web/src/test/core/mcp/remote/createRemoteFetch.test.ts b/clients/web/src/test/core/mcp/remote/createRemoteFetch.test.ts index 37e71d9d..27a42d61 100644 --- a/clients/web/src/test/core/mcp/remote/createRemoteFetch.test.ts +++ b/clients/web/src/test/core/mcp/remote/createRemoteFetch.test.ts @@ -272,10 +272,12 @@ describe("createRemoteFetch", () => { } it("rebuilds an OAuthRequestTimeoutError from the route's marker", async () => { - // The one path where a timeout reaches the browser without a client-side - // wrapper having fired first: the discovery the SDK runs from inside the - // transport has no wrapper at all, so the backend's deadline is the only - // one, and it arrives as an ordinary error response. + // The proxy boundary in isolation: whichever end's deadline fires, a + // timeout that comes back as an ordinary error response must still + // reconstruct as a typed one. Both ends run the same budget in + // production, so this path is the backend winning the race — a + // backgrounded tab throttling `setTimeout` is the plausible case — and + // which one fired must not decide whether the error names its endpoint. const remoteFetch = remoteReturning( 504, JSON.stringify({ diff --git a/core/auth/requestTimeout.ts b/core/auth/requestTimeout.ts index d74bff90..28efdbc4 100644 --- a/core/auth/requestTimeout.ts +++ b/core/auth/requestTimeout.ts @@ -391,9 +391,10 @@ export function withOAuthRequestTimeout( // `response.json()` would hang with nothing watching it (Copilot). Every // response on this path is a small, finite document — metadata, // a registration, a token — so buffering it under the same deadline - // bounds the whole exchange. This is the other reason the wrapper must - // never be applied to the transport fetch, whose bodies are streams that - // are supposed to stay open. + // bounds the whole exchange. It is also the second reason `isExempt` is + // not optional on a mixed chain: an exempt request skips this buffering + // entirely, which is what lets an SSE body stay open rather than being + // drained into memory here. const body = await Promise.race([response.arrayBuffer(), abandoned]); return rebuildResponse(response, body); } catch (err) { diff --git a/core/mcp/remote/createRemoteFetch.ts b/core/mcp/remote/createRemoteFetch.ts index 144f6681..d0757956 100644 --- a/core/mcp/remote/createRemoteFetch.ts +++ b/core/mcp/remote/createRemoteFetch.ts @@ -184,9 +184,14 @@ export function createRemoteFetch(options: RemoteFetchOptions): typeof fetch { // rebuild the typed error from its marker so the `instanceof` checks // downstream — most importantly the one that lets a stalled probe escape // `withRfc8414OidcCompat` — still see a timeout for what it is (#2319, - // Copilot). This is the only path on which such a timeout can reach the - // browser without a client-side wrapper having fired first: the discovery - // the SDK runs from inside the transport has no wrapper at all. + // Copilot). + // + // Both ends run the same budget, so the client's race usually settles + // first and this path is not taken. It matters when the backend's timer + // wins anyway — most plausibly a backgrounded tab, where the browser + // throttles `setTimeout` while the server's fires on schedule. Without + // this, which of two equal deadlines happened to fire would decide + // whether the error carried its endpoint. let parsed: unknown; try { parsed = JSON.parse(text);