mrkeyoor.com_
Thu 06 Aug 02:47 UTC
npmUtilsupdated 05 Aug 2026

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.

Verdict

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.

API stability3/5pRetry(fn, options) has been the same call for years, but majors move often: v6 reshaped onFailedAttempt into a context object, v7 and v8 dropped the retry package, added shouldRetry, shouldConsumeRetry, and makeRetriable, and raised the Node floor to 22.
Docs4/5The README documents every option with a runnable example, states the ordering of the three callbacks explicitly, and answers the timer-mocking and SIGINT questions in a FAQ; there is no separate site and no migration guide between majors.
Maintenance4/58.0.0 shipped in March 2026 with an essentially empty tracker, and it sits in Sindre Sorhus's promise-fun collection, which has been maintained for a decade; releases are infrequent and arrive as breaking majors.
Ecosystem5/5About 47M downloads a week as the retry layer inside a large number of SDKs and CLI tools, and it composes with the rest of the p-* family such as p-timeout and p-queue.

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
Skip it if

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 2

randomize 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

PackageRegistryPick it when
async-retrynpmYou need the same idea from CommonJS or an older Node version and can live with a less current API.
cockatielnpmYou want retry plus circuit breaker, timeout, bulkhead, and fallback as composable policies rather than retry alone.
promise-retrynpmYou want a thin wrapper over the classic retry package where your function receives an explicit retry callback.