3.3 KiB
retry
(no submodules)
Purpose
Provides withRetry, a small exponential-backoff helper used by the connection pool, the migration runner, and any caller-supplied wrapping of adapter calls. The helper is intentionally minimal: it owns the loop, the backoff calculation, the optional jitter, and the abort plumbing. Higher-level concerns (circuit breakers, dead-letter routing, OTel spans) are layered on top by the caller — keeping retry stateless makes it composable.
Backoff follows the canonical formula min(baseBackoffMs * 2^(attempt-1), maxBackoffMs). Jittered mode multiplies the result by a [0, 1) random factor so concurrent callers don't synchronise their retry windows. Both the RNG and the setTimeout setter are injectable so tests run deterministically without time travel.
Features
- Exponential backoff capped at
maxBackoffMs. - Optional full-jitter randomisation through an injectable
Rng. isRetryable(err, attempt)predicate for bail-out on permanent failures.AbortSignalpropagation cancels pending sleeps and the loop.- Pure async — no global state, no side effects beyond the optional sleep.
Usage: Basic
import { withRetry } from "@suaveplan/api-plugin-database/retry/retry";
const result = await withRetry(() => db.query("SELECT 1"), {
maxAttempts: 3,
baseBackoffMs: 50,
maxBackoffMs: 1_000,
jitter: true,
});
Usage: Advanced
import { withRetry } from "@suaveplan/api-plugin-database";
const controller = new AbortController();
setTimeout(() => controller.abort(), 30_000);
const data = await withRetry(
async (attempt) => {
const adapter = await pool.acquire();
try {
return await adapter.query("SELECT * FROM users WHERE active");
} finally {
pool.release(adapter);
}
},
{
maxAttempts: 5,
baseBackoffMs: 100,
maxBackoffMs: 5_000,
jitter: true,
signal: controller.signal,
isRetryable: (err) =>
err instanceof Error && /timeout|reset/i.test(err.message),
}
);
API Reference
withRetry<T>(fn, options): Promise<T>
Invokes fn(attempt) (1-indexed) until success or budget exhaustion. Returns the resolved value or rethrows the final error.
interface RetryOptions
maxAttempts(number, >=1) — total attempt budget.baseBackoffMs(number, >=1) — base interval.maxBackoffMs(number, >=1) — interval ceiling.jitter(boolean) — multiply byrandom()for full jitter.isRetryable?((err, attempt) => boolean) — bail predicate.rng?(Rng) — injectable random source.signal?(AbortSignal) — cancels in-flight sleep + loop.schedule?((cb, ms) => void) — injectablesetTimeoutfor tests.
Implementation Notes
- Sleeps are interrupted promptly by
AbortSignal; the inner promise rejects with the signal'sreason(when anError) or a genericError("withRetry aborted"). - Aborts at the top of the loop (before
fn(attempt)) short-circuit the retry budget immediately. - The helper does not catch or rewrap thrown values — callers see the underlying error. This keeps the helper independent of the package's error registry, which composes downstream.
- Helper functions are intentionally inline-sized; the file stays under the 300-line hard limit.