Skip to content

@studnicky/retry

Generic async retry utility with extensible error classification.

Install

bash
pnpm add @studnicky/retry

Requires @studnicky:registry=https://npm.pkg.github.com in .npmrc.

Usage

Create a Retry instance with Retry.create(config), then pass any operation to execute. The instance tracks stats and retries on transient failures:

ts
import { Retry } from '../src/index.js';
import { BasicRetryFixtures } from './fixtures/basicRetryFixtures.js';

class Counter {
  #value = 0;

  increment(): number {
    this.#value++;
    return this.#value;
  }

  get value(): number {
    return this.#value;
  }
}

const counter = new Counter();

const retry = Retry.create({ 'maximumRetries': 3 });

const result = await retry.execute(() => {
  const attempt = counter.increment();
  if (attempt <= BasicRetryFixtures.failCount) {
    throw RuntimeError.create(`Transient failure on attempt ${attempt}`);
  }
  const attemptResult = Promise.resolve(`success on attempt ${attempt}`);
  return attemptResult;
});

console.log(`Result: ${result}`);
console.log('Stats:', retry.getStats());

Try it

Loading example…

The output shows Retry.create({ maxRetries: 3 }), the operation failing twice before succeeding on the third attempt, and final stats reporting 2 retries.

Lifecycle hooks

TelemetryRetry subclasses Retry and overrides onAttempt, onRetryableError, onRetryScheduled, onGiveUp, and enterCall. Each hook logs its FSM transition as the retry cycle runs, and the give-up event fires after the maxRetries=2 budget is exhausted.

Loading example…

Public API

Import Retry, BackoffStrategy, and retry errors from @studnicky/retry. Configuration is parsed at the boundary through RetryConfigEntity.intake, so there are no standalone guard exports. Retry entities use @studnicky/retry/entities, and retry contracts use @studnicky/retry/interfaces; algorithm constants are implementation details.

Custom error classification

Subclass Retry and override classifyError to control which errors are retryable for your domain:

ts
import type { ErrorClassificationEntity } from '@studnicky/errors/entities';

import { BaseError } from '@studnicky/errors';
import assert from 'node:assert/strict';

import type { RetryConfigInterface } from '../src/interfaces/index.js';

import { Retry } from '../src/index.js';
import { CustomClassifierFixtures } from './fixtures/customClassifierFixtures.js';

class DatabaseError extends BaseError {
  constructor(
    message: string,
    readonly isDeadlock: boolean
  ) {
    super({
      'code': 'retry.database',
      'message': message
    });
  }
}

class DatabaseRetry extends Retry {
  constructor(config?: RetryConfigInterface) {
    super(config ?? {});
  }

  protected override classifyError(error: Error): ErrorClassificationEntity.Type {
    if (error instanceof DatabaseError && error.isDeadlock) {
      return { 'reason': 'Transient deadlock', 'retryable': true };
    }
    return { 'reason': 'Permanent database error', 'retryable': false };
  }
}

class AttemptCounter {
  #count = 0;

  next(): number {
    this.#count++;
    return this.#count;
  }

  get count(): number {
    return this.#count;
  }
}

const counter = new AttemptCounter();

const retry = new DatabaseRetry({
  'maximumRetries': 3
});

const result = await retry.execute(() => {
  const attemptNumber = counter.next();
  if (attemptNumber <= CustomClassifierFixtures.failUntil) {
    throw new DatabaseError(`Deadlock on attempt ${attemptNumber}`, true);
  }
  const attemptResult = Promise.resolve(`query succeeded on attempt ${attemptNumber}`);
  return attemptResult;
});

console.log(`Result: ${result}`);
console.log('Stats:', retry.getStats());

Observability hooks

classifyError(error, attemptNumber) and onRetryScheduled(context) are the in-band behavioral seams. Override them to define retryability and scheduling policy: classifyError(...) decides whether an error is retryable for your domain, and onRetryScheduled(context) can set context.delayMs (using a shipped BackoffStrategy), set context.abort to stop retrying, or mutate context.state across attempts (it may be async). The observation-only hooks — onAttempt, onSuccess, onRetryableError, onGiveUp, enterCall — let you collect telemetry without coupling the retry core to any metrics library:

