fix: make the proxy deadline per-request, not unconditional (#2319 round 13)

The server-side timer added in round 9 applied to every /api/fetch
request. But that route carries MCP traffic as well as OAuth work, so a
Streamable HTTP tool call that legitimately withholds its response
headers past the budget was aborted there — reintroducing on the
backend exactly the regression exemptMcpEndpoint prevents on the
client, and reporting it as an OAuth timeout besides.

The caller now decides. `withOAuthRequestTimeout` stamps its budget on
the RequestInit it hands down, `createRemoteFetch` reads it back and
puts it in the /api/fetch envelope, and the route applies a timer only
when one is present — so an exempt MCP request, which never gets an
init of its own, is bounded by nothing.

The carrier is a WeakMap keyed on the init rather than a header:
serializeRequest copies request headers verbatim into the payload and
the route re-sends them to the authorization server, so a marker header
would leak to a third party. It needs no cast onto RequestInit and is
collected with the object.

The `fetchTimeoutMs` option from round 9 is gone — the deadline now
lives in exactly one place, the client wrapper, which is also what
makes the 504 marker honest: only a request the OAuth wrapper bounded
can produce one.

Ten tests: the stamping and its rounding, nothing stamped on an exempt
call or an init that never went through the wrapper, the envelope
carrying the budget (and not a header), its omission on both exempt and
direct calls, and at the route the 504 marker, no deadline without one,
and a non-numeric deadline ignored. The no-deadline case is verified by
mutation: an unconditional timer makes it fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-11 05:24:24 -04:00
commit de6960ec5a

View file

@ -22,6 +22,7 @@
*/
import {
deadlineForRequestInit,
isOAuthRequestTimeoutWire,
OAuthRequestTimeoutError,
} from "../../auth/requestTimeout.js";
@ -154,10 +155,26 @@ export function createRemoteFetch(options: RemoteFetchOptions): typeof fetch {
? input.signal
: undefined;
// #2319: tell the route whether *this* request is bounded, and by how much.
// The route serves MCP traffic as well as OAuth work, and a Streamable HTTP
// tool call can legitimately withhold its response headers for minutes — so
// a timer there must be per-request rather than unconditional (Copilot). An
// exempt request carries no deadline and the route applies none.
//
// In the JSON envelope rather than a header: `headers` is re-sent verbatim
// to the upstream server, so a marker header would leak to a third party.
const timeoutMs = deadlineForRequestInit(init);
const res = await fetchFn(`${baseUrl}/api/fetch`, {
method: "POST",
headers: reqHeaders,
body: JSON.stringify({ url, method, headers, body }),
body: JSON.stringify({
url,
method,
headers,
body,
...(timeoutMs !== undefined && { timeoutMs }),
}),
signal,
});