@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
Build a Retry instance with the builder, 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.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
/** 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');
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.
/** 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');
Subpath exports
| Subpath | Contents |
|---|---|
@studnicky/retry | Retry, RetryBuilder, DefaultHttpErrorClassifier, ErrorClassifier, errors, type guards |
@studnicky/retry/backoff | Backoff strategy helpers |
@studnicky/retry/constants | Default configuration constants |
@studnicky/retry/errors | ConfigurationError, MaxRetriesExceededError, NonRetryableError, RetryError |
@studnicky/retry/interfaces | Interface types |
@studnicky/retry/matchers | Error matcher utilities |
@studnicky/retry/types | BackoffStrategyType, ErrorClassifierFunctionType |
Custom error classification
Subclass Retry and override classifyError to control which errors are retryable for your domain:
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:
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.