@backendkit-labs/retry
v0.2.0
Published
Enterprise-grade retry library for Node.js -- exponential backoff, sliding window budget, error classification, duck-typed integrations, and optional NestJS support
Maintainers
Readme
@backendkit-labs/retry
Enterprise-grade retry library for Node.js — exponential backoff, sliding-window budget, error classification, circuit-breaker and bulkhead integration, and optional NestJS support. Returns Result<T, RetryError>, never throws.
Minimal Example
Self-contained runnable example — no NestJS, one file, realistic scenario.
git clone https://github.com/BackendKit-labs/backendkit-monorepo.git
cd backendkit-monorepo/examples/minimal-retry
npm install && npm startShows a payment gateway that fails 60% of the time retried with exponential backoff + jitter. Lifecycle hooks log each attempt in real time. → full source
Table of Contents
- Installation
- Quick Start
- RetryEngine — full control
- Configuration Reference
- Backoff Strategies
- Error Types
- BackendKit Integrations
- NestJS Integration
- RetryRegistry
- API Reference
Installation
npm install @backendkit-labs/retry @backendkit-labs/resultNestJS peer dependencies (optional — only if using RetryModule):
npm install @nestjs/common @nestjs/core rxjsQuick Start
retry() is a standalone function backed by a global registry. It covers 90% of use cases in two lines:
import { retry } from '@backendkit-labs/retry';
import { match } from '@backendkit-labs/result';
const result = await retry(() => fetchUser(userId), {
maxAttempts: 3,
backoff: { type: 'exponential', baseDelay: 200 },
});
match(result, {
ok: (user) => res.json(user),
fail: (error) => res.status(502).json({ error: error.message }),
});retry() returns Result<T, RetryError> — a plain { ok, value } | { ok, error } object — and never throws. Check result.ok, or use match() from @backendkit-labs/result to handle both paths.
Minimal retry with default config
// 3 attempts, fixed 200ms delay
const result = await retry(() => callExternalApi());Handle specific error types
const result = await retry(() => chargePayment(order), {
maxAttempts: 4,
backoff: { type: 'exponential', baseDelay: 300, maxDelay: 5_000 },
});
if (!result.ok) {
const { type, status, metadata } = result.error;
if (type === 'http' && status === 429) {
// rate-limited — already retried, still failed
}
console.log(`Failed after ${metadata.attempts} attempt(s)`);
}RetryEngine — full control
RetryEngine is the stateful core. Use it when you need shared configuration, per-engine metrics, or external integrations (circuit breaker, bulkhead, observability).
import { RetryEngine } from '@backendkit-labs/retry';
import { circuitBreaker } from '@backendkit-labs/circuit-breaker';
import { bulkhead } from '@backendkit-labs/bulkhead';
const engine = new RetryEngine({
name: 'payment-gateway',
defaultConfig: {
maxAttempts: 3,
backoff: { type: 'exponential', baseDelay: 200, maxDelay: 8_000, jitter: 'full' },
timeout: { attemptTimeoutMs: 5_000, globalTimeoutMs: 20_000 },
},
integrations: {
circuitBreaker: circuitBreaker({ failureThreshold: 5, cooldownMs: 10_000 }),
bulkhead: bulkhead({ maxConcurrent: 10, maxQueue: 20 }),
observability: {
logger: myLogger,
metrics: myMetricsEmitter,
},
},
});
const result = await engine.execute(() => chargePayment(order));
// Per-execution overrides
const result2 = await engine.execute(
() => refundPayment(order),
{ maxAttempts: 5 },
);
// With correlationId for distributed tracing
const result3 = await engine.executeWithContext(
() => fetchInventory(productId),
{ correlationId: req.headers['x-request-id'] },
);Configuration Reference
All options are in RetryConfig. Every field except maxAttempts and backoff is optional.
interface RetryConfig {
maxAttempts: number;
backoff: BackoffConfig | BackoffStrategy;
retryIf?: RetryCondition | RetryConditionFn;
abortIf?: AbortCondition | AbortConditionFn;
timeout?: Partial<TimeoutConfig>;
budget?: Partial<RetryBudgetConfig>;
idempotency?: Partial<IdempotencyConfig>;
classifiers?: ClassifierRule[];
dynamicDelay?: (error: RetryErrorPayload, attempt: number) => number;
hooks?: RetryHooks;
fallback?: (error: RetryErrorPayload) => unknown | Promise<unknown>;
correlationId?: string;
}maxAttempts
Total number of attempts including the first one. maxAttempts: 3 means one initial call plus two retries.
await retry(task, { maxAttempts: 5 });backoff
Controls the delay between retries. Accepts a config object (shorthand) or a BackoffStrategy instance (composable). See Backoff Strategies for all options.
// Shorthand (most common)
{ backoff: { type: 'exponential', baseDelay: 200, maxDelay: 10_000, jitter: 'full' } }
// Strategy instance (composable)
import { ExponentialBackoff, JitterDecorator } from '@backendkit-labs/retry';
const strategy = new JitterDecorator(new ExponentialBackoff({ baseDelay: 200 }), 'full');
{ backoff: strategy }retryIf / abortIf
Fine-grained control over which errors trigger a retry and which abort immediately.
await retry(task, {
// Only retry on network errors and 5xx responses
retryIf: (error) => error.type === 'network' || (error.type === 'http' && (error.status ?? 0) >= 500),
// Abort immediately on 401/403 — retrying is pointless
abortIf: (error) => error.type === 'http' && [401, 403].includes(error.status ?? 0),
});Both accept a plain function (error: RetryErrorPayload) => boolean | Promise<boolean> or an object implementing RetryCondition / AbortCondition.
Default behavior is driven by the classifier: an error classified 'transient' retries, 'permanent' aborts (5xx/network/timeout → transient; 4xx except 429 → permanent, by default). Supplying custom classifiers changes this default without needing to also set retryIf/abortIf. retryIf/abortIf are independent overrides on top of that -- abortIf is checked first, so if you only override retryIf to force a retry, also set abortIf: () => false or the classifier-driven default abort will still win.
timeout
Per-attempt and global timeouts are independent:
await retry(task, {
timeout: {
attemptTimeoutMs: 5_000, // each individual call is capped at 5s
globalTimeoutMs: 30_000, // entire retry operation (including delays) capped at 30s
},
});attemptTimeoutMsexpiration → error classified astype: 'timeout'→ triggers retry (if retryable)globalTimeoutMsexpiration → operation aborted immediately regardless of attempt count0means unlimited (default)
Your task receives an AbortSignal that fires on either timeout -- pass it through to anything that supports cancellation (fetch, database drivers, etc.) so a timed-out attempt actually stops instead of just being abandoned:
await retry(
(signal) => fetch('https://api.example.com/data', { signal }).then(r => r.json()),
{ timeout: { attemptTimeoutMs: 5_000 } },
);A task that ignores the signal (most existing code) keeps running in the background after the timeout fires -- retry() stops waiting on it and moves to the next attempt, but the abandoned call isn't force-killed. The signal is optional to use; omitting the parameter is fine and behaves exactly as before.
budget
Sliding-window budget prevents retry storms. If the ratio of retries to total calls exceeds maxRetryRatio in the last windowMs milliseconds, further retries are rejected.
await retry(task, {
budget: {
windowMs: 60_000, // 1-minute sliding window
maxRetryRatio: 0.1, // max 10% of calls may be retries
minRequestCount: 20, // budget not enforced until at least 20 calls
},
});Budget exhaustion produces type: 'unknown' with metadata.budgetExhausted: true. Share a budget across calls by reusing the same RetryEngine instance.
hooks
Lifecycle hooks for observability, logging, and debugging. Hook errors are swallowed and never affect retry state.
await retry(task, {
hooks: {
beforeRetry: ({ attempt, delayMs, error }) => {
logger.warn(`Retry #${attempt} in ${delayMs}ms — ${error.message}`);
},
afterRetry: ({ attempt, error }) => {
logger.debug(`Attempt ${attempt} finished with error: ${error.type}`);
},
onRetrySuccess: ({ attempt, totalAttempts, totalElapsedMs }) => {
logger.info(`Succeeded on attempt ${attempt}/${totalAttempts} (${totalElapsedMs}ms total)`);
},
onExhausted: ({ lastError, totalAttempts, totalElapsedMs }) => {
logger.error(`All ${totalAttempts} attempts failed in ${totalElapsedMs}ms`, lastError);
},
onBudgetExhausted: () => {
logger.warn('Retry budget exhausted — skipping retry');
},
},
});fallback
Return a default value when all retries are exhausted instead of returning err(...):
const result = await retry(() => fetchConfig(), {
maxAttempts: 3,
fallback: () => DEFAULT_CONFIG,
});
// result.ok === true, result.value === DEFAULT_CONFIGdynamicDelay
Override backoff with a delay computed from the error — useful for respecting Retry-After headers:
await retry(task, {
dynamicDelay: (error, attempt) => {
if (error.type === 'http' && error.status === 429) {
// Use Retry-After header value if available in cause
const retryAfterMs = (error.cause as any)?.retryAfterMs ?? 5_000;
return retryAfterMs;
}
return 0; // 0 = fall back to backoff strategy
},
});classifiers
Add custom rules that classify errors as 'transient' (retryable) or 'permanent' (abort). This classification is the default retry/abort decision (see retryIf / abortIf) -- adding a rule here changes what actually gets retried, not just how it's logged:
await retry(task, {
classifiers: [
{
name: 'business-validation',
priority: 1, // evaluated before built-in rules
match: (error) => error.type === 'http' && error.status === 422,
classification: 'permanent',
},
{
name: 'gateway-timeout',
priority: 50,
match: (error) => error.type === 'http' && error.status === 504,
classification: 'transient',
},
],
});Built-in rules (lower priority number = evaluated first):
| Status / Type | Classification |
|---|---|
| 400, 401, 403, 404, 413, 422 | permanent |
| 429, 500–599 | transient |
| network, timeout | transient |
| circuit-open, bulkhead-rejected | transient |
| everything else | permanent |
Backoff Strategies
Three built-in strategies, all composable with JitterDecorator:
Fixed
Same delay every time:
import { FixedBackoff } from '@backendkit-labs/retry';
new FixedBackoff({ baseDelay: 500 });
// attempt 1→2: 500ms, 2→3: 500ms, ...Shorthand: { type: 'fixed', baseDelay: 500 }
Linear
Delay grows linearly:
import { LinearBackoff } from '@backendkit-labs/retry';
new LinearBackoff({ baseDelay: 200, multiplier: 1.5, maxDelay: 5_000 });
// attempt 1→2: 200ms, 2→3: 300ms, 3→4: 450ms, ...Shorthand: { type: 'linear', baseDelay: 200, multiplier: 1.5, maxDelay: 5_000 }
Exponential
Delay doubles (or scales by multiplier) each attempt:
import { ExponentialBackoff } from '@backendkit-labs/retry';
new ExponentialBackoff({ baseDelay: 200, multiplier: 2, maxDelay: 30_000, jitter: 'full' });
// attempt 1→2: ~200ms, 2→3: ~400ms, 3→4: ~800ms, ...Shorthand: { type: 'exponential', baseDelay: 200, maxDelay: 30_000, jitter: 'full' }
Jitter types: 'full' (uniform random in [0, delay]), 'equal' (delay/2 + random in [0, delay/2]), 'decorrelated' (delay based on previous delay × random).
JitterDecorator
Wrap any strategy with jitter:
import { LinearBackoff, JitterDecorator } from '@backendkit-labs/retry';
const strategy = new JitterDecorator(
new LinearBackoff({ baseDelay: 300 }),
'full',
);Error Types
RetryError is the union of RetryErrorPayload and RetryMetadata:
type RetryError = RetryErrorPayload & { metadata: RetryMetadata };
interface RetryErrorPayload {
type: ErrorType; // 'http' | 'network' | 'timeout' | 'circuit-open' | 'bulkhead-rejected' | 'business' | 'unknown'
message: string;
status?: number; // HTTP status code (only when type === 'http')
cause?: unknown; // original thrown error
attempt: number; // attempt number when this error occurred
elapsedMs: number; // elapsed ms at this point
}
interface RetryMetadata {
attempts: number; // total attempts made
totalElapsedMs: number; // total operation duration
lastError?: RetryErrorPayload;
budgetExhausted?: boolean; // true if stopped by budget
circuitOpen?: boolean; // true if stopped by circuit breaker
}Handling different error types:
const result = await retry(() => callApi());
if (!result.ok) {
const error = result.error;
switch (error.type) {
case 'http':
console.log(`HTTP ${error.status} after ${error.metadata.attempts} attempts`);
break;
case 'timeout':
console.log(`Timed out after ${error.metadata.totalElapsedMs}ms`);
break;
case 'circuit-open':
console.log('Circuit breaker is OPEN — fast-failed without retrying');
break;
case 'network':
console.log('Network error:', error.cause);
break;
}
}BackendKit Integrations
All integrations are duck-typed — Retry never imports any other BackendKit library at compile time. You connect them by passing the instance directly to RetryEngine. Any object that satisfies the minimal interface works, including mocks in tests.
@backendkit-labs/circuit-breaker
The circuit breaker controls whether to attempt a call. Retry checks it before each attempt:
import { CircuitBreaker } from '@backendkit-labs/circuit-breaker';
import { RetryEngine } from '@backendkit-labs/retry';
const cb = new CircuitBreaker({ name: 'payments', failureThreshold: 50, minimumCalls: 5 });
const engine = new RetryEngine({
name: 'payments',
integrations: {
circuitBreaker: cb, // duck-typed: canAttempt() / onSuccess() / onError()
},
});
const result = await engine.execute(() => chargePayment(order));
if (!result.ok && result.error.type === 'circuit-open') {
// Breaker was OPEN — Retry returned immediately without calling the task
}Execution flow:
Retrycallscb.canAttempt()before every attempt. Iffalse→ returns{ type: 'circuit-open' }immediately.- On success →
cb.onSuccess(durationMs)— registers the healthy call. - On transient failure →
cb.onError(err)— updates the breaker's failure counter. - On permanent/business failure (e.g. 422) → CB is not notified — this is not an infrastructure problem.
The real value: the circuit breaker stops calls when the service is known to be down. Retry acts as the gradual recovery mechanism — it waits with backoff and retries when the breaker transitions to half-open.
@backendkit-labs/bulkhead
Limits the concurrency of attempts. Every attempt — including retries — passes through the bulkhead:
import { Bulkhead } from '@backendkit-labs/bulkhead';
import { RetryEngine } from '@backendkit-labs/retry';
const bulkhead = new Bulkhead({ maxConcurrent: 10, maxQueue: 20 });
const engine = new RetryEngine({
name: 'orders',
integrations: {
bulkhead, // duck-typed: execute(fn) / isFull()
},
});Execution flow:
- Each attempt (including retries) is wrapped in
bulkhead.execute(fn). - If the bulkhead is full → it rejects →
Retryclassifies astype: 'bulkhead-rejected'(transient by default) → waits with backoff and re-queues.
@backendkit-labs/result
retry() already returns Result<T, RetryError> — direct integration, no adapter needed:
import { retry } from '@backendkit-labs/retry';
import { match } from '@backendkit-labs/result';
const result = await retry(() => fetchOrder(id), {
maxAttempts: 3,
backoff: { type: 'exponential', baseDelay: 200 },
});
match(result, {
ok: (order) => res.json(order),
fail: (err) => res.status(502).json({ message: err.message, attempts: err.metadata.attempts }),
});When combining with @backendkit-labs/http-client (which also returns Result), unwrap between layers so Retry sees a thrown error instead of a nested Result:
const result = await retry(
async () => {
const r = await httpClient.get<Order>('/orders/1');
if (!r.ok) throw Object.assign(new Error(r.error.message), { status: r.error.status });
return r.value;
},
{ maxAttempts: 3, backoff: { type: 'exponential', baseDelay: 100 } },
);The thrown error with a .status property is detected as type: 'http' and classified correctly by the built-in rules.
@backendkit-labs/observability
Plug in any logger and metrics emitter that satisfy the minimal duck-typed interfaces:
import { Logger } from '@backendkit-labs/observability';
const logger = new Logger({ service: 'payments' });
const engine = new RetryEngine({
name: 'payments',
integrations: {
observability: {
logger, // info / warn / error
metrics: metricsRegistry, // emit(event)
},
},
});Logs emitted automatically:
WARN "Retry: attempt failed" { attempt: 2, type: 'http', classification: 'transient' }
ERROR "Retry: exhausted" { attempts: 3, type: 'http', totalElapsedMs: 1842 }Metrics emitted automatically:
| Metric | When | Tags |
|---|---|---|
| Retry.attempt_failed | After each failed attempt | attempt, type, classification |
| Retry.success | On eventual success | attempt (attempt number that succeeded) |
| Retry.exhausted | All retries exhausted | type |
| Retry.budget_exhausted | Budget refused a retry | — |
All three layers together
The most complete pattern for a production service — circuit breaker, bulkhead, budget, timeout, and observability in a single engine:
import { CircuitBreaker } from '@backendkit-labs/circuit-breaker';
import { Bulkhead } from '@backendkit-labs/bulkhead';
import { Logger } from '@backendkit-labs/observability';
import { RetryEngine } from '@backendkit-labs/retry';
const cb = new CircuitBreaker({ name: 'payments', failureThreshold: 50, minimumCalls: 5 });
const bulkhead = new Bulkhead({ maxConcurrent: 10, maxQueue: 20 });
const logger = new Logger({ service: 'payments-client' });
const engine = new RetryEngine({
name: 'payments-client',
defaultConfig: {
maxAttempts: 4,
backoff: { type: 'exponential', baseDelay: 200, maxDelay: 5_000, jitter: 'full' },
budget: { windowMs: 60_000, maxRetryRatio: 0.15 }, // max 15% retries per minute
timeout: { attemptTimeoutMs: 3_000, globalTimeoutMs: 12_000 },
},
integrations: {
circuitBreaker: cb,
bulkhead,
observability: { logger, metrics: metricsRegistry },
},
});
const result = await engine.execute(() => chargePayment(order));What happens per attempt:
budget.recordCall()
→ checkGlobalTimeout() ← abort if 12s total exceeded
→ cb.canAttempt() ← fast-fail if circuit is OPEN
→ bulkhead.execute(...) ← limit concurrency
→ executeWithAttemptTimeout(3_000) ← cap each call at 3s
→ success: cb.onSuccess() / budget.recordSuccess()
→ failure: cb.onError() / budget.recordFailure()
→ abort? retry? budget exhausted? → backoff and loopNestJS Integration
Import RetryModule once at the application root. It registers RetryService and, only if globalInterceptor: true is set (default: false), a global RetryInterceptor.
Module setup
import { RetryModule } from '@backendkit-labs/retry/nestjs';
@Module({
imports: [
RetryModule.forRoot({
engineConfig: {
name: 'default',
defaultConfig: {
maxAttempts: 3,
backoff: { type: 'exponential', baseDelay: 200, jitter: 'full' },
},
},
globalInterceptor: false, // set true to apply @Retry to all controllers globally
}),
],
})
export class AppModule {}RetryService — inject and execute
import { Injectable } from '@nestjs/common';
import { RetryService } from '@backendkit-labs/retry/nestjs';
@Injectable()
export class PaymentsService {
constructor(private readonly Retry: RetryService) {}
async charge(order: Order) {
const result = await this.Retry.execute(
() => this.gateway.charge(order),
{ maxAttempts: 4, backoff: { type: 'exponential', baseDelay: 300 } },
);
if (!result.ok) throw new ServiceUnavailableException(result.error.message);
return result.value;
}
}@Retry decorator
Wraps the method directly -- works on any @Injectable() (or plain) class, with or without NestJS DI, no RetryModule import required:
import { Retry } from '@backendkit-labs/retry/nestjs';
@Injectable()
export class InventoryService {
@Retry({
maxAttempts: 3,
backoff: { type: 'exponential', baseDelay: 150 },
})
async reserveStock(productId: string, quantity: number) {
return this.http.post('/inventory/reserve', { productId, quantity });
}
}Each @Retry-decorated method gets its own named RetryEngine, so budget/idempotency config actually accumulates across calls. By default that engine lives in an internal registry private to @Retry; inject public readonly retryRegistry: RetryRegistry on the class to use your own instead (so you can read its metrics, or share it with other code):
import { RetryRegistry } from '@backendkit-labs/retry';
@Injectable()
export class InventoryService {
constructor(public readonly retryRegistry: RetryRegistry) {}
@Retry({ maxAttempts: 3 })
async reserveStock(productId: string, quantity: number) { ... }
}@Retry also sets metadata for RetryInterceptor to read, but you don't need the interceptor for this to work -- the decorator retries the method itself. Only turn on RetryModule's globalInterceptor if you specifically want pipeline-level retry (re-running next.handle(), including other interceptors) instead of method-level retry; enabling both for the same method retries it twice.
DI tokens
Inject the underlying engine or registry directly:
import { Inject } from '@nestjs/common';
import { RETRY_ENGINE_TOKEN, RETRY_REGISTRY_TOKEN } from '@backendkit-labs/retry/nestjs';
import type { RetryEngine, RetryRegistry } from '@backendkit-labs/retry';
@Injectable()
export class MyService {
constructor(
@Inject(RETRY_ENGINE_TOKEN) private engine: RetryEngine,
@Inject(RETRY_REGISTRY_TOKEN) private registry: RetryRegistry,
) {}
}RetryRegistry
RetryRegistry manages named RetryEngine instances — useful when different services need different retry configurations in the same process.
import { RetryRegistry } from '@backendkit-labs/retry';
const registry = new RetryRegistry();
const paymentEngine = registry.getOrCreate('payments', {
defaultConfig: { maxAttempts: 3, backoff: { type: 'exponential', baseDelay: 300 } },
});
const emailEngine = registry.getOrCreate('email', {
defaultConfig: { maxAttempts: 5, backoff: { type: 'fixed', baseDelay: 1_000 } },
});
// Retrieve later by name
const engine = registry.get('payments');
// Metrics snapshot for all engines
const allMetrics = registry.getAllMetrics();
// Reset a specific engine's state
registry.reset('payments');API Reference
retry(task, options?)
Standalone function using a global default registry.
| Param | Type | Description |
|---|---|---|
| task | () => Promise<T> | The async operation to retry |
| options | Partial<RetryConfig> | Optional config overrides |
| Returns | Promise<Result<T, RetryError>> | Never throws |
RetryEngine
| Method | Signature | Description |
|---|---|---|
| execute | <T>(task, options?) => Promise<Result<T, RetryError>> | Execute with retry |
| executeWithContext | <T>(task, { correlationId? }, options?) => Promise<Result<T, RetryError>> | Execute with correlationId |
| updateDefaults | (partial: Partial<RetryConfig>) => void | Update engine defaults at runtime |
| getMetrics | () => RetryMetricsSnapshot | Get current metrics |
| resetMetrics | () => void | Reset metrics counters |
RetryRegistry
| Method | Signature | Description |
|---|---|---|
| getOrCreate | (name, config?) => RetryEngine | Get or create a named engine |
| get | (name) => RetryEngine \| undefined | Get engine by name |
| reset | (name) => void | Remove a named engine |
| resetAll | () => void | Remove all engines |
| getAllMetrics | () => Record<string, RetryMetricsSnapshot> | Metrics for all engines |
NestJS exports (@backendkit-labs/retry/nestjs)
| Export | Type | Description |
|---|---|---|
| RetryModule | DynamicModule | RetryModule.forRoot(options?) |
| RetryService | Injectable | .execute(task, options?) |
| Retry | MethodDecorator | @Retry(config) |
| RetryInterceptor | NestInterceptor | Intercepts methods decorated with @Retry |
| RETRY_ENGINE_TOKEN | string | DI token for RetryEngine |
| RETRY_REGISTRY_TOKEN | string | DI token for RetryRegistry |
License
Apache-2.0 — see LICENSE for details.
Part of the BackendKit Labs ecosystem.
