@selentia/async-retry
v1.0.0
Published
Zero-runtime-deps async retry engine with backoff, full jitter, and Retry-After support (Node + Browser).
Maintainers
Readme
@selentia/async-retry
A zero-dependency retry policy library for Node.js and browsers.
It supports exponential backoff, Retry-After handling, AbortSignal integration, full jitter,
and an overall max elapsed time limit via maxElapsedMs. Runs on Node.js ≥18 and modern browsers.
Used in production by Pastellink, a Discord bot trusted by 2,500+ servers.
📄 Other languages:
Table of Contents
Install
npm i @selentia/async-retryQuick Start
retry
import { retry } from '@selentia/async-retry';
const data = await retry(async ({ attempt }) => {
const res = await fetch('/api/data');
if (!res.ok) throw new Error(`HTTP ${res.status} (attempt=${attempt})`);
return res.json();
});createRetry
createRetry() applies default options and supports per-call overrides.
import { createRetry } from '@selentia/async-retry';
const retryFetch = createRetry({
maxAttempts: 5,
baseMs: 200,
capMs: 4000,
jitter: 'full',
});
const json = await retryFetch(
async () => {
const r = await fetch('/api/data');
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
},
{
// per-call overrides (shallow merge)
maxElapsedMs: 10_000,
},
);API
retry(task, options?) → Promise<T>
task receives a RetryContext:
await retry(async (ctx) => {
ctx.attempt; // 1..maxAttempts
ctx.maxAttempts; // max attempts
ctx.startedAt; // epoch ms when retry() started
ctx.elapsedMs; // elapsed ms since startedAt (int)
ctx.signal; // AbortSignal (if provided)
return 'ok';
});createRetry(defaultOptions) → (task, overrides?) => Promise<T>
Returns a retry-compatible function that applies defaultOptions first.
Overrides are merged shallowly ({ ...defaultOptions, ...overrides }), so nested objects are not deep-merged.
Options
The following defaults are applied:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| maxAttempts | number | 3 | Total attempts including the first call. Must be an integer ≥ 1. |
| baseMs | number | 200 | Base backoff (ms). Must be finite ≥ 0. |
| capMs | number | 2000 | Backoff cap (ms). Must be finite ≥ 0. |
| factor | number | 2 | Exponential factor. Must be finite > 0. |
| jitter | 'full' \| 'none' | 'full' | Full jitter randomizes delay in [0, backoff). |
| rng | () => number | Math.random | Random source for jitter. Non-finite results are treated as 0. |
| signal | AbortSignal | undefined | Aborts the entire retry loop (including sleep). |
| maxElapsedMs | number | undefined | Overall time budget (ms), checked before each attempt and before sleeping. |
| shouldRetry | (err, ctx) => boolean \| Promise<boolean> | defaultShouldRetry | Determines whether the error is retriable. |
| onRetry | (event) => void | undefined | Hook called immediately before sleeping. |
| wrapError | boolean | false | If true, wraps exhausted/non-retriable failures into RetryExhaustedError. |
| respectRetryAfter | boolean | true | If true, respects Retry-After for 429. |
| retryAfterHeaderName | string | 'retry-after' | Header name (case-insensitive). Whitespace is trimmed; empty falls back to default. |
| retryAfterBodyUnit | false \| 'seconds' \| 'milliseconds' | false | If enabled, reads retry_after from the response body when the header is missing. |
Retry-After semantics
When status === 429 and respectRetryAfter === true:
- The Retry-After header is checked first (case-insensitive key match).
- Numeric values are treated as seconds.
- HTTP-date values are parsed and converted to
max(0, date - now)in ms.
- If there is no usable header and
retryAfterBodyUnit !== false, a body value is used:
- Reads
retry_afterin the following order:err.response.data.retry_after,err.rawError.retry_after,err.data.retry_after - String/number values are parsed; the unit is controlled by
retryAfterBodyUnit.
If neither yields a usable delay, it falls back to regular exponential backoff.
Within onRetry(event), event.reason will be:
'retry-after'when Retry-After is used'backoff'when exponential backoff is used
Abort & Timeout semantics
- If
signalis already aborted before an attempt,retry()throwsAbortErrorand does not call the task. - If aborted during sleep, sleep is interrupted and
AbortErroris thrown. - If the task throws an “abort-like” error (
name === 'AbortError'orcode === 'ABORT_ERR'/code === 'ERR_CANCELED'), it is propagated immediately (no retries). maxElapsedMsis enforced:- before each attempt
- and before sleeping (so a long delay cannot exceed the budget)
Errors
These errors can be handled via instanceof.
| Error | When it occurs |
|------|----------------|
| AbortError | The retry loop is aborted (before an attempt or during sleep). |
| RetryTimeoutError | The maxElapsedMs budget is exceeded (before an attempt or before sleeping). |
| RetryExhaustedError | wrapError=true and the loop ends due to exhaustion or a non-retriable decision (the original error is available as cause). |
Example:
import { retry } from '@selentia/async-retry';
import { AbortError, RetryTimeoutError, RetryExhaustedError } from '@selentia/async-retry/errors';
try {
await retry(async () => {
// ...
}, { maxElapsedMs: 2000, wrapError: true });
} catch (err) {
if (err instanceof AbortError) {
// aborted by signal
} else if (err instanceof RetryTimeoutError) {
// budget exceeded
} else if (err instanceof RetryExhaustedError) {
// exhausted or non-retriable (the original error is available as `err.cause`)
}
}Guarantees
- Attempts are 1-indexed: the first call is
attempt = 1. maxAttemptsis never exceeded.onRetry()is called only when a retry will actually happen, and it is called before sleeping.- When
Retry-Afteris used, jitter is not applied; the delay is taken as-is (normalized to a non-negative integer ms). - All delays are normalized to integer milliseconds (
>= 0).
License
MIT
