126 lines
4.6 KiB
TypeScript
126 lines
4.6 KiB
TypeScript
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<typeof setTimeout>
|
|
);
|
|
},
|
|
};
|
|
|
|
/** 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<T>(
|
|
fn: (attempt: number) => Promise<T>,
|
|
options: { signal?: AbortSignal; onRetry?: RetryHook; to?: string } = {}
|
|
): Promise<T> {
|
|
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<void> {
|
|
await new Promise<void>((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);
|
|
}
|