Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,63 @@
# constant
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `createConstantBackoff`, a factory that creates a retry strategy returning a fixed, unchanging delay for every attempt. Constant backoff is the simplest possible strategy: every retry waits the same number of milliseconds before the next call. This is useful when the failure mode does not benefit from increasing delays (e.g., transient network hiccups with known recovery windows) and when predictable timing is more important than reducing thundering-herd pressure.
The returned function satisfies the `RetryStrategy` interface (`(attempt: number) => number`) and can be passed directly to `RetryConfig.delay` or any helper that accepts a strategy.
## Features
- Zero allocation per call — closure captures delay once at factory time
- Deterministic: no randomness, no clock dependency
- Composable with `applyJitter` for external jitter if needed
## Usage: Basic Example
```typescript
import { createConstantBackoff } from "@suaveplan/retry";
import { withRetry } from "@suaveplan/retry";
const strategy = createConstantBackoff({ delay: 200 });
const result = await withRetry(fetchUser, {
maxAttempts: 5,
delay: strategy,
});
```
## Usage: Advanced Example
```typescript
import { createConstantBackoff, applyJitter } from "@suaveplan/retry";
const base = createConstantBackoff({ delay: 500 });
// Wrap with full jitter externally to spread retries under load
const withJitter = (attempt: number) => applyJitter(base(attempt), "full");
await withRetry(sendEmail, {
maxAttempts: 10,
delay: withJitter,
shouldRetry: (err) => isTransient(err),
});
```
## API Reference
### `createConstantBackoff(opts)`
| Parameter | Type | Description |
|-----------|------|-------------|
| `opts.delay` | `number` | Fixed delay in milliseconds returned for every attempt. |
**Returns:** `RetryStrategy` — `(attempt: number) => number`. The `attempt` argument is ignored.
**Throws:** Nothing.
## Implementation Notes
The factory captures `delay` in a closure at construction time rather than reading from `opts` on each call, which avoids one property lookup per retry. The returned arrow function is a single expression with no branching, making it trivially inlineable by JIT compilers. For scenarios where the delay needs to vary per environment, the recommended pattern is to construct a new strategy at startup with the environment-configured value rather than mutating options at runtime.

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createConstantBackoff } from "./constant.js";
describe("createConstantBackoff", () => {
it("returns the same delay for attempt 1", { timeout: 5000 }, () => {
const strategy = createConstantBackoff({ delay: 100 });
expect(strategy(1)).toBe(100);
});
it("returns the same delay for every attempt", { timeout: 5000 }, () => {
const strategy = createConstantBackoff({ delay: 250 });
for (let i = 1; i <= 10; i++) {
expect(strategy(i)).toBe(250);
}
});
it("works with delay of 0", { timeout: 5000 }, () => {
const strategy = createConstantBackoff({ delay: 0 });
expect(strategy(1)).toBe(0);
expect(strategy(5)).toBe(0);
});
it("works with large delays", { timeout: 5000 }, () => {
const strategy = createConstantBackoff({ delay: 60_000 });
expect(strategy(100)).toBe(60_000);
});
it("ignores attempt number", { timeout: 5000 }, () => {
const strategy = createConstantBackoff({ delay: 500 });
expect(strategy(1)).toBe(strategy(999));
});
});

View File

@@ -0,0 +1,17 @@
import type {
ConstantBackoffOptions,
RetryStrategy,
} from "../../types/retry.types.js";
/**
* Creates a retry strategy that always returns the same fixed delay.
*
* @param opts - `{ delay }` — fixed delay in milliseconds.
* @returns A `RetryStrategy` that ignores the attempt number.
*/
export function createConstantBackoff(
opts: ConstantBackoffOptions
): RetryStrategy {
const { delay } = opts;
return (_attempt: number): number => delay;
}

View File

@@ -0,0 +1 @@
export { createConstantBackoff } from "./constant";

View File

