129 lines
3.8 KiB
TypeScript
129 lines
3.8 KiB
TypeScript
export type RetryConfig = {
|
|
attempts?: number;
|
|
minDelayMs?: number;
|
|
maxDelayMs?: number;
|
|
jitter?: number;
|
|
};
|
|
|
|
export type RetryInfo = {
|
|
attempt: number;
|
|
maxAttempts: number;
|
|
delayMs: number;
|
|
err: unknown;
|
|
label?: string;
|
|
};
|
|
|
|
export type RetryOptions = RetryConfig & {
|
|
label?: string;
|
|
shouldRetry?: (err: unknown, attempt: number) => boolean;
|
|
retryAfterMs?: (err: unknown) => number | undefined;
|
|
onRetry?: (info: RetryInfo) => void;
|
|
};
|
|
|
|
const DEFAULT_RETRY_CONFIG = {
|
|
attempts: 3,
|
|
minDelayMs: 300,
|
|
maxDelayMs: 30_000,
|
|
jitter: 0,
|
|
};
|
|
|
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
const asFiniteNumber = (value: unknown): number | undefined =>
|
|
typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
|
|
const clampNumber = (value: unknown, fallback: number, min?: number, max?: number) => {
|
|
const next = asFiniteNumber(value);
|
|
if (next === undefined) return fallback;
|
|
const floor = typeof min === "number" ? min : Number.NEGATIVE_INFINITY;
|
|
const ceiling = typeof max === "number" ? max : Number.POSITIVE_INFINITY;
|
|
return Math.min(Math.max(next, floor), ceiling);
|
|
};
|
|
|
|
export function resolveRetryConfig(
|
|
defaults: Required<RetryConfig> = DEFAULT_RETRY_CONFIG,
|
|
overrides?: RetryConfig,
|
|
): Required<RetryConfig> {
|
|
const attempts = Math.max(1, Math.round(clampNumber(overrides?.attempts, defaults.attempts, 1)));
|
|
const minDelayMs = Math.max(
|
|
0,
|
|
Math.round(clampNumber(overrides?.minDelayMs, defaults.minDelayMs, 0)),
|
|
);
|
|
const maxDelayMs = Math.max(
|
|
minDelayMs,
|
|
Math.round(clampNumber(overrides?.maxDelayMs, defaults.maxDelayMs, 0)),
|
|
);
|
|
const jitter = clampNumber(overrides?.jitter, defaults.jitter, 0, 1);
|
|
return { attempts, minDelayMs, maxDelayMs, jitter };
|
|
}
|
|
|
|
function applyJitter(delayMs: number, jitter: number): number {
|
|
if (jitter <= 0) return delayMs;
|
|
const offset = (Math.random() * 2 - 1) * jitter;
|
|
return Math.max(0, Math.round(delayMs * (1 + offset)));
|
|
}
|
|
|
|
export async function retryAsync<T>(
|
|
fn: () => Promise<T>,
|
|
attemptsOrOptions: number | RetryOptions = 3,
|
|
initialDelayMs = 300,
|
|
): Promise<T> {
|
|
if (typeof attemptsOrOptions === "number") {
|
|
const attempts = Math.max(1, Math.round(attemptsOrOptions));
|
|
let lastErr: unknown;
|
|
for (let i = 0; i < attempts; i += 1) {
|
|
try {
|
|
return await fn();
|
|
} catch (err) {
|
|
lastErr = err;
|
|
if (i === attempts - 1) break;
|
|
const delay = initialDelayMs * 2 ** i;
|
|
await sleep(delay);
|
|
}
|
|
}
|
|
throw lastErr ?? new Error("Retry failed");
|
|
}
|
|
|
|
const options = attemptsOrOptions;
|
|
|
|
const resolved = resolveRetryConfig(DEFAULT_RETRY_CONFIG, options);
|
|
const maxAttempts = resolved.attempts;
|
|
const minDelayMs = resolved.minDelayMs;
|
|
const maxDelayMs =
|
|
Number.isFinite(resolved.maxDelayMs) && resolved.maxDelayMs > 0
|
|
? resolved.maxDelayMs
|
|
: Number.POSITIVE_INFINITY;
|
|
const jitter = resolved.jitter;
|
|
const shouldRetry = options.shouldRetry ?? (() => true);
|
|
let lastErr: unknown;
|
|
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
try {
|
|
return await fn();
|
|
} catch (err) {
|
|
lastErr = err;
|
|
if (attempt >= maxAttempts || !shouldRetry(err, attempt)) break;
|
|
|
|
const retryAfterMs = options.retryAfterMs?.(err);
|
|
const hasRetryAfter = typeof retryAfterMs === "number" && Number.isFinite(retryAfterMs);
|
|
const baseDelay = hasRetryAfter
|
|
? Math.max(retryAfterMs, minDelayMs)
|
|
: minDelayMs * 2 ** (attempt - 1);
|
|
let delay = Math.min(baseDelay, maxDelayMs);
|
|
delay = applyJitter(delay, jitter);
|
|
delay = Math.min(Math.max(delay, minDelayMs), maxDelayMs);
|
|
|
|
options.onRetry?.({
|
|
attempt,
|
|
maxAttempts,
|
|
delayMs: delay,
|
|
err,
|
|
label: options.label,
|
|
});
|
|
await sleep(delay);
|
|
}
|
|
}
|
|
|
|
throw lastErr ?? new Error("Retry failed");
|
|
}
|