retryfn
Retry async functions with exponential backoff, jitter,
AbortSignal, per-attempt timeouts, andRetry-Afterawareness. Zero dependencies.
Transient failures are a fact of life: a dropped connection, a 429 Too Many Requests from an LLM API, a service that's briefly 503. retryfn retries the
operation the right way — exponential backoff with jitter so you don't stampede,
honoring the server's Retry-After header when it sends one, with real
AbortSignal and per-attempt timeout support.
import { retry } from "@billdaddy/retryfn";
const data = await retry(
async ({ signal }) => {
const res = await fetch(url, { signal });
if (res.status === 429 || res.status >= 500) {
throw Object.assign(new Error(`HTTP ${res.status}`), { response: res });
}
return res.json();
},
{ retries: 5, timeout: 10_000 },
);If that 429 carried Retry-After: 2, the next attempt waits exactly 2 seconds —
not a guessed backoff.
Why retryfn?
- Server-aware. Reads a
Retry-Afterhint (seconds or HTTP-date, from aHeadersobject or a plain record) and waits exactly that long. - Cancellable. Each attempt gets an
AbortSignaldriven by yourtimeoutand your own externalsignal— forward it straight tofetch. - Good backoff by default. Exponential growth with full jitter, capped by
maxDelayand an optional totalmaxElapsedbudget. - Precise control.
shouldRetry(error)to decide per-error,onRetryto observe, deterministic via an injectablerng. - Zero dependencies, ESM + CJS + types, and a CLI to retry shell commands.
Install
npm install @billdaddy/retryfn
# or: pnpm add @billdaddy/retryfn / yarn add @billdaddy/retryfn / bun add @billdaddy/retryfnAPI
retry(fn, options?) → Promise<T>
fn receives { attempt, signal }. Throw to trigger a retry; return to resolve.
| Option | Type | Default | Description |
|---|---|---|---|
retries |
number |
3 |
Retries after the first try (4 attempts total). |
minDelay |
number (ms) |
200 |
Base delay for the first retry. |
maxDelay |
number (ms) |
30000 |
Cap on a single computed delay. |
factor |
number |
2 |
Exponential multiplier. |
jitter |
"full" | "equal" | "none" |
"full" |
Randomisation strategy. |
maxElapsed |
number (ms) |
— | Total time budget across all attempts/waits. |
timeout |
number (ms) |
— | Per-attempt timeout (aborts the attempt signal). |
signal |
AbortSignal |
— | Cancel the whole operation. |
honorRetryAfter |
boolean |
true |
Prefer a Retry-After hint over backoff. |
shouldRetry |
(error, attempt) => boolean | Promise |
retry all | Decide whether an error is retryable. |
onRetry |
({error, attempt, delay}) => void |
— | Observe each scheduled retry. |
Throws the last error when retries are exhausted, shouldRetry returns false,
or the external signal aborts.
calcBackoff(attempt, options?, rng?) → number
The backoff math on its own (zero-based attempt), exported for reuse and testing.
getRetryAfterMs(error, now?) → number | undefined
Extract a Retry-After wait (ms) from an error: error.retryAfterMs,
error.retryAfter (seconds), or a Retry-After header on
error.response.headers / error.headers.
isAbortError(err) → boolean
true for AbortError / TimeoutError thrown via an AbortSignal.
Recipes
Only retry network/5xx, never 4xx (except 429):
await retry(call, {
shouldRetry: (err) => {
const s = (err as any).response?.status;
return s == null || s === 429 || s >= 500;
},
});Hard ceiling on total time:
await retry(call, { retries: 20, maxElapsed: 15_000 });CLI
Retry a shell command until it succeeds:
retryfn -r 5 -- curl -fsS https://flaky.example.com/health
retryfn --min 1000 --factor 3 -- ./deploy.shContributors
This project follows the all-contributors specification. Contributions of any kind are welcome — code, docs, bug reports, ideas, reviews! See the emoji key for how each contribution is recognized, and open a PR or issue to get involved.
Thanks goes to these wonderful people:
Tung Tran |
License
MIT Tung Tran