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

Build a Retry instance with the builder, 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.builder()
  .maxRetries(3)
  .build();

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

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

Try it

Basic retry with backoff
/** basicRetry — flaky operation that fails twice then succeeds. Run: npx tsx examples/basicRetry.ts */

import assert from 'node:assert/strict';

// #region usage
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.builder()
  .maxRetries(3)
  .build();

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

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

assert.equal(result, `success on attempt ${BasicRetryFixtures.failCount + 1}`);
assert.equal(retry.getStats().totalRequests, 1);
assert.equal(retry.getStats().successfulRequests, 1);
assert.equal(retry.getStats().failedRequests, 0);
assert.equal(retry.getStats().totalRetries, BasicRetryFixtures.failCount);

console.log('basicRetry: all assertions passed');
Output
Press Execute to run this example against the real library.

The output shows the builder API (.builder().maxRetries(3).build()), 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.

Observed retry — lifecycle hook trace
/** observedRetry — override onRetryScheduled and onGiveUp to collect telemetry. Run: npx tsx examples/observedRetry.ts */

import assert from 'node:assert/strict';

// #region usage
import type { ErrorClassificationEntity, RetryConfigInterface , RetryContextType} from '../src/index.js';

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

class TelemetryRetry extends Retry {
  constructor(config?: Partial<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: RetryContextType): 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 maxRetries = 2;
const retry = new TelemetryRetry({
  'maxRetries': maxRetries
});

// Operation always fails — exercises scheduled and giveUp hooks
try {
  await retry.execute(() => {
    throw new Error('always fails');
  });
} catch (err) {
  assert.ok(err instanceof MaxRetriesExceededError, 'Expected MaxRetriesExceededError');
}

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

// onRetryScheduled fires once per scheduled retry (maxRetries times)
assert.equal(retry.scheduledEvents.length, maxRetries);
assert.ok(retry.scheduledEvents.every((e) => {return e.delayMs === 0;}));

// onGiveUp fires once with reason 'exhausted'
assert.equal(retry.giveUpEvents.length, 1);
assert.equal(retry.giveUpEvents[0]!.reason, 'exhausted');
assert.equal(retry.giveUpEvents[0]!.attemptNumber, maxRetries);

assert.equal(retry.getStats().totalRetries, maxRetries);
assert.equal(retry.getStats().failedRequests, 1);

console.log('observedRetry: all assertions passed');
Output
Press Execute to run this example against the real library.

Subpath exports

SubpathContents
@studnicky/retryRetry, RetryBuilder, DefaultHttpErrorClassifier, ErrorClassifier, errors, type guards
@studnicky/retry/backoffBackoff strategy helpers
@studnicky/retry/constantsDefault configuration constants
@studnicky/retry/errorsConfigurationError, MaxRetriesExceededError, NonRetryableError, RetryError
@studnicky/retry/interfacesInterface types
@studnicky/retry/matchersError matcher utilities
@studnicky/retry/typesBackoffStrategyType, ErrorClassifierFunctionType

Custom error classification

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

ts
import type { ErrorClassificationEntity , RetryConfigInterface} from '../src/index.js';

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

class DatabaseError extends Error {
  constructor(
    message: string,
    readonly isDeadlock: boolean
  ) {
    super(message);
    this.name = 'DatabaseError';
  }
}

class DatabaseRetry extends Retry {
  constructor(config?: Partial<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({
  'maxRetries': 3
});

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

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, RetryConfigInterface , RetryContextType} from '../src/index.js';

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

class TelemetryRetry extends Retry {
  constructor(config?: Partial<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: RetryContextType): 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 maxRetries = 2;
const retry = new TelemetryRetry({
  'maxRetries': maxRetries
});

// Operation always fails — exercises scheduled and giveUp hooks
try {
  await retry.execute(() => {
    throw new Error('always fails');
  });
} catch (err) {
  assert.ok(err instanceof MaxRetriesExceededError, 'Expected MaxRetriesExceededError');
}

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.

The observation-only hooks run through a composed HookInvoker (see @studnicky/errors). Pass .hookTimeoutMs(value) on the builder 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, matching prior behavior.

Source on GitHub