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,78 @@
# @suaveplan/retry - Retry strategies with exponential/linear/constant backoff, jitter, circuit breaker with half-open probing, sleep and timeout helpers
## Summary
Retry strategies and circuit-breaker for async operations. The package provides constant, linear, and exponential backoff with four jitter modes (full, equal, decorrelated, none), a `sleep` helper with AbortSignal support, a per-attempt `withTimeout` wrapper, and a three-state circuit breaker (closed → open → half-open) with event emission and configurable failure/success thresholds. All entry points accept an injectable `Clock` and `AbortSignal` so they integrate cleanly with test harnesses and request cancellation.
<!-- begin:auto-docs -->
## Documentation tree
- [circuit-breaker](./src/circuit-breaker/circuit-breaker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [circuit-breaker](./src/circuit-breaker/circuit-breaker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/retry-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [retry-error](./src/errors/retry-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/retry-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [retry-otel](./src/otel/retry-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [retry](./src/retry/retry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [retry](./src/retry/retry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [constant](./src/strategies/constant/constant.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [constant](./src/strategies/constant/constant.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [exponential](./src/strategies/exponential/exponential.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [exponential](./src/strategies/exponential/exponential.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [linear](./src/strategies/linear/linear.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [linear](./src/strategies/linear/linear.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [circuit-breaker](./src/circuit-breaker/circuit-breaker.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/retry-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [retry-error](./src/errors/retry-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/retry-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [retry-otel](./src/otel/retry-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [retry](./src/retry/retry.md) — <!-- begin:auto-docs --> _(no submodules)_- [retry](./src/retry/retry.md) — <!-- begin:auto-docs --> _(no submodules)_- [constant](./src/strategies/constant/constant.md) — <!-- begin:auto-docs --> _(no submodules)_- [constant](./src/strategies/constant/constant.md) — <!-- begin:auto-docs --> _(no submodules)_- [exponential](./src/strategies/exponential/exponential.md) — <!-- begin:auto-docs --> _(no submodules)_- [exponential](./src/strategies/exponential/exponential.md) — <!-- begin:auto-docs --> _(no submodules)_- [linear](./src/strategies/linear/linear.md) — <!-- begin:auto-docs --> _(no submodules)_- [linear](./src/strategies/linear/linear.md) — <!-- begin:auto-docs --> _(no submodules)_- [circuit-breaker](./src/circuit-breaker/circuit-breaker.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/retry-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [retry-error](./src/errors/retry-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/retry-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [retry-otel](./src/otel/retry-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [retry](./src/retry/retry.md) — <!-- begin:auto-docs --> _(no submodules)_- [retry](./src/retry/retry.md) — <!-- begin:auto-docs --> _(no submodules)_- [constant](./src/strategies/constant/constant.md) — <!-- begin:auto-docs --> _(no submodules)_- [constant](./src/strategies/constant/constant.md) — <!-- begin:auto-docs --> _(no submodules)_- [exponential](./src/strategies/exponential/exponential.md) — <!-- begin:auto-docs --> _(no submodules)_- [exponential](./src/strategies/exponential/exponential.md) — <!-- begin:auto-docs --> _(no submodules)_- [linear](./src/strategies/linear/linear.md) — <!-- begin:auto-docs --> _(no submodules)_- [linear](./src/strategies/linear/linear.md) — <!-- begin:auto-docs --> _(no submodules)_- [circuit-breaker](./src/circuit-breaker/circuit-breaker.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/retry-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [retry-error](./src/errors/retry-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/retry-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [retry-otel](./src/otel/retry-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [retry](./src/retry/retry.md) — <!-- begin:auto-docs --> _(no submodules)_- [retry](./src/retry/retry.md) — <!-- begin:auto-docs --> _(no submodules)_- [constant](./src/strategies/constant/constant.md) — <!-- begin:auto-docs --> _(no submodules)_- [constant](./src/strategies/constant/constant.md) — <!-- begin:auto-docs --> _(no submodules)_- [exponential](./src/strategies/exponential/exponential.md) — <!-- begin:auto-docs --> _(no submodules)_- [exponential](./src/strategies/exponential/exponential.md) — <!-- begin:auto-docs --> _(no submodules)_- [linear](./src/strategies/linear/linear.md) — <!-- begin:auto-docs --> _(no submodules)_- [linear](./src/strategies/linear/linear.md) — <!-- begin:auto-docs --> _(no submodules)_- [circuit-breaker](./src/circuit-breaker/circuit-breaker.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/retry-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [retry-error](./src/errors/retry-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/retry-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [retry-otel](./src/otel/retry-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [retry](./src/retry/retry.md) — <!-- begin:auto-docs --> _(no submodules)_ - [retry](./src/retry/retry.md) — <!-- begin:auto-docs --> _(no submodules)_- [constant](./src/strategies/constant/constant.md) — <!-- begin:auto-docs --> _(no submodules)_ - [constant](./src/strategies/constant/constant.md) — <!-- begin:auto-docs --> _(no submodules)_- [exponential](./src/strategies/exponential/exponential.md) — <!-- begin:auto-docs --> _(no submodules)_ - [exponential](./src/strategies/exponential/exponential.md) — <!-- begin:auto-docs --> _(no submodules)_- [linear](./src/strategies/linear/linear.md) — <!-- begin:auto-docs --> _(no submodules)_ - [linear](./src/strategies/linear/linear.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```sh
bun add @suaveplan/retry
```
## Quick Start
```ts
import {
withRetry,
createExponentialBackoff,
CircuitBreaker,
} from "@suaveplan/retry";
// Retry with exponential backoff and full jitter — up to 5 attempts.
const result = await withRetry(
async (signal) => {
const res = await fetch("https://api.example.com/data", { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
{
maxAttempts: 5,
delay: createExponentialBackoff({ initial: 100, max: 5000, jitter: "full" }),
}
);
// Wrap a sensitive downstream call in a circuit breaker.
const breaker = new CircuitBreaker({
failureThreshold: 3,
successThreshold: 2,
timeout: 10_000,
});
breaker.on("open", ({ openedAt }) => console.warn("Circuit opened at", openedAt));
const value = await breaker.call(async (signal) => {
return fetchDownstream(signal);
});
```
## Modules
| Module | Path |
|--------|------|
| `retry` — core loop, `retry`, `withRetry`, `sleep`, `withTimeout`, `createDelayFn` | [`src/retry/retry.md`](src/retry/retry.md) |
| `constant` — `createConstantBackoff` | [`src/strategies/constant/constant.md`](src/strategies/constant/constant.md) |
| `linear` — `createLinearBackoff` | [`src/strategies/linear/linear.md`](src/strategies/linear/linear.md) |
| `exponential` — `createExponentialBackoff`, `applyJitter` | [`src/strategies/exponential/exponential.md`](src/strategies/exponential/exponential.md) |
| `circuit-breaker` — `CircuitBreaker` class, `CircuitBreakerConfig`, event map | [`src/circuit-breaker/circuit-breaker.md`](src/circuit-breaker/circuit-breaker.md) |
| `retry-error` — `RetryExhaustedError`, `RetryAbortedError`, `AttemptTimeoutError`, `CircuitOpenError` | [`src/errors/retry-error.md`](src/errors/retry-error.md) |
| `retry-otel` — OpenTelemetry span/event helpers | [`src/otel/retry-otel.md`](src/otel/retry-otel.md) |

View File

@@ -0,0 +1,136 @@
{
"name": "@suaveplan/retry",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Retry strategies with exponential/linear/constant backoff, jitter, circuit breaker with half-open probing, sleep and timeout helpers",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./circuit-breaker/circuit-breaker": {
"import": {
"types": "./dist/circuit-breaker/circuit-breaker.d.ts",
"default": "./dist/circuit-breaker/circuit-breaker.js"
},
"require": {
"types": "./dist/circuit-breaker/circuit-breaker.d.ts",
"default": "./dist/circuit-breaker/circuit-breaker.js"
}
},
"./errors/retry-error": {
"import": {
"types": "./dist/errors/retry-error.d.ts",
"default": "./dist/errors/retry-error.js"
},
"require": {
"types": "./dist/errors/retry-error.d.ts",
"default": "./dist/errors/retry-error.js"
}
},
"./otel/retry-otel": {
"import": {
"types": "./dist/otel/retry-otel.d.ts",
"default": "./dist/otel/retry-otel.js"
},
"require": {
"types": "./dist/otel/retry-otel.d.ts",
"default": "./dist/otel/retry-otel.js"
}
},
"./retry/retry": {
"import": {
"types": "./dist/retry/retry.d.ts",
"default": "./dist/retry/retry.js"
},
"require": {
"types": "./dist/retry/retry.d.ts",
"default": "./dist/retry/retry.js"
}
},
"./strategies/constant/constant": {
"import": {
"types": "./dist/strategies/constant/constant.d.ts",
"default": "./dist/strategies/constant/constant.js"
},
"require": {
"types": "./dist/strategies/constant/constant.d.ts",
"default": "./dist/strategies/constant/constant.js"
}
},
"./strategies/exponential/exponential": {
"import": {
"types": "./dist/strategies/exponential/exponential.d.ts",
"default": "./dist/strategies/exponential/exponential.js"
},
"require": {
"types": "./dist/strategies/exponential/exponential.d.ts",
"default": "./dist/strategies/exponential/exponential.js"
}
},
"./strategies/linear/linear": {
"import": {
"types": "./dist/strategies/linear/linear.d.ts",
"default": "./dist/strategies/linear/linear.js"
},
"require": {
"types": "./dist/strategies/linear/linear.d.ts",
"default": "./dist/strategies/linear/linear.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"bench": "bun run src/__benchmarks__/sleep.bench.ts",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"@suaveplan/events": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms",
"@opentelemetry/api": "catalog:observability"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,97 @@
# circuit-breaker
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `CircuitBreaker`, a state-machine implementation of the circuit-breaker pattern for protecting async functions against cascading failures. The circuit starts `closed` (normal operation) and opens after a configurable number of consecutive failures, temporarily blocking calls to give the underlying resource time to recover. After a timeout, the circuit enters `half-open` state to probe whether the resource has recovered, and closes again on sufficient consecutive successes.
State-change events are emitted via an internal `EventBus` from `@suaveplan/events`, enabling observability integrations, metrics, and alerting without polling.
## Features
- Three states: `closed`, `open`, `half-open` with deterministic transitions
- Configurable `failureThreshold`, `successThreshold`, and `timeout` (open → half-open delay)
- Optional `halfOpenProbes` limit to bound probe calls in half-open
- Clock injection for deterministic testing and wall-time independence
- `stateChange` event emitted on every transition via `EventBus`
- `reset()` for forced closure (e.g., operator action or integration tests)
- `AbortSignal` passed through to wrapped function
## Usage: Basic Example
```typescript
import { CircuitBreaker } from "@suaveplan/retry";
const breaker = new CircuitBreaker({
failureThreshold: 5,
successThreshold: 2,
timeout: 30_000, // ms before half-open
});
// Use breaker.call() instead of calling fn directly
const data = await breaker.call((signal) => fetchData(url, { signal }));
```
## Usage: Advanced Example
```typescript
import { CircuitBreaker, withRetry, createExponentialBackoff } from "@suaveplan/retry";
const breaker = new CircuitBreaker({
failureThreshold: 3,
successThreshold: 2,
timeout: 10_000,
clock: () => performance.now(),
});
// Subscribe to state changes for metrics
breaker.on("stateChange", ({ from, to }) => {
metrics.increment("circuit_breaker.transition", { from, to });
});
// Combine with retry — circuit rejects fast while open
const delay = createExponentialBackoff({ initial: 200, max: 5_000 });
const result = await withRetry(
(signal) => breaker.call(() => callDownstreamService(signal)),
{
maxAttempts: 6,
delay,
shouldRetry: (err) => !(err instanceof CircuitOpenError),
},
);
```
## API Reference
### `new CircuitBreaker(config)`
| Parameter | Type | Description |
|-----------|------|-------------|
| `config.failureThreshold` | `number` | Consecutive failures to open the circuit. |
| `config.successThreshold` | `number` | Consecutive successes in half-open to close. |
| `config.timeout` | `number` | Ms before open transitions to half-open. |
| `config.halfOpenProbes` | `number?` | Max probes in half-open (default: `successThreshold`). |
| `config.clock` | `Clock?` | Time source. Defaults to `Date.now`. |
### `breaker.state`
`CircuitState` — one of `"closed"`, `"open"`, `"half-open"`.
### `breaker.on(event, handler)`
Subscribe to `"stateChange"` events. Returns an unsubscribe function.
### `breaker.call(fn, signal?)`
Execute `fn` through the circuit. Throws `CircuitOpenError` if open.
### `breaker.reset()`
Force circuit to `closed` and reset all counters. Emits `stateChange` unless already closed.
## Implementation Notes
The `checkHalfOpen` method is called lazily at the start of every `call()` — this avoids background timers and keeps the circuit breaker self-contained. The `openedAt` timestamp is set using the injected `clock` so deterministic tests can advance time by mutating the clock reference rather than using real timers. The `EventBus` is internal and not exposed as a public property to prevent external mutation. Listeners receive typed payloads without requiring any casting.

View File

@@ -0,0 +1,8 @@
import z from "zod";
export const CircuitBreakerConfigSchema = z.object({
failureThreshold: z.number().int().positive(),
successThreshold: z.number().int().positive(),
timeout: z.number().nonnegative(),
halfOpenProbes: z.number().int().positive().optional(),
});

View File

@@ -0,0 +1,100 @@
/**
* Spec-conformance tests for `CircuitBreaker`. Pins the full
* closed→open→half-open→closed→open→half-open→open transition path
* named in `openspec/specs/retry/spec.md` RT-06, plus the structural
* "open state never invokes fn" invariant.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CircuitOpenError } from "../errors/retry-error.js";
import { CircuitBreaker } from "./circuit-breaker.js";
import type { CircuitBreakerEventMap } from "./circuit-breaker.types.js";
describe("CircuitBreaker — full FSM walk (RT-06)", () => {
it("closed → open → half-open → closed → open → half-open → open", {
timeout: 5000,
}, async () => {
let now = 0;
const events: CircuitBreakerEventMap["stateChange"][] = [];
const breaker = new CircuitBreaker({
failureThreshold: 1,
successThreshold: 1,
timeout: 100,
clock: () => now,
});
breaker.on("stateChange", (e) => events.push(e));
// closed → open
await breaker
.call(() => Promise.reject(new Error("x")))
.catch(() => undefined);
expect(breaker.state).toBe("open");
// open → half-open (success closes immediately because successThreshold=1)
now = 200;
await breaker.call(() => Promise.resolve("ok"));
expect(breaker.state).toBe("closed");
// closed → open again
await breaker
.call(() => Promise.reject(new Error("y")))
.catch(() => undefined);
expect(breaker.state).toBe("open");
// open → half-open, then half-open → open on probe failure
now = 400;
await breaker
.call(() => Promise.reject(new Error("probe")))
.catch(() => undefined);
expect(breaker.state).toBe("open");
expect(events.map((e) => `${e.from}->${e.to}`)).toEqual([
"closed->open",
"open->half-open",
"half-open->closed",
"closed->open",
"open->half-open",
"half-open->open",
]);
});
it("open state never invokes fn", { timeout: 5000 }, async () => {
const breaker = new CircuitBreaker({
failureThreshold: 1,
successThreshold: 1,
timeout: 60_000,
});
await breaker
.call(() => Promise.reject(new Error("first")))
.catch(() => undefined);
expect(breaker.state).toBe("open");
let invoked = 0;
const fn = (): Promise<string> => {
invoked++;
return Promise.resolve("never");
};
await expect(breaker.call(fn)).rejects.toBeInstanceOf(CircuitOpenError);
expect(invoked).toBe(0);
});
it("CircuitOpenError carries openedAt timestamp from injected clock", {
timeout: 5000,
}, async () => {
let now = 1_000_000;
const breaker = new CircuitBreaker({
failureThreshold: 1,
successThreshold: 1,
timeout: 60_000,
clock: () => now,
});
await breaker
.call(() => Promise.reject(new Error("boom")))
.catch(() => undefined);
now = 1_000_500;
const err = await breaker
.call(() => Promise.resolve("blocked"))
.catch((e) => e);
expect(err).toBeInstanceOf(CircuitOpenError);
expect(err.context?.openedAt).toBe(1_000_000);
});
});

View File

@@ -0,0 +1,294 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import { CircuitOpenError } from "../errors/retry-error.js";
import { CircuitBreaker } from "./circuit-breaker.js";
import type { CircuitBreakerEventMap } from "./circuit-breaker.types.js";
function makeBreaker(
overrides: Partial<ConstructorParameters<typeof CircuitBreaker>[0]> = {}
): CircuitBreaker {
return new CircuitBreaker({
failureThreshold: 3,
successThreshold: 2,
timeout: 5000,
...overrides,
});
}
describe("CircuitBreaker – basic state", () => {
it("starts in closed state", { timeout: 5000 }, () => {
const breaker = makeBreaker();
expect(breaker.state).toBe("closed");
});
it("calls fn in closed state", { timeout: 30000 }, async () => {
const breaker = makeBreaker();
const fn = vi.fn().mockResolvedValue("ok");
const result = await breaker.call(fn);
expect(result).toBe("ok");
expect(fn).toHaveBeenCalledTimes(1);
});
it("resets failure counter on success in closed state", {
timeout: 30000,
}, async () => {
const breaker = makeBreaker({ failureThreshold: 3 });
const fail = (): Promise<never> => Promise.reject(new Error("fail"));
const ok = (): Promise<string> => Promise.resolve("ok");
// 2 failures
await breaker.call(fail).catch(() => undefined);
await breaker.call(fail).catch(() => undefined);
// success — resets counter
await breaker.call(ok);
// 2 more failures — should not open yet (counter was reset)
await breaker.call(fail).catch(() => undefined);
await breaker.call(fail).catch(() => undefined);
expect(breaker.state).toBe("closed");
});
});
describe("CircuitBreaker – open state", () => {
it("opens after failureThreshold consecutive failures", {
timeout: 30000,
}, async () => {
const breaker = makeBreaker({ failureThreshold: 3 });
const fail = (): Promise<never> => Promise.reject(new Error("fail"));
await breaker.call(fail).catch(() => undefined);
await breaker.call(fail).catch(() => undefined);
await breaker.call(fail).catch(() => undefined);
expect(breaker.state).toBe("open");
});
it("throws CircuitOpenError when open", { timeout: 30000 }, async () => {
const breaker = makeBreaker({ failureThreshold: 1 });
await breaker
.call(() => Promise.reject(new Error("x")))
.catch(() => undefined);
await expect(
breaker.call(() => Promise.resolve("ok"))
).rejects.toBeInstanceOf(CircuitOpenError);
});
it("does not call fn when open", { timeout: 30000 }, async () => {
const breaker = makeBreaker({ failureThreshold: 1 });
await breaker
.call(() => Promise.reject(new Error("x")))
.catch(() => undefined);
const fn = vi.fn().mockResolvedValue("ok");
await breaker.call(fn).catch(() => undefined);
expect(fn).not.toHaveBeenCalled();
});
});
describe("CircuitBreaker – half-open probing", () => {
it("transitions to half-open after timeout", {
timeout: 30000,
}, async () => {
let now = 0;
const clock = () => now;
const breaker = makeBreaker({
failureThreshold: 1,
timeout: 1000,
clock,
});
await breaker
.call(() => Promise.reject(new Error("x")))
.catch(() => undefined);
expect(breaker.state).toBe("open");
// advance past timeout
now = 1001;
const fn = vi.fn().mockResolvedValue("probe");
await breaker.call(fn);
expect(breaker.state).toBe("half-open");
});
it("closes after successThreshold successes in half-open", {
timeout: 30000,
}, async () => {
let now = 0;
const clock = () => now;
const breaker = makeBreaker({
failureThreshold: 1,
successThreshold: 2,
timeout: 1000,
clock,
});
await breaker
.call(() => Promise.reject(new Error("x")))
.catch(() => undefined);
now = 2000;
// first success — transitions to half-open, counted as 1 success
await breaker.call(() => Promise.resolve("ok"));
expect(breaker.state).toBe("half-open");
// second success — closes
await breaker.call(() => Promise.resolve("ok"));
expect(breaker.state).toBe("closed");
});
it("returns to open on failure in half-open", {
timeout: 30000,
}, async () => {
let now = 0;
const clock = () => now;
const breaker = makeBreaker({
failureThreshold: 1,
successThreshold: 2,
timeout: 1000,
clock,
});
await breaker
.call(() => Promise.reject(new Error("x")))
.catch(() => undefined);
now = 2000;
// probe succeeds → half-open
await breaker.call(() => Promise.resolve("ok"));
expect(breaker.state).toBe("half-open");
// probe fails → back to open
await breaker
.call(() => Promise.reject(new Error("probe fail")))
.catch(() => undefined);
expect(breaker.state).toBe("open");
});
});
describe("CircuitBreaker – reset", () => {
it("reset() forces closed from open", { timeout: 30000 }, async () => {
const breaker = makeBreaker({ failureThreshold: 1 });
await breaker
.call(() => Promise.reject(new Error("x")))
.catch(() => undefined);
expect(breaker.state).toBe("open");
breaker.reset();
expect(breaker.state).toBe("closed");
});
it("reset() from closed does not emit stateChange", {
timeout: 5000,
}, () => {
const breaker = makeBreaker();
const handler = vi.fn();
breaker.on("stateChange", handler);
breaker.reset();
expect(handler).not.toHaveBeenCalled();
});
it("reset() resets counters so fn can be called again", {
timeout: 30000,
}, async () => {
const breaker = makeBreaker({ failureThreshold: 1 });
await breaker
.call(() => Promise.reject(new Error("x")))
.catch(() => undefined);
breaker.reset();
const result = await breaker.call(() => Promise.resolve("after-reset"));
expect(result).toBe("after-reset");
});
});
describe("CircuitBreaker – events", () => {
it("emits stateChange closed→open", { timeout: 30000 }, async () => {
const events: CircuitBreakerEventMap["stateChange"][] = [];
const breaker = makeBreaker({ failureThreshold: 1 });
breaker.on("stateChange", (e) => events.push(e));
await breaker
.call(() => Promise.reject(new Error("x")))
.catch(() => undefined);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ from: "closed", to: "open" });
});
it("emits stateChange open→half-open and half-open→closed", {
timeout: 30000,
}, async () => {
let now = 0;
const clock = () => now;
const events: CircuitBreakerEventMap["stateChange"][] = [];
const breaker = makeBreaker({
failureThreshold: 1,
successThreshold: 1,
timeout: 100,
clock,
});
breaker.on("stateChange", (e) => events.push(e));
await breaker
.call(() => Promise.reject(new Error("x")))
.catch(() => undefined);
now = 200;
await breaker.call(() => Promise.resolve("ok"));
// closed after 1 success (successThreshold=1) — this call was a probe
// Transition: closed→open, open→half-open, half-open→closed
expect(events.map((e) => `${e.from}→${e.to}`)).toEqual([
"closed→open",
"open→half-open",
"half-open→closed",
]);
});
it("unsubscribe stops receiving events", { timeout: 30000 }, async () => {
const breaker = makeBreaker({ failureThreshold: 1 });
const handler = vi.fn();
const unsub = breaker.on("stateChange", handler);
unsub();
await breaker
.call(() => Promise.reject(new Error("x")))
.catch(() => undefined);
expect(handler).not.toHaveBeenCalled();
});
});
describe("CircuitBreaker – signal passthrough", () => {
it("passes signal to fn", { timeout: 30000 }, async () => {
const ctrl = new AbortController();
let received: AbortSignal | undefined;
const breaker = makeBreaker();
await breaker.call((sig) => {
received = sig;
return Promise.resolve("ok");
}, ctrl.signal);
expect(received).toBe(ctrl.signal);
});
});
describe("CircuitBreaker – half-open failure resets openedAt for new timeout", {
timeout: 30000,
}, () => {
it("reopened circuit requires another timeout before half-open", async () => {
let now = 0;
const clock = () => now;
const breaker = makeBreaker({
failureThreshold: 1,
successThreshold: 3,
timeout: 1000,
clock,
});
// open circuit
await breaker
.call(() => Promise.reject(new Error("x")))
.catch(() => undefined);
// advance → half-open
now = 1001;
// fail in half-open → re-open
await breaker
.call(() => Promise.reject(new Error("probe fail")))
.catch(() => undefined);
expect(breaker.state).toBe("open");
// Not enough time for new open window
now = 1500;
// should still be open (only 499ms since re-open)
await expect(
breaker.call(() => Promise.resolve("ok"))
).rejects.toBeInstanceOf(CircuitOpenError);
// Advance past new timeout
now = 2002; // 1001ms since re-open at 1001
const result = await breaker.call(() => Promise.resolve("probed"));
expect(result).toBe("probed");
expect(breaker.state).toBe("half-open");
});
});

View File

@@ -0,0 +1,146 @@
import { EventBus } from "@suaveplan/events";
import type { Clock } from "@suaveplan/types";
import { CircuitOpenError } from "../errors/retry-error.js";
import type {
CircuitBreakerConfig,
CircuitBreakerEventMap,
CircuitState,
} from "./circuit-breaker.types.js";
/**
* A state-machine circuit breaker that wraps async calls with
* automatic failure detection and recovery probing.
*
* State transitions:
* - `closed` → `open`: after `failureThreshold` consecutive failures.
* - `open` → `half-open`: after `timeout` ms (checked lazily on each call).
* - `half-open` → `closed`: after `successThreshold` consecutive successes.
* - `half-open` → `open`: on any failure during probing.
*
* State-change events are emitted via an internal `EventBus<CircuitBreakerEventMap>`.
*/
export class CircuitBreaker {
private _state: CircuitState = "closed";
private failureCount = 0;
private successCount = 0;
private openedAt: number | null = null;
private readonly bus = new EventBus<CircuitBreakerEventMap>();
private readonly config: CircuitBreakerConfig;
private readonly clock: Clock;
constructor(config: CircuitBreakerConfig) {
this.config = config;
this.clock = config.clock ?? (() => Date.now());
}
/** Current circuit state. */
get state(): CircuitState {
return this._state;
}
/**
* Subscribe to circuit-breaker events.
*
* @param event - Event name to listen to.
* @param handler - Callback receiving event payload.
* @returns Unsubscribe function.
*/
on<K extends keyof CircuitBreakerEventMap>(
event: K,
handler: (data: CircuitBreakerEventMap[K]) => void
): () => void {
return this.bus.on(event, handler);
}
/**
* Execute `fn` through the circuit breaker.
*
* - If the circuit is open, throws `CircuitOpenError` immediately.
* - If the circuit is half-open and the probe limit is reached, throws `CircuitOpenError`.
* - Passes `signal` through to `fn`.
*
* @param fn - Async function to protect.
* @param signal - Optional cancellation signal passed to `fn`.
* @returns The resolved value of `fn`.
*/
async call<T>(
fn: (signal?: AbortSignal) => Promise<T>,
signal?: AbortSignal
): Promise<T> {
this.checkHalfOpen();
if (this._state === "open") {
// openedAt is invariantly set when transitioning to "open"
throw new CircuitOpenError({ openedAt: this.openedAt as number });
}
try {
const result = await fn(signal);
this.onSuccess();
return result;
} catch (err) {
this.onFailure();
throw err;
}
}
/**
* Force the circuit to `closed` state, resetting all counters.
* Emits a `stateChange` event if the current state is not already closed.
*/
reset(): void {
const from = this._state;
this.failureCount = 0;
this.successCount = 0;
this.openedAt = null;
if (from !== "closed") {
this.transition("closed");
}
}
private onSuccess(): void {
if (this._state === "half-open") {
this.successCount++;
if (this.successCount >= this.config.successThreshold) {
this.failureCount = 0;
this.successCount = 0;
this.openedAt = null;
this.transition("closed");
}
} else {
// closed — reset consecutive failure counter
this.failureCount = 0;
}
}
private onFailure(): void {
if (this._state === "half-open") {
// Any failure in half-open → back to open
this.successCount = 0;
this.openedAt = this.clock();
this.transition("open");
} else {
// closed — increment failure counter
this.failureCount++;
if (this.failureCount >= this.config.failureThreshold) {
this.openedAt = this.clock();
this.transition("open");
}
}
}
private checkHalfOpen(): void {
if (this._state !== "open" || this.openedAt === null) return;
const elapsed = this.clock() - this.openedAt;
if (elapsed >= this.config.timeout) {
this.successCount = 0;
this.transition("half-open");
}
}
private transition(to: CircuitState): void {
const from = this._state;
this._state = to;
this.bus.emit("stateChange", { from, to });
}
}

View File

@@ -0,0 +1,27 @@
import type { Clock } from "@suaveplan/types";
/** The three states of a circuit breaker. */
export type CircuitState = "closed" | "open" | "half-open";
/** Configuration for a `CircuitBreaker` instance. */
export interface CircuitBreakerConfig {
/** Number of consecutive failures required to open the circuit. */
failureThreshold: number;
/** Number of consecutive successes in half-open to close the circuit. */
successThreshold: number;
/** Milliseconds the circuit stays open before transitioning to half-open. */
timeout: number;
/**
* Maximum number of probe calls allowed in half-open state.
* Defaults to `successThreshold`.
*/
halfOpenProbes?: number;
/** Clock used to measure elapsed time since the circuit opened. */
clock?: Clock;
}
/** Event map emitted by `CircuitBreaker`. */
export interface CircuitBreakerEventMap extends Record<string, unknown> {
/** Fired when the circuit transitions between states. */
stateChange: { from: CircuitState; to: CircuitState };
}

View File

@@ -0,0 +1,7 @@
export { CircuitBreaker } from "./circuit-breaker";
export { CircuitBreakerConfigSchema } from "./circuit-breaker.schemas";
export type {
CircuitBreakerConfig,
CircuitBreakerEventMap,
CircuitState,
} from "./circuit-breaker.types";

View File

@@ -0,0 +1,9 @@
export {
AttemptTimeoutError,
CircuitOpenError,
RETRY_CODES,
RetryAbortedError,
type RetryCode,
RetryError,
RetryExhaustedError,
} from "./retry-error";

View File

@@ -0,0 +1,115 @@
# retry-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the error hierarchy for the `@suaveplan/retry` package. Every exception thrown by the retry engine, circuit breaker, or timeout wrapper is an instance of one of these classes, all of which extend `SuaveplanError` from `@suaveplan/error`. This gives callers a stable `code` property for programmatic branching without parsing error messages.
The `RETRY_CODES` registry is produced by `defineCodes("RETRY", [...])` and maps short reason keys to fully-qualified dot-separated strings (`RETRY.EXHAUSTED`, `RETRY.ABORTED`, `RETRY.TIMEOUT`, `RETRY.CIRCUIT_OPEN`). All codes are frozen and tree-shaken into the host bundle only when referenced.
## Features
- `RETRY_CODES` — frozen code registry with four stable codes
- `RetryError` — abstract base; narrows `SuaveplanError<RetryCode>`
- `RetryExhaustedError` — preserves `attempts` count and `lastError` reference
- `RetryAbortedError` — signals graceful cancellation via `AbortSignal`
- `AttemptTimeoutError` — carries `attemptNumber` and `timeoutMs` for observability
- `CircuitOpenError` — carries optional `openedAt` timestamp
## Usage: Basic Example
```typescript
import { RetryExhaustedError, RETRY_CODES } from "@suaveplan/retry";
try {
await withRetry(fn, { maxAttempts: 3, delay: () => 100 });
} catch (err) {
if (err instanceof RetryExhaustedError) {
console.error(`Failed after ${err.attempts} attempts`, err.lastError);
}
}
```
## Usage: Advanced Example
```typescript
import {
RetryAbortedError,
AttemptTimeoutError,
CircuitOpenError,
RETRY_CODES,
} from "@suaveplan/retry";
function handleRetryError(err: unknown): never {
if (err instanceof AttemptTimeoutError) {
metrics.record("retry.timeout", { attempt: err.attemptNumber });
} else if (err instanceof CircuitOpenError) {
metrics.record("circuit.rejected");
}
// Use code for switch-based routing
if (err instanceof RetryAbortedError) {
// err.code === "RETRY.ABORTED"
throw err; // re-throw for caller to handle
}
throw err;
}
```
## API Reference
### `RETRY_CODES`
```typescript
const RETRY_CODES: {
readonly EXHAUSTED: "RETRY.EXHAUSTED";
readonly ABORTED: "RETRY.ABORTED";
readonly TIMEOUT: "RETRY.TIMEOUT";
readonly CIRCUIT_OPEN: "RETRY.CIRCUIT_OPEN";
}
```
Frozen code registry. Use `err.code === RETRY_CODES.EXHAUSTED` for type-safe comparisons.
### `RetryError`
Abstract base. Extends `SuaveplanError<RetryCode>`. All retry-package errors are `instanceof RetryError`.
### `RetryExhaustedError`
```typescript
constructor(opts: { attempts: number; lastError: unknown; message?: string })
```
- `attempts` — total number of attempts made before giving up
- `lastError` — the exception thrown on the final attempt
### `RetryAbortedError`
```typescript
constructor(opts?: { cause?: unknown })
```
Thrown when the `AbortSignal` fires between attempts.
### `AttemptTimeoutError`
```typescript
constructor(opts: { attemptNumber: number; timeoutMs: number })
```
- `attemptNumber` — which attempt timed out (1-based)
- `timeoutMs` — the budget that was exceeded
### `CircuitOpenError`
```typescript
constructor(opts?: { openedAt?: number })
```
- `openedAt` — optional timestamp (ms) when the circuit opened
## Implementation Notes
`RetryError` is abstract so it cannot be constructed directly, forcing callers to use one of the concrete subclasses. Each concrete class hardcodes its own `RETRY_CODES.*` value, preventing mismatches between code and class type. The `cause` property of `RetryExhaustedError` is only set when `lastError` is an `Error` instance to avoid confusing the `cause` chain with non-Error values (which are stored separately on `lastError`). All classes pass `context` through to `SuaveplanError` so OTel span-event adapters can extract structured metadata without parsing messages.

View File

@@ -0,0 +1,140 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AttemptTimeoutError,
CircuitOpenError,
RETRY_CODES,
RetryAbortedError,
RetryError,
RetryExhaustedError,
} from "./retry-error.js";
describe("RETRY_CODES", () => {
it("has stable code values", { timeout: 5000 }, () => {
expect(RETRY_CODES.EXHAUSTED).toBe("RETRY.EXHAUSTED");
expect(RETRY_CODES.ABORTED).toBe("RETRY.ABORTED");
expect(RETRY_CODES.TIMEOUT).toBe("RETRY.TIMEOUT");
expect(RETRY_CODES.CIRCUIT_OPEN).toBe("RETRY.CIRCUIT_OPEN");
});
it("is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(RETRY_CODES)).toBe(true);
});
});
describe("RetryExhaustedError", () => {
it("carries attempts and lastError", { timeout: 5000 }, () => {
const cause = new Error("original");
const err = new RetryExhaustedError({ attempts: 3, lastError: cause });
expect(err.attempts).toBe(3);
expect(err.lastError).toBe(cause);
expect(err.code).toBe(RETRY_CODES.EXHAUSTED);
expect(err instanceof RetryError).toBe(true);
});
it("uses custom message when provided", { timeout: 5000 }, () => {
const err = new RetryExhaustedError({
attempts: 1,
lastError: "str",
message: "custom msg",
});
expect(err.message).toBe("custom msg");
});
it("uses default message", { timeout: 5000 }, () => {
const err = new RetryExhaustedError({ attempts: 5, lastError: null });
expect(err.message).toContain("5");
});
it("sets cause when lastError is Error", { timeout: 5000 }, () => {
const inner = new Error("inner");
const err = new RetryExhaustedError({ attempts: 1, lastError: inner });
expect(err.cause).toBe(inner);
});
it("does not set cause when lastError is not Error", {
timeout: 5000,
}, () => {
const err = new RetryExhaustedError({
attempts: 1,
lastError: "string",
});
expect(err.cause).toBeUndefined();
});
it("has name RetryExhaustedError", { timeout: 5000 }, () => {
const err = new RetryExhaustedError({ attempts: 1, lastError: null });
expect(err.name).toBe("RetryExhaustedError");
});
});
describe("RetryAbortedError", () => {
it("has ABORTED code", { timeout: 5000 }, () => {
const err = new RetryAbortedError();
expect(err.code).toBe(RETRY_CODES.ABORTED);
expect(err instanceof RetryError).toBe(true);
});
it("accepts cause", { timeout: 5000 }, () => {
const reason = new Error("reason");
const err = new RetryAbortedError({ cause: reason });
expect(err.cause).toBe(reason);
});
it("works with no options", { timeout: 5000 }, () => {
const err = new RetryAbortedError();
expect(err.message).toBe("Retry aborted");
});
});
describe("AttemptTimeoutError", () => {
it("carries attemptNumber and timeoutMs", { timeout: 5000 }, () => {
const err = new AttemptTimeoutError({
attemptNumber: 2,
timeoutMs: 500,
});
expect(err.attemptNumber).toBe(2);
expect(err.timeoutMs).toBe(500);
expect(err.code).toBe(RETRY_CODES.TIMEOUT);
});
it("message includes numbers", { timeout: 5000 }, () => {
const err = new AttemptTimeoutError({
attemptNumber: 3,
timeoutMs: 1000,
});
expect(err.message).toContain("3");
expect(err.message).toContain("1000");
});
it("is instanceof RetryError", { timeout: 5000 }, () => {
expect(
new AttemptTimeoutError({
attemptNumber: 1,
timeoutMs: 100,
}) instanceof RetryError
).toBe(true);
});
});
describe("CircuitOpenError", () => {
it("has CIRCUIT_OPEN code", { timeout: 5000 }, () => {
const err = new CircuitOpenError();
expect(err.code).toBe(RETRY_CODES.CIRCUIT_OPEN);
expect(err instanceof RetryError).toBe(true);
});
it("works without options", { timeout: 5000 }, () => {
const err = new CircuitOpenError();
expect(err.message).toContain("open");
});
it("accepts openedAt", { timeout: 5000 }, () => {
const err = new CircuitOpenError({ openedAt: 12345 });
expect(err.context?.openedAt).toBe(12345);
});
it("works without openedAt (no context)", { timeout: 5000 }, () => {
const err = new CircuitOpenError({});
expect(err.context).toBeUndefined();
});
});

View File

@@ -0,0 +1,80 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/** Stable error codes for the retry package. */
export const RETRY_CODES = defineCodes("RETRY", [
"EXHAUSTED",
"ABORTED",
"TIMEOUT",
"CIRCUIT_OPEN",
] as const);
export type RetryCode = (typeof RETRY_CODES)[keyof typeof RETRY_CODES];
/** Base error class for all retry-package errors. */
export abstract class RetryError extends SuaveplanError<RetryCode> {}
/** Thrown when all retry attempts have been exhausted without a success. */
export class RetryExhaustedError extends RetryError {
readonly attempts: number;
readonly lastError: unknown;
constructor(opts: {
attempts: number;
lastError: unknown;
message?: string;
}) {
super(
RETRY_CODES.EXHAUSTED,
opts.message ?? `Retry exhausted after ${opts.attempts} attempt(s)`,
{
cause:
opts.lastError instanceof Error
? opts.lastError
: undefined,
}
);
this.attempts = opts.attempts;
this.lastError = opts.lastError;
}
}
/** Thrown when the retry loop is cancelled via an AbortSignal. */
export class RetryAbortedError extends RetryError {
constructor(opts: { cause?: unknown } = {}) {
super(RETRY_CODES.ABORTED, "Retry aborted", { cause: opts.cause });
}
}
/** Thrown when a single attempt exceeds its time budget. */
export class AttemptTimeoutError extends RetryError {
readonly attemptNumber: number;
readonly timeoutMs: number;
constructor(opts: { attemptNumber: number; timeoutMs: number }) {
super(
RETRY_CODES.TIMEOUT,
`Attempt ${opts.attemptNumber} timed out after ${opts.timeoutMs}ms`,
{
context: {
attemptNumber: opts.attemptNumber,
timeoutMs: opts.timeoutMs,
},
}
);
this.attemptNumber = opts.attemptNumber;
this.timeoutMs = opts.timeoutMs;
}
}
/** Thrown when a call is rejected because the circuit is open. */
export class CircuitOpenError extends RetryError {
constructor(opts: { openedAt?: number } = {}) {
super(
RETRY_CODES.CIRCUIT_OPEN,
"Circuit breaker is open — calls rejected",
opts.openedAt !== undefined
? { context: { openedAt: opts.openedAt } }
: {}
);
}
}

View File

@@ -0,0 +1,66 @@
// Error codes registration (side-effect import)
import "./errors/retry-error.js";
// Circuit breaker
export { CircuitBreaker } from "./circuit-breaker/circuit-breaker.js";
export { CircuitBreakerConfigSchema } from "./circuit-breaker/circuit-breaker.schemas.js";
export type {
CircuitBreakerConfig,
CircuitBreakerEventMap,
CircuitState,
} from "./circuit-breaker/circuit-breaker.types.js";
export type { RetryCode } from "./errors/retry-error.js";
// Errors
export {
AttemptTimeoutError,
CircuitOpenError,
RETRY_CODES,
RetryAbortedError,
RetryError,
RetryExhaustedError,
} from "./errors/retry-error.js";
export type { RetryMetrics } from "./otel/retry-otel.js";
// OTel
export {
createRetryMetrics,
recordAttempt,
recordCircuitCall,
recordCircuitStateChange,
recordDelay,
} from "./otel/retry-otel.js";
// Retry engine
export {
createDelayFn,
retry,
sleep,
withRetry,
withTimeout,
} from "./retry/retry.js";
// Strategies
export { createConstantBackoff } from "./strategies/constant/constant.js";
export {
applyJitter,
clampDelay,
createExponentialBackoff,
} from "./strategies/exponential/exponential.js";
export { createLinearBackoff } from "./strategies/linear/linear.js";
// Schemas
export {
ConstantBackoffOptionsSchema,
ExponentialBackoffOptionsSchema,
JitterModeSchema,
LinearBackoffOptionsSchema,
RetryConfigSchema,
} from "./types/retry.schemas.js";
// Types
export type {
ConstantBackoffOptions,
DelayFunction,
ExponentialBackoffOptions,
JitterMode,
LinearBackoffOptions,
RetryConfig,
RetryResult,
RetryStrategy,
} from "./types/retry.types.js";

View File

@@ -0,0 +1,8 @@
export {
createRetryMetrics,
type RetryMetrics,
recordAttempt,
recordCircuitCall,
recordCircuitStateChange,
recordDelay,
} from "./retry-otel";

View File

@@ -0,0 +1,95 @@
# retry-otel
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides OpenTelemetry instrumentation helpers for `@suaveplan/retry`. This module defines the `RetryMetrics` interface and the `createRetryMetrics` factory that creates all four instruments from a caller-supplied OTel `Meter`. Instrument creation is lazy — no metrics are registered until `createRetryMetrics` is called — and the instruments are decoupled from the circuit breaker and retry engine, allowing callers to integrate metrics at the call site without modifying core retry logic.
The design follows the OTel semantic conventions for reliability metrics: counters track event totals, histograms capture distributions that matter for latency and load analysis.
## Features
- `createRetryMetrics(meter)` — creates all four instruments at once
- `recordAttempt` — increments `retry.attempt.total` with `outcome` and `strategy` attributes
- `recordDelay` — records to `retry.attempt.delay_ms` histogram with `strategy` attribute
- `recordCircuitStateChange` — increments `circuit_breaker.state_change.total` with `from`/`to` attributes
- `recordCircuitCall` — records to `circuit_breaker.call.duration_ms` histogram with `outcome` attribute
- Fully tree-shakeable — each helper is a standalone function
## Usage: Basic Example
```typescript
import { metrics } from "@opentelemetry/api";
import { createRetryMetrics, recordAttempt } from "@suaveplan/retry";
const meter = metrics.getMeter("my-service");
const retryMetrics = createRetryMetrics(meter);
// After each attempt:
recordAttempt(retryMetrics, { outcome: "failure", strategy: "exponential" });
```
## Usage: Advanced Example
```typescript
import { metrics } from "@opentelemetry/api";
import {
createRetryMetrics,
recordAttempt,
recordDelay,
recordCircuitStateChange,
recordCircuitCall,
CircuitBreaker,
} from "@suaveplan/retry";
const meter = metrics.getMeter("payment-service");
const m = createRetryMetrics(meter);
const breaker = new CircuitBreaker({ failureThreshold: 5, successThreshold: 2, timeout: 10_000 });
breaker.on("stateChange", ({ from, to }) => recordCircuitStateChange(m, from, to));
// Instrument a call manually:
const start = Date.now();
try {
const result = await breaker.call(() => chargeCard(payload));
recordCircuitCall(m, Date.now() - start, "success");
recordAttempt(m, { outcome: "success", strategy: "circuit-breaker" });
return result;
} catch (err) {
recordCircuitCall(m, Date.now() - start, "failure");
recordAttempt(m, { outcome: "failure", strategy: "circuit-breaker" });
throw err;
}
```
## API Reference
### `createRetryMetrics(meter)`
| Parameter | Type | Description |
|-----------|------|-------------|
| `meter` | `Meter` | OTel Meter from `@opentelemetry/api`. |
**Returns:** `RetryMetrics` — `{ attemptTotal, attemptDelayMs, circuitStateChangeTotal, circuitCallDurationMs }`.
### `recordAttempt(metrics, opts)`
Increments `retry.attempt.total` by 1. Attributes: `outcome` (`"success"` \| `"failure"`), `strategy` (string, default `"unknown"`).
### `recordDelay(metrics, delayMs, strategy?)`
Records `delayMs` to `retry.attempt.delay_ms`. Attribute: `strategy`.
### `recordCircuitStateChange(metrics, from, to)`
Increments `circuit_breaker.state_change.total` by 1. Attributes: `from`, `to` (both `CircuitState`).
### `recordCircuitCall(metrics, durationMs, outcome)`
Records `durationMs` to `circuit_breaker.call.duration_ms`. Attribute: `outcome`.
## Implementation Notes
All helper functions accept `RetryMetrics` as their first parameter rather than using module-level singletons, making them safe for multi-tenant applications that may configure multiple meters. The functions do not set up any automatic instrumentation — callers instrument their retry loops explicitly. This is intentional: wrapping `retry()` internally with OTel would couple the core engine to the OTel API and add mandatory overhead even when metrics are not needed. The `strategy` attribute defaults to `"unknown"` when not provided to ensure all recorded events have a value for that dimension, preventing sparse attribute cardinality issues in OTel backends.

View File

@@ -0,0 +1,136 @@
import type { Counter, Histogram, Meter } from "@opentelemetry/api";
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import {
createRetryMetrics,
recordAttempt,
recordCircuitCall,
recordCircuitStateChange,
recordDelay,
} from "./retry-otel.js";
function makeInstrument(type: "counter" | "histogram") {
if (type === "counter") {
return {
add: vi.fn(),
} satisfies Partial<Counter> as unknown as Counter;
}
return {
record: vi.fn(),
} satisfies Partial<Histogram> as unknown as Histogram;
}
function makeMeter(): {
meter: Meter;
counters: Counter[];
histograms: Histogram[];
} {
const counters: Counter[] = [];
const histograms: Histogram[] = [];
const meter = {
createCounter: vi.fn(() => {
const c = makeInstrument("counter");
counters.push(c);
return c;
}),
createHistogram: vi.fn(() => {
const h = makeInstrument("histogram");
histograms.push(h);
return h;
}),
} as unknown as Meter;
return { meter, counters, histograms };
}
describe("createRetryMetrics", () => {
it("creates all four instruments", { timeout: 5000 }, () => {
const { meter, counters, histograms } = makeMeter();
const m = createRetryMetrics(meter);
expect(counters).toHaveLength(2);
expect(histograms).toHaveLength(2);
expect(m.attemptTotal).toBeDefined();
expect(m.attemptDelayMs).toBeDefined();
expect(m.circuitStateChangeTotal).toBeDefined();
expect(m.circuitCallDurationMs).toBeDefined();
});
});
describe("recordAttempt", () => {
it("calls add with outcome and strategy attributes", {
timeout: 5000,
}, () => {
const { meter } = makeMeter();
const m = createRetryMetrics(meter);
recordAttempt(m, { outcome: "success", strategy: "exponential" });
expect(vi.mocked(m.attemptTotal.add)).toHaveBeenCalledWith(1, {
outcome: "success",
strategy: "exponential",
});
});
it("uses unknown as default strategy", { timeout: 5000 }, () => {
const { meter } = makeMeter();
const m = createRetryMetrics(meter);
recordAttempt(m, { outcome: "failure" });
expect(vi.mocked(m.attemptTotal.add)).toHaveBeenCalledWith(1, {
outcome: "failure",
strategy: "unknown",
});
});
});
describe("recordDelay", () => {
it("records delay with strategy attribute", { timeout: 5000 }, () => {
const { meter } = makeMeter();
const m = createRetryMetrics(meter);
recordDelay(m, 250, "linear");
expect(vi.mocked(m.attemptDelayMs.record)).toHaveBeenCalledWith(250, {
strategy: "linear",
});
});
it("defaults strategy to unknown", { timeout: 5000 }, () => {
const { meter } = makeMeter();
const m = createRetryMetrics(meter);
recordDelay(m, 100);
expect(vi.mocked(m.attemptDelayMs.record)).toHaveBeenCalledWith(100, {
strategy: "unknown",
});
});
});
describe("recordCircuitStateChange", () => {
it("records from/to attributes", { timeout: 5000 }, () => {
const { meter } = makeMeter();
const m = createRetryMetrics(meter);
recordCircuitStateChange(m, "closed", "open");
expect(vi.mocked(m.circuitStateChangeTotal.add)).toHaveBeenCalledWith(
1,
{
from: "closed",
to: "open",
}
);
});
});
describe("recordCircuitCall", () => {
it("records duration and outcome", { timeout: 5000 }, () => {
const { meter } = makeMeter();
const m = createRetryMetrics(meter);
recordCircuitCall(m, 123, "success");
expect(vi.mocked(m.circuitCallDurationMs.record)).toHaveBeenCalledWith(
123,
{ outcome: "success" }
);
});
it("records failure outcome", { timeout: 5000 }, () => {
const { meter } = makeMeter();
const m = createRetryMetrics(meter);
recordCircuitCall(m, 456, "failure");
expect(vi.mocked(m.circuitCallDurationMs.record)).toHaveBeenCalledWith(
456,
{ outcome: "failure" }
);
});
});

View File

@@ -0,0 +1,113 @@
import type { Counter, Histogram, Meter } from "@opentelemetry/api";
import type { CircuitState } from "../circuit-breaker/circuit-breaker.types.js";
/**
* OTel instruments for the retry package.
*
* All instruments are created lazily from the supplied `Meter` so callers
* can use any OpenTelemetry SDK provider.
*/
export interface RetryMetrics {
/** Counter: `retry.attempt.total` */
attemptTotal: Counter;
/** Histogram: `retry.attempt.delay_ms` */
attemptDelayMs: Histogram;
/** Counter: `circuit_breaker.state_change.total` */
circuitStateChangeTotal: Counter;
/** Histogram: `circuit_breaker.call.duration_ms` */
circuitCallDurationMs: Histogram;
}
/**
* Create all retry OTel instruments from the supplied `Meter`.
*
* @param meter - An OTel `Meter` instance.
* @returns Populated `RetryMetrics` record.
*/
export function createRetryMetrics(meter: Meter): RetryMetrics {
return {
attemptTotal: meter.createCounter("retry.attempt.total", {
description: "Total number of retry attempts",
unit: "{attempt}",
}),
attemptDelayMs: meter.createHistogram("retry.attempt.delay_ms", {
description: "Delay before each retry attempt",
unit: "ms",
}),
circuitStateChangeTotal: meter.createCounter(
"circuit_breaker.state_change.total",
{
description: "Number of circuit breaker state transitions",
unit: "{transition}",
}
),
circuitCallDurationMs: meter.createHistogram(
"circuit_breaker.call.duration_ms",
{
description: "Duration of calls through the circuit breaker",
unit: "ms",
}
),
};
}
/**
* Record a retry attempt outcome.
*
* @param metrics - `RetryMetrics` from `createRetryMetrics`.
* @param opts - Attempt metadata.
*/
export function recordAttempt(
metrics: RetryMetrics,
opts: { outcome: "success" | "failure"; strategy?: string }
): void {
metrics.attemptTotal.add(1, {
outcome: opts.outcome,
strategy: opts.strategy ?? "unknown",
});
}
/**
* Record the delay before a retry attempt.
*
* @param metrics - `RetryMetrics` from `createRetryMetrics`.
* @param delayMs - Delay in milliseconds.
* @param strategy - Strategy name for the attribute.
*/
export function recordDelay(
metrics: RetryMetrics,
delayMs: number,
strategy?: string
): void {
metrics.attemptDelayMs.record(delayMs, { strategy: strategy ?? "unknown" });
}
/**
* Record a circuit breaker state transition.
*
* @param metrics - `RetryMetrics` from `createRetryMetrics`.
* @param from - Previous state.
* @param to - New state.
*/
export function recordCircuitStateChange(
metrics: RetryMetrics,
from: CircuitState,
to: CircuitState
): void {
metrics.circuitStateChangeTotal.add(1, { from, to });
}
/**
* Record the duration of a circuit-breaker-protected call.
*
* @param metrics - `RetryMetrics` from `createRetryMetrics`.
* @param durationMs - Call duration in milliseconds.
* @param outcome - "success" or "failure".
*/
export function recordCircuitCall(
metrics: RetryMetrics,
durationMs: number,
outcome: "success" | "failure"
): void {
metrics.circuitCallDurationMs.record(durationMs, { outcome });
}

View File

@@ -0,0 +1 @@
export { createDelayFn, retry, sleep, withRetry, withTimeout } from "./retry";

View File

@@ -0,0 +1,221 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { TimersLike } from "@suaveplan/types";
import {
AttemptTimeoutError,
RetryAbortedError,
RetryExhaustedError,
} from "../errors/retry-error.js";
import { createConstantBackoff } from "../strategies/constant/constant.js";
import { createExponentialBackoff } from "../strategies/exponential/exponential.js";
import { retry, sleep, withTimeout } from "./retry.js";
interface FakeState {
now: number;
nextId: number;
pending: Map<number, { fireAt: number; cb: () => void }>;
}
function makeFakeTimers(state: FakeState): {
timers: TimersLike;
advance: (ms: number) => Promise<void>;
pending: () => number;
} {
const timers: TimersLike = {
setTimer: (cb, ms) => {
const id = state.nextId++;
state.pending.set(id, { fireAt: state.now + ms, cb });
return id;
},
clearTimer: (h) => {
state.pending.delete(h);
},
};
const advance = async (ms: number): Promise<void> => {
const target = state.now + ms;
for (;;) {
let nextId: number | undefined;
let nextAt = Number.POSITIVE_INFINITY;
for (const [id, entry] of state.pending) {
if (entry.fireAt <= target && entry.fireAt < nextAt) {
nextId = id;
nextAt = entry.fireAt;
}
}
if (nextId === undefined) break;
const entry = state.pending.get(nextId);
if (entry === undefined) break;
state.pending.delete(nextId);
state.now = entry.fireAt;
entry.cb();
await Promise.resolve();
}
state.now = target;
};
return { timers, advance, pending: () => state.pending.size };
}
describe("sleep with injected timers", () => {
it("uses injected timers backend instead of platform setTimeout", {
timeout: 5000,
}, async () => {
const state: FakeState = {
now: 0,
nextId: 1,
pending: new Map(),
};
const { timers, advance, pending } = makeFakeTimers(state);
const sleepPromise = sleep(10_000, undefined, timers);
expect(pending()).toBe(1);
await advance(10_000);
await sleepPromise;
expect(pending()).toBe(0);
});
it("clears the timer via injected backend on abort", {
timeout: 5000,
}, async () => {
const state: FakeState = {
now: 0,
nextId: 1,
pending: new Map(),
};
const { timers, pending } = makeFakeTimers(state);
const ctrl = new AbortController();
const promise = sleep(60_000, ctrl.signal, timers);
expect(pending()).toBe(1);
ctrl.abort();
await expect(promise).rejects.toMatchObject({
name: "AbortError",
});
expect(pending()).toBe(0);
});
});
describe("withTimeout with injected timers", () => {
it("fires AttemptTimeoutError when fake clock advances past budget", {
timeout: 5000,
}, async () => {
const state: FakeState = {
now: 0,
nextId: 1,
pending: new Map(),
};
const { timers, advance } = makeFakeTimers(state);
const fn = (): Promise<never> => new Promise<never>(() => undefined);
const promise = withTimeout(fn, 500, 1, undefined, timers).catch(
(e: unknown) => e
);
await advance(500);
const err = await promise;
expect(err).toBeInstanceOf(AttemptTimeoutError);
});
it("clears the timeout via injected backend on early resolve", {
timeout: 5000,
}, async () => {
const state: FakeState = {
now: 0,
nextId: 1,
pending: new Map(),
};
const { timers, pending } = makeFakeTimers(state);
const fn = (): Promise<number> => Promise.resolve(42);
const value = await withTimeout(fn, 60_000, 1, undefined, timers);
expect(value).toBe(42);
// Pending timeout was cleared on resolve.
expect(pending()).toBe(0);
});
});
describe("retry with injected timers", () => {
it("drives exponential backoff deterministically via FakeClock", {
timeout: 5000,
}, async () => {
const state: FakeState = {
now: 0,
nextId: 1,
pending: new Map(),
};
const { timers, advance } = makeFakeTimers(state);
const handler = (): Promise<never> =>
Promise.reject(new Error("transient"));
const delay = createExponentialBackoff({ initial: 1000, base: 2 });
const promise = retry(handler, {
maxAttempts: 5,
delay,
timers,
}).catch((e: unknown) => e);
// Sum of 4 inter-attempt sleeps: 1000 + 2000 + 4000 + 8000 = 15000ms.
for (let i = 0; i < 10; i++) {
await Promise.resolve();
await Promise.resolve();
await advance(20_000);
}
const err = await promise;
expect(err).toBeInstanceOf(RetryExhaustedError);
expect((err as RetryExhaustedError).attempts).toBe(5);
// Real wall clock should still be near zero — we drove every
// sleep through the fake timer pair.
expect(state.pending.size).toBe(0);
});
it("aborts during a timer-driven sleep via RetryAbortedError", {
timeout: 5000,
}, async () => {
const state: FakeState = {
now: 0,
nextId: 1,
pending: new Map(),
};
const { timers, pending } = makeFakeTimers(state);
const ctrl = new AbortController();
const handler = (): Promise<never> =>
Promise.reject(new Error("transient"));
const delay = createConstantBackoff({ delay: 60_000 });
const promise = retry(handler, {
maxAttempts: 5,
delay,
signal: ctrl.signal,
timers,
}).catch((e: unknown) => e);
// Let the first attempt fail and the sleep schedule itself.
await Promise.resolve();
await Promise.resolve();
expect(pending()).toBe(1);
ctrl.abort();
const err = await promise;
expect(err).toBeInstanceOf(RetryAbortedError);
expect(pending()).toBe(0);
});
it("passes timers through to inter-attempt delay then succeeds", {
timeout: 5000,
}, async () => {
const state: FakeState = {
now: 0,
nextId: 1,
pending: new Map(),
};
const { timers, advance } = makeFakeTimers(state);
let attempts = 0;
const handler = async (): Promise<string> => {
attempts += 1;
if (attempts < 3) throw new Error("transient");
return "ok";
};
const delay = createConstantBackoff({ delay: 500 });
const promise = retry(handler, {
maxAttempts: 5,
delay,
timers,
});
for (let i = 0; i < 10; i++) {
await Promise.resolve();
await Promise.resolve();
await advance(500);
}
const result = await promise;
expect(result.value).toBe("ok");
expect(result.attempts).toBe(3);
});
});

View File

@@ -0,0 +1,119 @@
# retry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the core retry execution engine for `@suaveplan/retry`. This module exports `sleep`, `createDelayFn`, `withTimeout`, `retry`, and `withRetry` — the five primitives that power all retry-loop functionality in the package. The design prioritises correctness under cancellation: every function checks `AbortSignal` at the right moments to avoid wasted work after a caller has abandoned the operation.
The `retry` function is the heart of the package. It runs a user-supplied async function up to `maxAttempts` times, applies the configured delay strategy between failures, respects the abort signal, and optionally consults a `shouldRetry` predicate to stop early. It returns a rich `RetryResult` object rather than just the value, so callers can observe how many attempts were needed and how long the total operation took.
## Features
- `sleep(ms, signal?, timers?)` — cancellable delay with immediate rejection on pre-aborted signals
- `createDelayFn(strategy)` — identity adapter for explicit typing in composition chains
- `withTimeout(fn, ms, attempt, signal?, timers?)` — per-attempt deadline with `AttemptTimeoutError`
- `retry(fn, config)` — full retry loop with abort, jitter, and predicate support
- `withRetry(fn, config)` — simplified wrapper that returns the resolved value directly
- Clock injection for deterministic elapsed-time measurement
- `TimersLike` injection for deterministic sleep + timeout under `FakeClock`-driven tests
## Usage: Basic Example
```typescript
import { withRetry, createExponentialBackoff } from "@suaveplan/retry";
const delay = createExponentialBackoff({ initial: 100, max: 5_000, jitter: "full" });
const user = await withRetry(
(signal) => fetchUser(userId, { signal }),
{ maxAttempts: 5, delay },
);
```
## Usage: Advanced Example
```typescript
import { retry, withTimeout, createLinearBackoff, RetryExhaustedError } from "@suaveplan/retry";
const controller = new AbortController();
const delay = createLinearBackoff({ initial: 200, increment: 100, max: 2_000 });
const result = await retry(
(signal) => withTimeout(processJob, 3_000, 1, signal),
{
maxAttempts: 8,
delay,
shouldRetry: (err) => !(err instanceof FatalError),
signal: controller.signal,
clock: () => performance.now(),
},
);
console.log(`Succeeded in ${result.attempts} attempt(s), ${result.totalMs}ms total`);
```
### Usage: Deterministic tests with FakeClock
```typescript
import type { TimersLike } from "@suaveplan/types";
import { retry, createExponentialBackoff } from "@suaveplan/retry";
// FakeClock-backed timer pair — see @suaveplan/testing for a canned impl.
const fakeTimers: TimersLike = makeFakeTimers(state);
const delay = createExponentialBackoff({ initial: 1_000, base: 2 });
const promise = retry(handler, {
maxAttempts: 5,
delay,
timers: fakeTimers,
});
// Advance the fake clock past every inter-attempt sleep. The retry loop
// runs in zero real wall-clock time.
advance(20_000);
```
## API Reference
### `sleep(ms, signal?, timers?)`
| Parameter | Type | Description |
|-----------|------|-------------|
| `ms` | `number` | Duration to sleep (ms). |
| `signal` | `AbortSignal?` | Cancellation signal. |
| `timers` | `TimersLike?` | Injected timer pair. Defaults to platform `setTimeout`/`clearTimeout`. |
**Returns:** `Promise<void>`. Rejects with `DOMException("Aborted")` if signal fires.
### `createDelayFn(strategy)`
Returns `strategy` typed as `DelayFunction`. No-op wrapper for ergonomic composition.
### `withTimeout(fn, ms, attemptNumber, signal?, timers?)`
| Parameter | Type | Description |
|-----------|------|-------------|
| `fn` | `(signal?) => Promise<T>` | Function to race against deadline. |
| `ms` | `number` | Time budget (ms). |
| `attemptNumber` | `number` | 1-based attempt number (for error context). |
| `signal` | `AbortSignal?` | Passed through to `fn`. |
| `timers` | `TimersLike?` | Injected timer pair. Defaults to platform `setTimeout`/`clearTimeout`. |
**Throws:** `AttemptTimeoutError` if `ms` elapses before `fn` resolves.
### `retry(fn, config)`
Runs `fn` up to `config.maxAttempts` times. Returns `RetryResult<T>` on success.
`config.timers` (optional `TimersLike`) drives the inter-attempt sleep when supplied — pass a `FakeClock`-backed pair to make the entire loop deterministic in tests. When omitted, the loop falls back to `globalThis.setTimeout` / `clearTimeout`.
**Throws:** `RetryExhaustedError` on exhaustion, `RetryAbortedError` on abort.
### `withRetry(fn, config)`
Like `retry` but returns `T` directly. Throws the same errors.
## Implementation Notes
The abort-signal check happens both before each attempt and before each sleep. This means a signal fired mid-sleep aborts the sleep immediately (via the `addEventListener` in `sleep`), while a signal fired after a successful `fn` call is noticed at the top of the next loop iteration. The `shouldRetry` predicate is consulted after each failure and can stop retrying by returning `false` — in this case `RetryExhaustedError` is thrown immediately with the current attempt count rather than waiting for `maxAttempts`. The clock injection in `RetryConfig` is used purely for elapsed-time measurement in `RetryResult.totalMs`; it has no effect on actual sleep durations.

View File

@@ -0,0 +1,175 @@
/**
* Spec-conformance tests for `@suaveplan/retry`. Pins implicit scenarios
* from `openspec/specs/retry/spec.md` that the original test suite did
* not assert explicitly: RetryConfig<T> generic narrowing, deterministic
* jitter under an injected Rng, the AbortSignal-forwarded-to-fn shape,
* and the cause chain on RetryExhaustedError.
*/
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import type { Rng } from "@suaveplan/types";
import {
RetryAbortedError,
RetryExhaustedError,
} from "../errors/retry-error.js";
import { createConstantBackoff } from "../strategies/constant/constant.js";
import {
applyJitter,
createExponentialBackoff,
} from "../strategies/exponential/exponential.js";
import { createLinearBackoff } from "../strategies/linear/linear.js";
import type { RetryConfig, RetryResult } from "../types/retry.types.js";
import { retry, withRetry } from "./retry.js";
// A reproducible Rng — yields a deterministic sequence so jitter outputs
// match across runs.
function seededRng(seed: number): Rng {
let s = seed >>> 0;
return {
nextUint32(): number {
// xorshift32
s ^= s << 13;
s ^= s >>> 17;
s ^= s << 5;
return s >>> 0;
},
nextFloat(): number {
return this.nextUint32() / 0x1_0000_0000;
},
};
}
describe("RetryConfig<T> — generic threading", () => {
it("retry<T> infers T from fn return", { timeout: 5000 }, async () => {
const result = await retry(async () => 42 as const, {
maxAttempts: 1,
delay: () => 0,
});
expectTypeOf(result).toEqualTypeOf<RetryResult<42>>();
expectTypeOf(result.value).toEqualTypeOf<42>();
expect(result.value).toBe(42);
});
it("RetryConfig<string> is assignable from minimal config", {
timeout: 5000,
}, () => {
const cfg: RetryConfig<string> = { maxAttempts: 3, delay: () => 0 };
expectTypeOf(cfg).toMatchTypeOf<RetryConfig<string>>();
expect(cfg.maxAttempts).toBe(3);
});
it("withRetry<T> returns Promise<T>, not Promise<unknown>", {
timeout: 5000,
}, async () => {
const value = await withRetry(async () => "hello", {
maxAttempts: 1,
delay: () => 0,
});
expectTypeOf(value).toEqualTypeOf<string>();
});
});
describe("Deterministic jitter under injected Rng (RT-03)", () => {
it("two strategies sharing a seeded Rng yield identical sequences", {
timeout: 5000,
}, () => {
const a = createExponentialBackoff(
{ initial: 100, jitter: "full" },
seededRng(0xc0ffee)
);
const b = createExponentialBackoff(
{ initial: 100, jitter: "full" },
seededRng(0xc0ffee)
);
const seqA = [a(1), a(2), a(3), a(4), a(5)];
const seqB = [b(1), b(2), b(3), b(4), b(5)];
expect(seqA).toEqual(seqB);
});
it("applyJitter forwards Rng to all jitter modes deterministically", {
timeout: 5000,
}, () => {
const seed = 0x1234_5678;
const full = applyJitter(1000, "full", seededRng(seed));
const fullAgain = applyJitter(1000, "full", seededRng(seed));
expect(full).toBe(fullAgain);
const equal = applyJitter(1000, "equal", seededRng(seed));
const equalAgain = applyJitter(1000, "equal", seededRng(seed));
expect(equal).toBe(equalAgain);
const decor = applyJitter(1000, "decorrelated", seededRng(seed));
const decorAgain = applyJitter(1000, "decorrelated", seededRng(seed));
expect(decor).toBe(decorAgain);
});
});
describe("AbortSignal forwarding to fn (RT-02)", () => {
it("retry forwards config.signal to the wrapped fn on every attempt", {
timeout: 5000,
}, async () => {
const ctrl = new AbortController();
const seen: (AbortSignal | undefined)[] = [];
let calls = 0;
const fn = (sig?: AbortSignal): Promise<string> => {
seen.push(sig);
calls++;
if (calls < 3) return Promise.reject(new Error("fail"));
return Promise.resolve("ok");
};
const result = await retry(fn, {
maxAttempts: 5,
delay: () => 0,
signal: ctrl.signal,
});
expect(result.attempts).toBe(3);
for (const sig of seen) expect(sig).toBe(ctrl.signal);
});
it("cancelled signal mid-backoff rejects with RetryAbortedError, not RetryExhaustedError", {
timeout: 5000,
}, async () => {
const ctrl = new AbortController();
const fn = async (): Promise<never> => {
// Schedule abort to fire while sleep() is pending.
queueMicrotask(() => ctrl.abort("mid-sleep"));
throw new Error("fail");
};
const err = await retry(fn, {
maxAttempts: 5,
delay: () => 50,
signal: ctrl.signal,
}).catch((e) => e);
expect(err).toBeInstanceOf(RetryAbortedError);
expect(err).not.toBeInstanceOf(RetryExhaustedError);
});
});
describe("Backoff factories (RT-04)", () => {
it("constant / linear / exponential are pure functions of attempt", {
timeout: 5000,
}, () => {
const c = createConstantBackoff({ delay: 100 });
const l = createLinearBackoff({ initial: 50, increment: 25 });
const e = createExponentialBackoff({ initial: 100, base: 2 });
// Idempotent — same input, same output across calls.
expect(c(1)).toBe(c(1));
expect(l(3)).toBe(l(3));
expect(e(4)).toBe(e(4));
});
});
describe("RetryExhaustedError cause chain (RT-07)", () => {
it("wraps the lastError as Error.cause when it is an Error", {
timeout: 5000,
}, async () => {
const inner = new Error("root cause");
const err = await retry(() => Promise.reject(inner), {
maxAttempts: 2,
delay: () => 0,
}).catch((e) => e);
expect(err).toBeInstanceOf(RetryExhaustedError);
expect(err.cause).toBe(inner);
expect(err.lastError).toBe(inner);
});
});

View File

@@ -0,0 +1,303 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import fc from "fast-check";
import {
AttemptTimeoutError,
RetryAbortedError,
RetryExhaustedError,
} from "../errors/retry-error.js";
import {
createDelayFn,
retry,
sleep,
withRetry,
withTimeout,
} from "./retry.js";
// ---- sleep ----
describe("sleep", () => {
it("resolves after ms", { timeout: 30000 }, async () => {
await expect(sleep(10)).resolves.toBeUndefined();
});
it("resolves with signal that is not aborted", {
timeout: 30000,
}, async () => {
const ctrl = new AbortController();
await expect(sleep(10, ctrl.signal)).resolves.toBeUndefined();
});
it("rejects immediately if signal is already aborted", {
timeout: 30000,
}, async () => {
const ctrl = new AbortController();
ctrl.abort();
await expect(sleep(100, ctrl.signal)).rejects.toMatchObject({
name: "AbortError",
});
});
it("rejects when signal fires during sleep", {
timeout: 30000,
}, async () => {
const ctrl = new AbortController();
const p = sleep(5000, ctrl.signal);
ctrl.abort();
await expect(p).rejects.toMatchObject({ name: "AbortError" });
});
});
// ---- createDelayFn ----
describe("createDelayFn", () => {
it("returns the same strategy function", { timeout: 5000 }, () => {
const s = (attempt: number) => attempt * 10;
expect(createDelayFn(s)).toBe(s);
});
});
// ---- withTimeout ----
describe("withTimeout", () => {
it("resolves with fn result when within budget", {
timeout: 30000,
}, async () => {
const result = await withTimeout(() => Promise.resolve(42), 1000, 1);
expect(result).toBe(42);
});
it("throws AttemptTimeoutError when fn takes too long", {
timeout: 30000,
}, async () => {
const fn = () =>
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("never")), 5000)
);
await expect(withTimeout(fn, 50, 2)).rejects.toBeInstanceOf(
AttemptTimeoutError
);
});
it("AttemptTimeoutError has correct attemptNumber and timeoutMs", {
timeout: 30000,
}, async () => {
const fn = () => new Promise<never>(() => undefined);
const err = await withTimeout(fn, 30, 3).catch((e) => e);
expect(err).toBeInstanceOf(AttemptTimeoutError);
expect(err.attemptNumber).toBe(3);
expect(err.timeoutMs).toBe(30);
});
it("propagates fn rejection", { timeout: 30000 }, async () => {
const inner = new Error("inner");
await expect(
withTimeout(() => Promise.reject(inner), 1000, 1)
).rejects.toBe(inner);
});
it("passes signal to fn", { timeout: 30000 }, async () => {
const ctrl = new AbortController();
let received: AbortSignal | undefined;
const fn = (sig?: AbortSignal): Promise<void> => {
received = sig;
return Promise.resolve();
};
await withTimeout(fn, 1000, 1, ctrl.signal);
expect(received).toBe(ctrl.signal);
});
});
// ---- retry ----
describe("retry", () => {
it("returns result on first success", { timeout: 30000 }, async () => {
const fn = vi.fn().mockResolvedValue("ok");
const result = await retry(fn, { maxAttempts: 3, delay: () => 0 });
expect(result.value).toBe("ok");
expect(result.attempts).toBe(1);
expect(fn).toHaveBeenCalledTimes(1);
});
it("retries and succeeds on second attempt", {
timeout: 30000,
}, async () => {
let calls = 0;
const fn = async (): Promise<string> => {
calls++;
if (calls < 2) throw new Error("fail");
return "success";
};
const result = await retry(fn, { maxAttempts: 3, delay: () => 0 });
expect(result.value).toBe("success");
expect(result.attempts).toBe(2);
});
it("throws RetryExhaustedError after maxAttempts", {
timeout: 30000,
}, async () => {
const inner = new Error("always");
const fn = vi.fn().mockRejectedValue(inner);
await expect(
retry(fn, { maxAttempts: 3, delay: () => 0 })
).rejects.toBeInstanceOf(RetryExhaustedError);
expect(fn).toHaveBeenCalledTimes(3);
});
it("RetryExhaustedError has correct attempts and lastError", {
timeout: 30000,
}, async () => {
const inner = new Error("boom");
const err = await retry(vi.fn().mockRejectedValue(inner), {
maxAttempts: 2,
delay: () => 0,
}).catch((e) => e);
expect(err).toBeInstanceOf(RetryExhaustedError);
expect(err.attempts).toBe(2);
expect(err.lastError).toBe(inner);
});
it("stops early when shouldRetry returns false", {
timeout: 30000,
}, async () => {
const fn = vi.fn().mockRejectedValue(new Error("nope"));
const shouldRetry = vi.fn().mockReturnValue(false);
const err = await retry(fn, {
maxAttempts: 5,
delay: () => 0,
shouldRetry,
}).catch((e) => e);
expect(fn).toHaveBeenCalledTimes(1);
expect(shouldRetry).toHaveBeenCalledTimes(1);
expect(err).toBeInstanceOf(RetryExhaustedError);
});
it("shouldRetry receives error and attempt", {
timeout: 30000,
}, async () => {
const boom = new Error("boom");
const calls: Array<[unknown, number]> = [];
const fn = vi.fn().mockRejectedValue(boom);
await retry(fn, {
maxAttempts: 3,
delay: () => 0,
shouldRetry: (err, attempt) => {
calls.push([err, attempt]);
return true;
},
}).catch(() => undefined);
expect(calls).toEqual([
[boom, 1],
[boom, 2],
[boom, 3],
]);
});
it("throws RetryAbortedError when signal is pre-aborted", {
timeout: 30000,
}, async () => {
const ctrl = new AbortController();
ctrl.abort();
const fn = vi.fn().mockResolvedValue("ok");
await expect(
retry(fn, { maxAttempts: 3, delay: () => 0, signal: ctrl.signal })
).rejects.toBeInstanceOf(RetryAbortedError);
expect(fn).not.toHaveBeenCalled();
});
it("aborts between attempts", { timeout: 30000 }, async () => {
const ctrl = new AbortController();
let calls = 0;
const fn = async (): Promise<string> => {
calls++;
throw new Error("fail");
};
const delayFn = (_attempt: number): number => {
ctrl.abort(); // abort before sleeping
return 100;
};
const err = await retry(fn, {
maxAttempts: 5,
delay: delayFn,
signal: ctrl.signal,
}).catch((e) => e);
expect(calls).toBe(1);
expect(err).toBeInstanceOf(RetryAbortedError);
});
it("uses clock for totalMs", { timeout: 30000 }, async () => {
let t = 1000;
const clock = () => t;
const fn = async (): Promise<string> => {
t += 500;
return "done";
};
const result = await retry(fn, {
maxAttempts: 1,
delay: () => 0,
clock,
});
expect(result.totalMs).toBe(500);
});
it("aborts synchronously after fn fails (pre-delay check)", {
timeout: 30000,
}, async () => {
const ctrl = new AbortController();
let calls = 0;
// Abort inside fn so signal is aborted when retry checks before delay()
const fn = async (): Promise<string> => {
calls++;
ctrl.abort("sync-abort");
throw new Error("fail");
};
const err = await retry(fn, {
maxAttempts: 5,
delay: () => 0,
signal: ctrl.signal,
}).catch((e) => e);
expect(calls).toBe(1);
expect(err).toBeInstanceOf(RetryAbortedError);
});
it("property: never exceeds maxAttempts calls to fn", {
timeout: 30000,
}, async () => {
await fc.assert(
fc.asyncProperty(
fc.integer({ min: 1, max: 10 }),
async (maxAttempts) => {
let count = 0;
const fn = async (): Promise<never> => {
count++;
throw new Error("fail");
};
await retry(fn, { maxAttempts, delay: () => 0 }).catch(
() => undefined
);
return count <= maxAttempts;
}
)
);
});
});
// ---- withRetry ----
describe("withRetry", () => {
it("returns value directly on success", { timeout: 30000 }, async () => {
const result = await withRetry(() => Promise.resolve(99), {
maxAttempts: 1,
delay: () => 0,
});
expect(result).toBe(99);
});
it("throws on exhaustion", { timeout: 30000 }, async () => {
await expect(
withRetry(() => Promise.reject(new Error("x")), {
maxAttempts: 2,
delay: () => 0,
})
).rejects.toBeInstanceOf(RetryExhaustedError);
});
});

View File

@@ -0,0 +1,195 @@
import type { Clock, TimersLike } from "@suaveplan/types";
import {
AttemptTimeoutError,
RetryAbortedError,
RetryExhaustedError,
} from "../errors/retry-error.js";
import type {
DelayFunction,
RetryConfig,
RetryResult,
RetryStrategy,
} from "../types/retry.types.js";
/**
* Platform-backed default `TimersLike`. Wraps `globalThis.setTimeout` /
* `clearTimeout`, casting the return value through `unknown` so the handle
* is a plain `number` on every runtime (Node's `Timeout` object and the
* browser's numeric handle both pass cleanly).
*/
function defaultTimers(): TimersLike {
return {
setTimer: (cb, ms) =>
globalThis.setTimeout(cb, ms) as unknown as number,
clearTimer: (h) => {
globalThis.clearTimeout(h);
},
};
}
/**
* Resolves after `ms` milliseconds. Rejects immediately with a `DOMException`
* ("AbortError") if `signal` is already aborted, or as soon as `signal`
* fires during the wait.
*
* @param ms - Sleep duration in milliseconds.
* @param signal - Optional cancellation signal.
* @param timers - Optional injected timer pair. Defaults to the platform
* `setTimeout`/`clearTimeout`.
*/
export async function sleep(
ms: number,
signal?: AbortSignal,
timers?: TimersLike
): Promise<void> {
if (signal?.aborted) {
throw new DOMException("Aborted", "AbortError");
}
const t = timers ?? defaultTimers();
return new Promise<void>((resolve, reject) => {
const handle = t.setTimer(resolve, ms);
if (signal) {
const onAbort = (): void => {
t.clearTimer(handle);
reject(new DOMException("Aborted", "AbortError"));
};
signal.addEventListener("abort", onAbort, { once: true });
}
});
}
/**
* Adapts a `RetryStrategy` to a `DelayFunction` (they are the same type,
* but this function makes the intent explicit in composition chains).
*
* @param strategy - A retry strategy function.
* @returns The same function typed as `DelayFunction`.
*/
export function createDelayFn(strategy: RetryStrategy): DelayFunction {
return strategy;
}
/**
* Wraps `fn` with a per-call time budget. If `fn` does not resolve within
* `ms` milliseconds, rejects with `AttemptTimeoutError`.
*
* The `signal` argument (if provided) is passed through to `fn` and also
* races against the timeout.
*
* @param fn - Async function to execute.
* @param ms - Time budget in milliseconds.
* @param attemptNumber - 1-based attempt number used in the error.
* @param signal - Optional cancellation signal passed to `fn`.
*/
export async function withTimeout<T>(
fn: (signal?: AbortSignal) => Promise<T>,
ms: number,
attemptNumber: number,
signal?: AbortSignal,
timers?: TimersLike
): Promise<T> {
const t = timers ?? defaultTimers();
return new Promise<T>((resolve, reject) => {
const handle = t.setTimer(() => {
reject(new AttemptTimeoutError({ attemptNumber, timeoutMs: ms }));
}, ms);
const cleanup = (): void => {
t.clearTimer(handle);
};
fn(signal).then(
(value) => {
cleanup();
resolve(value);
},
(err: unknown) => {
cleanup();
reject(err);
}
);
});
}
/**
* Core retry loop. Runs `fn` up to `config.maxAttempts` times, sleeping
* between failures according to `config.delay`.
*
* - Checks `config.signal` before each sleep and before each call.
* - Stops early if `config.shouldRetry` returns `false`.
* - Returns a `RetryResult` on success.
* - Throws `RetryExhaustedError` when all attempts fail.
* - Throws `RetryAbortedError` when aborted via signal.
*
* @param fn - The async function to retry.
* @param config - Retry configuration.
*/
export async function retry<T>(
fn: (signal?: AbortSignal) => Promise<T>,
config: RetryConfig<T>
): Promise<RetryResult<T>> {
const { maxAttempts, delay, shouldRetry, signal, clock, timers } = config;
const now: Clock = clock ?? (() => Date.now());
const startMs = now();
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
// Check abort before each attempt
if (signal?.aborted) {
throw new RetryAbortedError({ cause: signal.reason });
}
try {
const value = await fn(signal);
return {
value,
attempts: attempt,
totalMs: now() - startMs,
};
} catch (err) {
lastError = err;
// Check shouldRetry predicate
if (shouldRetry !== undefined && !shouldRetry(err, attempt)) {
throw new RetryExhaustedError({
attempts: attempt,
lastError: err,
});
}
// If this was the last attempt, don't sleep — just exhaust
if (attempt === maxAttempts) {
break;
}
// Check abort before sleeping
if (signal?.aborted) {
throw new RetryAbortedError({ cause: signal.reason });
}
const delayMs = delay(attempt);
// sleep only throws when aborted; wrap as RetryAbortedError
await sleep(delayMs, signal, timers).catch(() => {
throw new RetryAbortedError({ cause: signal?.reason });
});
}
}
throw new RetryExhaustedError({ attempts: maxAttempts, lastError });
}
/**
* Convenience wrapper around {@link retry} that unwraps the result value
* and throws on exhaustion.
*
* @param fn - The async function to retry.
* @param config - Retry configuration.
* @returns The resolved value of `fn`.
*/
export async function withRetry<T>(
fn: (signal?: AbortSignal) => Promise<T>,
config: RetryConfig<T>
): Promise<T> {
const result = await retry(fn, config);
return result.value;
}

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;
};
}

View File

@@ -0,0 +1,17 @@
export {
ConstantBackoffOptionsSchema,
ExponentialBackoffOptionsSchema,
JitterModeSchema,
LinearBackoffOptionsSchema,
RetryConfigSchema,
} from "./retry.schemas";
export type {
ConstantBackoffOptions,
DelayFunction,
ExponentialBackoffOptions,
JitterMode,
LinearBackoffOptions,
RetryConfig,
RetryResult,
RetryStrategy,
} from "./retry.types";

View File

@@ -0,0 +1,108 @@
/**
* Spec-conformance tests for the retry Zod schemas (RT-09). Pins that
* every option type listed in `openspec/specs/retry/spec.md` has a
* matching Zod schema that round-trips a valid sample and rejects an
* invalid one. Schema-as-boundary-only — invocation guarded by the
* conformance script's "schema: boundary-only" comment in the schema
* file is intentional.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CircuitBreakerConfigSchema } from "../circuit-breaker/circuit-breaker.schemas.js";
import {
ConstantBackoffOptionsSchema,
ExponentialBackoffOptionsSchema,
JitterModeSchema,
LinearBackoffOptionsSchema,
RetryConfigSchema,
} from "./retry.schemas.js";
describe("retry schemas — round-trip valid samples (RT-09)", () => {
it("JitterModeSchema accepts the four documented modes", {
timeout: 5000,
}, () => {
for (const m of ["full", "equal", "decorrelated", "none"] as const) {
expect(JitterModeSchema.parse(m)).toBe(m);
}
expect(JitterModeSchema.safeParse("nope").success).toBe(false);
});
it("ConstantBackoffOptionsSchema requires nonnegative delay", {
timeout: 5000,
}, () => {
expect(ConstantBackoffOptionsSchema.parse({ delay: 0 })).toEqual({
delay: 0,
});
expect(
ConstantBackoffOptionsSchema.safeParse({ delay: -1 }).success
).toBe(false);
});
it("LinearBackoffOptionsSchema validates initial/increment/max", {
timeout: 5000,
}, () => {
const ok = LinearBackoffOptionsSchema.parse({
initial: 100,
increment: 50,
max: 1000,
});
expect(ok).toEqual({ initial: 100, increment: 50, max: 1000 });
expect(
LinearBackoffOptionsSchema.safeParse({
initial: -1,
increment: 0,
}).success
).toBe(false);
});
it("ExponentialBackoffOptionsSchema validates initial/base/max/jitter", {
timeout: 5000,
}, () => {
const ok = ExponentialBackoffOptionsSchema.parse({
initial: 100,
base: 2,
max: 5000,
jitter: "full",
});
expect(ok.jitter).toBe("full");
expect(
ExponentialBackoffOptionsSchema.safeParse({
initial: 100,
base: 0,
}).success
).toBe(false);
});
it("RetryConfigSchema validates structural shape", {
timeout: 5000,
}, () => {
const cfg = RetryConfigSchema.parse({
maxAttempts: 3,
delay: () => 0,
});
expect(cfg.maxAttempts).toBe(3);
expect(
RetryConfigSchema.safeParse({
maxAttempts: 0,
delay: () => 0,
}).success
).toBe(false);
});
it("CircuitBreakerConfigSchema validates thresholds and timeout", {
timeout: 5000,
}, () => {
const cfg = CircuitBreakerConfigSchema.parse({
failureThreshold: 3,
successThreshold: 2,
timeout: 1000,
});
expect(cfg.failureThreshold).toBe(3);
expect(
CircuitBreakerConfigSchema.safeParse({
failureThreshold: 0,
successThreshold: 1,
timeout: 100,
}).success
).toBe(false);
});
});

View File

@@ -0,0 +1,32 @@
import z from "zod";
export const JitterModeSchema = z.enum([
"full",
"equal",
"decorrelated",
"none",
]);
export const ConstantBackoffOptionsSchema = z.object({
delay: z.number().nonnegative(),
});
export const LinearBackoffOptionsSchema = z.object({
initial: z.number().nonnegative(),
increment: z.number(),
max: z.number().nonnegative().optional(),
});
export const ExponentialBackoffOptionsSchema = z.object({
initial: z.number().nonnegative(),
base: z.number().positive().optional(),
max: z.number().nonnegative().optional(),
jitter: JitterModeSchema.optional(),
});
export const RetryConfigSchema = z.object({
maxAttempts: z.number().int().positive(),
delay: z.function(),
shouldRetry: z.function().optional(),
signal: z.instanceof(AbortSignal).optional(),
});

View File

@@ -0,0 +1,75 @@
# retry.types
## Purpose
Shared type definitions for the `@suaveplan/retry` package. These interfaces describe the strategy function shape, the per-strategy options bags (constant, linear, exponential), and the top-level retry-loop configuration, plus the `RetryResult<T>` envelope returned by every successful run. Centralising the contracts here keeps the backoff builders, jitter helpers, and loop driver all speaking the same language.
## Features
- Zero runtime cost — all exports are pure TypeScript types
- `RetryStrategy` — a pure function `(attempt: number) => number` that maps attempt count to delay; every backoff factory in the package returns one
- `JitterMode` union covering the four supported jitter flavours: `full`, `equal`, `decorrelated`, `none`
- Separate options interfaces per backoff flavour (`ConstantBackoffOptions`, `LinearBackoffOptions`, `ExponentialBackoffOptions`) so factories get named, checked parameters
- `RetryConfig<T>` pulls together strategy, max-attempts, predicate, `AbortSignal`, injected `Clock`, and injected `Rng` (from `@suaveplan/types`)
- `RetryResult<T>` carries the resolved value, total attempts, and cumulative wall-clock time for telemetry
## Usage: Basic Example
```ts
import type { RetryConfig, RetryStrategy } from "@suaveplan/retry/types/retry.types";
const constant: RetryStrategy = () => 100;
const config: RetryConfig<string> = {
maxAttempts: 5,
delay: constant,
};
```
## Usage: Advanced Example
```ts
import type {
ExponentialBackoffOptions,
RetryConfig,
RetryResult,
} from "@suaveplan/retry/types/retry.types";
const options: ExponentialBackoffOptions = {
initial: 200,
base: 2,
max: 10_000,
jitter: "decorrelated",
};
const controller = new AbortController();
const config: RetryConfig<number> = {
maxAttempts: 7,
delay: (attempt) => Math.min(options.max ?? Infinity, options.initial * 2 ** (attempt - 1)),
shouldRetry: (err) => !(err instanceof TypeError),
signal: controller.signal,
clock: () => performance.now(),
rng: () => Math.random(),
};
declare function retryLoop<T>(fn: () => Promise<T>, cfg: RetryConfig<T>): Promise<RetryResult<T>>;
const result = await retryLoop(async () => 42, config);
console.log(result.value, result.attempts, result.totalMs);
```
## API Reference
- `JitterMode` — `"full" | "equal" | "decorrelated" | "none"`.
- `RetryStrategy` — `(attempt: number) => number` delay-in-ms function.
- `DelayFunction` — alias for `RetryStrategy`.
- `ConstantBackoffOptions` — `{ delay }`.
- `LinearBackoffOptions` — `{ initial, increment, max? }`.
- `ExponentialBackoffOptions` — `{ initial, base?, max?, jitter? }`.
- `RetryConfig<T>` — top-level loop config.
- `RetryResult<T>` — `{ value, attempts, totalMs }` success envelope.
## Implementation Notes
The `RetryStrategy` shape is deliberately a plain function rather than an object with `next()` / `reset()` methods so callers can compose strategies with ordinary function composition, and so the factory functions (`constantBackoff`, `linearBackoff`, `exponentialBackoff`) can return closures rather than classes. The `Rng` injection from `@suaveplan/types` lets the decorrelated-jitter variant be driven by a deterministic seeded RNG in property-based tests while defaulting to `Math.random` in production code.

View File

@@ -0,0 +1,82 @@
import type { Clock, Rng, TimersLike } from "@suaveplan/types";
/** How jitter is applied to a base delay. */
export type JitterMode = "full" | "equal" | "decorrelated" | "none";
/**
* A function that maps an attempt number (1-based) to a delay in milliseconds.
* Returned by all backoff factory functions.
*/
export type RetryStrategy = (attempt: number) => number;
/** Alias for RetryStrategy — used in delay-function composition contexts. */
export type DelayFunction = RetryStrategy;
/** Options for constant backoff. */
export interface ConstantBackoffOptions {
/** Fixed delay in milliseconds between every attempt. */
delay: number;
}
/** Options for linear backoff. */
export interface LinearBackoffOptions {
/** Delay for the first retry (ms). */
initial: number;
/** Amount added per retry step (ms). */
increment: number;
/** Maximum delay cap (ms). */
max?: number;
}
/** Options for exponential backoff. */
export interface ExponentialBackoffOptions {
/** Delay for the first retry (ms). */
initial: number;
/** Exponent base. Defaults to 2. */
base?: number;
/** Maximum delay cap (ms). */
max?: number;
/** Jitter mode. Defaults to "none". */
jitter?: JitterMode;
}
/**
* Configuration for the retry loop.
*
* @template T The resolved value type of the retried function.
*/
export interface RetryConfig<_T = unknown> {
/** Maximum number of attempts (including the first call). */
maxAttempts: number;
/** Delay strategy — returns ms to wait before the next attempt. */
delay: RetryStrategy;
/**
* Optional predicate. Return `false` to stop retrying immediately.
* Defaults to always retrying.
*/
shouldRetry?: (error: unknown, attempt: number) => boolean;
/** If provided, abort the retry loop when signalled. */
signal?: AbortSignal;
/** Clock used for elapsed-time measurement. Defaults to `Date.now`. */
clock?: Clock;
/** Rng used by jitter helpers. */
rng?: Rng;
/**
* Pluggable timer pair backing inter-attempt `sleep` and per-attempt
* `withTimeout`. Defaults to `globalThis.setTimeout` / `clearTimeout`
* when omitted. Supply a `FakeClock`-backed pair from
* `@suaveplan/testing` to drive retry loops deterministically in
* zero real wall-clock time.
*/
timers?: TimersLike;
}
/** The successful return value of a completed retry loop. */
export interface RetryResult<T> {
/** The resolved value from the successful attempt. */
value: T;
/** Total number of attempts made (including the successful one). */
attempts: number;
/** Total elapsed wall-clock time across all attempts and delays (ms). */
totalMs: number;
}

View File

@@ -0,0 +1,28 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("dep-graph", () => {
it("runtime @suaveplan/* deps are [@suaveplan/error, @suaveplan/events, @suaveplan/types] sorted", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { dependencies?: Record<string, string> };
const runtime = Object.keys(pkg.dependencies ?? {})
.filter((k) => k.startsWith("@suaveplan/"))
.sort();
expect(runtime).toEqual([
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/types",
]);
});
it("no peerDependencies", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { peerDependencies?: Record<string, string> };
expect(pkg.peerDependencies).toBeUndefined();
});
});

View File

@@ -0,0 +1,38 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const SRC = join(__dirname, "..", "src");
const BANNED_GLOBAL: ReadonlyArray<RegExp> = [
/\bnode:fs\b/,
/\bnode:path\b/,
/\bnode:url\b/,
/\bprocess\./,
/\bBuffer\b/,
/\bdocument\b/,
/\bwindow\b/,
];
function* walk(dir: string): Generator<string> {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) yield* walk(full);
else if (full.endsWith(".ts") && !full.endsWith(".test.ts")) yield full;
}
}
describe("no-disallowed", () => {
it("src/ avoids Node + browser built-ins", { timeout: 5000 }, () => {
const hits: string[] = [];
for (const file of walk(SRC)) {
if (file.endsWith(".bench.ts")) continue;
const content = readFileSync(file, "utf8");
for (const pattern of BANNED_GLOBAL) {
if (pattern.test(content)) hits.push(`${file} :: ${pattern}`);
}
}
expect(hits).toEqual([]);
});
});

View File

@@ -0,0 +1,8 @@
// Ensure `globalThis.crypto` is available in bare-Node vitest runs.
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,12 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("tree-shake", () => {
it("sideEffects is false", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { sideEffects?: boolean };
expect(pkg.sideEffects).toBe(false);
});
});

View File

@@ -0,0 +1,7 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": { "rootDir": "./src", "outDir": "./dist", "lib": ["ES2022"], "types": ["node"] },
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/retry",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/types",
"@suaveplan/events",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: [
"src/__benchmarks__/**",
"dist/**",
"node_modules/**",
],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.schemas.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});