@studnicky/retry
Generic async retry utility with extensible error classification.
Install
pnpm add @studnicky/retryRequires @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:
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
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.
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:
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:
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.
Entities
@studnicky/retry/entities exports every schema namespace in src/entities.
import { RetryConfigEntity } from '@studnicky/retry/entities';Interfaces
@studnicky/retry/interfaces exports every TypeScript interface in src/interfaces, including configuration and state contracts.
import type { RetryConfigInterface } from '@studnicky/retry/interfaces';Exports
| Symbol | Purpose | Import path |
|---|---|---|
BackoffStrategy | Provides backoff strategy functionality. | @studnicky/retry |
BackoffStrategyInterface | Defines the backoff strategy contract. | @studnicky/retry |
Retry | Provides retry functionality. | @studnicky/retry |
MaximumRetriesExceededError | Represents maximum retries exceeded failures. | @studnicky/retry |
NonRetryableError | Represents non retryable failures. | @studnicky/retry |
RetryError | Represents retry failures. | @studnicky/retry |
RetryConfigInterface | Defines retry settings and the optional clock collaborator. | @studnicky/retry/interfaces |