timeout-flow
Version:
Fluent, composable, pauseable JavaScript timers and time control flows — plus RAF utilities for frame-based logic.
164 lines (136 loc) • 4.66 kB
JavaScript
// ./src/retry.js
import { parseDuration } from './parseDuration.js';
import { attachAbort, createAbortError } from './abort.js';
/**
* Retries a promise-returning function with delays.
*
* @param {(attemptIndex: number) => Promise<any>} fn - async function
* @param {Object} [options]
* @param {number} [options.attempts=3] - total attempts (including the first)
* @param {string|number} [options.delay='500ms'] - base delay between retries
* @param {boolean} [options.backoff=false] - exponential backoff if true
* @param {number} [options.factor=2] - backoff multiplier (only if backoff=true)
* @param {string|number} [options.maxDelay] - clamp delay to this max
* @param {boolean|'full'|'equal'|'decorrelated'} [options.jitter=false] - jitter strategy
* @param {AbortSignal} [options.signal] - optional AbortSignal to cancel retries
* @param {() => number} [options.random=Math.random] - injectable RNG for tests
* @param {(err: any, attempt: number) => boolean | Promise<boolean>} [options.shouldRetry]
* Predicate deciding whether to retry after a failure.
* @param {(err: any, attempt: number, delayMs: number) => void | Promise<void>} [options.onRetry]
* Hook invoked after deciding to retry and before waiting.
* @returns {Promise<any>}
*/
export async function retry(
fn,
{
attempts = 3,
delay = '500ms',
backoff = false,
factor = 2,
maxDelay,
jitter = false,
signal,
random = Math.random,
shouldRetry,
onRetry,
} = {}
) {
if (typeof fn !== 'function') throw new TypeError('retry: fn must be a function');
const totalAttempts = Math.max(1, attempts | 0);
if (signal?.aborted) throw createAbortError();
const baseDelay = parseDuration(delay);
const maxDelayMs = maxDelay != null ? parseDuration(maxDelay) : Infinity;
// decorrelated jitter needs to remember prior sleep
let prevSleep = Math.min(baseDelay, maxDelayMs);
for (let attempt = 0; attempt < totalAttempts; attempt++) {
if (signal?.aborted) throw createAbortError();
try {
return await fn(attempt);
} catch (err) {
const lastAttempt = attempt === totalAttempts - 1;
if (lastAttempt) throw err;
// Decide if we should retry
if (shouldRetry) {
const ok = await shouldRetry(err, attempt);
if (!ok) throw err;
}
const expDelay = backoff ? baseDelay * Math.pow(factor, attempt) : baseDelay;
const sleep = computeSleep({
expDelay,
baseDelay,
maxDelayMs,
jitter,
random,
prevSleep,
});
prevSleep = sleep;
// Notify caller we're retrying (before waiting)
if (onRetry) {
await onRetry(err, attempt, sleep);
}
await waitWithAbort(sleep, signal);
}
}
// Unreachable, but keeps type checkers happy.
// eslint-disable-next-line no-throw-literal
throw new Error('retry: exhausted attempts unexpectedly');
}
/**
* Compute retry sleep with optional backoff clamp + jitter.
*
* Jitter strategies:
* - false: no jitter
* - 'full': random(0, cap)
* - 'equal': cap/2 + random(0, cap/2)
* - 'decorrelated': random(baseDelay, prevSleep*3) (clamped)
*/
function computeSleep({ expDelay, baseDelay, maxDelayMs, jitter, random, prevSleep }) {
const cap = Math.min(expDelay, maxDelayMs);
if (!jitter) return cap;
const mode = jitter === true ? 'full' : jitter;
if (mode === 'full') {
return Math.min(maxDelayMs, randBetween(0, cap, random));
}
if (mode === 'equal') {
const half = cap / 2;
return Math.min(maxDelayMs, half + randBetween(0, half, random));
}
if (mode === 'decorrelated') {
const next = randBetween(baseDelay, prevSleep * 3, random);
return Math.min(maxDelayMs, next);
}
// Unknown mode -> treat as no jitter (safe fallback)
return cap;
}
function randBetween(min, max, random) {
const r = random();
const t = Number.isFinite(r) ? Math.min(1, Math.max(0, r)) : 0;
return min + (max - min) * t;
}
/**
* Wait helper that supports AbortSignal.
* Rejects with AbortError if aborted during delay.
*/
function waitWithAbort(ms, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(createAbortError());
return;
}
let timeoutId = 0;
const cleanup = () => {
if (timeoutId) clearTimeout(timeoutId);
timeoutId = 0;
cleanupAbort();
};
const onAbort = () => {
cleanup();
reject(createAbortError());
};
const cleanupAbort = attachAbort(signal, onAbort);
timeoutId = setTimeout(() => {
cleanup();
resolve();
}, ms);
});
}