@verifyhash/retry
v0.1.0
Published
Zero-dependency async retry for Node.js with exponential backoff and full jitter (AWS-style), AbortSignal support that cancels even mid-sleep, shouldRetry/onRetry hooks, and injectable sleep + rand so tests are exact and instant without fake-timer librari
Maintainers
Readme
@verifyhash/retry
Zero-dependency async retry with exponential backoff + jitter for
Node.js, typed from birth (hand-written index.d.ts ships with the
package). The backoff model is the AWS-recommended one: the un-jittered
delay is min(minDelay * factor^(attempt-1), maxDelay), and the default
'full' jitter multiplies it by a uniform random in [0, 1) — so 10
clients that all fail at once do NOT hammer the server again in lockstep.
Its distinguishing feature is determinism without fake timers: the
backoff math lives in a pure, exported computeDelay(attempt, opts, rand)
(the random source is an argument), and retry() accepts an injectable
sleep(ms, signal). Your tests pass a stub sleep that records the requested
delays and resolves immediately, plus a fixed rand — every backoff vector
is exact and the whole suite runs in milliseconds. This library's own
70-check suite does exactly that (node test/index.test.js finishes in
under 100 ms with zero dev dependencies).
Who it's for: Node.js developers wrapping flaky calls (HTTP, DNS,
databases, rate-limited APIs) who want cancellation via a standard
AbortSignal — including mid-sleep, where naive implementations keep a
dead timer pending — and who want to unit-test their retry policy exactly
instead of sprinkling jest.useFakeTimers() around.
Honest limits: JavaScript cannot interrupt a promise that is already
running, so aborting does NOT cancel an in-flight fn() call — the abort
takes effect at the next boundary (mid-sleep or before the next attempt).
If you need the attempt itself cancelled, pass the same signal into fn
(e.g. to fetch). There is also no retry budget/deadline option; compose
one with shouldRetry if you need it.
Install
npm install @verifyhash/retryExample
const { retry, computeDelay } = require('@verifyhash/retry');
// Retry a flaky call up to 5 times: 100ms, 200ms, 400ms, 800ms base delays
// (each multiplied by Math.random() under the default 'full' jitter).
const controller = new AbortController();
const data = await retry(
(attempt) => fetchThing({ signal: controller.signal, attempt }),
{
attempts: 5,
minDelay: 100,
maxDelay: 2000, // caps the un-jittered delay
signal: controller.signal, // abort() rejects promptly, even mid-sleep
shouldRetry: (err) => err.code !== 'FATAL', // give up on fatal errors
onRetry: (err, attempt, delay) =>
console.warn(`attempt ${attempt} failed (${err.message}), retrying in ${delay}ms`),
}
);
// The pure calculator, pinned exactly in tests via an injected rand:
computeDelay(3, { minDelay: 100, factor: 2, jitter: 'none' }); // 400
computeDelay(3, { minDelay: 100, factor: 2 }, () => 0.5); // 200 (full jitter, rand=0.5)
computeDelay(10, { minDelay: 100, maxDelay: 30000, jitter: 'none' }); // 30000 (clamped from 51200)Deterministic testing pattern (what this library's own suite does — no fake timers, no real waiting):
const { retry } = require('@verifyhash/retry');
const delays = [];
const stubSleep = (ms) => { delays.push(ms); return Promise.resolve(); };
let n = 0;
const v = await retry(() => { if (++n < 3) throw new Error('boom'); return 42; },
{ attempts: 5, minDelay: 100, jitter: 'none', sleep: stubSleep });
// v === 42, delays === [100, 200] — exact, instant.API
retry(fn, options?) → Promise
Calls fn(attempt) (attempt is 1-based) until it resolves. Resolves with
fn's value. Rejects with:
- the last error, once
attemptsare exhausted; - the error itself, immediately, when
shouldRetry(error, attempt)returns false (no sleep happens); - an AbortError-shaped error (
name: 'AbortError',code: 'ABORT_ERR') whenoptions.signalaborts — before the first attempt, mid-sleep (the default sleep clears its pending timer), or between attempts. No further attempts run after an abort. If the signal was aborted with anErrorreason (Node's default reason is a DOMException namedAbortError), that reason is the rejection, matchingfetch()semantics.
Options (all optional):
| option | default | meaning |
| ------------- | ------------- | ------- |
| attempts | 3 | total tries including the first (integer ≥ 1) |
| minDelay | 100 | base delay in ms before the first retry |
| maxDelay | 30000 | cap on the un-jittered delay (must be ≥ minDelay) |
| factor | 2 | exponential base (≥ 1; 1 = flat backoff) |
| jitter | 'full' | 'full' = delay × rand(); 'none' = exact |
| signal | — | AbortSignal; see abort semantics above |
| shouldRetry | retry all | (error, attempt) => boolean \| Promise<boolean> |
| onRetry | — | (error, attempt, delay) hook, fired before each sleep |
| sleep | real timer | injectable (ms, signal) => Promise<void> |
| rand | Math.random | injectable random source in [0, 1) for 'full' jitter |
Invalid options (e.g. attempts: 0, jitter: 'half', maxDelay <
minDelay) reject with RangeError/TypeError. Because fn cannot be
interrupted, a value fn resolves AFTER an abort is still delivered.
computeDelay(attempt, options?, rand?) → number
Pure backoff calculator used by retry and exported for exact tests and
custom schedulers: min(minDelay * factor^(attempt-1), maxDelay),
multiplied by rand() when jitter is 'full'. attempt is the 1-based
attempt that just failed (integer ≥ 1, else RangeError). Accepts the same
minDelay/maxDelay/factor/jitter options as retry and returns
milliseconds (fractional under 'full' jitter — e.g. rand = () => 0.999
at attempt 1 gives 99.9).
TypeScript users also get the exported types RetryOptions,
BackoffOptions, Jitter, SleepFn, and RandomSource.
How to test
cd retry
node test/index.test.js # 70 checks, zero dependencies, < 100 msThe suite pins golden vectors for computeDelay (growth, clamp, both jitter
modes with fixed rand), success-after-N-failures, exhaustion,
shouldRetry short-circuit (sync and async), the onRetry call log, and
three abort paths (pre-start, mid-sleep via a stub sleep, and mid-sleep
through the real default setTimeout sleep — aborted synchronously, so even
that test never actually waits).
License
MIT
