agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
878 lines • 42.8 kB
JavaScript
"use strict";
/**
* 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.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.parseRetryAfter = exports.retryingFetchFor = exports.ledgeredFetch = exports.joinableRetry = exports.joinedSpec = exports.seriesWithoutThrottle = exports.retryingFetch = exports.retryRule = exports.THROTTLE_RETRY_DEFAULTS = exports.throttled = void 0;
const footprintjs_1 = require("footprintjs");
const abort_js_1 = require("../lib/abort.js");
const backoff_js_1 = require("./backoff.js");
const replay_js_1 = require("./replay.js");
const requestGuard_js_1 = require("./requestGuard.js");
const seriesEvidence_js_1 = require("./seriesEvidence.js");
/** HTTP 429, with the server's own `Retry-After` when it sent one. */
function throttled(response) {
if (response.status !== 429)
return undefined;
const retryAfterMs = parseRetryAfter(response.headers.get('retry-after'));
return retryAfterMs === undefined ? {} : { retryAfterMs };
}
exports.throttled = throttled;
/**
* `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.
*/
exports.THROTTLE_RETRY_DEFAULTS = Object.freeze({
maxAttempts: 3,
maxWaitMs: 10_000,
maxElapsedMs: Infinity,
baseDelayMs: 200,
multiplier: 2,
jitter: 'equal',
maxDelayMs: Infinity,
});
/**
* @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.
*/
function retryRule(kind, decide, config, defaults, where, assumes) {
if (config === false)
return undefined;
const opts = typeof config === 'object' && config !== null ? config : {};
const option = new OptionReader(where);
const rule = {
kind,
decide,
maxAttempts: option.atLeast('maxAttempts', Math.max(1, opts.maxAttempts ?? defaults.maxAttempts)),
maxWaitMs: option.atLeast('maxWaitMs', Math.max(0, opts.maxWaitMs ?? defaults.maxWaitMs)),
maxElapsedMs: option.positive('maxElapsedMs', opts.maxElapsedMs ?? defaults.maxElapsedMs),
maxDelayMs: option.nonNegative('maxDelayMs', opts.maxDelayMs ?? defaults.maxDelayMs),
baseDelayMs: option.finite('baseDelayMs', opts.baseDelayMs ?? defaults.baseDelayMs, 0),
multiplier: option.finite('multiplier', opts.multiplier ?? defaults.multiplier, 1),
jitter: option.jitter(opts.jitter ?? defaults.jitter),
...(opts.endpointLabel !== undefined && { endpointLabel: option.label(opts.endpointLabel) }),
...(opts.onRetry && { onRetry: opts.onRetry }),
...(assumes !== undefined && { assumes }),
};
return rule;
}
exports.retryRule = retryRule;
// ─── The decorator ───────────────────────────────────────────────────
/**
* 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.
*/
function retryingFetch(inner, config) {
const rule = retryRule('throttle', throttled, config, exports.THROTTLE_RETRY_DEFAULTS, 'retryOnThrottle');
if (rule === undefined)
return inner;
const rebuild = inner === undefined ? undefined : JOINS.get(inner);
return rebuild !== undefined
? rebuild({ rule })
: retryingFetchFor(inner ?? platformFetch, { rules: [rule] });
}
exports.retryingFetch = retryingFetch;
/**
* @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.
*/
function seriesWithoutThrottle(inner) {
const rule = retryRule('throttle', recognisesNothing, true, exports.THROTTLE_RETRY_DEFAULTS, 'retryOnThrottle');
const rebuild = inner === undefined ? undefined : JOINS.get(inner);
return rebuild !== undefined
? rebuild({ rule })
: retryingFetchFor(inner ?? platformFetch, { rules: [rule] });
}
exports.seriesWithoutThrottle = seriesWithoutThrottle;
/** A decision that recognises no response — a rule kept for its budget alone. */
const recognisesNothing = () => undefined;
/** @internal `spec` with `join` in it. */
function joinedSpec(spec, join) {
if ('rule' in join)
return { ...spec, rules: [...spec.rules, join.rule] };
return {
...spec,
ledger: join.ledger,
...(join.replay !== undefined && { replay: join.replay }),
};
}
exports.joinedSpec = joinedSpec;
/**
* Fetches that run their retries in one series, each with the way to rebuild
* it with one more rule or a ledger. Module-private: only `joinableRetry` adds
* to it, so a caller cannot make a fetch claim a series it does not run.
*/
const JOINS = new WeakMap();
/**
* @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.
*/
function joinableRetry(fetch, rebuild) {
JOINS.set(fetch, rebuild);
return fetch;
}
exports.joinableRetry = joinableRetry;
/**
* @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.
*/
function ledgeredFetch(fetch, ledger, replay) {
return JOINS.get(fetch)?.({ ledger, ...(replay !== undefined && { replay }) });
}
exports.ledgeredFetch = ledgeredFetch;
/**
* @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.
*/
function retryingFetchFor(inner, spec) {
const { rules, ledger, replay = replay_js_1.COMPATIBILITY } = spec;
const [owner] = rules;
if (owner === undefined)
return inner;
const bound = rules.map((rule) => boundRule(rule, owner));
return joinableRetry((input, init) => new Series(bound, input, init, replay, ledger).run(inner), (join) => retryingFetchFor(inner, joinedSpec(spec, join)));
}
exports.retryingFetchFor = retryingFetchFor;
/** The platform's fetch, looked up at call time so a later polyfill still wins. */
const platformFetch = (input, init) => globalThis.fetch(input, init);
function boundRule(rule, owner) {
const maxDelayMs = Math.min(rule.maxDelayMs, owner.maxDelayMs);
const schedule = (0, backoff_js_1.exponentialBackoff)({
baseDelayMs: rule.baseDelayMs,
multiplier: rule.multiplier,
jitter: rule.jitter,
maxDelayMs,
});
return {
...rule,
maxAttempts: Math.min(rule.maxAttempts, owner.maxAttempts),
maxWaitMs: Math.min(rule.maxWaitMs, owner.maxWaitMs),
maxElapsedMs: Math.min(rule.maxElapsedMs, owner.maxElapsedMs),
maxDelayMs,
// The cap is on the schedule's ceiling only: a stated wait wins, whole.
backoffMs: (0, backoff_js_1.statedWaitFirst)(retryAfterOf, schedule),
};
}
/** The wait a refusal stated (its parsed `Retry-After`), or none. */
function retryAfterOf(error) {
return error instanceof RetryableRefusal ? error.retryAfterMs : undefined;
}
/** What a series names its records' target — a name from code, never a value. */
const SERIES_TARGET = 'mcp-request';
/**
* One call: one `attempt()` series, and what its policy's functions share while
* it runs — the waiting so far, the latest refusal, and, for a call the agent
* dispatched, where its attempts are filed.
*/
class Series {
rules;
input;
init;
declaration;
owner;
startedAt = Date.now();
deadline;
/** The transport's own signal — what the request carried before this series existed. */
signal;
/**
* What ends the series as a CANCELLATION: the transport's signal; the MCP
* SDK cancelling this request (`notifications/cancelled`, seen by the ledger,
* whether or not the request was matched to a call); and, for a call the
* agent dispatched, the call's own — its signal, the client's, its settling.
*/
cancel;
/** Whether a request in flight is cut by more than the transport's own signal. */
cutsInFlight;
/** Where this request's attempts are filed — a call the agent dispatched. */
evidence;
replayable;
/** What this request is, as the app's replay declaration reads it (`replay.ts`). */
target;
/** What this request's series and its call share — through the ledger, else this series' own. */
shared;
attemptNo = 0;
/** When the current attempt was sent — a cut attempt's duration is measured from it. */
attemptSentAt = 0;
waitedMs = 0;
plannedWaitMs = 0;
latest;
/** The current attempt's response status, before the runner records it. */
seen;
/** Why `retryOn` last said no. */
declined;
/** What allowed the repeat `retryOn` last said yes to. */
permitted;
/** The attempt sent and not yet recorded — what a cancellation cuts in flight. */
inFlight;
/**
* The send gate of the pooled operation this request belongs to
* (`requestGuard.ts`) — on the request's `init`, or on its ledger claim.
* Absent for a request no pool sent.
*/
gate;
/** The attempt the gate stopped before it was sent: it has no row. */
gateStopped;
/** The error the latest failed attempt ended with — what a gate stop ends the series on. */
lastError;
constructor(rules, input, init, declaration, ledger) {
this.rules = rules;
this.input = input;
this.init = init;
this.declaration = declaration;
this.owner = rules[0];
this.signal = init?.signal ?? undefined;
this.replayable = isReplayable(input, init);
this.target = (0, replay_js_1.replayTargetOf)(init?.body, declaration);
const { maxElapsedMs } = this.owner;
this.deadline = Number.isFinite(maxElapsedMs) ? this.startedAt + maxElapsedMs : undefined;
const claimed = ledger?.claim(init?.body);
this.gate = (0, requestGuard_js_1.guardOf)(init) ?? claimed?.guard;
const ticket = claimed?.ticket;
// A request no ledger claimed keeps its facts here, with no answer to watch.
this.shared = claimed?.replay ?? new replay_js_1.RequestReplay('', false);
if (declaration.policy === 'app-authorized')
this.shared.appAuthorizedPolicy();
this.cancel = (0, abort_js_1.eitherSignal)(this.signal, ticket?.signal, claimed?.cancelled);
this.cutsInFlight = this.deadline !== undefined || claimed !== undefined;
this.evidence =
ticket === undefined
? undefined
: new seriesEvidence_js_1.SeriesEvidence(ticket.sink, whereOf(rules, input), settlementOf(ticket, this.shared));
}
async run(inner) {
const unwatch = this.watchCancel();
try {
const response = await (0, footprintjs_1.attempt)((t) => this.send(inner, t), this.policy(), this.options());
this.shared.deliveredStatus(response.status);
this.evidence?.delivered();
return response;
}
catch (error) {
return this.settle(error);
}
finally {
unwatch();
}
}
/**
* File a cancellation the MOMENT it lands. A cancelled run stops waiting for
* its stage at once; a record filed when this series later unwinds would land
* after the run was saved. The runner records nothing for the attempt it
* abandons then, or for a wait it was cut out of, so this is also the only
* place those are written down.
*/
watchCancel() {
const { cancel } = this;
if (cancel === undefined || this.evidence === undefined || cancel.aborted) {
return () => undefined;
}
const onAbort = () => this.fileCancellation();
cancel.addEventListener('abort', onAbort, { once: true });
return () => cancel.removeEventListener('abort', onAbort);
}
/** A cancellation, written down: the attempt it cut in flight, or the wait. */
fileCancellation() {
const { evidence, inFlight } = this;
if (inFlight !== undefined) {
evidence?.cutInFlight(inFlight, Math.max(0, Date.now() - this.attemptSentAt));
}
else {
evidence?.cancelled();
}
}
/**
* The owner's budget as footprintjs reads it; the rest is this series'
* `retryOn` and backoff. `maxBackoffMs` is deliberately unset: it would cap a
* stated `Retry-After` too, and send the request before the server said.
*/
policy() {
const { maxAttempts } = this.owner;
return {
// A whole number for the runner; "no count" is a count no call reaches.
attempts: Number.isFinite(maxAttempts) ? Math.ceil(maxAttempts) : Number.MAX_SAFE_INTEGER,
retryOn: (error) => this.retryOn(error),
// `retryOn` planned the wait — it needs the length to hold the ceilings.
backoffMs: () => this.plannedWaitMs,
};
}
options() {
return {
target: SERIES_TARGET,
...(this.cancel !== undefined && { signal: this.cancel }),
...(this.deadline !== undefined && { deadline: this.deadline }),
onAttempt: (record) => this.report(record),
};
}
/**
* One attempt: the request, sent once, and its answer classified. A refusal
* a rule recognises is thrown for the runner to decide on.
*
* With no deadline of ours and no call to answer to, the request carries the
* caller's own `init`, exactly as before this series existed. Otherwise the
* runner can cut the attempt before it is ANSWERED — the deadline, or the
* dispatched call's cancellation — and an answer is not in until it has been
* classified, which can mean reading its body (a session-busy reply is JSON
* in a 200). So the cut stays connected through the classification: a stall
* in the request or in that body stops the request and releases the body.
* Only then does it let go: an answer handed back to the caller keeps
* streaming for as long as the caller's own signal allows.
*/
async send(inner, t) {
// A pooled operation's send gate, asked before every attempt — the first
// was asked by the pool's own fetch an instant before the series began; a
// later one is asked here, after the wait, before anything is sent. A
// binding that went stale or an operation out of time sends nothing more:
// the attempt is never sent, so it has no row (`report`).
if (t.attempt > 1 && this.gate !== undefined) {
try {
this.gate.check();
}
catch (refusal) {
this.gateStopped = t.attempt;
throw refusal;
}
}
this.attemptNo = t.attempt;
// From here the request is SENT — counted, as a row, however the attempt ends.
this.inFlight = t.attempt;
this.attemptSentAt = Date.now();
this.shared.sentOne();
this.seen = undefined;
// The previous attempt's refusal will not be delivered: release its body, so
// a keep-alive connection is not pinned by an unread stream.
this.supersede();
const cut = this.cutsInFlight ? (0, abort_js_1.linkedController)(t.signal) : undefined;
const signal = (0, abort_js_1.eitherSignal)(this.signal, cut?.controller.signal);
try {
const sent = cut === undefined ? this.init : { ...this.init, ...(signal && { signal }) };
// The gate rides every attempt's `init`, so the vending fetch and the AWS
// command ask it after their own waits (`requestGuard.ts`).
const init = this.gate !== undefined && (0, requestGuard_js_1.guardOf)(sent) === undefined ? (0, requestGuard_js_1.withGuard)(sent, this.gate) : sent;
const pending = inner(this.input, init);
// An attempt this series gives up on — a cut, a deadline — may still be
// running underneath: it stays counted under the operation until it settles.
this.gate?.track(pending);
const response = await pending;
this.seen = { attempt: t.attempt, status: response.status };
const refused = await this.recognise(response, signal);
if (refused === undefined)
return response;
this.latest = refused;
throw refused.refusal;
}
finally {
cut?.unlink();
}
}
/**
* The first rule whose decision says "again", or none. A decision the
* request's signal cut short leaves an answer nobody will read: it is
* released here, so its connection is not held open.
*/
async recognise(response, signal) {
try {
for (const rule of this.rules) {
const verdict = await rule.decide(response, signal);
if (verdict === undefined)
continue;
return { refusal: new RetryableRefusal(rule.kind, verdict), rule, verdict, response };
}
return undefined;
}
catch (error) {
discardBody(response);
throw error;
}
}
/**
* The runner's `retryOn`: may this request be sent again after this attempt?
* First WHO may say so (`replay.ts` · `permit`: the app's declaration, the
* adapter's own retry assumption, the compatibility policy's assumption — and
* the side-effect guard before all three), then whether the budget allows
* it. Plans the wait as it answers, because the ceilings need its length, and
* keeps what it decided, for the record.
*/
retryOn(error) {
this.plannedWaitMs = 0;
this.permitted = undefined;
// An attempt the gate stopped was never sent: nothing follows it, and the
// failure the series ends on stays the last attempt that was.
if (this.gateStopped !== undefined)
return this.decline('not-permitted');
this.lastError = error;
const refused = this.latest;
const refusal = refused !== undefined && error === refused.refusal ? refused : undefined;
// What failed: a refusal a rule recognised, or a request that failed in
// transport (footprintjs's `network`). Anything else — a 5xx is an answer,
// not a failure — is never sent again.
const failure = refusal === undefined ? (0, replay_js_1.thrownFailure)(error) : refusalOf(refusal.rule);
if (failure === undefined)
return this.decline('not-retryable');
// The pooled operation's gate, asked before a repeat is planned: a stale
// binding or an operation out of time permits nothing more to be sent.
if (!this.gateAllows())
return this.decline('not-permitted');
const decision = (0, replay_js_1.permit)(this.declaration, this.target, failure);
if (!decision.repeat)
return this.decline(decision.declined);
// A body we cannot send twice makes a retry a different request. Rare for
// MCP (JSON strings), but silently replaying half a stream would be worse
// than surfacing the refusal.
if (!this.replayable)
return this.decline('not-replayable');
const rule = refusal?.rule ?? this.owner;
if (this.attemptNo >= rule.maxAttempts)
return this.decline('exhausted');
// The stated wait whole, else the schedule under `maxDelayMs`.
const waitMs = rule.backoffMs(this.attemptNo, error);
// The rail on Retry-After: never let a server talk us into a wait that
// breaks the caller's budget. It stops the series; it never shortens the
// wait. Past the deadline, the runner stops it the same way (`no-time-left`).
if (this.waitedMs + waitMs > rule.maxWaitMs)
return this.decline('wait-limit');
// A joined rule's own, sooner deadline. The owner's is the runner's to hold.
if (rule.maxElapsedMs < this.owner.maxElapsedMs &&
this.elapsedAfter(waitMs) > rule.maxElapsedMs) {
return this.decline('no-time-left');
}
this.declined = undefined;
this.plannedWaitMs = waitMs;
this.permitted = decision.permission;
return true;
}
decline(reason) {
this.declined = reason;
return false;
}
/** Whether the pooled operation's gate lets another attempt be sent. True with no gate. */
gateAllows() {
if (this.gate === undefined)
return true;
try {
this.gate.check();
return true;
}
catch {
return false;
}
}
elapsedAfter(waitMs) {
return Date.now() - this.startedAt + waitMs;
}
/**
* Each record as the runner ends an attempt: written down for the call's run
* first, then — for a retry — counted and reported to its rule. The record
* goes first because `onRetry` is the caller's, and a throw from it must not
* cost the run its evidence.
*/
report(record) {
// Stopped by the gate before it was sent: no row, nothing to count.
if (record.attempt === this.gateStopped)
return;
const refused = this.latest;
if (this.inFlight === record.attempt)
this.inFlight = undefined;
const seen = this.seenOf(record, refused);
const facts = this.factsOf(record);
// An attempt that got no response at all — a lost connection, a timeout,
// a cut — genuinely lacked an answer. A received refusal (a 429, a busy
// reply) is an answer: its effect is unknown, but the server answered.
if (record.outcome !== 'ok' && seen?.refusal === undefined)
this.shared.noAnswer();
this.evidence?.attempt(record, seen, this.declined, facts);
if (record.next !== 'retry')
return;
const waitMs = record.delayMs ?? 0;
this.waitedMs += waitMs;
// A refusal is reported to the rule that recognised it; a declared repeat
// after a network failure, to the series' first rule.
const rule = refused?.rule ?? this.owner;
const retryAfterMs = refused?.verdict.retryAfterMs;
rule.onRetry?.({
kind: refused?.rule.kind ?? 'network',
attempt: record.attempt + 1,
maxAttempts: rule.maxAttempts,
waitMs,
...(retryAfterMs !== undefined && { retryAfterMs }),
url: originOf(this.input),
...(rule.endpointLabel !== undefined && { endpoint: rule.endpointLabel }),
});
}
/**
* What the series knows of the attempt `record` ends beyond the record — the
* three facts kept apart (`replay.ts`). Its effect: `unknown` for every
* attempt that was sent and did not answer — a refusal included, whatever an
* adapter assumes of it; for an answer, nothing yet — the call's verdict
* decides, when the record is filed. What permitted the next attempt, when
* there is one. How long it took.
*/
factsOf(record) {
const permission = record.next === 'retry' ? this.permitted : undefined;
return {
...(record.outcome !== 'ok' && { effect: 'unknown' }),
...(permission !== undefined && { permission }),
durationMs: record.durationMs,
};
}
/** What this series saw of the attempt `record` ends: its status, and the refusal it threw. */
seenOf(record, refused) {
const seen = this.seen;
if (seen === undefined || seen.attempt !== record.attempt)
return undefined;
// `latest` is cleared as each attempt starts, so a refusal here is this attempt's.
const { kind } = refused?.rule ?? {};
const retryAfterMs = refused?.verdict.retryAfterMs;
return {
status: seen.status,
...(kind !== undefined && {
refusal: { kind, ...(retryAfterMs !== undefined && { retryAfterMs }) },
}),
};
}
/** How the call ends when the series did not answer. */
settle(error) {
// The caller's cancellation wins: the call ends with their reason, and the
// refusal nobody is waiting for any more is released.
if (this.cancel?.aborted) {
this.fileCancellation();
this.supersede();
throw (0, abort_js_1.abortErrorOf)(this.cancel);
}
// The series ended on a refusal — attempts spent, a ceiling reached, no
// time left, or a pooled operation's gate that permits nothing more. The
// server's answer is delivered as it sent it.
const stopped = this.gateStopped !== undefined;
// A gate stop is the decision the record ends on — never a deadline the
// series did not reach, never an attempt that was not sent.
const stoppedBy = stopped ? 'not-permitted' : undefined;
if (this.latest !== undefined && (error === this.latest.refusal || stopped)) {
this.shared.deliveredStatus(this.latest.response.status);
this.evidence?.delivered(stoppedBy);
return this.latest.response;
}
this.evidence?.threw(this.deadline !== undefined && Date.now() >= this.deadline, stoppedBy);
// A gate stop after a failure with no answer ends on that failure: the
// attempt that was sent is the one the caller hears about.
throw stopped ? this.lastError : error;
}
supersede() {
if (this.latest !== undefined)
discardBody(this.latest.response);
this.latest = undefined;
}
}
/** A refusal a rule recognised, as the permission rule reads it: its kind, and an adapter's assumption. */
function refusalOf(rule) {
return { kind: rule.kind, ...(rule.assumes !== undefined && { assumes: rule.assumes }) };
}
/**
* Where a dispatched call's record waits for the call to settle: the ticket's
* signal fires when it settles or is cancelled. A ticket without one (a test's)
* files at once.
*/
function settlementOf(ticket, replay) {
return ticket.signal === undefined ? undefined : { signal: ticket.signal, replay };
}
/** Where a series' requests go, as its evidence may say it: a label the app declared, or the origin. */
function whereOf(rules, input) {
const label = rules.find((rule) => rule.endpointLabel !== undefined)?.endpointLabel;
if (label !== undefined)
return { endpoint: label };
const origin = originOf(input);
return origin === '' ? {} : { origin };
}
/**
* A response a rule recognised as a refusal (a 429, a session-busy reply), thrown
* so the attempt runner decides on it. It carries only what an attempt record may
* keep: a fixed sentence, its kind as `code`, a 429's status and the wait the
* server stated. Never the server's text, the request or the response — the
* series holds the response, not the error.
*/
class RetryableRefusal extends Error {
name = 'RetryableRefusal';
code;
/** A throttle's status, so footprintjs's `errorCategory` reads it as rate-limited. */
status;
retryAfterMs;
constructor(kind, verdict) {
super(REFUSAL_SENTENCES[kind]);
this.code = kind;
this.status = kind === 'throttle' ? 429 : undefined;
this.retryAfterMs = verdict.retryAfterMs;
}
}
const REFUSAL_SENTENCES = {
throttle: 'The server answered with HTTP 429, a response the retry policy recognises as a refusal. It is recorded as sent, which is not proof that nothing ran.',
'session-busy': 'AgentCore Runtime answered that its session was starting or stopping, a response the retry policy recognises as a refusal. It is recorded as sent, which is not proof that nothing ran.',
};
// ─── Helpers ─────────────────────────────────────────────────────────
/** Reads one rule's numeric options, refusing by name what the schedule cannot mean. */
class OptionReader {
where;
constructor(where) {
this.where = where;
}
/** A number that is not NaN (`Infinity` allowed) — the clamps already applied. */
atLeast(name, value) {
if (typeof value !== 'number' || Number.isNaN(value))
this.refuse(name, 'a number');
return value;
}
/** A number > 0; `Infinity` is none. */
positive(name, value) {
if (!(typeof value === 'number' && value > 0))
this.refuse(name, 'a number > 0');
return value;
}
/** A number ≥ 0; `Infinity` is none. */
nonNegative(name, value) {
if (!(typeof value === 'number' && value >= 0))
this.refuse(name, 'a number ≥ 0');
return value;
}
/** A finite number ≥ `min`. */
finite(name, value, min) {
if (!(typeof value === 'number' && Number.isFinite(value) && value >= min)) {
this.refuse(name, `a finite number ≥ ${min}`);
}
return value;
}
jitter(value) {
if (value === 'none' || value === 'equal' || value === 'full')
return value;
return this.refuse('jitter', "'none', 'equal' or 'full'");
}
/** A caller's label: 1–128 characters, no control character. Never echoed in a refusal. */
label(value) {
if (typeof value === 'string' &&
value.length >= 1 &&
value.length <= 128 &&
!hasControl(value)) {
return value;
}
return this.refuse('endpointLabel', 'a string of 1–128 characters with no control character');
}
refuse(name, wanted) {
throw new TypeError(`${this.where}: \`${name}\` must be ${wanted}.`);
}
}
/**
* `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.
*/
function parseRetryAfter(value) {
if (!value)
return undefined;
const trimmed = value.trim();
if (/^\d+$/.test(trimmed))
return Number(trimmed) * 1000;
const when = Date.parse(trimmed);
if (Number.isNaN(when))
return undefined;
return Math.max(0, when - Date.now());
}
exports.parseRetryAfter = parseRetryAfter;
/** Can this exact request be sent again? A stream body cannot be rewound. */
function isReplayable(input, init) {
// Defensive: the declared type says otherwise, but a `Request` carries its
// own (possibly already-consumed) body and must never be replayed.
if (typeof Request !== 'undefined' && input instanceof Request)
return false;
const body = init?.body;
if (body === undefined || body === null)
return true;
if (typeof body === 'string')
return true;
if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams)
return true;
if (typeof Blob !== 'undefined' && body instanceof Blob)
return true;
if (body instanceof ArrayBuffer || ArrayBuffer.isView(body))
return true;
return false;
}
/** Best-effort release of an unread response body. Never throws. */
function discardBody(response) {
try {
void response.body?.cancel?.();
}
catch {
// A body that refuses to cancel is not worth failing a retry over.
}
}
/**
* The endpoint's ORIGIN, for the `onRetry` report: scheme, host and port. The
* path, user info, query and fragment are all dropped — a token, a credential
* or a customer id can ride in any of them. A URL that does not parse has no
* origin to report: the empty string.
*/
function originOf(input) {
try {
const url = new URL(typeof input === 'string' ? input : input.toString());
return url.host === '' ? '' : `${url.protocol}//${url.host}`;
}
catch {
return '';
}
}
/** Whether `text` holds a control character (C0, DEL or C1). */
function hasControl(text) {
for (let i = 0; i < text.length; i++) {
const code = text.charCodeAt(i);
if (code < 0x20 || (code >= 0x7f && code <= 0x9f))
return true;
}
return false;
}
//# sourceMappingURL=throttleRetry.js.map