@@ -0,0 +1,92 @@
# exponential
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `createExponentialBackoff`, `applyJitter`, and `clampDelay` — the three core building blocks for exponential backoff with optional jitter. Exponential backoff is the industry-standard strategy for retrying failed calls against overloaded or degraded services: each subsequent delay doubles (or grows by configurable base), reducing collision probability under high concurrency far better than constant or linear strategies.
Jitter breaks the synchronization problem that arises when many clients retry at identical intervals, which can cause "thundering herd" effects that worsen rather than relieve overloaded services. This module supports four jitter modes aligned with the AWS/Google SRE recommendations: `full`, `equal`, `decorrelated`, and `none`.
## Features
- Configurable `initial` delay, `base` exponent (default 2), and `max` cap
- Four jitter modes: `full`, `equal`, `decorrelated`, `none`
- Pluggable `Rng` for deterministic testing — falls back to `Math.random()`
- `applyJitter` usable independently on any delay value
- `clampDelay` utility for explicit range enforcement
- All functions are pure (no side effects beyond rng call)
## Usage: Basic Example
```typescript
import { createExponentialBackoff, withRetry } from "@suaveplan/retry";
const strategy = createExponentialBackoff({ initial: 100, max: 30_000, jitter: "full" });
// attempt 1 → ~0–100ms, attempt 2 → ~0–200ms, etc.
await withRetry(fetchQuote, { maxAttempts: 6, delay: strategy });
```
## Usage: Advanced Example
```typescript
import { createExponentialBackoff, applyJitter, clampDelay } from "@suaveplan/retry";
// Seeded RNG for deterministic tests
const rng = { nextUint32: () => 2147483648, nextFloat: () => 0.5 };
const strategy = createExponentialBackoff(
{ initial: 50, base: 3, max: 10_000, jitter: "equal" },
rng,
);
// Manual composition: clamp after jitter for extra safety
const bounded = (attempt: number) => clampDelay(strategy(attempt), 50, 5_000);
await withRetry(writeRecord, {
maxAttempts: 8,
delay: bounded,
shouldRetry: (err) => isTransient(err),
signal: AbortSignal.timeout(60_000),
});
```
## API Reference
### `createExponentialBackoff(opts, rng?)`
| Parameter | Type | Description |
|-----------|------|-------------|
| `opts.initial` | `number` | Base delay for attempt 1 (ms). |
| `opts.base` | `number?` | Exponent base. Default: `2`. |
| `opts.max` | `number?` | Maximum delay cap (ms). |
| `opts.jitter` | `JitterMode?` | Jitter mode. Default: `"none"`. |
| `rng` | `Rng?` | Random source. Falls back to `Math.random()`. |
**Returns:** `RetryStrategy` — `(attempt: number) => number`.
### `applyJitter(delay, mode, rng?)`
| Parameter | Type | Description |
|-----------|------|-------------|
| `delay` | `number` | Base delay to apply jitter to (ms). |
| `mode` | `JitterMode` | One of `"full"`, `"equal"`, `"decorrelated"`, `"none"`. |
| `rng` | `Rng?` | Random source. Falls back to `Math.random()`. |
**Returns:** `number` — jitter-adjusted delay.
### `clampDelay(delay, min, max)`
| Parameter | Type | Description |
|-----------|------|-------------|
| `delay` | `number` | Unclamped delay (ms). |
| `min` | `number` | Inclusive lower bound. |
| `max` | `number` | Inclusive upper bound. |
**Returns:** `number` — `Math.max(min, Math.min(max, delay))`.
## Implementation Notes
The `createExponentialBackoff` factory captures all configuration in a closure and calls `applyJitter` internally if a jitter mode other than `"none"` is configured. This ensures the correct `rng` is threaded through without callers needing to pass it on every call. The `"decorrelated"` jitter mode is a simplified variant that maps `delay` to `[delay, 2*delay]` — the classic AWS decorrelated formula uses `rand(delay/2, prev*3)` but requires external state; this simplified form achieves similar dispersion without statefulness. `clampDelay` is intentionally a standalone function rather than part of the strategy to keep concerns separated and allow explicit composition.

View File

