import type { TimersLike } from "@suaveplan/types"; import { SmsAbortedError } from "../error-codes/error-codes.js"; import type { RetryPolicyOptions, SmsRetryEvent } from "../types/sms.types.js"; /** Platform timers fall back to globalThis. */ const platformTimers: TimersLike = { setTimer(callback: () => void, delayMs: number): number { return globalThis.setTimeout(callback, delayMs) as unknown as number; }, clearTimer(handle: number): void { globalThis.clearTimeout( handle as unknown as ReturnType ); }, }; /** Default jitter random source — `Math.random`. */ const defaultRandom = (): number => Math.random(); /** Hook invoked between attempts. */ export type RetryHook = (event: SmsRetryEvent) => void; /** * Exponential-backoff retry policy with full jitter. Each attempt is * awaited against the injectable `Timers.setTimer` seam so tests can drive * the policy without real wall-clock waits. The callback receives a * zero-based attempt counter so callers can record per-attempt telemetry. * * The policy is abort-aware: a triggered `signal.aborted` interrupts the * pending delay AND prevents the next attempt, throwing `SmsAbortedError`. */ export class RetryPolicy { private readonly maxAttempts: number; private readonly initialDelayMs: number; private readonly maxDelayMs: number; private readonly timers: TimersLike; private readonly random: () => number; public constructor(options: RetryPolicyOptions = {}) { this.maxAttempts = options.maxAttempts ?? 3; this.initialDelayMs = options.initialDelayMs ?? 50; this.maxDelayMs = options.maxDelayMs ?? 5_000; this.timers = options.timers ?? platformTimers; this.random = options.random ?? defaultRandom; if (this.maxAttempts < 1) { throw new Error("RetryPolicy.maxAttempts must be ≥ 1"); } } /** Execute `fn`, retrying on rejection up to `maxAttempts` times. */ public async execute( fn: (attempt: number) => Promise, options: { signal?: AbortSignal; onRetry?: RetryHook; to?: string } = {} ): Promise { let lastError: unknown; for (let attempt = 0; attempt < this.maxAttempts; attempt += 1) { this.throwIfAborted(options.signal); try { return await fn(attempt); } catch (error) { lastError = error; if (attempt === this.maxAttempts - 1) break; if (options.onRetry !== undefined) { options.onRetry({ to: options.to ?? "", attempt, error: error instanceof Error ? error : new Error(String(error)), }); } await this.delay(this.computeDelay(attempt), options.signal); } } if (lastError instanceof Error) throw lastError; throw new Error(String(lastError)); } private computeDelay(attempt: number): number { const base = this.initialDelayMs * 2 ** attempt; const capped = Math.min(this.maxDelayMs, base); // Full jitter — random in [0, capped]. return Math.floor(this.random() * capped); } private throwIfAborted(signal?: AbortSignal): void { if (signal?.aborted) { throw new SmsAbortedError({ context: { reason: stringifyReason(signal.reason) }, }); } } private async delay(ms: number, signal?: AbortSignal): Promise { await new Promise((resolve, reject) => { const handle = this.timers.setTimer(() => { cleanup(); resolve(); }, ms); const cleanup = (): void => { if (signal !== undefined) { signal.removeEventListener("abort", onAbort); } }; const onAbort = (): void => { this.timers.clearTimer(handle); cleanup(); reject( new SmsAbortedError({ context: { reason: stringifyReason(signal?.reason) }, }) ); }; if (signal !== undefined) { signal.addEventListener("abort", onAbort, { once: true }); } }); } } function stringifyReason(reason: unknown): string { if (reason instanceof Error) return reason.message; return String(reason); }