Initial Commit
This commit is contained in:
@@ -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.
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { createConstantBackoff } from "./constant";
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
applyJitter,
|
||||
clampDelay,
|
||||
createExponentialBackoff,
|
||||
} from "./exponential";
|
||||
7
packages/foundation/flow/retry/src/strategies/index.ts
Normal file
7
packages/foundation/flow/retry/src/strategies/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { createConstantBackoff } from "./constant";
|
||||
export {
|
||||
applyJitter,
|
||||
clampDelay,
|
||||
createExponentialBackoff,
|
||||
} from "./exponential";
|
||||
export { createLinearBackoff } from "./linear";
|
||||
@@ -0,0 +1 @@
|
||||
export { createLinearBackoff } from "./linear";
|
||||
@@ -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`.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user