@@ -0,0 +1,208 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import {
applyJitter,
clampDelay,
createExponentialBackoff,
} from "./exponential.js";
describe("createExponentialBackoff", () => {
it("returns initial on attempt 1", { timeout: 5000 }, () => {
const s = createExponentialBackoff({ initial: 100 });
expect(s(1)).toBe(100);
});
it("doubles each attempt with base=2", { timeout: 5000 }, () => {
const s = createExponentialBackoff({ initial: 100, base: 2 });
expect(s(1)).toBe(100);
expect(s(2)).toBe(200);
expect(s(3)).toBe(400);
expect(s(4)).toBe(800);
});
it("uses default base of 2", { timeout: 5000 }, () => {
const s = createExponentialBackoff({ initial: 50 });
expect(s(2)).toBe(100);
expect(s(3)).toBe(200);
});
it("caps at max", { timeout: 5000 }, () => {
const s = createExponentialBackoff({ initial: 100, max: 300 });
expect(s(1)).toBe(100);
expect(s(2)).toBe(200);
expect(s(3)).toBe(300);
expect(s(4)).toBe(300);
});
it("applies full jitter in [0, delay]", { timeout: 5000 }, () => {
const rng = { nextUint32: () => 0, nextFloat: () => 0.5 };
const s = createExponentialBackoff(
{ initial: 100, jitter: "full" },
rng
);
expect(s(1)).toBe(50); // 0.5 * 100
});
it("applies none jitter (identity)", { timeout: 5000 }, () => {
const s = createExponentialBackoff({ initial: 100, jitter: "none" });
expect(s(1)).toBe(100);
expect(s(2)).toBe(200);
});
it("applies equal jitter in [delay/2, delay]", { timeout: 5000 }, () => {
const rng = { nextUint32: () => 0, nextFloat: () => 0 };
const s = createExponentialBackoff(
{ initial: 100, jitter: "equal" },
rng
);
expect(s(1)).toBe(50); // 100/2 + 0*(100/2)
});
it("applies decorrelated jitter in [delay, 2*delay]", {
timeout: 5000,
}, () => {
const rng = { nextUint32: () => 0, nextFloat: () => 0 };
const s = createExponentialBackoff(
{ initial: 100, jitter: "decorrelated" },
rng
);
expect(s(1)).toBe(100); // 100 * (1 + 0)
});
it("uses Math.random when no rng provided for full jitter", {
timeout: 5000,
}, () => {
const s = createExponentialBackoff({ initial: 1000, jitter: "full" });
const val = s(1);
expect(val).toBeGreaterThanOrEqual(0);
expect(val).toBeLessThanOrEqual(1000);
});
it("property: no jitter delay(n+1) >= delay(n)", { timeout: 5000 }, () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 50 }),
fc.integer({ min: 1, max: 5 }),
fc.integer({ min: 10, max: 100 }),
(attempt, base, initial) => {
const s = createExponentialBackoff({
initial,
base: base + 1,
});
return s(attempt + 1) >= s(attempt);
}
)
);
});
});
describe("applyJitter", () => {
it("full mode returns value in [0, delay]", { timeout: 5000 }, () => {
const rng = { nextUint32: () => 0, nextFloat: () => 0.75 };
expect(applyJitter(200, "full", rng)).toBe(150);
});
it("full mode: 0 random => 0", { timeout: 5000 }, () => {
const rng = { nextUint32: () => 0, nextFloat: () => 0 };
expect(applyJitter(500, "full", rng)).toBe(0);
});
it("full mode: 1 random => delay", { timeout: 5000 }, () => {
const rng = { nextUint32: () => 0, nextFloat: () => 1 };
expect(applyJitter(500, "full", rng)).toBe(500);
});
it("equal mode returns value in [delay/2, delay]", {
timeout: 5000,
}, () => {
const rng0 = { nextUint32: () => 0, nextFloat: () => 0 };
const rng1 = { nextUint32: () => 0, nextFloat: () => 1 };
expect(applyJitter(200, "equal", rng0)).toBe(100);
expect(applyJitter(200, "equal", rng1)).toBe(200);
});
it("decorrelated mode returns value in [delay, 2*delay]", {
timeout: 5000,
}, () => {
const rng0 = { nextUint32: () => 0, nextFloat: () => 0 };
const rng1 = { nextUint32: () => 0, nextFloat: () => 1 };
expect(applyJitter(100, "decorrelated", rng0)).toBe(100);
expect(applyJitter(100, "decorrelated", rng1)).toBe(200);
});
it("none mode is identity", { timeout: 5000 }, () => {
expect(applyJitter(123, "none")).toBe(123);
expect(applyJitter(0, "none")).toBe(0);
});
it("uses Math.random when no rng provided (full mode)", {
timeout: 5000,
}, () => {
const val = applyJitter(1000, "full");
expect(val).toBeGreaterThanOrEqual(0);
expect(val).toBeLessThanOrEqual(1000);
});
it("uses Math.random when no rng provided (equal mode)", {
timeout: 5000,
}, () => {
const val = applyJitter(1000, "equal");
expect(val).toBeGreaterThanOrEqual(500);
expect(val).toBeLessThanOrEqual(1000);
});
it("uses Math.random when no rng provided (decorrelated mode)", {
timeout: 5000,
}, () => {
const val = applyJitter(1000, "decorrelated");
expect(val).toBeGreaterThanOrEqual(1000);
expect(val).toBeLessThanOrEqual(2000);
});
it("property: full jitter always in [0, delay]", { timeout: 5000 }, () => {
fc.assert(
fc.property(
fc.float({ min: 0, max: 10_000, noNaN: true }),
fc.float({ min: 0, max: 1, noNaN: true }),
(delay, rand) => {
const rng = { nextUint32: () => 0, nextFloat: () => rand };
const result = applyJitter(delay, "full", rng);
return result >= 0 && result <= delay + 0.001;
}
)
);
});
});
describe("clampDelay", () => {
it("returns delay when within bounds", { timeout: 5000 }, () => {
expect(clampDelay(500, 100, 1000)).toBe(500);
});
it("returns min when below min", { timeout: 5000 }, () => {
expect(clampDelay(50, 100, 1000)).toBe(100);
});
it("returns max when above max", { timeout: 5000 }, () => {
expect(clampDelay(5000, 100, 1000)).toBe(1000);
});
it("handles min === max", { timeout: 5000 }, () => {
expect(clampDelay(500, 250, 250)).toBe(250);
expect(clampDelay(0, 250, 250)).toBe(250);
});
it("property: always in [min, max]", { timeout: 5000 }, () => {
fc.assert(
fc.property(
fc.float({ min: 0, max: 10_000, noNaN: true }),
fc.float({ min: 0, max: 5_000, noNaN: true }),
fc.float({ min: 5_000, max: 10_000, noNaN: true }),
(delay, min, max) => {
const result = clampDelay(delay, min, max);
return result >= min && result <= max;
}
)
);
});
});

View File

@@ -0,0 +1,77 @@
import type { Rng } from "@suaveplan/types";
import type {
ExponentialBackoffOptions,
JitterMode,
RetryStrategy,
} from "../../types/retry.types.js";
/**
* Creates a retry strategy with exponentially increasing delay.
*
* `baseDelay(attempt) = initial * base^(attempt-1)`, capped at `max`.
* Jitter (if configured) is applied via {@link applyJitter} internally.
*
* @param opts - Exponential backoff options.
* @param rng - Optional Rng for jitter. If omitted and jitter is enabled,
* `Math.random()` is used as a fallback.
* @returns A `RetryStrategy` function.
*/
export function createExponentialBackoff(
opts: ExponentialBackoffOptions,
rng?: Rng
): RetryStrategy {
const base = opts.base ?? 2;
const jitter = opts.jitter ?? "none";
const { initial, max } = opts;
return (attempt: number): number => {
const raw = initial * base ** (attempt - 1);
const clamped = max !== undefined ? Math.min(raw, max) : raw;
return applyJitter(clamped, jitter, rng);
};
}
/**
* Applies jitter to a computed delay.
*
* | Mode | Output range |
* |------|-------------|
* | `"full"` | `[0, delay]` — full random scatter |
* | `"equal"` | `[delay/2, delay]` — equal jitter, preserves minimum half |
* | `"decorrelated"` | `[delay, delay * 2]` — amplified scatter |
* | `"none"` | `delay` unchanged |
*
* @param delay - The base delay in milliseconds.
* @param mode - Jitter mode to apply.
* @param rng - Optional Rng source. Falls back to `Math.random()` when absent.
* @returns The jitter-adjusted delay.
*/
export function applyJitter(
delay: number,
mode: JitterMode,
rng?: Rng
): number {
const random = rng ? rng.nextFloat() : Math.random();
switch (mode) {
case "full":
return random * delay;
case "equal":
return delay / 2 + random * (delay / 2);
case "decorrelated":
return delay * (1 + random);
case "none":
return delay;
}
}
/**
* Clamps a delay value to `[min, max]`.
*
* @param delay - Unclamped delay (ms).
* @param min - Lower bound (inclusive).
* @param max - Upper bound (inclusive).
* @returns Clamped delay.
*/
export function clampDelay(delay: number, min: number, max: number): number {
return Math.max(min, Math.min(max, delay));
}

View File

@@ -0,0 +1,5 @@
export {
applyJitter,
clampDelay,
createExponentialBackoff,
} from "./exponential";

View File

@@ -0,0 +1,7 @@
export { createConstantBackoff } from "./constant";
export {
applyJitter,
clampDelay,
createExponentialBackoff,
} from "./exponential";
export { createLinearBackoff } from "./linear";

View File

@@ -0,0 +1 @@
export { createLinearBackoff } from "./linear";

View File

