UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

435 lines (434 loc) • 24.3 kB
/** * throttleRetry — when an MCP server refuses a request with a response the * retry policy recognises (HTTP 429), 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. * * ── Who may ask again: the app, by declaration (`replay.ts`) ───────────────── * HTTP 429 says only that the request was rate-limited (RFC 6585 §4). It does * not prove the server ran nothing, and `Retry-After` says when to ask again, * not that asking is safe. So permission to send a tool call again is the * APP's, declared per (server, tool) when the client is built (`mcpClient({ * replay })`), and kept apart from what is known of an attempt's effect. Before * every repeat the series asks `permit`; the record keeps its answer. * * - `'compatibility'` (the default, kept in 12.0) keeps today's behaviour by * name: a 429 is repeated for every tool, ASSUMING the server refused it * before running it — and every such repeat records that assumption. * - `'app-authorized'` (opt-in, by name) repeats a 429 only for a tool the * app declared repeatable on it. * - A request that failed in transport without an answer (footprintjs's * `network`: a lost connection, a timeout below the series) is repeated * only when the app declared that for the tool. A 5xx is an answer, never * repeated. A cut or a deadline ends the series. * - Under both, a tool the app declares as making changes is never repeated * automatically unless the server is declared to deduplicate by a key the * call carries — footprintjs's side-effect guard, now wired here. * * The loop takes its question as a {@link RetryDecision}, so the one other * refusal a service documents as one to retry — AgentCore Runtime's "session * operation in progress" — reuses this loop rather than growing a second one. * That decision is applied only by that vendor's own transport, and its rule * carries the adapter's retry assumption (`RetryRule.assumes`) — a policy, * never evidence: its attempts record effect `unknown`, as a 429's do. * * ── 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. * * ── The loop is footprintjs's attempt runner, one series per call ──────────── * Each call runs ONE `attempt()` series (footprintjs, used as it ships). Its * runner makes an attempt record per try. `onRetry`, before each wait, is what * reaches a caller; the RUN gets the records: for a call the agent dispatched, * the series files every attempt and how the request ended into that call's * run (`seriesEvidence.ts`, through the connection's `requestLedger.ts`). An * attempt sends the request once, and an attempt that got a RESPONSE is not an * operation that succeeded: a 503 is delivered as the answer it was, never * retried — and written down as `answered` with its status, never success. * A response a rule's decision recognises is thrown as a refusal that names * its kind, and the policy's `retryOn` accepts those refusals — and a network * failure the app declared for the tool — when the replay rule permits them, * and nothing else: a 5xx ends the series on the attempt it happened in, as it * always did. When the series ends on a refusal (attempts spent, a ceiling * reached, no time left, no permission), the refusal's response is delivered * as the server sent it: the caller sees the 429, never an error of ours. The * call decides what that means for it (`callLifetime.ts`): under * 'app-authorized', a call that genuinely lacked an answer somewhere and does * not complete fails as "may have run". This series reports the HTTP half of * that — an attempt that got no response at all, and the status it delivered; * a received refusal is an answer, its effect still unknown. * * ── A fetch that already retries is JOINED, never wrapped ──────────────────── * A transport whose own fetch runs a series (AgentCore Runtime's session-busy * retry) says how to rebuild itself with one more rule (`joinableRetry`). * `retryingFetch` over such a fetch — what `mcpClient` applies to every http * transport — adds its 429 rule to THAT series instead of starting a second * one around it. One series means one attempt count, one waiting total and one * deadline per call, by construction: the layers can never multiply requests. * The series' FIRST rule holds the call's budget — its `maxAttempts`, * `maxWaitMs` and `maxElapsedMs` bound every attempt and wait, whatever the * reason. A rule that joins keeps its own values as limits on its own kind: * they can stop its retries sooner, never make the call longer. * * ── The wait: a stated one wins, whole ─────────────────────────────────────── * Each retry's wait is `statedWaitFirst(retry-after, exponentialBackoff({ …, * maxDelayMs }))`: the server's `Retry-After` when it sent one, else our * jittered backoff, whose CEILING is capped at `maxDelayMs` before the draw * (so capped waits keep their jitter). The cap is on the SCHEDULE only — * never on a stated wait — so the footprintjs policy's `maxBackoffMs`, * which would cut every wait and send the request again before the server * said it may, is left unset. A stated wait too long for `maxWaitMs` or the * deadline is never shortened either: the series stops, and the 429 is * delivered as the server sent it. * * Pattern: Decorator over `fetch`. Role: Layer-3 tool transport. */ import { type ReplayDeclaration } from './replay.js'; import type { RequestLedger } from './requestLedger.js'; /** * 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<Response>; /** * Which refusal a retry answers: HTTP 429 (`'throttle'`), or AgentCore * Runtime's "session operation in progress" (`'session-busy'`). */ export type RetryKind = 'throttle' | 'session-busy'; /** Reported to {@link ThrottleRetryOptions.onRetry} before each wait. */ export interface ThrottleRetryInfo { /** * Which refusal this retry answers — or `'network'`, a tool call the app * declared repeatable after a request that failed without an answer * (`McpClientOptions.replay`). It is reported to the series' first rule. */ readonly kind: RetryKind | 'network'; /** The attempt about to START. `2` is the first retry. */ readonly attempt: number; /** Ceiling in force for this refusal, 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 ORIGIN of the endpoint being retried — scheme, host and port, nothing * else. Never its path, user info (`user:pass@`), query or fragment: a * token, a credential or a customer id can ride in any of them. The empty * string when the URL has no origin (a relative one). */ readonly url: string; /** * The {@link ThrottleRetryOptions.endpointLabel} you declared for this * endpoint — absent when you declared none. Never derived from the URL. */ readonly endpoint?: string; } /** * Fine-tuning for {@link McpClientOptions.retryOnThrottle}, and — with its own * defaults — for `agentCoreRuntimeMcpTransport`'s `retry`. The defaults below * are `retryOnThrottle`'s. * * Every limit here is the CALL's — unless this retry joins a series a * transport already runs (an AgentCore Runtime transport under `mcpClient`). * Then the transport's own values are the call's limits, and these limit only * the 429 retries inside them: each can stop a 429 retry sooner (a 429 is * retried only while the call has made fewer attempts than `maxAttempts`, has * waited less than `maxWaitMs`, …), never make the call longer. The deadline * that cuts a request in flight is the transport's `maxElapsedMs`. */ 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; /** * A deadline for the whole call, in milliseconds from its start — requests * included, not just waits. A wait that would end past it is never begun * (the refusal is delivered at once), and a request still waiting for its * answer when it passes is cut: the call fails with the deadline's error. * Default: none — `maxWaitMs` is the default rail. */ readonly maxElapsedMs?: number; /** * The ceiling of the first wait our own backoff draws, in milliseconds, when * the server stated none. Default 200. */ readonly baseDelayMs?: number; /** How much each backoff ceiling grows over the last. Default 2. At least 1. */ readonly multiplier?: number; /** * How a backoff wait is drawn below its ceiling: `'equal'` (the default) * waits between half the ceiling and all of it, `'full'` anywhere from zero * up to it, `'none'` exactly it. Jitter is what keeps clients refused at the * same moment from asking again at the same moment. */ readonly jitter?: 'none' | 'equal' | 'full'; /** * The most one of OUR backoff waits may last, in milliseconds. Default: none. * * It caps the scheduled wait's ceiling before the jittered draw, so capped * waits still spread between half the cap and the cap. It never caps a * server's `Retry-After` — unlike footprintjs's `RetryPolicy.maxBackoffMs`, * which caps every wait. A stated wait is waited whole; asking sooner than * the server said would only spend an attempt on a refusal it announced. */ readonly maxDelayMs?: number; /** * A name for this endpoint, declared by you, that `onRetry` reports as * `endpoint` — so a log line can say which server was retried without * carrying any part of its URL past the origin. A dispatched call's saved * attempts name the endpoint by it too, in place of the origin. 1–128 * characters, none of them control characters. Absent, `onRetry` has no * `endpoint` field; the library never makes one up from the URL. */ readonly endpointLabel?: string; /** * A notification before each wait. It is told the refusal's kind, the * attempt about to start, the wait, the endpoint's origin and the label you * declared for it (`endpointLabel`) — never the URL's path, the request's * arguments or the server's answer. It is not told how the call ended. The * contract matches what `withRetry` and `withCredentialRetry` already use: a * decorator at a transport boundary reports to its consumer. * * What the RUN keeps is separate and needs no hook: for a call the agent * dispatched, the series files every attempt — its outcome, its reason, its * wait — and how the request ended, as `agentfootprint.tools.request_attempts` * (the label is reported there as `endpoint`). * * 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". * * A throw from it is isolated: the call goes on as if it had returned. */ 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; /** * What one response says about asking again. `undefined` delivers the response * as it is; a verdict waits, then sends the SAME request once more. */ export interface RetryVerdict { /** * How long the server asked for, in milliseconds, when it said. Absent, the * loop waits its own jittered backoff. */ readonly retryAfterMs?: number; } /** * The one question the loop asks of each response. * * ── The license every decision must hold ───────────────────────────────────── * A decision RECOGNISES a refusal; whether the request is sent again is the * replay rule's (`replay.ts`), never the decision's. It may recognise only a * response that is a refusal — never one that could be the record of * half-done work. The library holds exactly two: {@link throttled} below, a * 429, and AgentCore Runtime's "session operation in progress" refusal, which * lives with that vendor's adapter (`adapters/mcp/agentcoreRuntime/`) and whose * rule carries the adapter's retry assumption (`RetryRule.assumes`). Either * one's effect stays unknown: a 429 says the request was rate-limited, and the * busy body is matched by its code and words, which a server behind the * runtime can send after it ran the call. Each stays ONE small function, so a * refusal has exactly one place it is recognised — and each attempt is written * down as one footprintjs attempt record. */ export type RetryDecision = (response: Response, /** * The request's signal. It fires when the caller cancels or the call's * deadline passes; a decision that reads the body must stop reading then, * and release what it read from. */ signal?: AbortSignal) => RetryVerdict | undefined | Promise<RetryVerdict | undefined>; /** HTTP 429, with the server's own `Retry-After` when it sent one. */ export declare function throttled(response: Response): RetryVerdict | undefined; /** * @internal What a rule's options fall back to. `Infinity` means none: no * deadline (`maxElapsedMs`), no cap on the scheduled wait (`maxDelayMs`). */ export interface RetryDefaults { readonly maxAttempts: number; readonly maxWaitMs: number; readonly maxElapsedMs: number; readonly baseDelayMs: number; readonly multiplier: number; readonly jitter: 'none' | 'equal' | 'full'; readonly maxDelayMs: number; } /** * `retryOnThrottle`'s defaults — unchanged since 8.11.0: 3 attempts, a 200 ms * backoff doubling with equal jitter (100–200 ms, then 200–400 ms), never more * than 10 s of waiting in total. No deadline and no cap on one wait. */ export declare const THROTTLE_RETRY_DEFAULTS: RetryDefaults; /** * @internal One refusal a series may retry: the decision that recognises it, * the kind it reports as, and the limits and backoff it retries by. */ export interface RetryRule { readonly kind: RetryKind; readonly decide: RetryDecision; /** Total attempts while this refusal is the reason to go on. ≥ 1; `Infinity` is no count. */ readonly maxAttempts: number; readonly maxWaitMs: number; readonly maxElapsedMs: number; readonly maxDelayMs: number; readonly baseDelayMs: number; readonly multiplier: number; readonly jitter: 'none' | 'equal' | 'full'; /** The caller's own name for the endpoint, reported as `endpoint`. */ readonly endpointLabel?: string; readonly onRetry?: (info: ThrottleRetryInfo) => void; /** * Set only by an adapter that retries this refusal as its own policy * (`sessionBusy.ts`): what it assumes in doing so. The assumption is recorded * beside each repeat (`permission: { by: 'adapter', assumes }`) — never as * evidence: the attempt's effect stays `unknown`. */ readonly assumes?: 'refused-before-execution'; } /** * @internal A rule from a caller's options — `undefined` when retry is off * (`false`). An option field left out, or set to `undefined`, takes its * default. `maxAttempts` and `maxWaitMs` keep the clamps they always had (below * 1 reads as 1, below 0 as 0). * * @throws TypeError, when the rule is BUILT, for a value the schedule cannot * mean — named by option, never echoing a value that is not a number. */ export declare function retryRule(kind: RetryKind, decide: RetryDecision, config: RetryOnThrottle | undefined, defaults: RetryDefaults, where: string, assumes?: 'refused-before-execution'): RetryRule | undefined; /** * 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. * * When `inner` already runs a retry series of its own (an AgentCore Runtime * transport's fetch), the 429 rule JOINS that series rather than wrapping it, * so the two can never multiply requests — see the module note. * * ── 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 and the backoff. * @throws TypeError when an option is outside its range — here, when the fetch * is built, never at the first call. */ export declare function retryingFetch(inner: ThrottleFetch | undefined, config: RetryOnThrottle | undefined): ThrottleFetch | undefined; /** * @internal The series for a transport whose 429 rule is off (`retryOnThrottle: * false`) while the app's replay declaration still needs one (`replay.ts` · * `needsSeries`): a tool declared repeatable on `'network'`, or the * `'app-authorized'` policy, whose record and "may have run" rest on the series. * `retryOnThrottle: false` turns off the 429 rule and nothing else, so its one * rule is that rule switched off: it recognises no response and holds the * call's budget (`retryOnThrottle`'s defaults). A transport fetch that already * runs a series is joined, as `retryingFetch` joins it. */ export declare function seriesWithoutThrottle(inner: ThrottleFetch | undefined): ThrottleFetch; /** * @internal What one call's series is built from: the rules it asks, in order; * the connection's ledger — which tool call each request belongs to, so the * series can file its attempts into that call's run (`requestLedger.ts`); and * the app's replay declaration — who may send a tool call again (`replay.ts`). * No declaration is the compatibility policy, naming no tool. */ export interface SeriesSpec { readonly rules: readonly RetryRule[]; readonly ledger?: RequestLedger; readonly replay?: ReplayDeclaration; } /** * @internal What a joinable fetch's series can be rebuilt with: one more rule, * or a ledger and the replay declaration (they arrive together, from `mcpClient`). */ export type SeriesJoin = { readonly rule: RetryRule; } | { readonly ledger: RequestLedger; readonly replay?: ReplayDeclaration; }; /** @internal `spec` with `join` in it. */ export declare function joinedSpec(spec: SeriesSpec, join: SeriesJoin): SeriesSpec; /** * @internal Declare that `fetch` runs its retries in ONE series, and how to * rebuild it with one more rule — or with a ledger — in that same series. * `retryingFetch` joins such a fetch instead of wrapping it. A transport whose * public fetch wraps its series (a session applied around it, its answers * rewritten after it) uses this so the rule lands INSIDE that wrapping. */ export declare function joinableRetry(fetch: ThrottleFetch, rebuild: (join: SeriesJoin) => ThrottleFetch): ThrottleFetch; /** * @internal `fetch` rebuilt so its series files each request's attempts through * `ledger`, and asks `replay` before sending a tool call again — or `undefined` * when `fetch` runs no series (retry off), so there is nothing to file and the * caller keeps `fetch` as it is. */ export declare function ledgeredFetch(fetch: ThrottleFetch, ledger: RequestLedger, replay?: ReplayDeclaration): ThrottleFetch | undefined; /** * @internal The one series per call over `inner`, asking `spec.rules` in order — * `inner` itself when there are none. The first rule holds the call's budget; * see the module note. The result is joinable. */ export declare function retryingFetchFor(inner: ThrottleFetch, spec: SeriesSpec): ThrottleFetch; /** * `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;