src/transport/retry.ts
v0.3.0 · 3.9 KB
// Retry with exponential backoff and jitter.
//
// Default profile: 4 retries, 250 ms x 2.5^n, jitter +-40%
// (about 250 / 625 / 1560 / 3900 ms, ~6.3 s in total). Jitter matters more than the
// exponent: it spreads a burst of N parallel callers that failed together, which is the
// main relief against IP-level 429s. Every attempt must go back through the limiter.
/** Backoff parameters. */
export interface BackoffOptions {
/** Delay before the first retry, ms. Default 250. */
baseDelayMs?: number;
/** Multiplier per attempt. Default 2.5. */
factor?: number;
/** Relative jitter, 0.4 = +-40%. Default 0.4. */
jitter?: number;
/** Upper bound for a single delay, ms. Default: no cap. */
maxDelayMs?: number;
/** Random source in [0, 1). Default `Math.random`. */
random?: () => number;
}
/** Retry parameters (backoff + attempt budget). */
export interface RetryOptions extends BackoffOptions {
/** Retries after the first attempt. Default 4 (5 attempts in total). */
retries?: number;
}
export const DEFAULT_RETRY = Object.freeze({
retries: 4,
baseDelayMs: 250,
factor: 2.5,
jitter: 0.4,
});
/**
* Delay before retry number `attempt` (0-based): `base * factor^attempt * (1 +- jitter)`, rounded,
* never negative, capped by `maxDelayMs`.
*/
export function backoffDelayMs(attempt: number, opts: BackoffOptions = {}): number {
const base = opts.baseDelayMs ?? DEFAULT_RETRY.baseDelayMs;
const factor = opts.factor ?? DEFAULT_RETRY.factor;
const jitter = Math.min(1, Math.max(0, opts.jitter ?? DEFAULT_RETRY.jitter));
const random = opts.random ?? Math.random;
const n = Math.max(0, Math.floor(attempt));
const raw = base * factor ** n * (1 + jitter * (random() * 2 - 1));
const capped = opts.maxDelayMs !== undefined ? Math.min(opts.maxDelayMs, raw) : raw;
return Math.max(0, Math.round(capped));
}
/** Options for {@link withRetry}. */
export interface WithRetryOptions extends RetryOptions {
/** Decides whether an error is retryable. Pick the predicate by action class (see `limitExchange`). */
shouldRetry: (err: unknown, attempt: number) => boolean;
/** Aborts the backoff sleep; an aborted signal also stops further retries. */
signal?: AbortSignal;
/** Called before sleeping for a retry. */
onRetry?: (err: unknown, attempt: number, delayMs: number) => void;
}
function abortReason(signal: AbortSignal): unknown {
return signal.reason ?? new DOMException('This operation was aborted', 'AbortError');
}
/** Promise-based sleep that rejects with the signal's reason when aborted. */
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) return Promise.reject(abortReason(signal));
return new Promise<void>((resolve, reject) => {
const onAbort = () => {
clearTimeout(timer);
reject(abortReason(signal as AbortSignal));
};
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, ms);
signal?.addEventListener('abort', onAbort, { once: true });
});
}
/**
* Runs `fn` and retries it while `shouldRetry` approves, sleeping {@link backoffDelayMs} between
* attempts. Throws the last error once the budget is spent. `fn` receives the 0-based attempt.
*
* Retrying is a policy decision: /info is idempotent and may retry on 429/5xx/network, while an
* opening order may only retry on 429 (a retry after an ambiguous 5xx doubles the position).
*/
export async function withRetry<T>(fn: (attempt: number) => Promise<T>, opts: WithRetryOptions): Promise<T> {
const retries = Math.max(0, Math.floor(opts.retries ?? DEFAULT_RETRY.retries));
for (let attempt = 0; ; attempt++) {
try {
return await fn(attempt);
} catch (err) {
if (opts.signal?.aborted) throw err;
if (attempt >= retries || !opts.shouldRetry(err, attempt)) throw err;
const delay = backoffDelayMs(attempt, opts);
opts.onRetry?.(err, attempt, delay);
await sleep(delay, opts.signal);
}
}
}