/** * throttleRetry — when an MCP server says "too many requests", wait and ask again. * * ── Why this exists ────────────────────────────────────────────────────────── * Managed MCP gateways enforce per-principal rate limits. A throttled * `tools/call` is not a fault, it is the endpoint working as designed — and * before 8.11.0 it arrived at the model as a thrown tool error, which the model * reads as **"this tool is broken"**. It then apologises, picks a different * tool, or invents an answer. A designed, transient, self-clearing condition * was being turned into a wrong answer. * * ── Why retrying a 429 is safe, and why the policy is 429-ONLY ─────────────── * **A 429 is a pre-execution rejection.** The rate limiter refused the request * at the edge; the server never ran the tool. Retrying therefore cannot * double-execute anything — which is exactly what is NOT true of a 500 or a * timeout, where the call may have half-run and a retry could charge a card * twice. * * That asymmetry is the whole license for this module, so the policy must never * widen. Only HTTP 429 retries here. Not 5xx, not network errors, not timeouts * — those belong to a caller who knows whether the specific tool is idempotent, * and this seam does not. A property test pins the boundary. * * ── Why it lives at the fetch seam and not around `Tool.execute` ───────────── * Because `Retry-After` is only reachable here. The MCP SDK reads the response, * throws `StreamableHTTPError(status, text)` and drops the `Response` — so by * the time a throttle reaches `Tool.execute` the header is gone and the status * survives only as `err.code`. A retry wrapper around the tool could guess a * backoff; only a wrapper around `fetch` can honour the number the server * actually asked for. Honouring it is also what keeps a fleet of agents from * synchronising into a thundering herd. * * ── Ordering: retry OUTSIDE, signing and vending INSIDE ────────────────────── * The wrapper composes over the caller's `fetch` (a SigV4/DPoP signer) or over * `createVendingFetch` (a per-request credential). Sitting outside means every * attempt is signed afresh and vended afresh — signatures expire, and a token * that would have died during the wait is simply never the one reused. The * gateway secrecy invariant is untouched: each attempt vends, applies, drops. * * Pattern: Decorator over `fetch`. Role: Layer-3 tool transport. */ /** * The fetch shape the MCP SDK's `fetch` hook uses — deliberately NARROWER * than `gatewayTransport`'s `FetchLike` (no `Request` input), because that is * exactly what `StreamableHTTPClientTransport` declares and what * `McpHttpTransport.fetch` promises a consumer. A wider `FetchLike` still * passes in: a function that accepts more inputs satisfies a contract that * supplies fewer. */ export type ThrottleFetch = (input: string | URL, init?: RequestInit) => Promise; /** Reported to {@link ThrottleRetryOptions.onRetry} before each wait. */ export interface ThrottleRetryInfo { /** The attempt about to START. `2` is the first retry. */ readonly attempt: number; /** Ceiling in force for this call, including the first attempt. */ readonly maxAttempts: number; /** How long we are about to wait, in milliseconds. */ readonly waitMs: number; /** What the server's `Retry-After` asked for, when it sent one. Absent * means the wait below is our own jittered backoff. */ readonly retryAfterMs?: number; /** The endpoint being retried. Carries no credential: the URL is the URL * the transport was built with, never a signed or tokenised variant. */ readonly url: string; } /** Fine-tuning for {@link McpClientOptions.retryOnThrottle}. */ export interface ThrottleRetryOptions { /** Total attempts including the first. Default 3. Minimum 1. */ readonly maxAttempts?: number; /** * Ceiling on the TOTAL time spent waiting across one call, in * milliseconds. Default 10000. * * This is the safety rail on `Retry-After`: a server (or a misconfigured * proxy) that asks for a 24-hour wait does not get one — when the next wait * would push past this ceiling we stop retrying and let the throttle * surface, so the worst case is a slower failure and never a hung agent. */ readonly maxWaitMs?: number; /** * Called before each wait. The per-attempt visibility hook, matching the * contract `withRetry` and `withCredentialRetry` already use — a decorator * at a transport boundary reports to its consumer, and emits no events of * its own. * * A retried call is not invisible without this: the wait lands inside the * tool's `stream.tool_end` `durationMs`, and an exhausted retry still * reaches the model with `error: true`. This hook is what turns "that call * was slow" into "that call was throttled twice". */ readonly onRetry?: (info: ThrottleRetryInfo) => void; } /** * How a throttled request is handled. `true` (the default) means bounded * retry with default settings; `false` disables it entirely. */ export type RetryOnThrottle = boolean | ThrottleRetryOptions; /** * Wrap a `fetch` so HTTP 429 responses are retried, honouring `Retry-After`. * * Returns `inner` untouched when retry is disabled — including `undefined`, * so a transport that passed no custom fetch keeps passing none and its * behaviour is byte-identical to before this existed. * * ── Why this is public ─────────────────────────────────────────────────────── * `mcpClient({ transport })` applies it for you, ON by default. `mcpClient({ * connection })` cannot: the library builds no transport on that arm, so there * is no `fetch` of its own to wrap — and a browser consumer, who is the main * reason that arm exists, would silently lose the 429 handling Node gets for * free. That asymmetry is invisible from the outside, which is why the answer * is to hand over the same implementation rather than to document the gap: * * ```ts * import { retryingFetch } from 'agentfootprint/providers'; * import { StreamableHTTPClientTransport } * from '@modelcontextprotocol/sdk/client/streamableHttp.js'; * * const transport = new StreamableHTTPClientTransport(new URL('/mcp', location.href), { * fetch: retryingFetch(undefined, { maxAttempts: 5 }), * }); * ``` * * @param inner the fetch to wrap. `undefined` means the global `fetch`, * resolved at CALL time so a later polyfill still wins. * @param config `true` / `undefined` for the defaults, `false` to get `inner` * back unchanged, or an object to tune the ceilings. */ export declare function retryingFetch(inner: ThrottleFetch | undefined, config: RetryOnThrottle | undefined): ThrottleFetch | undefined; /** * `Retry-After` in either legal form: delta-seconds (`"2"`) or an HTTP-date * (`"Wed, 21 Oct 2026 07:28:00 GMT"`). Anything else is treated as absent so a * malformed header degrades to our own backoff rather than to a wrong wait. */ export declare function parseRetryAfter(value: string | null | undefined): number | undefined; //# sourceMappingURL=throttleRetry.d.ts.map