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
TypeScript
/**
* 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;