[ Operations ]
@sdxc/backoff
Retry delay schedules with exponential, linear, constant or stepped growth, a ceiling, free attempts and seedable jitter
- Installs with
- @sdxc/duration@sdxc/random
- Used by
- uptime, auth-saas, reader, blog
- Source
- packages/backoff
Retry delay schedules with exponential, linear, constant or stepped growth, a ceiling, free attempts and seedable jitter.
Installation
npm add @sdxc/backoff
Durations are @sdxc/duration inputs, and jitter draws from a @sdxc/random stream; install @sdxc/random too when you pass a seeded one.
Usage
Build a schedule
delay(attempt) answers the milliseconds to wait after attempt failures, counting from 1.
import { createBackoff } from "@sdxc/backoff";
let backoff = createBackoff({ base: "15 seconds", max: "12 hours" });
backoff.delay(1); // 15_000
backoff.delay(3); // 60_000
backoff.delay(20); // 43_200_000, the ceiling
That schedule is Math.min(15_000 * 2 ** (attempt - 1), 43_200_000).
Pick the growth
growth changes the curve, and steps replaces it with a table whose last entry repeats.
createBackoff({ base: "1 second", factor: 3 }); // 1s, 3s, 9s, 27s…
createBackoff({ base: "30 seconds", growth: "linear" }); // 30s, 60s, 90s…
createBackoff({ base: "5 seconds", growth: "constant" }); // 5s, 5s, 5s…
createBackoff({ steps: ["15 seconds", "1 minute", "5 minutes", "2 hours"] }); // …2h, 2h
Spread retries with jitter
A fraction f draws each delay from delay × (1 ± f); "full" draws from [0, delay]. Jitter applies after max, so retries waiting at the ceiling still spread out.
let backoff = createBackoff({ base: "15 seconds", max: "12 hours", jitter: 0.2 });
backoff.delay(1); // between 12_000 and 18_000
Allow free attempts
The first free failures answer 0, and the curve starts on the failure after them.
let lockout = createBackoff({ free: 3, base: "1 second", max: "15 minutes" });
lockout.delay(3); // 0
lockout.delay(4); // 1_000
lockout.delay(5); // 2_000
API
createBackoff(options: BackoffOptions): Backoff
Validates the options once and returns the schedule, so delay and at never fail. Throws RangeError for a negative or unparseable duration, a factor below 1, a max below base, a jitter outside [0, 1], empty steps, or a free that is not a non-negative integer.
A curve schedule takes:
base: the delay after the first counted failuregrowth:"exponential"(default) multiplies byfactorper failure,"linear"addsbase,"constant"repeatsbasefactor: the exponential multiplier,2by defaultmax: the ceiling before jitter; unbounded when omitted
A stepped schedule takes steps, one delay per counted failure. Both take:
free: failures that answer0,0by defaultjitter: aJitter; none when omitted. Jittered delays round to whole millisecondsrandom: the stream jitter draws from,systemRandom()by default
Backoff
delay(attempt: number): number: milliseconds afterattemptfailures;0for a non-positive or free attemptat(attempt: number, now: number): number:now + delay(attempt), for storing when to try again
Types
BackoffOptions
BackoffCurveOptions | BackoffStepsOptions, both extending BackoffSharedOptions (free, jitter, random).
Jitter
number | "full": a fraction in [0, 1], or a draw from zero to the full delay.
Pattern: Retrying A Queue Message
A queue consumer passes its delivery count straight in, so early retries come quickly and a receiver that stays down is left alone.
import { createBackoff } from "@sdxc/backoff";
let deliveryBackoff = createBackoff({ base: "1 minute", max: "6 hours", jitter: 0.2 });
export default {
async queue(batch: MessageBatch<{ url: string }>) {
for (let message of batch.messages) {
let response = await fetch(message.body.url, { method: "POST" });
if (response.ok) message.ack();
else
message.retry({ delaySeconds: Math.ceil(deliveryBackoff.delay(message.attempts) / 1000) });
}
},
};
Pattern: Retrying A Request And Honoring Retry-After
When a server says how long to wait, wait at least that long. Retry-After is either a number of seconds or an HTTP date; anything else counts as no answer.
import { createBackoff } from "@sdxc/backoff";
let backoff = createBackoff({ base: "1 second", max: "5 minutes", jitter: "full" });
function retryAfterMs(response: Response, now: number): number {
let header = response.headers.get("retry-after");
if (header === null) return 0;
let seconds = Number(header);
if (Number.isFinite(seconds)) return Math.max(seconds * 1000, 0);
let date = Date.parse(header);
return Number.isNaN(date) ? 0 : Math.max(date - now, 0);
}
async function fetchWithRetry(url: string, attempts = 5): Promise<Response> {
for (let attempt = 1; ; attempt++) {
let response = await fetch(url);
if (response.ok || attempt === attempts) return response;
let wait = Math.max(retryAfterMs(response, Date.now()), backoff.delay(attempt));
await new Promise((resolve) => setTimeout(resolve, wait));
}
}
Pattern: Locking Out Repeated Sign-In Failures
A stored failure count and at() give a row the time its next attempt opens. The first three failures cost nothing.
import { createBackoff } from "@sdxc/backoff";
let lockout = createBackoff({ free: 3, steps: ["30 seconds", "5 minutes", "1 hour"] });
interface SignInState {
failures: number;
lockedUntil: number;
}
function isLocked(state: SignInState, now: number): boolean {
return state.lockedUntil > now;
}
function recordFailure(state: SignInState, now: number): SignInState {
let failures = state.failures + 1;
return { failures, lockedUntil: lockout.at(failures, now) };
}
Pattern: Exact Delays In Tests
Pass a seeded Random and every jittered delay is reproducible, so a test asserts exact values.
import { createBackoff } from "@sdxc/backoff";
import { createRandom } from "@sdxc/random";
import { expect, test } from "vitest";
test("jitter is reproducible", () => {
let first = createBackoff({ base: 1000, jitter: 0.2, random: createRandom("test") });
let second = createBackoff({ base: 1000, jitter: 0.2, random: createRandom("test") });
expect(first.delay(3)).toBe(second.delay(3));
expect(first.delay(3)).toBeGreaterThanOrEqual(3200);
expect(first.delay(3)).toBeLessThanOrEqual(4800);
});