Skip to content

@backendkit-labs/retry

npmLicenseNode

Enterprise retry for Node.js — exponential backoff, sliding-window budget, error classification, circuit-breaker and bulkhead integration. Returns Result<T, RetryError>, never throws.

Installation

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

NestJS peer dependencies (optional):

bash
npm install @nestjs/common @nestjs/core rxjs

Quick Start

retry() is a standalone function backed by a global registry. Covers 90% of use cases in two lines:

typescript
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.

Core API

retry(task, options?)

Standalone function using a global default registry.

ParamTypeDescription
task() => Promise<T>The async operation to retry
optionsPartial<RetryConfig>Optional config overrides
ReturnsPromise<Result<T, RetryError>>Never throws
typescript
// Defaults: 3 attempts, fixed 200ms delay
const result = await retry(() => callExternalApi());

RetryEngine

Stateful core for shared config, metrics, and external integrations (circuit breaker, bulkhead, observability).

typescript
import { RetryEngine } from '@backendkit-labs/retry';

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

const result = await engine.execute(() => chargePayment(order));

// Per-execution override
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'] },
);
MethodSignatureDescription
execute<T>(task, options?) => Promise<Result<T, RetryError>>Execute with retry
executeWithContext<T>(task, ctx, options?) => Promise<Result<T, RetryError>>Execute with correlationId
updateDefaults(partial: Partial<RetryConfig>) => voidUpdate defaults at runtime
getMetrics() => RetryMetricsSnapshotCurrent metrics
resetMetrics() => voidReset counters

Configuration Reference

typescript
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 attempts including the first. maxAttempts: 3 = one initial call + two retries.

backoff

Controls delay between retries. Accepts a config shorthand or a BackoffStrategy instance.

typescript
// Shorthand
{ backoff: { type: 'exponential', baseDelay: 200, maxDelay: 10_000, jitter: 'full' } }

// Strategy instance (composable)
import { ExponentialBackoff, JitterDecorator } from '@backendkit-labs/retry';
{ backoff: new JitterDecorator(new ExponentialBackoff({ baseDelay: 200 }), 'full') }

Available strategies: 'fixed', 'linear', 'exponential'. Jitter types: 'full', 'equal', 'decorrelated'.

retryIf / abortIf

Fine-grained control over which errors trigger a retry and which abort immediately.

typescript
await retry(task, {
  retryIf: (error) => error.type === 'network' || (error.type === 'http' && (error.status ?? 0) >= 500),
  abortIf: (error) => error.type === 'http' && [401, 403].includes(error.status ?? 0),
});

Default is driven by the classifier: 5xx/network/timeout classify 'transient' (retry), 4xx (except 429) classify 'permanent' (abort). abortIf is checked before retryIf -- overriding only one still lets the classifier-driven default win on the other.

timeout

typescript
await retry(task, {
  timeout: {
    attemptTimeoutMs: 5_000,  // each call capped at 5s
    globalTimeoutMs: 30_000,  // entire operation (including delays) capped at 30s
  },
});

The task receives an AbortSignal ((signal) => Promise<T>) that fires on either timeout -- pass it to fetch/anything cancellable so a timed-out attempt actually stops instead of just being abandoned in the background. Ignoring the parameter is fine and behaves as before.

budget

Sliding-window budget prevents retry storms. Rejects retries when the retry ratio exceeds maxRetryRatio within windowMs.

typescript
await retry(task, {
  budget: {
    windowMs:        60_000,  // 1-minute sliding window
    maxRetryRatio:   0.1,     // max 10% of calls may be retries
    minRequestCount: 20,      // not enforced until at least 20 calls
  },
});

hooks

Lifecycle hooks for observability. Hook errors are swallowed and never affect retry state.

typescript
await retry(task, {
  hooks: {
    beforeRetry:      ({ attempt, delayMs, error }) => logger.warn(`Retry #${attempt} in ${delayMs}ms`),
    afterRetry:       ({ attempt, error })           => logger.debug(`Attempt ${attempt} done`),
    onRetrySuccess:   ({ attempt, totalElapsedMs })  => logger.info(`Recovered on attempt ${attempt}`),
    onExhausted:      ({ lastError, totalAttempts }) => logger.error(`Failed after ${totalAttempts} attempts`),
    onBudgetExhausted: ()                            => logger.warn('Budget exhausted'),
  },
});

