Initial Commit
This commit is contained in:
125
packages/communication/messaging/sms/src/retry/retry.ts
Normal file
125
packages/communication/messaging/sms/src/retry/retry.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user