Attempts and a deadline.
maxAttempts bounds the work of one call. totalTimeoutMs covers every attempt and every wait, so a huge attempt limit cannot outlive it.
A wait that would end after the deadline is never scheduled.
v0.1.0 · zero dependencies
Wrap any async function or fetch in a retry loop that makes you declare idempotency, enforces a deadline and a shared budget, honours Retry-After, and reports every decision.
npm install --save-dev retrypilotattempt-failedattempt 1 failed: connect ECONNRESET
retry-scheduledretry in 50 ms (backoff 50 ms)
attempt-failedattempt 2 failed: HTTP 503
retry-scheduledretry in 100 ms (backoff 100 ms)
operation-completedsucceeded
Real events from retry() on a fake clock. Every decision is reported.
Limits
Retries help with transient faults only, and they add load to a system that may already be struggling. Every default here is deliberately conservative.
maxAttempts bounds the work of one call. totalTimeoutMs covers every attempt and every wait, so a huge attempt limit cannot outlive it.
A wait that would end after the deadline is never scheduled.
Each retry spends a token and each success earns a little back. When the bucket is empty, retries stop for every call that shares it.
The first attempt is never charged.
Aborting interrupts a wait on the spot, aborts the in-flight attempt, and guarantees no further attempt starts.
RetryError.reason === "aborted"
What you get
retry() throws unless you pass idempotency. A non-idempotent operation is never retried, even on a network error, because the server may already have done the work.
Attempts, a total deadline, a shared budget and cancellation all apply. The first to say no ends the loop and names itself in RetryError.reason.
maxAttempts bounds one call. A RetryBudget bounds all of them, so an outage cannot multiply your traffic by the attempt limit.
A server delay is a floor on the wait. If it asks for more than maxServerDelayMs, the retry is abandoned instead of silently shortened.
No bodies or headers. The fetch wrapper strips query strings, fragments and URL credentials, and swallows errors thrown by your onEvent handler.
Wrap globalThis.fetch, undici, an instrumented fetch or a test double. Zero dependencies, ESM, and no Node imports outside retrypilot/node.
Workflow
Retries cannot fix a persistent failure. They can amplify load during an outage, which is why the budget, jitter and a deadline are part of the model and not extras.
Say whether repeating is safe. Pick attempts, a deadline and an optional shared budget.
retry(op, { idempotency: "idempotent" })After each failure the checks run in a fixed order, and the budget is charged last.
maxAttempts · totalTimeoutMs · budgetEvery decision becomes an event, and a failure becomes a RetryError with a reason.
onEvent(e) · RetryError.reasonHow it works
Forward the attempt's signal so abandoned work is actually cancelled.
import { RetryBudget, createRetryPolicy, retry } from "retrypilot";
declare const db: { fetchUser(id: string, options: { signal: AbortSignal }): Promise<{ name: string }> };
declare const cache: { get(key: string): Promise<string> };
// One call. Declaring idempotency is required: there is no default.
const user = await retry(({ signal }) => db.fetchUser("42", { signal }), {
idempotency: "idempotent",
maxAttempts: 4,
totalTimeoutMs: 10_000, // the whole call, waits included
attemptTimeoutMs: 2_000, // each attempt
});
// Many calls, one policy, one shared budget.
const budget = new RetryBudget({ capacity: 20, successRefund: 0.1 });
const policy = createRetryPolicy({ idempotency: "idempotent", maxAttempts: 3, budget });
await policy.execute(() => cache.get("a"));
await policy.execute(() => cache.get("b"), { maxAttempts: 5 });
export { user };GET is retried, POST is sent once unless you opt in, and stream bodies are never replayed.
import { createRetryFetch } from "retrypilot/http";
const fetchWithRetry = createRetryFetch(fetch, {
maxAttempts: 4,
totalTimeoutMs: 15_000,
attemptTimeoutMs: 5_000,
});
// GET is idempotent: retried on 503, 429 and network errors.
await fetchWithRetry("https://api.example.com/items");
// POST is not: sent once, unless you opt in per request or by header.
await fetchWithRetry("https://api.example.com/items", { method: "POST", body: "{}" });
await fetchWithRetry("https://api.example.com/items", { method: "POST", body: "{}", retry: { idempotent: true } });One bucket for every call, and a typed event for every decision.
import { RetryBudget } from "retrypilot";
// Each retry spends 1 token, each success earns 0.1 back. When the bucket is empty,
// retries stop for every call that shares it: an outage cannot triple your traffic.
export const budget = new RetryBudget({ capacity: 20, retryCost: 1, successRefund: 0.1 });import { RetryError, retry } from "retrypilot";
declare const op: () => Promise<string>;
declare const log: { warn(entry: object): void };
try {
await retry(op, {
idempotency: "idempotent",
name: "load-profile",
onEvent(e) {
if (e.type === "attempt-failed") log.warn({ attempt: e.attempt, ms: e.durationMs });
if (e.type === "retry-scheduled") log.warn({ waitMs: e.delayMs });
if (e.type === "retry-abandoned") log.warn({ reason: e.reason });
},
});
} catch (error) {
if (error instanceof RetryError) {
// 'non-retryable' | 'not-idempotent' | 'max-attempts' | 'deadline-exceeded'
// | 'budget-exhausted' | 'server-delay-too-long' | 'aborted'
log.warn({ reason: error.reason, attempts: error.attempts, elapsedMs: error.elapsedMs });
}
}Playground
This runs the retrypilot loop from this repository on a fake clock, so thirty seconds of waiting takes no time. Each line below is what one attempt returns.
attempt-startedattempt 1 started
attempt-failedattempt 1 failed: connect ECONNRESET
retry-scheduledretry in 50 ms (backoff 50 ms)
attempt-startedattempt 2 started
attempt-failedattempt 2 failed: HTTP 503
retry-scheduledretry in 100 ms (backoff 100 ms)
attempt-startedattempt 3 started
operation-completedsucceeded
A dropped connection and a 503, then success. Waits are exponential with full jitter, and every decision is an event. The random source is fixed at 0.5 here so the numbers are repeatable. How the limits interact →
import { retry, RetryBudget, RetryError, type Idempotency, type RetryEvent } from "retrypilot";
import { FakeClock } from "retrypilot/testing";
export type Input = {
/** One attempt per line: `ok`, `network`, `bug`, `503`, or `429 retry-after 2` (seconds). The last line repeats. */
script: string;
idempotency: Idempotency;
maxAttempts: number;
/** Whole-call deadline in milliseconds. 0 means none. */
totalTimeoutMs: number;
/** Shared retry budget capacity. 0 means no budget. */
budget: number;
};
export type Row = { atMs: number; type: RetryEvent["type"]; text: string };
type Reply = { status: number; retryAfterMs?: number };
const network = () => Object.assign(new Error("connect ECONNRESET"), { code: "ECONNRESET" });
function reply(line: string): Reply | Error {
const [word = "ok", , seconds] = line.trim().toLowerCase().split(/\s+/);
if (word === "network") return network();
if (word === "bug") return new Error("TypeError in my own code");
const status = word === "ok" ? 200 : Number(word) || 200;
return seconds === undefined ? { status } : { status, retryAfterMs: Number(seconds) * 1000 };
}
function describe(e: RetryEvent): string {
switch (e.type) {
case "attempt-started":
return `attempt ${e.attempt} started`;
case "attempt-failed": {
const why = e.detail ?? (e.error instanceof Error ? e.error.message : "failed");
return `attempt ${e.attempt} failed: ${why}`;
}
case "retry-scheduled": {
const server = e.serverDelayMs === undefined ? "" : `, server asked ${e.serverDelayMs} ms`;
const budget = e.budgetRemaining === undefined ? "" : `, budget left ${e.budgetRemaining}`;
return `retry in ${e.delayMs} ms (backoff ${e.backoffMs} ms${server}${budget})`;
}
case "retry-abandoned":
return `gave up: ${e.reason}${e.detail ? ` (${e.detail})` : ""}`;
case "deadline-exceeded":
return `deadline of ${e.totalTimeoutMs} ms exceeded`;
case "operation-completed":
return e.outcome === "success" ? "succeeded" : `failed: ${e.failure?.reason ?? "unknown"}`;
}
}
/** The real retry loop on a FakeClock, so a thirty-second wait takes no time at all. */
export async function simulate(input: Input) {
const steps = input.script.split("\n").map((l) => l.trim()).filter(Boolean);
if (steps.length === 0) steps.push("ok");
const clock = new FakeClock();
const rows: Row[] = [];
const budget = input.budget > 0 ? new RetryBudget({ capacity: input.budget, clock }) : undefined;
let done = false;
const settled = retry<Reply>(
({ attempt }) => {
const next = reply(steps[Math.min(attempt, steps.length) - 1] ?? "ok");
if (next instanceof Error) throw next;
return next;
},
{
idempotency: input.idempotency,
maxAttempts: input.maxAttempts,
...(input.totalTimeoutMs > 0 ? { totalTimeoutMs: input.totalTimeoutMs } : {}),
...(budget ? { budget } : {}),
classifyResult: (r) =>
r.status >= 500 || r.status === 429
? { retry: true, reason: `HTTP ${r.status}`, ...(r.retryAfterMs === undefined ? {} : { delayMs: r.retryAfterMs }) }
: undefined,
onEvent: (e) => void rows.push({ atMs: e.elapsedMs, type: e.type, text: describe(e) }),
clock,
random: () => 0.5,
},
).then(
(value) => ({ ok: true as const, value }),
(error: unknown) => ({ ok: false as const, error }),
).finally(() => {
done = true;
});
for (let i = 0; i < 2400 && !done; i++) await clock.advance(250);
const outcome = await settled;
const summary = outcome.ok
? `HTTP ${outcome.value.status}`
: outcome.error instanceof RetryError
? `${outcome.error.reason} after ${outcome.error.attempts} attempt${outcome.error.attempts === 1 ? "" : "s"}, ${outcome.error.elapsedMs} ms`
: String(outcome.error);
return { rows, ok: outcome.ok, summary, pending: clock.pendingTimers };
}
export type Run = Awaited<ReturnType<typeof simulate>>;Options
Backoff defaults to 100 ms, doubling, capped at 10 s, with full jitter. Test it without waiting by injecting a FakeClock.
idempotencyRequired. idempotent or non-idempotent. The library never guesses.maxAttemptsAttempts including the first. Default 3.totalTimeoutMsWall-clock limit for the whole call, waits included.attemptTimeoutMsLimit for each attempt, clipped to the time remaining.budgetA RetryBudget shared by every call that should draw on the same retries.isRetryableReplace the default classifier. It can also return a server-directed delay.Documentation
More from M2Toolkit