fallback

Return a default value when all retries are exhausted:

typescript
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 Retry-After headers:

typescript
await retry(task, {
  dynamicDelay: (error, attempt) => {
    if (error.type === 'http' && error.status === 429) {
      return (error.cause as any)?.retryAfterMs ?? 5_000;
    }
    return 0; // 0 = fall back to backoff strategy
  },
});

idempotency

Prevents duplicate side effects when a retry succeeds after an earlier attempt already completed server-side. On the first successful execution the result is serialized and stored; subsequent calls with the same key return the cached result immediately — the task is never called again.

typescript
import { retry } from '@backendkit-labs/retry';

const result = await retry(() => chargePayment(order), {
  maxAttempts: 3,
  backoff: { type: 'exponential', baseDelay: 300 },
  idempotency: {
    enabled: true,
    key: `charge:${order.id}`,  // unique per logical operation
    ttlMs: 24 * 60 * 60 * 1000, // cache result for 24h (default)
  },
});

IdempotencyConfig:

FieldTypeDefaultDescription
enabledbooleanfalseMust be true to activate idempotency
keystringIdempotency key for this execution
storeIdempotencyStoreInMemoryIdempotencyStoreStorage backend
ttlMsnumber86_400_000 (24h)How long to keep the cached result
idempotentMethodsstring[]['POST','PUT','PATCH']Used by the NestJS interceptor to filter requests
headerNamestring'Idempotency-Key'Header the NestJS interceptor reads the key from

Custom store

InMemoryIdempotencyStore is process-local and loses state on restart. For production use a shared store:

typescript
import { InMemoryIdempotencyStore, type IdempotencyStore } from '@backendkit-labs/retry';

// Implement any KV backend
class RedisIdempotencyStore implements IdempotencyStore {
  constructor(private redis: Redis) {}

  async get(key: string)                       { return this.redis.get(key); }
  async set(key: string, value: string, ttlMs) { await this.redis.set(key, value, 'PX', ttlMs ?? 86_400_000); }
  async exists(key: string)                    { return (await this.redis.exists(key)) === 1; }
}

const result = await retry(() => chargePayment(order), {
  maxAttempts: 3,
  idempotency: {
    enabled: true,
    key: `charge:${order.id}`,
    store: new RedisIdempotencyStore(redisClient),
  },
});

NestJS — deriving the key from the request

@Retry wraps the method directly, so it decorates before there's a request to read a header from -- pass the Idempotency-Key header through explicitly instead:

typescript
@Controller('payments')
export class PaymentsController {
  constructor(private readonly paymentsService: PaymentsService) {}

  @Post()
  charge(@Body() dto: ChargeDto, @Headers('idempotency-key') idempotencyKey: string) {
    return this.paymentsService.charge(dto, idempotencyKey);
  }
}

@Injectable()
export class PaymentsService {
  async charge(dto: ChargeDto, idempotencyKey: string) {
    const result = await retry(() => this.gateway.charge(dto), {
      maxAttempts: 3,
      backoff: { type: 'exponential', baseDelay: 200 },
      idempotency: { enabled: true, key: idempotencyKey, ttlMs: 3_600_000 },
    });
    if (!result.ok) throw new ServiceUnavailableException(result.error.message);
    return result.value;
  }
}

When to use idempotency

Use it for non-idempotent operations that can succeed server-side even when the response doesn't reach the client — payment charges, email sends, inventory deductions. GET/DELETE requests are already idempotent by nature and don't need this.

Error Types

typescript
type RetryError = RetryErrorPayload & { metadata: RetryMetadata };

interface RetryErrorPayload {
  type: 'http' | 'network' | 'timeout' | 'circuit-open' | 'bulkhead-rejected' | 'business' | 'unknown';
  message: string;
  status?: number;   // HTTP status (only when type === 'http')
  cause?: unknown;   // original thrown error
  attempt: number;
  elapsedMs: number;
}