ts
import type { ErrorClassificationEntity } from '@studnicky/errors/entities';
/** observedRetry — override onRetryScheduled and onGiveUp to collect telemetry. Run: npx tsx examples/observedRetry.ts */

import { RuntimeError } from '@studnicky/errors';
import assert from 'node:assert/strict';

import type { RetryConfigInterface, RetryContextInterface } from '../src/interfaces/index.js';

import { MaximumRetriesExceededError, Retry } from '../src/index.js';

class TelemetryRetry extends Retry {
  constructor(config?: RetryConfigInterface) {
    super(config ?? {});
  }

  readonly scheduledEvents: { 'attemptNumber': number; 'delayMs': number }[] = [];
  readonly giveUpEvents: { 'attemptNumber': number; 'reason': string }[] = [];

  protected override classifyError(_error: Error): ErrorClassificationEntity.Type {
    return { 'reason': 'always retryable', 'retryable': true };
  }

  protected override onAttempt(attemptNumber: number): void {
    console.log(`[retry] attempt ${attemptNumber} starting`);
  }

  protected override onRetryableError(
    attemptNumber: number,
    error: Error,
    classification: ErrorClassificationEntity.Type
  ): void {
    console.log(`[retry] attempt ${attemptNumber} retryable error: ${error.message} (${classification.reason ?? 'no reason'})`);
  }

  protected override onRetryScheduled(context: RetryContextInterface): void {
    console.log(`[retry] attempt ${context.attemptNumber} scheduled retry in ${context.delayMs}ms`);
    this.scheduledEvents.push({ 'attemptNumber': context.attemptNumber, 'delayMs': context.delayMs });
  }

  protected override onGiveUp(
    error: Error,
    attemptNumber: number,
    reason: 'aborted' | 'exhausted' | 'nonRetryable'
  ): void {
    console.log(`[retry] give up after ${attemptNumber} attempts: ${reason} — ${error.message}`);
    this.giveUpEvents.push({ 'attemptNumber': attemptNumber, 'reason': reason });
  }

  protected override enterCall(to: string, from: string): void {
    console.log(`[retry] call FSM ${from} → ${to}`);
  }
}

const retryLimit = 2;
const retry = new TelemetryRetry({
  'maximumRetries': retryLimit
});

// Operation always fails — exercises scheduled and giveUp hooks
try {
  await retry.execute(() => {
    throw RuntimeError.create('always fails');
  });
} catch (error) {
  assert.ok(error instanceof MaximumRetriesExceededError, 'Expected MaximumRetriesExceededError');
}

console.log('Scheduled events:', retry.scheduledEvents);
console.log('GiveUp events:', retry.giveUpEvents);
console.log('Stats:', retry.getStats());

The base class never calls any logger or metrics library. Observer hooks are no-ops by default and stay observational; by default onRetryScheduled leaves delayMs at 0, so retries fire immediately unless a backoff is applied.

Deterministic elapsed-time budget

Retry.create accepts an optional clock provider. It measures maximumElapsedMs, hook success durations, and retry-context elapsed time, so a virtual provider makes elapsed-budget behavior deterministic.

The observation-only hooks run through a composed HookInvoker (see @studnicky/errors). Pass hookTimeoutMs to Retry.create({ hookTimeoutMs }) to bound how long an async hook may run before it fails through onHookError with a HookTimeoutError cause. Left unset, a hook may take arbitrarily long.

Source on GitHub

Entities

@studnicky/retry/entities exports every schema namespace in src/entities.

typescript
import { RetryConfigEntity } from '@studnicky/retry/entities';

Interfaces

@studnicky/retry/interfaces exports every TypeScript interface in src/interfaces, including configuration and state contracts.

typescript
import type { RetryConfigInterface } from '@studnicky/retry/interfaces';

Exports

SymbolPurposeImport path
BackoffStrategyProvides backoff strategy functionality.@studnicky/retry
BackoffStrategyInterfaceDefines the backoff strategy contract.@studnicky/retry
RetryProvides retry functionality.@studnicky/retry
MaximumRetriesExceededErrorRepresents maximum retries exceeded failures.@studnicky/retry
NonRetryableErrorRepresents non retryable failures.@studnicky/retry
RetryErrorRepresents retry failures.@studnicky/retry
RetryConfigInterfaceDefines retry settings and the optional clock collaborator.@studnicky/retry/interfaces