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

@backendkit-labs/retry

v0.1.2

Published

Enterprise-grade retry library for Node.js -- exponential backoff, sliding window budget, error classification, duck-typed integrations, and optional NestJS support

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.

npm license node


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 start

Shows 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

npm install @backendkit-labs/retry @backendkit-labs/result

NestJS peer dependencies (optional — only if using RetryModule):

npm install @nestjs/common @nestjs/core rxjs

Quick 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';

const result = await retry(() => fetchUser(userId), {
  maxAttempts: 3,
  backoff: { type: 'exponential', baseDelay: 200 },
});

result.match(
  (user) => res.json(user),
  (error) => res.status(502).json({ error: error.message }),
);

Retry() returns Result<T, RetryError> — it never throws. Use .match(), .ok, or .error 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: retry on 5xx, network, and timeout errors; abort on 4xx (except 429).

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
  },
});
  • attemptTimeoutMs expiration → error classified as type: 'timeout' → triggers retry (if retryable)
  • globalTimeoutMs expiration → operation aborted immediately regardless of attempt count
  • 0 means unlimited (default)

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_CONFIG

dynamicDelay

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

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-typedRetry 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', threshold: 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:

  1. Retry calls cb.canAttempt() before every attempt. If false → returns { type: 'circuit-open' } immediately.
  2. On success → cb.onSuccess(durationMs) — registers the healthy call.
  3. On transient failure → cb.onError(err) — updates the breaker's failure counter.
  4. 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 → Retry classifies as type: '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';

const result = await retry(() => fetchOrder(id), {
  maxAttempts: 3,
  backoff: { type: 'exponential', baseDelay: 200 },
});

result.match(
  (order) => res.json(order),
  (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', threshold: 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 loop

NestJS Integration

Import RetryModule once at the application root. It registers RetryService and an optional 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

Mark a method for retry without changing its signature. Pairs with RetryInterceptor:

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 });
  }
}

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.