@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.
Maintainers
Readme
Retry-Box
A production-grade, framework-agnostic retry & resilience engine for Node.js
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, orSQLITE_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 for429responses. - 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,
4xxclient 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
- Quick start
- Core concepts
- Core API
- Framework adapters
- Examples
- Node.js support
- Testing
- Contributing
- License
Installation
npm install @hey-amanthakur/retry-box
pnpm add @hey-amanthakur/retry-box
yarn add @hey-amanthakur/retry-boxFramework 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 onlyQuick 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 delaysCircuit 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 disablesA 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;AbortErroris never retried, even byalwaysRetryClassifier.- A custom abort reason that is an
Erroris 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 hooksNode.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 + buildContributing
Contributions are welcome and appreciated. Please read the Contributing Guidelines before opening a pull request.
- Bug reports & feature requests → open an issue
- Pull requests → target the
mainbranch; include tests for any new behavior - Discussions & questions → start a discussion
By contributing, you agree that your contributions will be licensed under the MIT License.
License
MIT © 2026 Aman Thakur
