fix: address Copilot review round 1 on #2319

Three findings, all real:

- The deadline stopped at the response headers. `fetch` resolves once
  headers arrive, so a server that sent headers — or half a JSON
  document — and then stalled left the caller's `response.json()`
  hanging with nothing watching it. The body is now buffered under the
  same race and the response rebuilt around it, preserving `url`,
  `redirected` and `type`, which have no constructor option, and
  passing `null` for a null-body status.

- The caller's signal was only forwarded, not raced. On the browser's
  `createRemoteFetch` path the signal is dropped in flight, so an abort
  left the promise pending for the whole budget rather than the outer
  cancellation winning as documented. It is raced now, with the
  caller's own `reason` preserved, and the listener removed in
  `finally` since one caller signal outlives several requests.

- A `Request` carries its signal on the input, not in `init`, so always
  passing our own `signal` in `init` silently overrode it. The caller
  signal is now derived from the `Request` when `init` has no `signal`
  key — and an explicit `null` there still wins, per the fetch spec.

Eight new tests cover each: the stalled body, caller cancellation
against a signal-ignoring fetch, an already-aborted signal, the
Request-embedded signal, the explicit-null override, listener removal,
the rebuilt response's metadata and a null-body status.

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 02:24:30 -04:00
commit e89ea4cf19

View file

