p-retry
p-retry calls an async function again when it rejects, waiting longer between each attempt. You hand it a function, it returns a promise that settles with the first success or the last failure. Between attempts it waits minTimeout multiplied by factor to the power of the attempt number, capped at maxTimeout, optionally randomized to spread out load. Beyond the basics it gives you three hooks: onFailedAttempt to log or delay, shouldRetry to decide whether a given error is worth another go, and shouldConsumeRetry to let some failures not count against the budget. Throwing AbortError from inside your function stops everything immediately, and an AbortSignal cancels from outside.
The right size for the job: a few lines of configuration gets you correct exponential backoff with cancellation, and the newer shouldRetry and shouldConsumeRetry hooks cover the rate-limit case most retry helpers get wrong. Change the defaults before shipping, and reach for cockatiel the moment you need a circuit breaker too.
Use it if
- You want exponential backoff around a flaky network call and do not want to hand-roll the delay loop, the attempt counter, and the cancellation path
- You need to distinguish permanent from transient failures: AbortError or shouldRetry stops instantly on a 404 or a validation error instead of retrying it ten times
- You are rate limited and want 429 responses to wait without eating the retry budget, which is exactly what shouldConsumeRetry was added for
- You need retries to stop when a request is cancelled or the process gets SIGINT, via the signal option and an AbortController
- You want this to cost nothing: about 1.7 KB gzipped with one small dependency
- You are on CommonJS or older Node. Version 8 is ESM only and declares node >=22, so require() fails and older runtimes are out; the version4 dist-tag exists for CJS holdouts, and async-retry is the maintained CJS option
- The defaults are dangerous if you do not read them: retries defaults to 10 with factor 2, minTimeout 1000, and maxTimeout Infinity, so a call that keeps failing waits 1, 2, 4 and on up to 512 seconds and takes roughly 17 minutes to give up. Set retries and maxTimeout deliberately
- randomize is false by default, so a hundred clients failing at the same moment retry in lockstep and hit your service together again
- Non-network TypeErrors are never retried, even if your shouldRetry returns true. If a client library signals real transient failures as TypeError, this package will quietly refuse to retry them
- It is only retries. No circuit breaker, bulkhead, timeout, fallback, or hedging, so a dependency that is down stays hammered by every caller; cockatiel covers those policies
- Retry state lives in one process. Nothing is persisted, so a crash mid-backoff loses the work, and a queue with its own retry semantics is a better fit for anything that must not be dropped
Setup reality
npm install p-retry and import it: no build config, no peer dependencies, types included. The friction is the module format and the Node floor. Version 8 sets "type": "module" and engines.node >=22, which means CommonJS test files, older Jest transforms, and any project still emitting require() will fail to load it, and downgrading to v4 gets you a different API built on the older retry package. Second, the behavior around errors is subtle enough that reading it once saves an afternoon: onFailedAttempt runs after shouldConsumeRetry and before shouldRetry, none of the three run for AbortError, and throwing from any of them aborts the whole thing with your thrown error rather than the original. Finally, the delays use the global setTimeout, so tests need fake timers or every unit test of a retry path waits real seconds.
Patterns
Retry a failing async callbasic-retry
import pRetry from 'p-retry';
const result = await pRetry(
async () => {
const response = await fetch('https://api.example.com/data');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
},
{ retries: 3 }
);fetch only rejects on network failure, so you have to throw on a bad status yourself or a 500 is treated as success. Always set retries: the default of 10 with no maxTimeout runs for about 17 minutes.
Abort immediately on an error that will never succeedstop-retrying-permanent-errors
import pRetry, { AbortError } from 'p-retry';
await pRetry(async () => {
const response = await fetch(url);
if (response.status === 404) {
throw new AbortError('Not found');
}
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
});AbortError skips onFailedAttempt, shouldRetry, and shouldConsumeRetry entirely and rejects at once. Pass an existing error to keep it as the cause: new AbortError(originalError).
Configure the backoff curvetune-backoff
import pRetry from 'p-retry';
await pRetry(run, {
retries: 5,
factor: 2,
minTimeout: 200,
maxTimeout: 5000,
randomize: true,
});
// delays: 200, 400, 800, 1600, 3200 ms, each multiplied by 1 to 2randomize is off unless you set it, and without it every client that failed at the same instant retries at the same instant. maxTimeout is the ceiling per wait, not a total budget; use maxRetryTime for that.
Log or instrument each failurelog-failed-attempts
import pRetry from 'p-retry';
await pRetry(run, {
retries: 4,
onFailedAttempt({ error, attemptNumber, retriesLeft, retryDelay }) {
logger.warn(
`attempt ${attemptNumber} failed: ${error.message}; ` +
`retrying in ${retryDelay}ms, ${retriesLeft} left`
);
},
});attemptNumber starts at 1. If this callback throws, retrying stops and the promise rejects with your thrown error, not the original failure, so keep logging code inside a try or keep it trivial.
Decide per error whether to retryretry-only-some-errors
import pRetry from 'p-retry';
await pRetry(run, {
retries: 5,
shouldRetry({ error }) {
if (error instanceof ValidationError) return false;
const status = error.response?.status;
return status === undefined || status >= 500 || status === 429;
},
});shouldRetry runs after onFailedAttempt and never runs for AbortError or for a non-network TypeError, which is aborted regardless of what you return here.
Do not let 429 responses consume retriesrate-limit-without-burning-budget
import pRetry from 'p-retry';
await pRetry(run, {
retries: 3,
maxRetryTime: 60_000,
shouldConsumeRetry: ({ error }) => error.status !== 429,
});A failure that does not consume a retry also does not advance the backoff, so the delay stays flat. Pair it with maxRetryTime or a server that keeps returning 429 will loop until the process ends.
Wait for the server's Retry-After headerrespect-retry-after
import pRetry from 'p-retry';
import { setTimeout as sleep } from 'node:timers/promises';
await pRetry(run, {
retries: 3,
async onFailedAttempt({ error }) {
const after = Number(error.response?.headers?.get('retry-after'));
if (Number.isFinite(after)) {
await sleep(after * 1000);
}
},
});onFailedAttempt may return a promise, and p-retry awaits it before its own backoff wait, so this adds to the delay rather than replacing it. There is no built-in Retry-After support.
Cancel retries from the outsidecancel-with-abort-signal
import pRetry from 'p-retry';
const controller = new AbortController();
process.once('SIGINT', () => controller.abort(new Error('SIGINT received')));
try {
await pRetry(run, { retries: 5, signal: controller.signal });
} catch (error) {
console.log('stopped:', error.message);
}Aborting rejects with the reason you passed to abort(), and it interrupts the sleep between attempts, not just the gaps. The package deliberately does not install signal handlers itself.
Make a permanently retrying version of a functionwrap-a-function-once
import { makeRetriable } from 'p-retry';
const fetchWithRetry = makeRetriable(fetch, {
retries: 3,
minTimeout: 250,
maxTimeout: 2000,
});
const response = await fetchWithRetry('https://api.example.com/users');makeRetriable keeps the original signature and types, so it is the cleanest way to retrofit retries onto an existing client without touching call sites. Arguments are re-evaluated per call, not per attempt.
Retry a function that takes argumentspass-arguments
import pRetry from 'p-retry';
const getUser = async (id, options) => { /* ... */ };
// wrong: pRetry calls the function with the attempt number
await pRetry(getUser, { retries: 3 });
// right: close over the arguments
await pRetry(() => getUser(42, { expand: true }), { retries: 3 });p-retry passes the attempt number as the first argument to your function, so handing it a function that expects real parameters gives you getUser(1) on the first try. Wrap it in an arrow function.
Cap total retry time instead of attempt countbound-total-time
import pRetry from 'p-retry';
await pRetry(run, {
retries: Number.POSITIVE_INFINITY,
minTimeout: 500,
maxTimeout: 5000,
maxRetryTime: 30_000,
});maxRetryTime is measured with performance.now(), so a clock change does not extend or shorten it. Combining infinite retries with a time budget is the pattern for startup checks such as waiting on a database.
Test retry logic without waitingtest-with-fake-timers
import { mock, test } from 'node:test';
import assert from 'node:assert/strict';
import pRetry from 'p-retry';
test('retries twice then succeeds', async () => {
mock.timers.enable({ apis: ['setTimeout'] });
let calls = 0;
const promise = pRetry(async () => {
if (++calls < 3) throw new Error('boom');
return 'ok';
}, { retries: 5, minTimeout: 1000 });
await mock.timers.tickAsync(10_000);
assert.equal(await promise, 'ok');
});The delays come from the global setTimeout, so any fake timer library works. Without this a single test of a five-retry path can sit for half a minute of wall clock.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| async-retry | npm | You need the same idea from CommonJS or an older Node version and can live with a less current API. |
| cockatiel | npm | You want retry plus circuit breaker, timeout, bulkhead, and fallback as composable policies rather than retry alone. |
| promise-retry | npm | You want a thin wrapper over the classic retry package where your function receives an explicit retry callback. |