Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,125 @@
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);
}