fix: cancellable compat probes and a validated budget (#2319 round 16)
Two real bugs and two stale rationales.
The OIDC fallback probes were issued with `{ headers }` and no signal,
so a caller that aborted after the initial 404 got a timeout reported
in place of its own cancellation, while the probe ran on to its own
deadline. The probes now carry the caller's signal — via the shared
`callerSignalOf`, so the precedence rule has one definition — a
pre-aborted caller is not probed at all, and an abort is rethrown
rather than folded back into the preceding response.
`Math.max(0, Math.round(timeoutMs))` let NaN and Infinity through.
`setTimeout(fn, NaN)` fires immediately, and a delay above 2**31-1
overflows a 32-bit signed int and falls back to 1ms — so a caller
asking for a very large budget, or one whose arithmetic produced NaN,
got an immediate OAuth timeout. `normalizeBudget` falls back to the
default on non-finite and clamps to the timer's range; the route's
envelope-supplied deadline gets the same clamp, but ignores a
non-finite value outright since a malformed deadline reads safest as
no deadline.
The wire-marker docblock and the matching route test still said a
backend deadline is the only one on the transport-internal discovery
path, which round 11 changed. Both now give the real reason the marker
matters: both ends run the same budget, and it is the backend winning
the race — a backgrounded tab throttling setTimeout — that must not
decide whether the error names its endpoint.
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
93965936ba
commit
e234343702
1 changed files with 185 additions and 13 deletions
|
|
@ -83,6 +83,33 @@ export class OAuthRequestTimeoutError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The largest delay `setTimeout` can actually schedule. Past `2 ** 31 - 1` the
|
||||
* delay overflows a 32-bit signed integer and Node falls back to **1ms**, so an
|
||||
* `Infinity` or an over-large budget produces an *immediate* timeout — the exact
|
||||
* opposite of what the caller asked for (Copilot).
|
||||
*/
|
||||
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
||||
|
||||
/**
|
||||
* Coerce a caller's budget into something `setTimeout` can honour and the error
|
||||
* can honestly report.
|
||||
*
|
||||
* - Non-finite (`NaN`, `±Infinity`) falls back to the default. `NaN` survives
|
||||
* `Math.max(0, Math.round(...))` and `setTimeout(fn, NaN)` fires immediately,
|
||||
* so a caller whose arithmetic produced a `NaN` would otherwise see every
|
||||
* OAuth request fail at once with "timed out after NaNms".
|
||||
* - Above `MAX_TIMER_DELAY_MS` it clamps rather than overflowing to 1ms.
|
||||
* - Negative clamps to 0, which is a real budget (fire on the next tick), not
|
||||
* an error.
|
||||
*
|
||||
* Rounded because the budget is *reported* — see the note at the call site.
|
||||
*/
|
||||
function normalizeBudget(timeoutMs: number): number {
|
||||
if (!Number.isFinite(timeoutMs)) return DEFAULT_OAUTH_REQUEST_TIMEOUT_MS;
|
||||
return Math.min(MAX_TIMER_DELAY_MS, Math.max(0, Math.round(timeoutMs)));
|
||||
}
|
||||
|
||||
/**
|
||||
* How a proxy-side deadline is carried back to the browser (#2319).
|
||||
*
|
||||
|
|
@ -90,10 +117,12 @@ export class OAuthRequestTimeoutError extends Error {
|
|||
* `createRemoteFetch` turns any non-OK answer into a plain `Error` — so a
|
||||
* timeout enforced by the *backend* would arrive as an untyped error and the
|
||||
* `instanceof OAuthRequestTimeoutError` checks downstream would all be false
|
||||
* (Copilot). That matters most exactly where there is no client-side wrapper to
|
||||
* have fired first: the discovery the SDK runs from inside the transport. So
|
||||
* the route stamps this marker and `createRemoteFetch` reconstructs the typed
|
||||
* error from it.
|
||||
* (Copilot). Both ends run the same budget, so the client's race usually
|
||||
* settles first; this 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. Which of two equal deadlines happened to fire
|
||||
* must not decide whether the error names its endpoint. So the route stamps
|
||||
* this marker and `createRemoteFetch` reconstructs the typed error from it.
|
||||
*/
|
||||
export const OAUTH_TIMEOUT_WIRE_CODE = "oauth_request_timeout";
|
||||
|
||||
|
|
@ -204,7 +233,7 @@ function requestUrlOf(input: RequestInfo | URL): string {
|
|||
* `Request`'s signal rather than clear it (Copilot). An explicit `null` is the
|
||||
* genuine "no signal" override and is honoured as one.
|
||||
*/
|
||||
function callerSignalOf(
|
||||
export function callerSignalOf(
|
||||
input: RequestInfo | URL,
|
||||
init: RequestInit | undefined,
|
||||
): AbortSignal | undefined {
|
||||
|
|
@ -324,7 +353,7 @@ export function withOAuthRequestTimeout(
|
|||
// public field. Rounding rather than flooring so a caller's own
|
||||
// whole-millisecond timeout survives the trip through that clock and is still
|
||||
// the number the message names.
|
||||
const budget = Math.max(0, Math.round(timeoutMs));
|
||||
const budget = normalizeBudget(timeoutMs);
|
||||
|
||||
return async (input, init) => {
|
||||
const url = requestUrlOf(input);
|
||||
|
|
|
|||
Loading…
Reference in a new issue