162 lines
4.8 KiB
TypeScript
162 lines
4.8 KiB
TypeScript
export interface RetryOptions {
|
|
attempts?: number;
|
|
minDelayMs?: number;
|
|
maxDelayMs?: number;
|
|
factor?: number;
|
|
jitter?: number;
|
|
signal?: AbortSignal;
|
|
retryIf?: (error: unknown, attempt: number) => boolean | Promise<boolean>;
|
|
onRetry?: (error: unknown, attempt: number, delayMs: number) => void | Promise<void>;
|
|
}
|
|
|
|
function nonNegative(value: number, label: string): number {
|
|
if (!Number.isFinite(value) || value < 0) {
|
|
throw new RangeError(`${label} must be a non-negative number`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function backoffDelay(
|
|
attempt: number,
|
|
options: Pick<RetryOptions, "minDelayMs" | "maxDelayMs" | "factor" | "jitter"> = {},
|
|
): number {
|
|
if (!Number.isInteger(attempt) || attempt < 1) {
|
|
throw new RangeError("attempt must be a positive integer");
|
|
}
|
|
const min = nonNegative(options.minDelayMs ?? 100, "minDelayMs");
|
|
const max = nonNegative(options.maxDelayMs ?? 30_000, "maxDelayMs");
|
|
if (max < min) throw new RangeError("maxDelayMs must be at least minDelayMs");
|
|
const factor = options.factor ?? 2;
|
|
if (!Number.isFinite(factor) || factor < 1) {
|
|
throw new RangeError("factor must be at least 1");
|
|
}
|
|
const jitter = Math.min(1, Math.max(0, options.jitter ?? 0.2));
|
|
const raw = Math.min(max, min * factor ** Math.max(0, attempt - 1));
|
|
return Math.round(raw * (1 - jitter + Math.random() * jitter * 2));
|
|
}
|
|
|
|
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
nonNegative(ms, "sleep duration");
|
|
if (signal?.aborted) {
|
|
return Promise.reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const abort = (): void => {
|
|
clearTimeout(timer);
|
|
reject(signal?.reason ?? new DOMException("Aborted", "AbortError"));
|
|
};
|
|
const timer = setTimeout(() => {
|
|
signal?.removeEventListener("abort", abort);
|
|
resolve();
|
|
}, ms);
|
|
signal?.addEventListener("abort", abort, { once: true });
|
|
});
|
|
}
|
|
|
|
export async function retry<T>(
|
|
operation: (attempt: number, signal?: AbortSignal) => Promise<T>,
|
|
options: RetryOptions = {},
|
|
): Promise<T> {
|
|
const attempts = options.attempts ?? 3;
|
|
if (!Number.isInteger(attempts) || attempts < 1) {
|
|
throw new RangeError("attempts must be a positive integer");
|
|
}
|
|
|
|
let last: unknown;
|
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
options.signal?.throwIfAborted();
|
|
try {
|
|
return await operation(attempt, options.signal);
|
|
} catch (error) {
|
|
last = error;
|
|
const retryAllowed = options.retryIf ? await options.retryIf(error, attempt) : true;
|
|
if (attempt >= attempts || !retryAllowed) throw error;
|
|
const delay = backoffDelay(attempt, options);
|
|
await options.onRetry?.(error, attempt, delay);
|
|
await sleep(delay, options.signal);
|
|
}
|
|
}
|
|
throw last;
|
|
}
|
|
|
|
export async function withTimeout<T>(
|
|
promise: Promise<T>,
|
|
timeoutMs: number,
|
|
message = "Operation timed out",
|
|
signal?: AbortSignal,
|
|
): Promise<T> {
|
|
nonNegative(timeoutMs, "timeoutMs");
|
|
signal?.throwIfAborted();
|
|
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(new Error(message)), timeoutMs);
|
|
const abort = (): void => controller.abort(signal?.reason);
|
|
signal?.addEventListener("abort", abort, { once: true });
|
|
|
|
try {
|
|
return await Promise.race([
|
|
promise,
|
|
new Promise<T>((_resolve, reject) => {
|
|
controller.signal.addEventListener("abort", () => reject(controller.signal.reason), {
|
|
once: true,
|
|
});
|
|
}),
|
|
]);
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
signal?.removeEventListener("abort", abort);
|
|
}
|
|
}
|
|
|
|
export function stableStringify(value: unknown): string {
|
|
const ancestors = new Set<object>();
|
|
|
|
const normalize = (input: unknown): unknown => {
|
|
if (!input || typeof input !== "object") return input;
|
|
if (input instanceof Date) return input.toISOString();
|
|
if (ancestors.has(input)) {
|
|
throw new TypeError("Cannot stringify circular structure");
|
|
}
|
|
|
|
ancestors.add(input);
|
|
try {
|
|
if (Array.isArray(input)) return input.map(normalize);
|
|
const record = input as Record<string, unknown>;
|
|
return Object.fromEntries(
|
|
Object.keys(record)
|
|
.sort()
|
|
.map((key) => [key, normalize(record[key])]),
|
|
);
|
|
} finally {
|
|
ancestors.delete(input);
|
|
}
|
|
};
|
|
|
|
return JSON.stringify(normalize(value));
|
|
}
|
|
|
|
export function safeJsonParse<T>(value: string, fallback: T): T {
|
|
try {
|
|
return JSON.parse(value) as T;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
export function clamp(value: number, min: number, max: number): number {
|
|
return Math.min(max, Math.max(min, value));
|
|
}
|
|
|
|
export function once<T extends (...args: any[]) => any>(fn: T): T {
|
|
let called = false;
|
|
let result: ReturnType<T>;
|
|
return ((...args: Parameters<T>) => {
|
|
if (!called) {
|
|
called = true;
|
|
result = fn(...args);
|
|
}
|
|
return result;
|
|
}) as T;
|
|
}
|