@@ -0,0 +1,67 @@
# linear
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `createLinearBackoff`, a factory that creates a retry strategy with delays that increase linearly by a fixed increment for each subsequent attempt. Linear backoff is a good middle ground between constant backoff (no increase) and exponential backoff (rapid growth). It is particularly appropriate when the underlying resource is recovering at a predictable rate, such as a downstream service restarting after a deployment, where you expect recovery within a bounded window.
The formula is `delay(attempt) = initial + (attempt - 1) * increment`, optionally capped at `max`. Attempt numbers are 1-based: attempt 1 returns `initial`, attempt 2 returns `initial + increment`, and so on.
## Features
- Configurable `initial` delay (ms) — delay applied on attempt 1
- Configurable `increment` (ms) — added per retry step
- Optional `max` cap — prevents unbounded delay growth
- No randomness — deterministic delays for predictable retry budgets
## Usage: Basic Example
```typescript
import { createLinearBackoff, withRetry } from "@suaveplan/retry";
const strategy = createLinearBackoff({ initial: 100, increment: 50, max: 500 });
// attempt 1 → 100ms, attempt 2 → 150ms, attempt 3 → 200ms, ... capped at 500ms
const result = await withRetry(fetchUser, {
maxAttempts: 5,
delay: strategy,
});
```
## Usage: Advanced Example
```typescript
import { createLinearBackoff, applyJitter, withRetry } from "@suaveplan/retry";
const base = createLinearBackoff({ initial: 200, increment: 100 });
// Add equal jitter to spread load during recoveries
const jittered = (attempt: number) => applyJitter(base(attempt), "equal");
await withRetry(processJob, {
maxAttempts: 8,
delay: jittered,
shouldRetry: (err, attempt) => attempt < 4 || isRetriableError(err),
signal: AbortSignal.timeout(30_000),
});
```
## API Reference
### `createLinearBackoff(opts)`
| Parameter | Type | Description |
|-----------|------|-------------|
| `opts.initial` | `number` | Delay for attempt 1 (ms). |
| `opts.increment` | `number` | Amount added to delay per retry step (ms). |
| `opts.max` | `number?` | Optional upper bound on delay (ms). |
**Returns:** `RetryStrategy` — `(attempt: number) => number`.
**Throws:** Nothing.
## Implementation Notes
The closure captures `initial`, `increment`, and `max` at construction time for efficiency. The `max` cap uses `Math.min` so the comparison only runs when `max` is defined — the factory uses `opts.max !== undefined` rather than a truthiness check to correctly handle `max: 0`. For values where `increment` is 0, the strategy degenerates to constant backoff. Callers who need monotonically increasing delays should ensure `increment > 0`.

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createLinearBackoff } from "./linear.js";
describe("createLinearBackoff", () => {
it("returns initial on attempt 1", { timeout: 5000 }, () => {
const s = createLinearBackoff({ initial: 100, increment: 50 });
expect(s(1)).toBe(100);
});
it("increments linearly", { timeout: 5000 }, () => {
const s = createLinearBackoff({ initial: 100, increment: 50 });
expect(s(1)).toBe(100);
expect(s(2)).toBe(150);
expect(s(3)).toBe(200);
expect(s(4)).toBe(250);
});
it("caps at max", { timeout: 5000 }, () => {
const s = createLinearBackoff({
initial: 100,
increment: 100,
max: 250,
});
expect(s(1)).toBe(100);
expect(s(2)).toBe(200);
expect(s(3)).toBe(250);
expect(s(4)).toBe(250);
});
it("no max — grows unbounded", { timeout: 5000 }, () => {
const s = createLinearBackoff({ initial: 0, increment: 10 });
expect(s(10)).toBe(90);
expect(s(100)).toBe(990);
});
it("increment of 0 is constant", { timeout: 5000 }, () => {
const s = createLinearBackoff({ initial: 200, increment: 0 });
expect(s(1)).toBe(200);
expect(s(5)).toBe(200);
});
});

View File

@@ -0,0 +1,21 @@
import type {
LinearBackoffOptions,
RetryStrategy,
} from "../../types/retry.types.js";
/**
* Creates a retry strategy with linearly increasing delay.
*
* `delay(attempt) = initial + (attempt - 1) * increment`, capped at `max`
* if provided.
*
* @param opts - Linear backoff options.
* @returns A `RetryStrategy` function.
*/
export function createLinearBackoff(opts: LinearBackoffOptions): RetryStrategy {
const { initial, increment, max } = opts;
return (attempt: number): number => {
const delay = initial + (attempt - 1) * increment;
return max !== undefined ? Math.min(delay, max) : delay;
};
}