interface RetryMetadata {
  attempts: number;
  totalElapsedMs: number;
  lastError?: RetryErrorPayload;
  budgetExhausted?: boolean;
  circuitOpen?: boolean;
}
typescript
if (!result.ok) {
  const { type, status, metadata } = result.error;
  switch (type) {
    case 'http':         console.log(`HTTP ${status} after ${metadata.attempts} attempts`); break;
    case 'timeout':      console.log(`Timed out after ${metadata.totalElapsedMs}ms`); break;
    case 'circuit-open': console.log('Fast-failed — circuit breaker is OPEN'); break;
    case 'network':      console.log('Network error:', result.error.cause); break;
  }
}

Built-in classification rules:

Status / TypeClassification
400, 401, 403, 404, 413, 422permanent (abort)
429, 500–599transient (retry)
network, timeouttransient (retry)
circuit-open, bulkhead-rejectedtransient (retry)

BackendKit Integrations

All integrations are duck-typedretry never imports other BackendKit packages at compile time. Pass the instance directly to RetryEngine.

Circuit Breaker

typescript
import { CircuitBreaker } from '@backendkit-labs/circuit-breaker';

const cb = new CircuitBreaker({ name: 'payments', failureThreshold: 50, minimumCalls: 5 });

const engine = new RetryEngine({
  name: 'payments',
  integrations: { circuitBreaker: cb },
});

Before each attempt: calls cb.canAttempt(). Returns { type: 'circuit-open' } immediately if false. On success → cb.onSuccess(durationMs) for that attempt. On transient failure → cb.onError(); a permanent/business failure (e.g. 422) does not notify the breaker.

Bulkhead

typescript
import { Bulkhead } from '@backendkit-labs/bulkhead';

const bulkhead = new Bulkhead({ maxConcurrent: 10, maxQueue: 20 });

const engine = new RetryEngine({
  name: 'orders',
  integrations: { bulkhead },
});

Every attempt (including retries) passes through bulkhead.execute(). Rejection → classified as 'bulkhead-rejected' → retried with backoff.

Full production stack

Circuit breaker + bulkhead + budget + timeout + observability in a single engine:

typescript
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 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 },
    timeout:  { attemptTimeoutMs: 3_000, globalTimeoutMs: 12_000 },
  },
  integrations: {
    circuitBreaker: new CircuitBreaker({ name: 'payments', failureThreshold: 50, minimumCalls: 5 }),
    bulkhead:       new Bulkhead({ maxConcurrent: 10, maxQueue: 20 }),
    observability:  { logger: new Logger({ service: 'payments-client' }), metrics: metricsRegistry },
  },
});

const result = await engine.execute(() => chargePayment(order));

RetryRegistry

Manages named RetryEngine instances — different configs per service in the same process.

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

registry.getAllMetrics(); // Record<string, RetryMetricsSnapshot>
registry.reset('payments');

NestJS Integration

typescript
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,
    }),
  ],
})
export class AppModule {}

RetryService

typescript
@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 -- no RetryModule import needed, works with or without DI:

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

RetryInterceptor (globalInterceptor: true) is a separate, opt-in mechanism for retrying the whole request pipeline instead of just the method -- don't enable both for the same handler, or it retries twice.

DI tokens

typescript
import { RETRY_ENGINE_TOKEN, RETRY_REGISTRY_TOKEN } from '@backendkit-labs/retry/nestjs';

@Injectable()
export class MyService {
  constructor(
    @Inject(RETRY_ENGINE_TOKEN)    private engine:   RetryEngine,
    @Inject(RETRY_REGISTRY_TOKEN)  private registry: RetryRegistry,
  ) {}
}

Architecture

@backendkit-labs/retry          (core — no framework deps)
  retry()                       standalone function, global registry
  RetryEngine                   stateful engine with integrations
  RetryRegistry                 named engine instances

@backendkit-labs/retry/nestjs   (optional NestJS layer)
  RetryModule                   RetryModule.forRoot(options)
  RetryService                  injectable execute()
  @Retry                        method decorator (wraps directly, self-contained)
  RetryInterceptor              opt-in pipeline-level retry (globalInterceptor: true)
  RETRY_ENGINE_TOKEN            DI token for RetryEngine
  RETRY_REGISTRY_TOKEN          DI token for RetryRegistry

Released under the Apache 2.0 License.