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:
parent
234d0a9ac4
commit
de6960ec5a
2 changed files with 259 additions and 50 deletions
|
|
@ -56,6 +56,7 @@ export {
|
|||
DEFAULT_OAUTH_REQUEST_TIMEOUT_MS,
|
||||
OAUTH_TIMEOUT_WIRE_CODE,
|
||||
OAuthRequestTimeoutError,
|
||||
deadlineForRequestInit,
|
||||
exemptMcpEndpoint,
|
||||
isOAuthRequestTimeoutWire,
|
||||
withOAuthRequestTimeout,
|
||||
|
|
|
|||
|
|
@ -148,6 +148,39 @@ export function exemptMcpEndpoint(
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The deadline a wrapped call is running under, keyed by the exact `RequestInit`
|
||||
* the wrapper handed down (#2319).
|
||||
*
|
||||
* This is how the budget crosses the proxy hop without travelling *upstream*.
|
||||
* `/api/fetch` must not time every request it serves — `createRemoteFetch` also
|
||||
* carries MCP traffic, and a Streamable HTTP tool call can legitimately withhold
|
||||
* its response headers for minutes — so the route needs to know which requests
|
||||
* are bounded and which are not (Copilot). A header would be the obvious
|
||||
* carrier and is the wrong one: `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.
|
||||
*
|
||||
* A `WeakMap` keyed on the init object has neither problem. It is invisible to
|
||||
* serialization, it needs no cast onto `RequestInit`, and it is collected with
|
||||
* the object — the wrapper builds a fresh init per call, so there is one entry
|
||||
* per in-flight request and nothing accumulates.
|
||||
*/
|
||||
const REQUEST_DEADLINES = new WeakMap<object, number>();
|
||||
|
||||
/**
|
||||
* The deadline `withOAuthRequestTimeout` stamped on this init, if any.
|
||||
*
|
||||
* `undefined` means the request is not bounded by this wrapper and must not be
|
||||
* bounded by anything downstream either — an exempt MCP request reads as
|
||||
* `undefined` because the exempt path never builds an init of its own.
|
||||
*/
|
||||
export function deadlineForRequestInit(
|
||||
init: RequestInit | undefined,
|
||||
): number | undefined {
|
||||
return init ? REQUEST_DEADLINES.get(init) : undefined;
|
||||
}
|
||||
|
||||
/** The request URL, whatever form `fetch`'s first argument took. */
|
||||
function requestUrlOf(input: RequestInfo | URL): string {
|
||||
if (typeof input === "string") return input;
|
||||
|
|
@ -342,9 +375,14 @@ export function withOAuthRequestTimeout(
|
|||
}
|
||||
});
|
||||
|
||||
// Built once and stamped, so `createRemoteFetch` can read the budget off it
|
||||
// and tell `/api/fetch` this particular request is bounded.
|
||||
const nextInit: RequestInit = { ...init, signal };
|
||||
REQUEST_DEADLINES.set(nextInit, budget);
|
||||
|
||||
try {
|
||||
const response = await Promise.race([
|
||||
fetchFn(input, { ...init, signal }),
|
||||
fetchFn(input, nextInit),
|
||||
abandoned,
|
||||
]);
|
||||
// `fetch` resolves once the response *headers* arrive, so stopping here
|
||||
|
|
|
|||
Loading…
Reference in a new issue