export type Duration = number | `${number}${"ms" | "s" | "m" | "h"}`; export type BackoffStrategy = "fixed" | "exponential" | ((attempt: number) => Duration); export interface CircuitBreakerOptions { failures: number; resetAfter: Duration; successesToClose?: number; } export interface CircuitBreakerSnapshot { state: "closed" | "open" | "half-open"; failures: number; successes: number; retryAfterMs: number; } export class ResilienceError extends Error { constructor( public readonly code: | "WRN-RESILIENCE-TIMEOUT" | "WRN-RESILIENCE-ABORTED" | "WRN-RESILIENCE-CIRCUIT-OPEN" | "WRN-RESILIENCE-BULKHEAD-FULL", message: string, options?: ErrorOptions, ) { super(message, options); this.name = "ResilienceError"; } } export function durationMs(value: Duration): number { if (typeof value === "number") { if (!Number.isFinite(value) || value < 0) throw new TypeError("Duration must be finite and non-negative."); return value; } const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)$/.exec(value); if (!match) throw new TypeError(`Invalid duration: ${value}`); const scale = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000 }[match[2]!]!; return Number(match[1]) * scale; } export class CircuitBreaker { private failures = 0; private successes = 0; private openedAt = 0; private probing = false; constructor(private readonly options: CircuitBreakerOptions) { if (!Number.isInteger(options.failures) || options.failures < 1) { throw new TypeError("Circuit breaker failures must be a positive integer."); } durationMs(options.resetAfter); } snapshot(now = Date.now()): CircuitBreakerSnapshot { const resetAfter = durationMs(this.options.resetAfter); const elapsed = now - this.openedAt; const open = this.openedAt > 0 && elapsed < resetAfter; return { state: open ? "open" : this.openedAt > 0 ? "half-open" : "closed", failures: this.failures, successes: this.successes, retryAfterMs: open ? Math.max(0, resetAfter - elapsed) : 0, }; } async execute(operation: () => Promise): Promise { const health = this.snapshot(); if (health.state === "open" || (health.state === "half-open" && this.probing)) { throw new ResilienceError( "WRN-RESILIENCE-CIRCUIT-OPEN", `Circuit is open; retry after ${health.retryAfterMs}ms.`, ); } if (health.state === "half-open") this.probing = true; try { const value = await operation(); this.failures = 0; this.successes += 1; if (this.successes >= (this.options.successesToClose ?? 1)) this.openedAt = 0; return value; } catch (error) { this.successes = 0; this.failures += 1; if (this.failures >= this.options.failures) this.openedAt = Date.now(); throw error; } finally { this.probing = false; } } } export interface BulkheadOptions { concurrency: number; queue?: number; } export class Bulkhead { private active = 0; private readonly waiting: Array<() => void> = []; constructor(private readonly options: BulkheadOptions) { if (!Number.isInteger(options.concurrency) || options.concurrency < 1) { throw new TypeError("Bulkhead concurrency must be a positive integer."); } if (options.queue !== undefined && (!Number.isInteger(options.queue) || options.queue < 0)) { throw new TypeError("Bulkhead queue must be a non-negative integer."); } } get snapshot(): Readonly<{ active: number; queued: number; capacity: number }> { return { active: this.active, queued: this.waiting.length, capacity: this.options.concurrency }; } async execute(operation: () => Promise): Promise { if (this.active >= this.options.concurrency) { if (this.waiting.length >= (this.options.queue ?? 0)) { throw new ResilienceError( "WRN-RESILIENCE-BULKHEAD-FULL", "Bulkhead capacity is exhausted.", ); } await new Promise((resolve) => this.waiting.push(resolve)); } this.active += 1; try { return await operation(); } finally { this.active -= 1; this.waiting.shift()?.(); } } } export interface ResilientCallOptions { run: (signal: AbortSignal, attempt: number) => Promise; timeout?: Duration; retries?: number; retryDelay?: Duration; backoff?: BackoffStrategy; circuitBreaker?: CircuitBreaker | CircuitBreakerOptions; bulkhead?: Bulkhead | BulkheadOptions; signal?: AbortSignal; retryWhen?: (error: unknown, attempt: number) => boolean | Promise; fallback?: (error: unknown, signal: AbortSignal) => T | Promise; onRetry?: (error: unknown, attempt: number, delayMs: number) => void; } const breakerInstances = new WeakMap(); const bulkheadInstances = new WeakMap(); function breakerFor(value: CircuitBreaker | CircuitBreakerOptions): CircuitBreaker { if (value instanceof CircuitBreaker) return value; const existing = breakerInstances.get(value); if (existing) return existing; const created = new CircuitBreaker(value); breakerInstances.set(value, created); return created; } function bulkheadFor(value: Bulkhead | BulkheadOptions): Bulkhead { if (value instanceof Bulkhead) return value; const existing = bulkheadInstances.get(value); if (existing) return existing; const created = new Bulkhead(value); bulkheadInstances.set(value, created); return created; } async function abortable(operation: Promise, signal: AbortSignal): Promise { if (signal.aborted) throw signal.reason; let cleanup = () => {}; const aborted = new Promise((_resolve, reject) => { const listener = () => reject(signal.reason); signal.addEventListener("abort", listener, { once: true }); cleanup = () => signal.removeEventListener("abort", listener); }); try { return await Promise.race([operation, aborted]); } finally { cleanup(); } } function abortError(signal: AbortSignal): ResilienceError { return new ResilienceError("WRN-RESILIENCE-ABORTED", "Resilient call was aborted.", { cause: signal.reason, }); } async function delay(ms: number, signal?: AbortSignal): Promise { if (signal?.aborted) throw abortError(signal); await new Promise((resolve, reject) => { const timer = setTimeout(resolve, ms); signal?.addEventListener( "abort", () => { clearTimeout(timer); reject(abortError(signal)); }, { once: true }, ); }); } export async function resilientCall(options: ResilientCallOptions): Promise { const retries = options.retries ?? 0; if (!Number.isInteger(retries) || retries < 0) throw new TypeError("Retries must be a non-negative integer."); const breaker = options.circuitBreaker ? breakerFor(options.circuitBreaker) : undefined; const bulkhead = options.bulkhead ? bulkheadFor(options.bulkhead) : undefined; const invoke = async (): Promise => { let lastError: unknown; for (let attempt = 1; attempt <= retries + 1; attempt += 1) { if (options.signal?.aborted) throw abortError(options.signal); const controller = new AbortController(); const forwardAbort = () => controller.abort(options.signal?.reason); options.signal?.addEventListener("abort", forwardAbort, { once: true }); const timeout = options.timeout === undefined ? undefined : durationMs(options.timeout); const timer = timeout === undefined ? undefined : setTimeout(() => controller.abort("timeout"), timeout); try { const run = () => abortable(options.run(controller.signal, attempt), controller.signal); return await (breaker ? breaker.execute(run) : run()); } catch (caught) { lastError = controller.signal.aborted && !options.signal?.aborted ? new ResilienceError( "WRN-RESILIENCE-TIMEOUT", `Operation timed out after ${timeout}ms.`, { cause: caught }, ) : caught; if (attempt > retries || !(await (options.retryWhen?.(lastError, attempt) ?? true))) break; const base = durationMs(options.retryDelay ?? 100); const wait = typeof options.backoff === "function" ? durationMs(options.backoff(attempt)) : options.backoff === "exponential" ? base * 2 ** (attempt - 1) : base; options.onRetry?.(lastError, attempt, wait); await delay(wait, options.signal); } finally { if (timer !== undefined) clearTimeout(timer); options.signal?.removeEventListener("abort", forwardAbort); } } if (options.fallback) return options.fallback(lastError, options.signal ?? new AbortController().signal); throw lastError; }; return bulkhead ? bulkhead.execute(invoke) : invoke(); }