A tiny zero-dependency retry library that knows the difference between a rate limit, a timeout, and a bug — and only retries the ones worth retrying.
Most hand-rolled retry code is wrong in the same few ways. It retries a 400 or a 401, which will return the same answer forever. It has no jitter, so a whole fleet of clients wakes up at the same instant and re-stampedes a server that was just getting back on its feet. It ignores the Retry-After header the API explicitly sent. It retries a POST that charges a card. And it has no overall deadline, so one slow call can hang for minutes.
retry-ladder gets the classification right by default and stays out of your way.
- Retries network errors and
408 / 429 / 500 / 502 / 503 / 504. Nothing else. - Never retries other 4xx (
400,401,403,404,409,422, ...). They won't change. - Honors
Retry-After(both integer seconds and HTTP-date) when the server sends it. - Backs off exponentially with full jitter, so clients don't retry in lock-step.
- Caps total wall-clock with
maxElapsedMs— a hard deadline across all attempts. - Forwards your
AbortSignalso a cancel stops both the in-flight call and the backoff. - Refuses to retry a non-idempotent operation unless you mark it
idempotent: true. - Ships a
fetchRetryhelper that wraps globalfetchwith all of the above.
Pure ESM, a bundled .d.ts, zero runtime dependencies. The whole thing is unit-tested against a fake clock, so the test suite finishes in well under a second with no real waiting.
npm install @velkina/retry-ladderNeeds Node 18+ (for global fetch and AbortSignal). Works in any runtime that has them.
retry(fn, options) runs fn, retries it on transient failures, and returns the result — or throws once the retries or the deadline run out.
import { retry } from '@velkina/retry-ladder';
const data = await retry(
async ({ attempt, signal }) => {
const res = await fetch('https://api.example.com/things', { signal });
if (!res.ok) {
// Attach the status so retry-ladder can classify it.
const err = new Error(`HTTP ${res.status}`);
err.status = res.status;
throw err;
}
return res.json();
},
{
retries: 4, // up to 4 retries after the first try
baseMs: 200, // first backoff window
maxElapsedMs: 30_000, // give up after 30s total, no matter what
idempotent: true, // a GET is safe to repeat
onRetry({ attempt, delayMs, error }) {
console.warn(`attempt ${attempt} failed (${error.message}); retrying in ${Math.round(delayMs)}ms`);
},
},
);A 404 or a 401 thrown out of fn is returned to you immediately — no retry, no waiting. A 503 is retried with backoff until it succeeds or the budget runs out.
By default, retry-ladder will not retry, even on a retryable failure, unless you tell it the operation is safe to repeat:
// Throws IdempotencyError on a 503 — retrying could double-charge the card.
await retry(() => chargeCard(order), { retries: 3 });
// You added an idempotency key, so repeating is safe. Now it retries.
await retry(() => chargeCard(order, { idempotencyKey }), { retries: 3, idempotent: true });This is opt-in on purpose. The most expensive retry bug is retrying a write that already half-succeeded.
fetchRetry has the same signature as fetch, plus the retry options. It reads Retry-After off the response for you, and infers idempotency from the HTTP method — GET / HEAD / PUT / DELETE / OPTIONS are retried automatically; POST and PATCH are retried only if you pass idempotent: true.
import { fetchRetry } from '@velkina/retry-ladder';
// A 429 with `Retry-After: 2` makes this wait exactly 2 seconds, not the backoff window.
const res = await fetchRetry('https://api.openai.com/v1/models', {
headers: { authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
retries: 5,
maxElapsedMs: 60_000,
onRetry({ attempt, delayMs, retryAfter }) {
console.warn(`429 — waiting ${Math.round(delayMs)}ms (${retryAfter ? 'Retry-After' : 'backoff'})`);
},
});
const models = await res.json();A retryable status you never shake (a 503 that stays down past your budget) comes back as a normal Response, so you handle it like any other. Network errors and the maxElapsedMs deadline throw.
const res = await fetchRetry('https://api.stripe.com/v1/charges', {
method: 'POST',
headers: {
authorization: `Bearer ${key}`,
'idempotency-key': idempotencyKey, // makes the retry safe
},
body,
idempotent: true,
retries: 3,
});Pass an AbortSignal and a cancel stops everything — the request in flight and any backoff that's currently sleeping. An AbortError is never treated as retryable.
const ac = new AbortController();
setTimeout(() => ac.abort(), 5_000);
await fetchRetry('https://api.example.com/slow', { signal: ac.signal, retries: 5 });The delay before retry n (0-based) is:
window = min(maxDelayMs, baseMs * factor ** n)
delay = random() * window // full jitter
Full jitter — picking a random point in [0, window] rather than always sleeping the whole window — is what keeps a fleet of clients from retrying at the same moment and re-overloading a recovering server. It's the "Full Jitter" approach from the AWS Architecture Blog post Exponential Backoff And Jitter by Marc Brooker: https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
When the server sends a Retry-After, that wins over the computed backoff, because the server knows when it'll be ready and you don't.
| Option | Default | What it does |
|---|---|---|
retries |
3 |
Max retries after the first try (so up to 4 calls). |
baseMs |
200 |
The first backoff window, in ms. |
factor |
2 |
Exponential growth per attempt. |
maxDelayMs |
30000 |
Ceiling on any single backoff window. |
maxElapsedMs |
Infinity |
Hard deadline across all attempts, in ms. |
idempotent |
false |
Must be true to retry. (fetchRetry infers it from the method.) |
signal |
— | An AbortSignal that cancels the call and the backoff. |
retryableStatuses |
408,429,500,502,503,504 |
Override the retryable HTTP status set. |
retryableErrorCodes |
network codes | Override the retryable error-code set (ECONNRESET, ETIMEDOUT, ...). |
shouldRetry |
built-in classifier | Custom decision for a thrown error. |
shouldRetryResult |
— | Opt a successful return value into retry classification. |
getRetryAfterMs |
— | Pull a server-dictated delay (ms) from a failure. fetchRetry wires this up. |
onRetry |
— | Called before each backoff: { attempt, error, delayMs, elapsedMs, retryAfter }. |
random |
Math.random |
RNG in [0,1) for jitter. Injectable for tests. |
| Class | When |
|---|---|
IdempotencyError |
A retryable failure on an operation that wasn't marked idempotent. |
RetryExhaustedError |
All retries used up. Has .attempts, .elapsedMs, and the last failure as .cause. |
RetryTimeoutError |
The maxElapsedMs deadline was reached. Same fields as above. |
| (the original error) | A non-retryable failure (e.g. a 400) passes straight through. |
AbortError |
The signal was aborted. |
Also exported for building your own logic: isRetryable, extractStatus, parseRetryAfter, retryAfterFromHeaders, backoffWindow, jitteredDelay, DEFAULT_RETRYABLE_STATUSES, DEFAULT_RETRYABLE_ERROR_CODES.
npm test # node --test, runs against a fake clock — finishes in ~0.2s
node examples/basic.js
node examples/fetch.jsMIT. See LICENSE.
Built by Velkina — https://velkina.com