retry-primitives
v1.0.1
Published
Retry and circuit breaker as pure functions — exponential, decorrelated jitter, fibonacci backoff, consecutive and rolling-window breakers. JSON-serializable state, no clock reads, no I/O, no dependencies. Survives a process restart, so it works in Cloudf
Maintainers
Readme
retry-primitives
Retry decisions, not a retry framework.
Every other package in this space owns the loop: you hand it a function, it calls the function, it sleeps between attempts. That works right up until the retry has to outlive the call stack — a Worker that is evicted after the response, a Lambda that froze, a job that must come back in four hours, a circuit breaker whose state has to be visible to a hundred isolates at once.
This one ships the arithmetic and hands the state back to you:
Policy and breaker state are plain JSON-serializable objects. State transitions are pure synchronous functions of
(state, now, outcome). The package never reads the clock, never allocates a timer, and never performs I/O in its core.
That is the whole design. It is what lets the same backoff run inside a while loop, a Durable Object alarm, an SQS visibilityTimeout, a database retry_at column, or a Cloudflare queue — without an adapter for each.
- Zero dependencies. No polyfills, no
node:imports in the core. - Edge-native. Runs unchanged in Workers, Deno, Bun, browsers and Node >= 18.
- 1.1 kB gzipped for all five backoff policies; 0.8 kB if you only import
exponential. - Serializable state. Save it, ship it, restore it after a restart, diff it in a test.
import { exponential } from 'retry-primitives/backoff';
const policy = exponential({ baseMs: 100, maxAttempts: 5, deadlineMs: 30_000 });
// State is three numbers. Put it wherever you like.
let state = policy.init(Date.now()); // { attempts: 0, startedAt: 1758124800000, prevDelayMs: 0 }
const { state: next, decision } = policy.next(state, Date.now());
// decision → { retry: true, delayMs: 63, attempts: 1, remainingMs: 30000 }
if (decision.retry) state = next;Install
npm install retry-primitives| Import | What you get | Pulls in |
| --------------------------- | ----------------------------------------------------------------- | ------------------------------ |
| retry-primitives | every policy, both breakers, the classifiers, types | everything below except timers |
| retry-primitives/backoff | the five policies, the backoff factory, preview | nothing |
| retry-primitives/breaker | consecutiveBreaker, rollingBreaker, CircuitOpenError | nothing |
| retry-primitives/classify | Retry-After parsing, HTTP and transport-error rules | nothing |
| retry-primitives/async | retry and protect — the only module that touches setTimeout | nothing |
The split is deliberate: a Worker that only needs the arithmetic never bundles an executor, and timers never enter an edge bundle that has no use for them.
Start with exponential
exponential({ baseMs, factor?, maxAttempts?, deadlineMs?, maxDelayMs?, jitter?, random? });
// state: { attempts, startedAt, prevDelayMs }baseMs * factor ^ (attempt - 1), jittered. It is the only curve that sheds load fast enough to let an overloaded dependency recover, and the jitter matters more than the curve: a thousand clients that failed at the same instant and back off by exactly 1 s will retry at the same instant, and the herd that knocked the service over knocks it over again. So jitter defaults to 'full'.
Everything else is a variation on the two questions every policy answers — may I try again? and how long do I wait?
import { retry } from 'retry-primitives/async';
import { exponential } from 'retry-primitives/backoff';
const data = await retry(({ signal }) => fetchJson(url, { signal }), {
policy: exponential({ baseMs: 200, maxAttempts: 5, deadlineMs: 20_000 }),
});That is the batteries-included path, and for a call inside one process it is the right one. The rest of this README is about the cases it cannot reach.
Examples
A retry that outlives the process
The reason this package exists. In a Durable Object there is no loop to sleep in — the isolate goes away between requests, so a retry is an alarm you set and a state you store:
import { exponential } from 'retry-primitives/backoff';
import { httpOutcome } from 'retry-primitives/classify';
import type { RetryState } from 'retry-primitives';
const policy = exponential({ baseMs: 1_000, maxAttempts: 8, deadlineMs: 3_600_000 });
export class Webhook implements DurableObject {
constructor(private state: DurableObjectState) {}
async deliver(payload: unknown) {
await this.state.storage.put('payload', payload);
await this.state.storage.put('retry', policy.init(Date.now()));
await this.attempt();
}
async alarm() {
await this.attempt();
}
private async attempt() {
const payload = await this.state.storage.get('payload');
const saved = await this.state.storage.get<RetryState>('retry');
const response = await fetch(this.endpoint, {
method: 'POST',
body: JSON.stringify(payload),
});
if (response.ok) return this.state.storage.deleteAll();
const now = Date.now();
const { state, decision } = policy.next(saved!, now, httpOutcome(response, now));
if (!decision.retry) return this.giveUp(decision.reason);
await this.state.storage.put('retry', state);
await this.state.storage.setAlarm(now + decision.delayMs);
}
}No timer is held open, nothing is pinned in memory, and the delivery survives an eviction, a deploy, or a datacentre moving your object somewhere else. The same shape covers a queue (retryAt in the message), SQS (visibilityTimeout: decision.delayMs / 1000), or a jobs table polled by a cron.
Deciding what a failure means
fetch does not reject on a 500, so something has to turn a response into a decision. That is what classify is:
import { retry } from 'retry-primitives/async';
import { exponential } from 'retry-primitives/backoff';
import { httpOutcome, isNetworkError } from 'retry-primitives/classify';
class HttpError extends Error {
constructor(readonly response: Response) {
super(`HTTP ${response.status}`);
}
}
const charge = await retry(
async ({ signal }) => {
const response = await fetch('/charges', { method: 'POST', body, signal });
if (!response.ok) throw new HttpError(response);
return response.json();
},
{
policy: exponential({ baseMs: 500, maxAttempts: 4, deadlineMs: 30_000 }),
classify: (error, { now }) =>
error instanceof HttpError
? httpOutcome(error.response, now, { method: 'POST' })
: isNetworkError(error),
}
);httpOutcome reads Retry-After — both the delta-seconds and the HTTP-date form — and hands it back as retryAfterMs, which the policy treats as a floor it will never undercut. It also knows that a POST that failed with a 500 may already have charged the card, so it refuses to repeat it; a 429 or a 503 is safe to repeat, because those say the server did nothing. Pass idempotent: true once you have an idempotency key.
A circuit breaker several isolates can see
A breaker whose state lives in a closure protects one isolate. Ten thousand isolates with a closure each protect nothing. Here the state is a value, so it can live wherever the isolates can all reach:
import { consecutiveBreaker } from 'retry-primitives/breaker';
import type { ConsecutiveBreakerState } from 'retry-primitives';
const breaker = consecutiveBreaker({ failureThreshold: 5, openMs: 30_000 });
export async function callUpstream(env: Env, request: Request) {
const now = Date.now();
const saved =
(await env.KV.get<ConsecutiveBreakerState>('breaker', 'json')) ?? breaker.init(now);
const { state, verdict } = breaker.request(saved, now);
if (!verdict.ok) {
return new Response('upstream is down', {
status: 503,
headers: { 'retry-after': String(Math.ceil(verdict.retryAfterMs / 1000)) },
});
}
try {
const response = await fetch(upstream, request);
await env.KV.put('breaker', JSON.stringify(breaker.succeed(state, Date.now())));
return response;
} catch (error) {
await env.KV.put('breaker', JSON.stringify(breaker.fail(state, Date.now())));
throw error;
}
}Read concurrency before deploying that: KV will not serialize the read-modify-write for you, and a breaker is one of the places where that is usually fine.
A breaker and a policy, composed
Retrying against an open breaker burns attempts on a door that is closed. Carrying retryAfterMs across makes the two agree:
import { protect, retry, isCircuitOpenError } from 'retry-primitives/async';
import { consecutiveBreaker } from 'retry-primitives/breaker';
import { exponential } from 'retry-primitives/backoff';
const upstream = protect(consecutiveBreaker({ failureThreshold: 5, openMs: 10_000 }));
const result = await retry(() => upstream.call(() => fetch(url)), {
policy: exponential({ baseMs: 200, maxAttempts: 6, deadlineMs: 60_000 }),
// The breaker knows exactly how long it will refuse for. Wait that long
// instead of spending five attempts finding out.
classify: error => (isCircuitOpenError(error) ? { retryAfterMs: error.retryAfterMs } : true),
});protect is the single-process convenience — it keeps the breaker state in a closure. state() and onChange are the seam back to the pure layer when the state needs to go somewhere else.
Seeing what a configuration actually does
import { preview, exponential, decorrelatedJitter } from 'retry-primitives/backoff';
preview(exponential({ baseMs: 100, maxAttempts: 6, jitter: 'none' }), 10);
// → [100, 200, 400, 800, 1600]
preview(exponential({ baseMs: 100, deadlineMs: 1_000, jitter: 'none' }), 10);
// → [100, 200, 400] the fourth wait would land past the deadline
preview(decorrelatedJitter({ baseMs: 100, maxAttempts: 5, random: () => 1 }), 10);
// → [300, 900, 2700, 8100] the top of each rangepreview runs the policy against an imaginary clock, which means "how long will this hammer the API for?" is a question with an answer in a test, not a thing you find out in production.
Choosing a policy
| Policy | Shape | Pick it when |
| -------------------- | --------------------------------- | ------------------------------------------------------------------ |
| exponential | base * factor ^ (n - 1) | default. Anything remote, anything that can be overloaded |
| decorrelatedJitter | random(base, previous * factor) | many clients share the dependency and you want the fewest calls |
| fibonacci | base * 1, 1, 2, 3, 5, 8 ... | you want more attempts inside the same budget than doubling allows |
| linear | base * n | a lock, a lease or a quota window that frees itself |
| constant | base | polling for something you expect to appear |
| backoff | whatever you write | a curve from a config table, with the budget handling for free |
The honest short version: use exponential. Use decorrelatedJitter when you are one of many clients hammering the same dependency — AWS measured it finishing the same work in fewer total calls. Everything else is here because a spec or a habit asks for it.
Jitter
jitter applies to every policy except decorrelatedJitter, which randomizes by construction.
| Mode | Delay | Effect |
| --------- | ------------------ | ------------------------------------------------------------ |
| 'full' | random(0, d) | default. Strongest de-synchronizer; halves the mean wait |
| 'equal' | random(d / 2, d) | keeps most of the backoff, still spreads clients out |
| 'none' | d | predictable, and the reason herds retry in lockstep |
Randomness enters through the random option, which defaults to Math.random and is the only nondeterminism in the package. Pass a seeded generator and a policy becomes reproducible:
exponential({ baseMs: 100, maxAttempts: 5, random: seededPrng() });Budgets
Every policy takes the same three limits, and at least one stopping condition is required — a policy with neither maxAttempts nor deadlineMs is an infinite loop, so it throws rather than build one. maxAttempts: Infinity is accepted, because retrying forever behind a breaker is a real choice; it just has to be spelled out.
| Option | Default | Meaning |
| ------------- | -------- | -------------------------------------------------------------------- |
| maxAttempts | none | total attempts, counting the first |
| deadlineMs | none | wall-clock budget for the whole operation, measured from startedAt |
| maxDelayMs | 30 000 | ceiling on a single delay, applied before jitter |
maxDelayMs defaults to something rather than nothing because an uncapped exponential curve is measured in days within twenty attempts, which is never what anyone means. Pass Infinity to opt out.
Decisions
interface Decision {
retry: boolean; // may we try again?
delayMs: number; // how long to wait first; 0 when retry is false
attempts: number; // attempts made so far, counting the one that just failed
remainingMs: number; // deadline budget left at `now`; Infinity when unbounded
reason?: 'maxAttempts' | 'deadline' | 'nonRetryable'; // set iff retry is false
}Rules that hold for every policy, and are asserted for every policy in the test suite:
- A terminal decision costs nothing.
nextreturns the same state reference it was given when it stops. nextnever mutates its input. A retry returns a new object.- The next attempt starts strictly inside the deadline. Waiting past it only to fail on arrival wastes the caller's time, so the policy stops instead.
retryAfterMsfrom an outcome is a floor, not a suggestion. The delay is never shorter, andmaxDelayMsdoes not cap it — it is an instruction from the server, not one of your knobs. It is still checked against the deadline.- State survives
JSON.parse(JSON.stringify(state))with identical behaviour. - No policy reads a clock.
nowis always supplied by the caller, in milliseconds. - A backwards clock jump cannot corrupt anything. Elapsed time is clamped at zero.
Breakers
A breaker is a three-state machine over the same shape:
closed ──(enough failures)──> open ──(openMs elapsed)──> halfOpen ──(enough probes succeed)──> closed
^ │
└──────(any probe fails)──────┘interface Breaker<S> {
init(now: number): S;
request(state: S, now: number): { state: S; verdict: BreakerVerdict };
succeed(state: S, now: number): S;
fail(state: S, now: number): S;
peek(state: S, now: number): BreakerVerdict;
}request advances the state when it admits a half-open probe, so store what it returns before making the call. Outcomes recorded while the breaker is open are ignored — they belong to a call it never admitted.
consecutiveBreaker
consecutiveBreaker({ failureThreshold: 5, openMs: 30_000 });
// state: { status, changedAt, probes, successes, failures }Trips after failureThreshold failures in a row; any success resets the count. Five fields of state, no windows, no arithmetic worth arguing about — the one to reach for by default.
Its blind spot is worth knowing: a dependency failing half the time never produces a long enough run to trip it, so it keeps sending traffic at a service that is clearly unwell.
rollingBreaker
rollingBreaker({ windowMs: 30_000, failureRatio: 0.5, minimumThroughput: 20, openMs: 30_000 });
// state: { status, changedAt, probes, successes, start, prevOk, prevFail, currOk, currFail }Trips on the failure ratio over a trailing window — the case above. minimumThroughput stops one failed call from reading as a 100% failure rate.
The window is approximated the way a sliding-window counter is: two buckets, with the previous one weighted by how much of it still overlaps. That assumes the previous window's calls were spread evenly across it. For a trip decision it is a trade worth making; the alternative is a per-call array in every store write.
Recovery
Shared by both, and where most breaker implementations are vague:
| Option | Default | Meaning |
| ------------------ | ---------------- | ----------------------------------------------------------------- |
| openMs | required | how long calls are refused before a probe is admitted |
| halfOpenProbes | 1 | calls admitted per trial |
| successThreshold | halfOpenProbes | successful probes needed to close |
| halfOpenMs | openMs | how long a trial waits for its probes before starting a fresh one |
halfOpenMs exists because a probe that never reports back — the isolate was evicted, the process died, the caller forgot to record the outcome — would otherwise strand the breaker in half-open forever, refusing every call and never testing again. With it, a stranded trial degrades into one probe per halfOpenMs, which is a health check.
A trial that closes the breaker starts it on an empty ledger. The probe that just succeeded is the freshest evidence there is, so a failure run or a window recorded before the breaker opened does not carry into the new closed period.
forceOpen(state, now) and forceClose(state, now) are pure overrides for an admin endpoint or a feature flag. Being generic over the state is what lets them work with a breaker of your own, and it is also why they cannot see the failure ledger: forceClose on a breaker one failure short of tripping stays closed for exactly one more failure. When you mean "resume service and forget what happened", that is breaker.init(now).
Classification
Deciding whether a failure is worth repeating is a separate job from deciding when, and it is the one people get wrong.
parseRetryAfter(header, now); // → ms, or undefined. Handles delta-seconds and HTTP-date
httpOutcome(response, now, { method, idempotent }); // → Outcome
isRetryableStatus(status); // 408, 425, 429 and 5xx except 501 and 505
isIdempotentMethod(method); // GET, HEAD, PUT, DELETE, OPTIONS, TRACE
isNetworkError(error); // transport failure, walking the `cause` chain
isAbortError(error); // a deliberate cancellation — never retry one
isTimeoutError(error); // an elapsed deadline — usually worth retrying
errorOutcome(error); // the conservative default: transport failures and timeouts onlyTwo details that are easy to get wrong and are handled here:
Date.parseis far too eager.Date.parse('1.5')is a real timestamp in 2001, so a malformedRetry-After: 1.5would silently become "retry immediately".parseRetryAfterrequires the day name that every HTTP-date form begins with, and reports anything else as unparseable — which lets the policy's own curve decide.fetchhides the useful part. Node rejects with a bareTypeError: fetch failedand puts the actualECONNREFUSEDone level down incause.isNetworkErrorwalks the chain, undici'sUND_ERR_*codes included.
Concurrency
This package does not make your retries or breakers distributed-safe, and pure functions cannot.
A breaker's request/fail pair is a read-modify-write. With state shared across isolates, two concurrent calls can read the same state, both record a failure, and one of the writes is lost. The transition function being pure does not help: the race is in the store, not the arithmetic.
The good news is that a breaker degrades gracefully where a rate limiter does not. Losing a failure means it trips a call or two late; losing a success means it stays open slightly longer. Nobody is over-charged. For most breakers, approximately right and free beats exactly right and coordinated — and if it does not, the store has to provide the guarantee:
- A single-threaded owner. A Durable Object per dependency is the cleanest fit: the DO gives you serialization, this package gives you the transition.
- Compare-and-swap. Read with a version or ETag, compute, write conditionally, retry on conflict.
- Server-side execution. A Redis Lua script, a Postgres
UPDATE ... RETURNINGin a transaction.
Retry state has no such problem — it belongs to one operation and only one caller ever touches it.
Clocks
retry and protect default to Date.now(). That is the only clock read in the package, and it is a compromise you should make consciously.
Date.now() is wall-clock and not monotonic. NTP correction, a VM resume, or a manual clock change can move it backwards or jump it forwards. Every transition here tolerates a backwards jump without corrupting state — elapsed time is clamped at zero — but a forward jump can end a deadline early or reopen a breaker sooner than it should.
- Purely local work (one process, no shared state): pass
now: () => performance.now(). It is monotonic and immune to clock adjustment. State is then meaningless outside that process. - Anything shared across machines (state in KV, a DO, a database): you need wall-clock, because two machines must agree on what
nowmeans. Clock skew then translates directly into an early probe or a late one.
The policies and breakers themselves take now as an argument, so if you are calling them directly, the question is entirely yours.
Comparison
This package's claim is narrow: serializable state and a pure core. Where the alternatives are better, they are better.
p-retry — the one most people want. Small, well-maintained, AbortSignal-aware, and if the whole problem is "call this function again a few times in one process", it is a better fit than this package: fewer concepts, one import, done. It owns the loop and the state, so neither crosses a process boundary.
cockatiel — a much more complete resilience library: retries, breakers, bulkheads, timeouts, fallbacks, hedging, and a policy-composition model this package does not attempt. Its breaker state lives in the policy object, which is exactly right for a long-running Node service and exactly wrong for an isolate that dies after each request. If you are building a server and want one library for all of it, use cockatiel.
opossum — a mature, battle-tested circuit breaker with metrics, events, caching and a health dashboard story. Its state is in-process by design. Reach for it when you have one process and want observability out of the box.
exponential-backoff / async-retry — small and focused, and they own the loop too.
got, ky, axios-retry — if your retries are all HTTP and you already use one of these clients, their built-in retry is less code than any of the above.
Choose this package when the decision has to be separated from the waiting: an edge runtime with no place to sleep, a queue or alarm that schedules the next attempt, a breaker shared by isolates that do not share memory, a state you want to inspect in a test, or a process that restarts mid-operation.
API
// retry-primitives/backoff
exponential(opts: { baseMs: number; factor?: number } & BackoffOptions): Policy;
decorrelatedJitter(opts: { baseMs: number; factor?: number } & BackoffOptions): Policy;
fibonacci(opts: { baseMs: number } & BackoffOptions): Policy;
linear(opts: { baseMs: number } & BackoffOptions): Policy;
constant(opts: { delayMs: number } & BackoffOptions): Policy;
backoff(delayFor: (attempt, prevDelayMs, random) => number, opts?: BackoffOptions): Policy;
preview(policy: Policy, attempts: number, opts?: { now?: number; outcome?: Outcome }): number[];
interface BackoffOptions {
maxAttempts?: number; // total attempts, counting the first
deadlineMs?: number; // budget for the whole operation
maxDelayMs?: number; // ceiling on one delay, before jitter; default 30_000
jitter?: 'none' | 'full' | 'equal'; // default 'full'
random?: () => number; // default Math.random
}
interface Policy {
init(now: number): RetryState;
next(state: RetryState, now: number, outcome?: Outcome): { state: RetryState; decision: Decision };
peek(state: RetryState, now: number, outcome?: Outcome): Decision;
}
interface Outcome {
retryable?: boolean; // false stops the policy at once
retryAfterMs?: number; // a floor on the next delay
}
// retry-primitives/breaker
consecutiveBreaker(opts: { failureThreshold: number } & RecoveryOptions): Breaker<ConsecutiveBreakerState>;
rollingBreaker(opts: { windowMs: number; failureRatio?: number; minimumThroughput?: number } & RecoveryOptions): Breaker<RollingBreakerState>;
forceOpen<S>(state: S, now: number): S;
forceClose<S>(state: S, now: number): S;
isCircuitOpenError(error: unknown): error is CircuitOpenError;
interface BreakerVerdict {
ok: boolean;
status: 'closed' | 'open' | 'halfOpen';
retryAfterMs: number;
}
// retry-primitives/async
retry<T>(fn: (ctx: { attempt: number; signal?: AbortSignal }) => Promise<T>, opts: {
policy: Policy;
signal?: AbortSignal;
now?: () => number;
classify?: (error: unknown, ctx: { attempt: number; now: number }) => Outcome | boolean | void;
onRetry?: (info: { error: unknown; decision: Decision }) => void;
onGiveUp?: (info: { error: unknown; decision: Decision }) => void;
}): Promise<T>;
protect<S>(breaker: Breaker<S>, opts?: {
now?: () => number;
state?: S;
onChange?: (state: S) => void;
isFailure?: (error: unknown) => boolean;
}): { call<T>(fn: () => Promise<T>): Promise<T>; state(): S; load(state: S): void; status(): BreakerStatus };peek answers "what would this decide right now" without advancing anything. For a jittered policy it draws its own sample, so it previews the shape of the next delay rather than the exact number next will produce; every other field matches.
When retry gives up it rethrows the last error unchanged, so instanceof checks and cause chains survive. onGiveUp receives the decision if you need to know whether it ran out of attempts or out of time.
Contributing
npm install
npm test # vitest, no fake timers anywhere — time is injected
npm run typecheck
npm run build
npm run size # ./backoff must stay under 1.5 kB gzipped
npm run lint:pkg # publint + @arethetypeswrong/cliThe lint config and a test in src/core/purity.test.ts both enforce the design: nothing under src/backoff, src/breaker, src/classify or src/core may read a clock, allocate a timer, import a Node builtin, or return a promise. Math.random is named in exactly one file.
Releases run on changesets: add one with npm run changeset, and merging to main opens (or publishes) the release PR.
License
MIT © Pavel Lazarchuk
