UNPKG

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
"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