Files
WRNexusJS/packages/helpers/src/resilience.ts
T
2026-07-27 12:42:18 +05:30

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;
}