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:
parent
8dd7bc2920
commit
e89ea4cf19
1 changed files with 260 additions and 16 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue