UNPKG

agentfootprint

Version:

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

158 lines • 7.67 kB
/** * backoff — builders of a `RetryPolicy.backoffMs` function: an exponential * schedule, and a wait the server stated winning over it. * * STAND-IN, TO BE DELETED. footprintjs is adding these two helpers to its * attempt runner (`attempt/backoff.ts`): `exponentialBackoff({ baseDelayMs, * multiplier, jitter, maxDelayMs, random })` and * `statedWaitFirst(readStatedWaitMs, inner)`, with these names, signatures and * laws. Once a footprintjs release * ships them, import them in `throttleRetry.ts` and delete this file; nothing * else changes. * * Pattern: Two pure builders. Each returns `(attempt, error?) => ms`. * Role: The wait `throttleRetry.ts` plans for each retry. * Emits: N/A. * * ── THE LAW ────────────────────────────────────────────────────────────── * `exponentialBackoff`: after failed attempt `n` (1-based) the ceiling is * * c = min(maxDelayMs, baseDelayMs × multiplier^(n − 1)) * (saturating at MAX_SAFE_INTEGER) * * and, for `r = random()` in [0, 1]: * * 'none' c `random` is never called * 'equal' min(c, c/2 + r × c/2) never below half the ceiling * 'full' r × c anywhere from 0 up to it * * Waits are exact milliseconds — nothing is rounded. `random` defaults to * `Math.random`, taken when the function is BUILT; a fixed source makes a test * exact. A `random()` answering outside [0, 1] throws when asked. * * `statedWaitFirst`: when `readStatedWaitMs(error)` answers a finite number * ≥ 0, that is the wait; otherwise `inner` decides. A reader that throws or * answers anything else states nothing, so a malformed `Retry-After` falls back * to the schedule rather than to a wrong wait. * * `maxDelayMs` caps the CEILING, before the draw, so capped waits keep their * jitter: at the cap, equal jitter still spreads between c/2 and c. Capped * after the draw instead, every wait past twice the cap would BE the cap, and * clients refused together would retry in lockstep. A stated wait is never * capped: `statedWaitFirst` hands it back whole. */ const JITTERS = ['none', 'equal', 'full']; /** Where a ceiling saturates: a wait is never `Infinity` and never `NaN`. */ const MAX_WAIT_MS = Number.MAX_SAFE_INTEGER; /** * An exponential schedule: the wait after failed attempt `n` is drawn under * `baseDelayMs × multiplier^(n − 1)` by the declared jitter. * * @throws when built: a `TypeError` for an option of the wrong type, a * `RangeError` for one out of range — each naming the option. */ export function exponentialBackoff(spec) { const { baseDelayMs, multiplier, jitter, maxDelayMs, random } = readSpec(spec); return (attempt) => { // The cap bounds the ceiling, so the draw below still spreads under it. const ceiling = Math.min(maxDelayMs, ceilingOf(baseDelayMs, multiplier, stepOf(attempt))); if (jitter === 'none') return ceiling; const r = draw(random); // Never above the ceiling: halving a subnormal ceiling can round up. return jitter === 'equal' ? Math.min(ceiling, ceiling / 2 + (r * ceiling) / 2) : r * ceiling; }; } /** * A wait the outside system stated wins; otherwise `inner` — a backoff * function, or a fixed number of milliseconds — decides. * * @throws when built: a `TypeError` for a reader that is not a function, or an * `inner` that is neither a function nor a finite number ≥ 0. */ export function statedWaitFirst(readStatedWaitMs, inner) { if (typeof readStatedWaitMs !== 'function') { throw new TypeError(`statedWaitFirst: readStatedWaitMs must be a function (got ${shown(readStatedWaitMs)})`); } if (typeof inner !== 'function') { checkNumber('statedWaitFirst: inner', inner, 0, 'a backoff function or a finite number >= 0'); } return (attempt, error) => { const stated = statedWaitOf(readStatedWaitMs, error); if (stated !== undefined) return stated; return typeof inner === 'function' ? inner(attempt, error) : inner; }; } /** Each field read once, then checked: what is checked is exactly what the function keeps. */ function readSpec(spec) { if (spec === null || typeof spec !== 'object') { throw new TypeError(`exponentialBackoff: the spec must be an object ({ baseDelayMs, multiplier?, jitter?, maxDelayMs?, random? }) (got ${shown(spec)})`); } const { baseDelayMs, multiplier = 2, jitter = 'none', maxDelayMs = Infinity, random = Math.random, } = spec; checkNumber('exponentialBackoff: baseDelayMs', baseDelayMs, 0, 'a finite number >= 0'); checkNumber('exponentialBackoff: multiplier', multiplier, 1, 'a finite number >= 1'); if (typeof maxDelayMs !== 'number') { throw new TypeError(`exponentialBackoff: maxDelayMs must be a number >= 0 (got ${shown(maxDelayMs)})`); } if (!(maxDelayMs >= 0)) { throw new RangeError(`exponentialBackoff: maxDelayMs must be a number >= 0 (got ${shown(maxDelayMs)})`); } if (!JITTERS.includes(jitter)) { throw new RangeError(`exponentialBackoff: jitter must be 'none', 'equal' or 'full' (got ${shown(jitter)})`); } if (typeof random !== 'function') { throw new TypeError(`exponentialBackoff: random must be a function answering in [0, 1] (got ${shown(random)})`); } return { baseDelayMs, multiplier, jitter, maxDelayMs, random }; } /** A finite number ≥ `min`, or the error naming `field`: a `TypeError` for a non-number, else a `RangeError`. */ function checkNumber(field, value, min, rule) { if (typeof value !== 'number') throw new TypeError(`${field} must be ${rule} (got ${shown(value)})`); if (!Number.isFinite(value) || value < min) { throw new RangeError(`${field} must be ${rule} (got ${shown(value)})`); } } /** The attempt as a schedule step: its whole part, at least 1 (anything not a finite number reads as 1). */ function stepOf(attempt) { return typeof attempt === 'number' && Number.isFinite(attempt) && attempt >= 1 ? Math.floor(attempt) : 1; } /** `baseDelayMs × multiplier^(step − 1)`, saturating at {@link MAX_WAIT_MS}. */ function ceilingOf(baseDelayMs, multiplier, step) { if (baseDelayMs === 0) return 0; const ceiling = baseDelayMs * multiplier ** (step - 1); return ceiling < MAX_WAIT_MS ? ceiling : MAX_WAIT_MS; } /** One draw from the random source, which must answer a number in [0, 1]. */ function draw(random) { const r = random(); if (typeof r !== 'number' || !(r >= 0 && r <= 1)) { throw new RangeError(`exponentialBackoff: random() must answer a number in [0, 1] (got ${shown(r)})`); } return r; } /** The wait `error` states, read once — none when the reader throws or answers anything but a finite number ≥ 0. */ function statedWaitOf(read, error) { let value; try { value = read(error); } catch { return undefined; } return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined; } /** A value as an error message may show it: a number or a short string as itself, anything else by its type. */ function shown(value) { if (typeof value === 'number') return String(value); if (typeof value === 'string') { return JSON.stringify(value.length > 32 ? `${value.slice(0, 32)}…` : value); } return value === null ? 'null' : typeof value; } //# sourceMappingURL=backoff.js.map