Initial Commit
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
# `retry`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## 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.
|
||||
- `AbortSignal` propagation cancels pending sleeps and the loop.
|
||||
- Pure async — no global state, no side effects beyond the optional sleep.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
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
|
||||
|
||||
```ts
|
||||
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 by `random()` 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`) — injectable `setTimeout` for tests.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Sleeps are interrupted promptly by `AbortSignal`; the inner promise rejects with the signal's `reason` (when an `Error`) or a generic `Error("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.
|
||||
Reference in New Issue
Block a user