Skip to content

selentia/async-retry

Repository files navigation

@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-retry

Quick 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:

  1. 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.
  1. If there is no usable header and retryAfterBodyUnit !== false, a body value is used:
  • Reads retry_after in 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 signal is already aborted before an attempt, retry() throws AbortError and does not call the task.
  • If aborted during sleep, sleep is interrupted and AbortError is thrown.
  • If the task throws an “abort-like” error (name === 'AbortError' or code === 'ABORT_ERR' / code === 'ERR_CANCELED'), it is propagated immediately (no retries).
  • maxElapsedMs is 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.
  • maxAttempts is never exceeded.
  • onRetry() is called only when a retry will actually happen, and it is called before sleeping.
  • When Retry-After is 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

About

Zero-runtime-deps async retry engine with backoff, full jitter, and Retry-After support (Node + Browser).

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors