npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@hey-amanthakur/retry-box

v1.0.0

Published

Retry-Box — a production-grade, framework-agnostic retry and resilience engine for Node.js. Zero runtime dependencies. Retries, backoff strategies, jitter, failure classification, retry budgets, timeouts, cancellation and circuit breakers.

Readme

Retry-Box

A production-grade, framework-agnostic retry & resilience engine for Node.js

npm version npm downloads license types dependencies node

Zero runtime dependencies · Backoff & jitter · Classification · Budgets · Timeouts · Cancellation · Circuit breakers · Express · Fastify · Koa · NestJS


Overview

Retry-Box is a lightweight, framework-agnostic retry and resilience engine for Node.js. It retries failing operations with configurable backoff, decides which failures deserve a retry, caps how hard it tries, times out slow attempts, cooperates with AbortSignal cancellation, and protects downstream dependencies with a circuit breaker — all with zero runtime dependencies.

Highlights

| | | | --- | --- | | Zero dependencies | No transitive supply-chain risk; nothing to audit beyond Node itself. | | Backoff strategies | Fixed, linear, and exponential delays with full / equal / decorrelated jitter. | | Smart classification | Retry only what deserves it: transient network codes by default, or your own classifier. | | Retry budgets | Cap attempts, wall-clock time, and total delay per execution — stateless and safe to share. | | Circuit breaker | Standalone CircuitBreaker or engine integration; closed / open / half-open state machine. | | Per-attempt timeouts | Fresh timeout window per attempt; operations cooperate via AbortSignal. | | Cancellation | One AbortSignal cancels the whole execution, including pending retry delays. | | Observability hooks | onAttempt, onRetry, onSuccess, onFailure, onExhausted — throwing hooks never break the loop. | | Framework adapters | Drop-in wrappers for Express, Fastify, Koa, and NestJS. | | Dual ESM + CommonJS | Ships both module formats with full TypeScript type definitions. |


When to use it

Retry-Box is useful anywhere an operation can fail transiently — the failure is temporary and retrying it has a good chance of succeeding:

  • Outbound HTTP calls to third-party APIs, webhooks, or internal microservices that occasionally return 5xx, time out, or reset connections.
  • Database and cache access — transient ECONNRESET / ETIMEDOUT / connection-pool exhaustion, or SQLITE_BUSY-style locks.
  • Message / queue consumers — retrying job processing with backoff and jitter so a batch of failing jobs doesn't hammer a downstream service.
  • File and blob storage operations (S3, GCS) and other remote I/O prone to flaky networks.
  • Webhook delivery — with a Retry-After-respecting classifier for 429 responses.
  • HTTP APIs via the Express, Fastify, Koa, and NestJS adapters — retry route handlers that fail before writing a response.
  • Client-side resilience — wrap SDK/ORM calls so your application degrades gracefully instead of failing fast on every blip.

It is not a fit for:

  • Non-retryable operations — validation errors, 4xx client mistakes, or anything with side effects that must not run twice (unless your operation is idempotent).
  • Long-running background jobs that need persistence — the engine owns retry semantics only. Persistence, queues, and distributed coordination are intentionally out of scope; pair it with your own durable queue.

Tip: pair Retry-Box with a circuit breaker so a truly failing dependency stops consuming retry attempts, and keep operations idempotent so an unexpected retry after a success is harmless.


Table of Contents


Installation

npm install @hey-amanthakur/retry-box
pnpm add    @hey-amanthakur/retry-box
yarn add    @hey-amanthakur/retry-box

Framework packages are optional peer dependencies — install only the one you use:

npm install express
npm install fastify
npm install koa
npm install @nestjs/common @nestjs/core reflect-metadata   # NestJS only

Quick start

import { RetryEngine } from '@hey-amanthakur/retry-box';

const retry = new RetryEngine(); // sensible defaults out of the box

try {
  const value = await retry.run(() => fetchSomething());
} catch (error) {
  // gave up after exhausting retries — the original error is thrown
}

The default engine retries 3 attempts with exponential backoff + full jitter, retrying only well-known transient failures (e.g. ECONNRESET, ETIMEDOUT, EAI_AGAIN).


Core concepts

The engine is deliberately small and compositional. Each piece owns one concern:

  • Backoff strategy decides how long to wait.
  • Classifier decides whether to retry.
  • Budget caps how hard to try.
  • Circuit breaker stops calling a failing dependency entirely.
  • Timeout bounds each attempt.
  • Signal cancels the whole execution.

Core API

RetryEngine

import {
  RetryEngine,
  exponential,
  alwaysRetryClassifier,
  CircuitBreaker,
  RetryBudget,
} from '@hey-amanthakur/retry-box';

const engine = new RetryEngine({
  maxAttempts: 5,
  strategy: exponential({ initialDelay: 200, multiplier: 2, maxDelay: 10_000 }),
  classifier: alwaysRetryClassifier,
  budget: new RetryBudget({ maxDuration: 5_000 }),
  circuitBreaker: new CircuitBreaker({ failureThreshold: 5, resetTimeout: 30_000 }),
  timeout: 1_000,        // per-attempt default
  wrapFinalError: false, // throw the original error (default)
});

| Method | Description | | --- | --- | | run(fn, options?) | Run fn with the retry policy. Resolves with the value, rejects with the final error. | | runWithResult(fn, options?) | Like run(), but resolves with a RetryResult (value, attempts, duration, executionId, totalDelay). |

Per-call options:

await engine.run(fn, {
  timeout: 500,          // override the per-attempt timeout; 0 disables
  signal,                // cancel the whole execution (including delays)
  metadata: { customerId: '123' }, // arbitrary data visible to hooks/classifiers
  executionId: 'op-1',   // override the auto-generated id
});

Backoff strategies

import { fixed, linear, exponential } from '@hey-amanthakur/retry-box';

fixed({ delay: 200 });                    // [200, 200, 200, ...]
linear({ initialDelay: 200, increment: 200, maxDelay: 5_000 }); // [200, 400, 600, ...]
exponential({ initialDelay: 200, multiplier: 2, maxDelay: 10_000 }); // [200, 400, 800, ...]

The default strategy is exponential with full jitter — the industry-standard approach for avoiding retry thundering herds.

Jitter

exponential({
  initialDelay: 200,
  multiplier: 2,
  maxDelay: 10_000,
  jitter: { type: 'full' },          // random(0, base) — lowest latency, noisiest
  // jitter: { type: 'equal' },      // random(base/2, base) — keeps a minimum delay
  // jitter: { type: 'decorrelated' }, // grows from the previous delay, no waves
});

For reproducible tests:

jitter: { type: 'equal', deterministic: true }   // seeded by executionId (or `seed`)

The same executionId always produces the same delay sequence.

Failure classification

The default classifier retries only well-known transient failures and never retries cancellation or circuit-open errors:

import { defaultClassifier, alwaysRetryClassifier, type RetryClassifier } from '@hey-amanthakur/retry-box';

const classifier: RetryClassifier = {
  classify(error, context) {
    if (error instanceof TypeError) return { retry: false, reason: 'bug' };
    const status = (error as { status?: number }).status;
    if (status === 429) {
      return { retry: true, reason: 'rate-limited', retryAfter: 1_000 }; // overrides strategy delay
    }
    if (status && status >= 500) return { retry: true, reason: 'server' };
    if (context.attempt >= 2) return { retry: false, reason: 'giving-up' };
    return { retry: true, reason: 'transient' };
  },
};

retryAfter (ms) overrides the strategy's computed delay — perfect for honoring server-provided Retry-After hints. Classification is isolated from delays: the classifier never computes delays, the strategy never decides retryability.

Retry budgets

Budgets cap a single execution regardless of strategy or classifier. Budget state is derived from the context, so one RetryBudget instance is safe to share across concurrent executions.

import { RetryBudget } from '@hey-amanthakur/retry-box';

budget: { maxAttempts: 4 },          // cap total attempts (incl. the first)
budget: { maxDuration: 1_500 },      // cap wall-clock time: elapsed + nextDelay <= maxDuration
budget: { maxTotalDelay: 800 },      // cap the sum of all retry delays

Circuit breaker

Standalone:

import { CircuitBreaker, CircuitOpenError } from '@hey-amanthakur/retry-box';

const breaker = new CircuitBreaker({
  failureThreshold: 5,
  resetTimeout: 30_000,       // ms until half-open trials
  halfOpenMaxAttempts: 1,
  hooks: {
    onOpen: () => console.log('circuit open'),
    onHalfOpen: () => console.log('half-open'),
    onClose: () => console.log('closed'),
    onReject: () => console.log('rejected while open'),
  },
});

try {
  const value = await breaker.execute(fn);
} catch (error) {
  if (error instanceof CircuitOpenError) { /* fast-fail */ }
}

Engine integration — the breaker gates every attempt:

const engine = new RetryEngine({
  maxAttempts: 5,
  circuitBreaker: new CircuitBreaker({ failureThreshold: 3, resetTimeout: 10_000 }),
});

When the breaker is open, attempts are rejected immediately with CircuitOpenError instead of burning retries. State transitions are synchronous and atomic, so only halfOpenMaxAttempts trials can be in flight at once.

Timeouts

Timeouts are per attempt (not total): each attempt gets a fresh window.

const engine = new RetryEngine({ maxAttempts: 3, timeout: 1_000 }); // engine-wide default

await engine.run(fn, { timeout: 500 });   // per-call override; 0 disables

A timed-out attempt rejects with TimeoutError (retryable by default). Operations receive an AbortSignal per attempt so they can cooperate and stop early.

Cancellation

const controller = new AbortController();

const job = engine.run(fn, { signal: controller.signal }).catch((error) => {
  // AbortError
});

setTimeout(() => controller.abort(), 1_000); // cancels the operation AND pending delays
await job;
  • AbortError is never retried, even by alwaysRetryClassifier.
  • A custom abort reason that is an Error is preserved.
  • An already-aborted signal fails immediately without running the operation.

Errors

| Error | When | | --- | --- | | TimeoutError | An attempt exceeded its per-attempt timeout. Carries timeoutMs and attempt. | | AbortError | The execution was cancelled. Standard name === 'AbortError'. | | CircuitOpenError | A circuit breaker rejected the call before it started. Carries state. | | RetryExhaustedError | Only when wrapFinalError: true; wraps the original error as cause with full metadata. |

By default the engine throws the original last error when retries are exhausted, so existing catch blocks keep working unchanged:

const engine = new RetryEngine({ maxAttempts: 3, wrapFinalError: true });

Hooks

const engine = new RetryEngine({
  hooks: {
    onAttempt: (context) => {},               // attempt about to run
    onRetry: (context, info) => {},           // failed, about to delay (info.delay, info.reason)
    onSuccess: (context, value) => {},        // succeeded
    onFailure: (context, error) => {},        // an attempt failed
    onExhausted: (context, error, info) => {}, // gave up
  },
});

Hook exceptions are isolated by design: a throwing hook never breaks the retry loop or hides the operation's own error.


Framework adapters

Adapters only run the handler through a RetryEngine — the engine owns all retry semantics. Important: a handler is only retryable if it fails before sending a response; retrying a handler that already wrote headers is unsafe.

Express

import express from 'express';
import { retryMiddleware, RetryEngine } from '@hey-amanthakur/retry-box/express';

const engine = new RetryEngine({ maxAttempts: 4 });
const app = express();

app.get('/payments', retryMiddleware({ engine })(async (_req, res) => {
  res.json(await unreliableCall());
}));

Fastify

import Fastify from 'fastify';
import { retryHandler, retryPlugin, RetryEngine } from '@hey-amanthakur/retry-box/fastify';

const app = Fastify();
const engine = new RetryEngine({ maxAttempts: 4 });

retryPlugin({ engine })(app);                       // decorate app.retry(...)
app.get('/payments', app.retry(async () => unreliableCall()));

// or without the decorator:
app.get('/payments', retryHandler({ engine })(async () => unreliableCall()));

Koa

import Koa from 'koa';
import { retryMiddleware, RetryEngine } from '@hey-amanthakur/retry-box/koa';

const app = new Koa();
const engine = new RetryEngine({ maxAttempts: 4 });

app.use(retryMiddleware({ engine })(async (ctx) => {
  ctx.body = await unreliableCall();
}));

NestJS

import { UseInterceptors } from '@nestjs/common';
import { RetryInterceptor, createRetryInterceptor, RetryEngine } from '@hey-amanthakur/retry-box/nest';

const engine = new RetryEngine({ maxAttempts: 4 });

@Controller('payments')
class PaymentsController {
  @Get()
  @UseInterceptors(createRetryInterceptor({ engine }))   // per-route
  findAll() {
    return this.service.fetchAll();
  }
}

// global:
const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(new RetryInterceptor({ engine }));

Configuration reference

interface EngineOptions {
  /** Total attempts including the first. Integer >= 1. Default 3. */
  maxAttempts?: number;
  /** Backoff strategy. Default: exponential with full jitter. */
  strategy?: RetryStrategy;
  /** Failure classifier. Default: retries only well-known transient failures. */
  classifier?: RetryClassifier;
  /** Per-execution retry budget. Optional. */
  budget?: RetryBudgetConfig | RetryBudget;
  /** Circuit breaker. Optional. */
  circuitBreaker?: CircuitBreaker;
  /** Lifecycle hooks. Optional. */
  hooks?: RetryHooks;
  /** Default per-attempt timeout in ms. Optional. Override per call. */
  timeout?: number;
  /** Throw a RetryExhaustedError (original error as cause) when exhausted. Default false. */
  wrapFinalError?: boolean;
}

Examples

Runnable examples live in examples/ — run any of them with npx tsx:

npx tsx examples/basic.ts          # quick start with defaults
npx tsx examples/strategies.ts     # fixed / linear / exponential + jitter
npx tsx examples/classifier.ts     # default, always-retry and custom classifiers
npx tsx examples/budget.ts         # maxAttempts / maxDuration / maxTotalDelay
npx tsx examples/circuit-breaker.ts# standalone + engine integration
npx tsx examples/timeout.ts        # per-attempt timeouts and cooperation
npx tsx examples/cancellation.ts   # AbortSignal support
npx tsx examples/hooks.ts          # lifecycle hooks + metadata
npx tsx examples/run-with-result.ts# execution metadata
npx tsx examples/errors.ts         # error types and wrapFinalError
npx tsx examples/adapters.ts       # Express / Fastify / Koa / NestJS
npx tsx examples/observability.ts  # structured logs + metrics via hooks

Node.js support

Tested across the Node.js versions the industry currently runs and the newest line:

| Node line | Status | Supported | | --- | --- | :---: | | 20.x | EOL ~Apr 2026, still widely deployed | ✅ | | 22.x | Active LTS | ✅ | | 24.x | Active LTS (newest LTS) | ✅ | | 26.x | Current | ✅ |

engines.node: ">=20.19.0".


Testing

npm test          # run unit + integration tests with tsx + node:test
npm run test:coverage  # coverage with gates (lines/functions/statements >= 90, branches >= 85)
npm run typecheck # tsc --noEmit
npm run build     # tsup dual ESM/CJS + .d.ts
npm run verify    # lint + typecheck + test + build

Contributing

Contributions are welcome and appreciated. Please read the Contributing Guidelines before opening a pull request.

  • Bug reports & feature requestsopen an issue
  • Pull requests → target the main branch; include tests for any new behavior
  • Discussions & questionsstart a discussion

By contributing, you agree that your contributions will be licensed under the MIT License.


License

MIT © 2026 Aman Thakur