@ -22,6 +22,13 @@
* redirect is about to end the attempt. A stalled fetch inside that window is
* a silent, unbounded spinner rather than a surfaced failure.
*
* The deadline covers the **whole exchange**, not just the headers: `fetch`
* resolves as soon as response headers arrive, so a server that sends headers
* — or half a JSON document — and then stalls would leave the caller's
* `response.json()` hanging with nothing watching it. Every response on this
* path is a small finite document, so the body is buffered under the same race
* and the response rebuilt around it.
*
* Today the common case is bounded, but only incidentally: a discovery stall
* that happens to sit inside an SDK `initialize` rides that request's own
* timeout and surfaces after 60s as `Request timed out`. This module makes the
@ -77,6 +84,63 @@ function requestUrlOf(input: RequestInfo | URL): string {
return input.url;
}
/**
* The caller's own `AbortSignal`, if it supplied one.
*
* A `Request` carries its signal on the *input*, not in `init`, and this
* wrapper always passes a `signal` of its own in `init` — so reading `init`
* alone would silently override the embedded one and change ordinary `fetch`
* cancellation semantics for `withOAuthRequestTimeout(new Request(url, { signal }))`
* (Copilot).
*
* `init.signal` still wins whenever the key is *present*, per the fetch spec:
* an explicit `null` there means "no signal" even when `input` is a `Request`
* that has one.
*/
function callerSignalOf(
input: RequestInfo | URL,
init: RequestInit | undefined,
): AbortSignal | undefined {
if (init && "signal" in init) return init.signal ?? undefined;
if (typeof input !== "string" && !(input instanceof URL)) return input.signal;
return undefined;
}
/**
* Statuses the Fetch Standard defines as null-body. Handing the `Response`
* constructor a body with one of these throws a `TypeError`, so the rebuilt
* response below must pass `null` instead.
*/
const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]);
/**
* Rebuild a `Response` around an already-buffered body.
*
* `url`, `redirected` and `type` are getters with no constructor option, and
* callers do read them — the RFC 8414/OIDC compat wrapper stamps a header
* naming the URL a body came from, and the SDK's discovery reports the
* responding URL in its errors. So they are copied across explicitly rather
* than silently reset to `""` / `false` / `"default"`.
*/
function rebuildResponse(response: Response, body: ArrayBuffer): Response {
const rebuilt = new Response(
NULL_BODY_STATUSES.has(response.status) ? null : body,
{
status: response.status,
statusText: response.statusText,
headers: response.headers,
},
);
for (const key of ["url", "redirected", "type"] as const) {
Object.defineProperty(rebuilt, key, {
value: response[key],
enumerable: true,
configurable: true,
});
}
return rebuilt;
}
/**
* Wrap a `fetch` so every call through it is bounded by `timeoutMs`.
*
@ -87,9 +151,14 @@ function requestUrlOf(input: RequestInfo | URL): string {
* `buildEffectiveAuthFetch` rather than wrapping `this.fetchFn`, which the
* transport is handed directly.
*
* A caller-supplied `init.signal` is preserved — the two are composed with
* `AbortSignal.any`, so an outer cancellation still wins and this wrapper only
* ever *adds* a reason to give up.
* A caller's own signal is preserved — whether it arrived in `init` or embedded
* in a `Request` — and is both forwarded to the inner fetch (composed with
* `AbortSignal.any`) and raced here, so an outer cancellation still wins even on
* a fetch that drops the signal. This wrapper only ever *adds* a reason to give
* up; it never removes the caller's.
*
* The response **body** is buffered under the same deadline, because `fetch`
* resolves on headers and a stalled body would otherwise be unbounded.
*/
export function withOAuthRequestTimeout(
fetchFn: typeof fetch,
@ -106,16 +175,18 @@ export function withOAuthRequestTimeout(
return async (input, init) => {
const url = requestUrlOf(input);
const controller = new AbortController();
// `init.signal` is `AbortSignal | null | undefined`; only an actual signal
// is worth composing.
const callerSignal = init?.signal ?? undefined;
const callerSignal = callerSignalOf(input, init);
const signal = callerSignal
? AbortSignal.any([controller.signal, callerSignal])
: controller.signal;
let timer: ReturnType<typeof setTimeout> | undefined;
let timedOut = false;
const deadline = new Promise<never>((_resolve, reject) => {
let onCallerAbort: (() => void) | undefined;
// The losing side of every race below: it rejects when the budget runs out,
// and when the caller cancels.
const abandoned = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => {
timedOut = true;
// Abort first so a direct fetch is actually cancelled rather than left
@ -124,20 +195,48 @@ export function withOAuthRequestTimeout(
controller.abort(new OAuthRequestTimeoutError(url, budget));
reject(new OAuthRequestTimeoutError(url, budget));
}, budget);
// The caller's cancellation is raced too, not merely forwarded. Forwarding
// it is enough on a direct fetch, but on the browser's `createRemoteFetch`
// path — the one this wrapper exists for — the signal is dropped in
// flight, so an abort would otherwise leave this promise pending for the
// whole budget instead of the outer cancellation winning as documented
// (Copilot). The caller's own `reason` is preserved, so it still sees its
// abort rather than a substituted error.
if (callerSignal) {
if (callerSignal.aborted) {
reject(callerSignal.reason);
} else {
onCallerAbort = () => reject(callerSignal.reason);
callerSignal.addEventListener("abort", onCallerAbort, { once: true });
}
}
});
try {
return await Promise.race([
const response = await Promise.race([
fetchFn(input, { ...init, signal }),
deadline,
abandoned,
]);
// `fetch` resolves once the response *headers* arrive, so stopping here
// would leave the body unbounded: an authorization server can send
// headers, or half a JSON document, and then stall, and the caller's
// `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.
const body = await Promise.race([response.arrayBuffer(), abandoned]);
return rebuildResponse(response, body);
} catch (err) {
// `controller.abort()` above can reject the underlying fetch *before*
// `reject` runs, in which case the race settles with undici's
// `AbortError` instead of ours. Which of the two wins is an ordering
// detail of the fetch implementation, so normalize on the flag rather
// than on the error that surfaced: a timeout must always be reported as
// one, naming the endpoint.
// one, naming the endpoint. A caller-driven abort leaves the flag false
// and so passes through with its own reason intact.
if (timedOut) throw new OAuthRequestTimeoutError(url, budget);
throw err;
} finally {
@ -145,6 +244,12 @@ export function withOAuthRequestTimeout(
is always assigned by the time this runs; the guard exists only because
TypeScript cannot see that. */
if (timer !== undefined) clearTimeout(timer);
// Drop the listener even though it is `once`: a caller signal can outlive
// this request (one `AbortController` per connect attempt, several
// requests through it), and a listener per request would accumulate on it.
if (onCallerAbort && callerSignal) {
callerSignal.removeEventListener("abort", onCallerAbort);
}
}
};
}