Initial Commit
This commit is contained in:
78
packages/foundation/flow/retry/README.md
Normal file
78
packages/foundation/flow/retry/README.md
Normal 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) |
|
||||
|
||||
136
packages/foundation/flow/retry/package.json
Normal file
136
packages/foundation/flow/retry/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { CircuitBreaker } from "./circuit-breaker";
|
||||
export { CircuitBreakerConfigSchema } from "./circuit-breaker.schemas";
|
||||
export type {
|
||||
CircuitBreakerConfig,
|
||||
CircuitBreakerEventMap,
|
||||
CircuitState,
|
||||
} from "./circuit-breaker.types";
|
||||
9
packages/foundation/flow/retry/src/errors/index.ts
Normal file
9
packages/foundation/flow/retry/src/errors/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
AttemptTimeoutError,
|
||||
CircuitOpenError,
|
||||
RETRY_CODES,
|
||||
RetryAbortedError,
|
||||
type RetryCode,
|
||||
RetryError,
|
||||
RetryExhaustedError,
|
||||
} from "./retry-error";
|
||||
115
packages/foundation/flow/retry/src/errors/retry-error.md
Normal file
115
packages/foundation/flow/retry/src/errors/retry-error.md
Normal 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.
|
||||
140
packages/foundation/flow/retry/src/errors/retry-error.test.ts
Normal file
140
packages/foundation/flow/retry/src/errors/retry-error.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
80
packages/foundation/flow/retry/src/errors/retry-error.ts
Normal file
80
packages/foundation/flow/retry/src/errors/retry-error.ts
Normal 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 } }
|
||||
: {}
|
||||
);
|
||||
}
|
||||
}
|
||||
66
packages/foundation/flow/retry/src/index.ts
Normal file
66
packages/foundation/flow/retry/src/index.ts
Normal 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";
|
||||
8
packages/foundation/flow/retry/src/otel/index.ts
Normal file
8
packages/foundation/flow/retry/src/otel/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
createRetryMetrics,
|
||||
type RetryMetrics,
|
||||
recordAttempt,
|
||||
recordCircuitCall,
|
||||
recordCircuitStateChange,
|
||||
recordDelay,
|
||||
} from "./retry-otel";
|
||||
95
packages/foundation/flow/retry/src/otel/retry-otel.md
Normal file
95
packages/foundation/flow/retry/src/otel/retry-otel.md
Normal 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.
|
||||
136
packages/foundation/flow/retry/src/otel/retry-otel.test.ts
Normal file
136
packages/foundation/flow/retry/src/otel/retry-otel.test.ts
Normal 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" }
|
||||
);
|
||||
});
|
||||
});
|
||||
113
packages/foundation/flow/retry/src/otel/retry-otel.ts
Normal file
113
packages/foundation/flow/retry/src/otel/retry-otel.ts
Normal 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 });
|
||||
}
|
||||
1
packages/foundation/flow/retry/src/retry/index.ts
Normal file
1
packages/foundation/flow/retry/src/retry/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createDelayFn, retry, sleep, withRetry, withTimeout } from "./retry";
|
||||
221
packages/foundation/flow/retry/src/retry/retry-timers.test.ts
Normal file
221
packages/foundation/flow/retry/src/retry/retry-timers.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
119
packages/foundation/flow/retry/src/retry/retry.md
Normal file
119
packages/foundation/flow/retry/src/retry/retry.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
303
packages/foundation/flow/retry/src/retry/retry.test.ts
Normal file
303
packages/foundation/flow/retry/src/retry/retry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
195
packages/foundation/flow/retry/src/retry/retry.ts
Normal file
195
packages/foundation/flow/retry/src/retry/retry.ts
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
# constant
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides `createConstantBackoff`, a factory that creates a retry strategy returning a fixed, unchanging delay for every attempt. Constant backoff is the simplest possible strategy: every retry waits the same number of milliseconds before the next call. This is useful when the failure mode does not benefit from increasing delays (e.g., transient network hiccups with known recovery windows) and when predictable timing is more important than reducing thundering-herd pressure.
|
||||
|
||||
The returned function satisfies the `RetryStrategy` interface (`(attempt: number) => number`) and can be passed directly to `RetryConfig.delay` or any helper that accepts a strategy.
|
||||
|
||||
## Features
|
||||
|
||||
- Zero allocation per call — closure captures delay once at factory time
|
||||
- Deterministic: no randomness, no clock dependency
|
||||
- Composable with `applyJitter` for external jitter if needed
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { createConstantBackoff } from "@suaveplan/retry";
|
||||
import { withRetry } from "@suaveplan/retry";
|
||||
|
||||
const strategy = createConstantBackoff({ delay: 200 });
|
||||
|
||||
const result = await withRetry(fetchUser, {
|
||||
maxAttempts: 5,
|
||||
delay: strategy,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { createConstantBackoff, applyJitter } from "@suaveplan/retry";
|
||||
|
||||
const base = createConstantBackoff({ delay: 500 });
|
||||
|
||||
// Wrap with full jitter externally to spread retries under load
|
||||
const withJitter = (attempt: number) => applyJitter(base(attempt), "full");
|
||||
|
||||
await withRetry(sendEmail, {
|
||||
maxAttempts: 10,
|
||||
delay: withJitter,
|
||||
shouldRetry: (err) => isTransient(err),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createConstantBackoff(opts)`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `opts.delay` | `number` | Fixed delay in milliseconds returned for every attempt. |
|
||||
|
||||
**Returns:** `RetryStrategy` — `(attempt: number) => number`. The `attempt` argument is ignored.
|
||||
|
||||
**Throws:** Nothing.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The factory captures `delay` in a closure at construction time rather than reading from `opts` on each call, which avoids one property lookup per retry. The returned arrow function is a single expression with no branching, making it trivially inlineable by JIT compilers. For scenarios where the delay needs to vary per environment, the recommended pattern is to construct a new strategy at startup with the environment-configured value rather than mutating options at runtime.
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createConstantBackoff } from "./constant.js";
|
||||
|
||||
describe("createConstantBackoff", () => {
|
||||
it("returns the same delay for attempt 1", { timeout: 5000 }, () => {
|
||||
const strategy = createConstantBackoff({ delay: 100 });
|
||||
expect(strategy(1)).toBe(100);
|
||||
});
|
||||
|
||||
it("returns the same delay for every attempt", { timeout: 5000 }, () => {
|
||||
const strategy = createConstantBackoff({ delay: 250 });
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
expect(strategy(i)).toBe(250);
|
||||
}
|
||||
});
|
||||
|
||||
it("works with delay of 0", { timeout: 5000 }, () => {
|
||||
const strategy = createConstantBackoff({ delay: 0 });
|
||||
expect(strategy(1)).toBe(0);
|
||||
expect(strategy(5)).toBe(0);
|
||||
});
|
||||
|
||||
it("works with large delays", { timeout: 5000 }, () => {
|
||||
const strategy = createConstantBackoff({ delay: 60_000 });
|
||||
expect(strategy(100)).toBe(60_000);
|
||||
});
|
||||
|
||||
it("ignores attempt number", { timeout: 5000 }, () => {
|
||||
const strategy = createConstantBackoff({ delay: 500 });
|
||||
expect(strategy(1)).toBe(strategy(999));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import type {
|
||||
ConstantBackoffOptions,
|
||||
RetryStrategy,
|
||||
} from "../../types/retry.types.js";
|
||||
|
||||
/**
|
||||
* Creates a retry strategy that always returns the same fixed delay.
|
||||
*
|
||||
* @param opts - `{ delay }` — fixed delay in milliseconds.
|
||||
* @returns A `RetryStrategy` that ignores the attempt number.
|
||||
*/
|
||||
export function createConstantBackoff(
|
||||
opts: ConstantBackoffOptions
|
||||
): RetryStrategy {
|
||||
const { delay } = opts;
|
||||
return (_attempt: number): number => delay;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { createConstantBackoff } from "./constant";
|
||||
@@ -0,0 +1,92 @@
|
||||
# exponential
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides `createExponentialBackoff`, `applyJitter`, and `clampDelay` — the three core building blocks for exponential backoff with optional jitter. Exponential backoff is the industry-standard strategy for retrying failed calls against overloaded or degraded services: each subsequent delay doubles (or grows by configurable base), reducing collision probability under high concurrency far better than constant or linear strategies.
|
||||
|
||||
Jitter breaks the synchronization problem that arises when many clients retry at identical intervals, which can cause "thundering herd" effects that worsen rather than relieve overloaded services. This module supports four jitter modes aligned with the AWS/Google SRE recommendations: `full`, `equal`, `decorrelated`, and `none`.
|
||||
|
||||
## Features
|
||||
|
||||
- Configurable `initial` delay, `base` exponent (default 2), and `max` cap
|
||||
- Four jitter modes: `full`, `equal`, `decorrelated`, `none`
|
||||
- Pluggable `Rng` for deterministic testing — falls back to `Math.random()`
|
||||
- `applyJitter` usable independently on any delay value
|
||||
- `clampDelay` utility for explicit range enforcement
|
||||
- All functions are pure (no side effects beyond rng call)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { createExponentialBackoff, withRetry } from "@suaveplan/retry";
|
||||
|
||||
const strategy = createExponentialBackoff({ initial: 100, max: 30_000, jitter: "full" });
|
||||
|
||||
// attempt 1 → ~0–100ms, attempt 2 → ~0–200ms, etc.
|
||||
await withRetry(fetchQuote, { maxAttempts: 6, delay: strategy });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { createExponentialBackoff, applyJitter, clampDelay } from "@suaveplan/retry";
|
||||
|
||||
// Seeded RNG for deterministic tests
|
||||
const rng = { nextUint32: () => 2147483648, nextFloat: () => 0.5 };
|
||||
|
||||
const strategy = createExponentialBackoff(
|
||||
{ initial: 50, base: 3, max: 10_000, jitter: "equal" },
|
||||
rng,
|
||||
);
|
||||
|
||||
// Manual composition: clamp after jitter for extra safety
|
||||
const bounded = (attempt: number) => clampDelay(strategy(attempt), 50, 5_000);
|
||||
|
||||
await withRetry(writeRecord, {
|
||||
maxAttempts: 8,
|
||||
delay: bounded,
|
||||
shouldRetry: (err) => isTransient(err),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createExponentialBackoff(opts, rng?)`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `opts.initial` | `number` | Base delay for attempt 1 (ms). |
|
||||
| `opts.base` | `number?` | Exponent base. Default: `2`. |
|
||||
| `opts.max` | `number?` | Maximum delay cap (ms). |
|
||||
| `opts.jitter` | `JitterMode?` | Jitter mode. Default: `"none"`. |
|
||||
| `rng` | `Rng?` | Random source. Falls back to `Math.random()`. |
|
||||
|
||||
**Returns:** `RetryStrategy` — `(attempt: number) => number`.
|
||||
|
||||
### `applyJitter(delay, mode, rng?)`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `delay` | `number` | Base delay to apply jitter to (ms). |
|
||||
| `mode` | `JitterMode` | One of `"full"`, `"equal"`, `"decorrelated"`, `"none"`. |
|
||||
| `rng` | `Rng?` | Random source. Falls back to `Math.random()`. |
|
||||
|
||||
**Returns:** `number` — jitter-adjusted delay.
|
||||
|
||||
### `clampDelay(delay, min, max)`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `delay` | `number` | Unclamped delay (ms). |
|
||||
| `min` | `number` | Inclusive lower bound. |
|
||||
| `max` | `number` | Inclusive upper bound. |
|
||||
|
||||
**Returns:** `number` — `Math.max(min, Math.min(max, delay))`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `createExponentialBackoff` factory captures all configuration in a closure and calls `applyJitter` internally if a jitter mode other than `"none"` is configured. This ensures the correct `rng` is threaded through without callers needing to pass it on every call. The `"decorrelated"` jitter mode is a simplified variant that maps `delay` to `[delay, 2*delay]` — the classic AWS decorrelated formula uses `rand(delay/2, prev*3)` but requires external state; this simplified form achieves similar dispersion without statefulness. `clampDelay` is intentionally a standalone function rather than part of the strategy to keep concerns separated and allow explicit composition.
|
||||
@@ -0,0 +1,208 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import {
|
||||
applyJitter,
|
||||
clampDelay,
|
||||
createExponentialBackoff,
|
||||
} from "./exponential.js";
|
||||
|
||||
describe("createExponentialBackoff", () => {
|
||||
it("returns initial on attempt 1", { timeout: 5000 }, () => {
|
||||
const s = createExponentialBackoff({ initial: 100 });
|
||||
expect(s(1)).toBe(100);
|
||||
});
|
||||
|
||||
it("doubles each attempt with base=2", { timeout: 5000 }, () => {
|
||||
const s = createExponentialBackoff({ initial: 100, base: 2 });
|
||||
expect(s(1)).toBe(100);
|
||||
expect(s(2)).toBe(200);
|
||||
expect(s(3)).toBe(400);
|
||||
expect(s(4)).toBe(800);
|
||||
});
|
||||
|
||||
it("uses default base of 2", { timeout: 5000 }, () => {
|
||||
const s = createExponentialBackoff({ initial: 50 });
|
||||
expect(s(2)).toBe(100);
|
||||
expect(s(3)).toBe(200);
|
||||
});
|
||||
|
||||
it("caps at max", { timeout: 5000 }, () => {
|
||||
const s = createExponentialBackoff({ initial: 100, max: 300 });
|
||||
expect(s(1)).toBe(100);
|
||||
expect(s(2)).toBe(200);
|
||||
expect(s(3)).toBe(300);
|
||||
expect(s(4)).toBe(300);
|
||||
});
|
||||
|
||||
it("applies full jitter in [0, delay]", { timeout: 5000 }, () => {
|
||||
const rng = { nextUint32: () => 0, nextFloat: () => 0.5 };
|
||||
const s = createExponentialBackoff(
|
||||
{ initial: 100, jitter: "full" },
|
||||
rng
|
||||
);
|
||||
expect(s(1)).toBe(50); // 0.5 * 100
|
||||
});
|
||||
|
||||
it("applies none jitter (identity)", { timeout: 5000 }, () => {
|
||||
const s = createExponentialBackoff({ initial: 100, jitter: "none" });
|
||||
expect(s(1)).toBe(100);
|
||||
expect(s(2)).toBe(200);
|
||||
});
|
||||
|
||||
it("applies equal jitter in [delay/2, delay]", { timeout: 5000 }, () => {
|
||||
const rng = { nextUint32: () => 0, nextFloat: () => 0 };
|
||||
const s = createExponentialBackoff(
|
||||
{ initial: 100, jitter: "equal" },
|
||||
rng
|
||||
);
|
||||
expect(s(1)).toBe(50); // 100/2 + 0*(100/2)
|
||||
});
|
||||
|
||||
it("applies decorrelated jitter in [delay, 2*delay]", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const rng = { nextUint32: () => 0, nextFloat: () => 0 };
|
||||
const s = createExponentialBackoff(
|
||||
{ initial: 100, jitter: "decorrelated" },
|
||||
rng
|
||||
);
|
||||
expect(s(1)).toBe(100); // 100 * (1 + 0)
|
||||
});
|
||||
|
||||
it("uses Math.random when no rng provided for full jitter", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const s = createExponentialBackoff({ initial: 1000, jitter: "full" });
|
||||
const val = s(1);
|
||||
expect(val).toBeGreaterThanOrEqual(0);
|
||||
expect(val).toBeLessThanOrEqual(1000);
|
||||
});
|
||||
|
||||
it("property: no jitter delay(n+1) >= delay(n)", { timeout: 5000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 1, max: 50 }),
|
||||
fc.integer({ min: 1, max: 5 }),
|
||||
fc.integer({ min: 10, max: 100 }),
|
||||
(attempt, base, initial) => {
|
||||
const s = createExponentialBackoff({
|
||||
initial,
|
||||
base: base + 1,
|
||||
});
|
||||
return s(attempt + 1) >= s(attempt);
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyJitter", () => {
|
||||
it("full mode returns value in [0, delay]", { timeout: 5000 }, () => {
|
||||
const rng = { nextUint32: () => 0, nextFloat: () => 0.75 };
|
||||
expect(applyJitter(200, "full", rng)).toBe(150);
|
||||
});
|
||||
|
||||
it("full mode: 0 random => 0", { timeout: 5000 }, () => {
|
||||
const rng = { nextUint32: () => 0, nextFloat: () => 0 };
|
||||
expect(applyJitter(500, "full", rng)).toBe(0);
|
||||
});
|
||||
|
||||
it("full mode: 1 random => delay", { timeout: 5000 }, () => {
|
||||
const rng = { nextUint32: () => 0, nextFloat: () => 1 };
|
||||
expect(applyJitter(500, "full", rng)).toBe(500);
|
||||
});
|
||||
|
||||
it("equal mode returns value in [delay/2, delay]", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const rng0 = { nextUint32: () => 0, nextFloat: () => 0 };
|
||||
const rng1 = { nextUint32: () => 0, nextFloat: () => 1 };
|
||||
expect(applyJitter(200, "equal", rng0)).toBe(100);
|
||||
expect(applyJitter(200, "equal", rng1)).toBe(200);
|
||||
});
|
||||
|
||||
it("decorrelated mode returns value in [delay, 2*delay]", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const rng0 = { nextUint32: () => 0, nextFloat: () => 0 };
|
||||
const rng1 = { nextUint32: () => 0, nextFloat: () => 1 };
|
||||
expect(applyJitter(100, "decorrelated", rng0)).toBe(100);
|
||||
expect(applyJitter(100, "decorrelated", rng1)).toBe(200);
|
||||
});
|
||||
|
||||
it("none mode is identity", { timeout: 5000 }, () => {
|
||||
expect(applyJitter(123, "none")).toBe(123);
|
||||
expect(applyJitter(0, "none")).toBe(0);
|
||||
});
|
||||
|
||||
it("uses Math.random when no rng provided (full mode)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const val = applyJitter(1000, "full");
|
||||
expect(val).toBeGreaterThanOrEqual(0);
|
||||
expect(val).toBeLessThanOrEqual(1000);
|
||||
});
|
||||
|
||||
it("uses Math.random when no rng provided (equal mode)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const val = applyJitter(1000, "equal");
|
||||
expect(val).toBeGreaterThanOrEqual(500);
|
||||
expect(val).toBeLessThanOrEqual(1000);
|
||||
});
|
||||
|
||||
it("uses Math.random when no rng provided (decorrelated mode)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const val = applyJitter(1000, "decorrelated");
|
||||
expect(val).toBeGreaterThanOrEqual(1000);
|
||||
expect(val).toBeLessThanOrEqual(2000);
|
||||
});
|
||||
|
||||
it("property: full jitter always in [0, delay]", { timeout: 5000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.float({ min: 0, max: 10_000, noNaN: true }),
|
||||
fc.float({ min: 0, max: 1, noNaN: true }),
|
||||
(delay, rand) => {
|
||||
const rng = { nextUint32: () => 0, nextFloat: () => rand };
|
||||
const result = applyJitter(delay, "full", rng);
|
||||
return result >= 0 && result <= delay + 0.001;
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampDelay", () => {
|
||||
it("returns delay when within bounds", { timeout: 5000 }, () => {
|
||||
expect(clampDelay(500, 100, 1000)).toBe(500);
|
||||
});
|
||||
|
||||
it("returns min when below min", { timeout: 5000 }, () => {
|
||||
expect(clampDelay(50, 100, 1000)).toBe(100);
|
||||
});
|
||||
|
||||
it("returns max when above max", { timeout: 5000 }, () => {
|
||||
expect(clampDelay(5000, 100, 1000)).toBe(1000);
|
||||
});
|
||||
|
||||
it("handles min === max", { timeout: 5000 }, () => {
|
||||
expect(clampDelay(500, 250, 250)).toBe(250);
|
||||
expect(clampDelay(0, 250, 250)).toBe(250);
|
||||
});
|
||||
|
||||
it("property: always in [min, max]", { timeout: 5000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.float({ min: 0, max: 10_000, noNaN: true }),
|
||||
fc.float({ min: 0, max: 5_000, noNaN: true }),
|
||||
fc.float({ min: 5_000, max: 10_000, noNaN: true }),
|
||||
(delay, min, max) => {
|
||||
const result = clampDelay(delay, min, max);
|
||||
return result >= min && result <= max;
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import type {
|
||||
ExponentialBackoffOptions,
|
||||
JitterMode,
|
||||
RetryStrategy,
|
||||
} from "../../types/retry.types.js";
|
||||
|
||||
/**
|
||||
* Creates a retry strategy with exponentially increasing delay.
|
||||
*
|
||||
* `baseDelay(attempt) = initial * base^(attempt-1)`, capped at `max`.
|
||||
* Jitter (if configured) is applied via {@link applyJitter} internally.
|
||||
*
|
||||
* @param opts - Exponential backoff options.
|
||||
* @param rng - Optional Rng for jitter. If omitted and jitter is enabled,
|
||||
* `Math.random()` is used as a fallback.
|
||||
* @returns A `RetryStrategy` function.
|
||||
*/
|
||||
export function createExponentialBackoff(
|
||||
opts: ExponentialBackoffOptions,
|
||||
rng?: Rng
|
||||
): RetryStrategy {
|
||||
const base = opts.base ?? 2;
|
||||
const jitter = opts.jitter ?? "none";
|
||||
const { initial, max } = opts;
|
||||
|
||||
return (attempt: number): number => {
|
||||
const raw = initial * base ** (attempt - 1);
|
||||
const clamped = max !== undefined ? Math.min(raw, max) : raw;
|
||||
return applyJitter(clamped, jitter, rng);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies jitter to a computed delay.
|
||||
*
|
||||
* | Mode | Output range |
|
||||
* |------|-------------|
|
||||
* | `"full"` | `[0, delay]` — full random scatter |
|
||||
* | `"equal"` | `[delay/2, delay]` — equal jitter, preserves minimum half |
|
||||
* | `"decorrelated"` | `[delay, delay * 2]` — amplified scatter |
|
||||
* | `"none"` | `delay` unchanged |
|
||||
*
|
||||
* @param delay - The base delay in milliseconds.
|
||||
* @param mode - Jitter mode to apply.
|
||||
* @param rng - Optional Rng source. Falls back to `Math.random()` when absent.
|
||||
* @returns The jitter-adjusted delay.
|
||||
*/
|
||||
export function applyJitter(
|
||||
delay: number,
|
||||
mode: JitterMode,
|
||||
rng?: Rng
|
||||
): number {
|
||||
const random = rng ? rng.nextFloat() : Math.random();
|
||||
switch (mode) {
|
||||
case "full":
|
||||
return random * delay;
|
||||
case "equal":
|
||||
return delay / 2 + random * (delay / 2);
|
||||
case "decorrelated":
|
||||
return delay * (1 + random);
|
||||
case "none":
|
||||
return delay;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamps a delay value to `[min, max]`.
|
||||
*
|
||||
* @param delay - Unclamped delay (ms).
|
||||
* @param min - Lower bound (inclusive).
|
||||
* @param max - Upper bound (inclusive).
|
||||
* @returns Clamped delay.
|
||||
*/
|
||||
export function clampDelay(delay: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, delay));
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
applyJitter,
|
||||
clampDelay,
|
||||
createExponentialBackoff,
|
||||
} from "./exponential";
|
||||
7
packages/foundation/flow/retry/src/strategies/index.ts
Normal file
7
packages/foundation/flow/retry/src/strategies/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { createConstantBackoff } from "./constant";
|
||||
export {
|
||||
applyJitter,
|
||||
clampDelay,
|
||||
createExponentialBackoff,
|
||||
} from "./exponential";
|
||||
export { createLinearBackoff } from "./linear";
|
||||
@@ -0,0 +1 @@
|
||||
export { createLinearBackoff } from "./linear";
|
||||
@@ -0,0 +1,67 @@
|
||||
# linear
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides `createLinearBackoff`, a factory that creates a retry strategy with delays that increase linearly by a fixed increment for each subsequent attempt. Linear backoff is a good middle ground between constant backoff (no increase) and exponential backoff (rapid growth). It is particularly appropriate when the underlying resource is recovering at a predictable rate, such as a downstream service restarting after a deployment, where you expect recovery within a bounded window.
|
||||
|
||||
The formula is `delay(attempt) = initial + (attempt - 1) * increment`, optionally capped at `max`. Attempt numbers are 1-based: attempt 1 returns `initial`, attempt 2 returns `initial + increment`, and so on.
|
||||
|
||||
## Features
|
||||
|
||||
- Configurable `initial` delay (ms) — delay applied on attempt 1
|
||||
- Configurable `increment` (ms) — added per retry step
|
||||
- Optional `max` cap — prevents unbounded delay growth
|
||||
- No randomness — deterministic delays for predictable retry budgets
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { createLinearBackoff, withRetry } from "@suaveplan/retry";
|
||||
|
||||
const strategy = createLinearBackoff({ initial: 100, increment: 50, max: 500 });
|
||||
|
||||
// attempt 1 → 100ms, attempt 2 → 150ms, attempt 3 → 200ms, ... capped at 500ms
|
||||
const result = await withRetry(fetchUser, {
|
||||
maxAttempts: 5,
|
||||
delay: strategy,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { createLinearBackoff, applyJitter, withRetry } from "@suaveplan/retry";
|
||||
|
||||
const base = createLinearBackoff({ initial: 200, increment: 100 });
|
||||
|
||||
// Add equal jitter to spread load during recoveries
|
||||
const jittered = (attempt: number) => applyJitter(base(attempt), "equal");
|
||||
|
||||
await withRetry(processJob, {
|
||||
maxAttempts: 8,
|
||||
delay: jittered,
|
||||
shouldRetry: (err, attempt) => attempt < 4 || isRetriableError(err),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createLinearBackoff(opts)`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `opts.initial` | `number` | Delay for attempt 1 (ms). |
|
||||
| `opts.increment` | `number` | Amount added to delay per retry step (ms). |
|
||||
| `opts.max` | `number?` | Optional upper bound on delay (ms). |
|
||||
|
||||
**Returns:** `RetryStrategy` — `(attempt: number) => number`.
|
||||
|
||||
**Throws:** Nothing.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The closure captures `initial`, `increment`, and `max` at construction time for efficiency. The `max` cap uses `Math.min` so the comparison only runs when `max` is defined — the factory uses `opts.max !== undefined` rather than a truthiness check to correctly handle `max: 0`. For values where `increment` is 0, the strategy degenerates to constant backoff. Callers who need monotonically increasing delays should ensure `increment > 0`.
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createLinearBackoff } from "./linear.js";
|
||||
|
||||
describe("createLinearBackoff", () => {
|
||||
it("returns initial on attempt 1", { timeout: 5000 }, () => {
|
||||
const s = createLinearBackoff({ initial: 100, increment: 50 });
|
||||
expect(s(1)).toBe(100);
|
||||
});
|
||||
|
||||
it("increments linearly", { timeout: 5000 }, () => {
|
||||
const s = createLinearBackoff({ initial: 100, increment: 50 });
|
||||
expect(s(1)).toBe(100);
|
||||
expect(s(2)).toBe(150);
|
||||
expect(s(3)).toBe(200);
|
||||
expect(s(4)).toBe(250);
|
||||
});
|
||||
|
||||
it("caps at max", { timeout: 5000 }, () => {
|
||||
const s = createLinearBackoff({
|
||||
initial: 100,
|
||||
increment: 100,
|
||||
max: 250,
|
||||
});
|
||||
expect(s(1)).toBe(100);
|
||||
expect(s(2)).toBe(200);
|
||||
expect(s(3)).toBe(250);
|
||||
expect(s(4)).toBe(250);
|
||||
});
|
||||
|
||||
it("no max — grows unbounded", { timeout: 5000 }, () => {
|
||||
const s = createLinearBackoff({ initial: 0, increment: 10 });
|
||||
expect(s(10)).toBe(90);
|
||||
expect(s(100)).toBe(990);
|
||||
});
|
||||
|
||||
it("increment of 0 is constant", { timeout: 5000 }, () => {
|
||||
const s = createLinearBackoff({ initial: 200, increment: 0 });
|
||||
expect(s(1)).toBe(200);
|
||||
expect(s(5)).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type {
|
||||
LinearBackoffOptions,
|
||||
RetryStrategy,
|
||||
} from "../../types/retry.types.js";
|
||||
|
||||
/**
|
||||
* Creates a retry strategy with linearly increasing delay.
|
||||
*
|
||||
* `delay(attempt) = initial + (attempt - 1) * increment`, capped at `max`
|
||||
* if provided.
|
||||
*
|
||||
* @param opts - Linear backoff options.
|
||||
* @returns A `RetryStrategy` function.
|
||||
*/
|
||||
export function createLinearBackoff(opts: LinearBackoffOptions): RetryStrategy {
|
||||
const { initial, increment, max } = opts;
|
||||
return (attempt: number): number => {
|
||||
const delay = initial + (attempt - 1) * increment;
|
||||
return max !== undefined ? Math.min(delay, max) : delay;
|
||||
};
|
||||
}
|
||||
17
packages/foundation/flow/retry/src/types/index.ts
Normal file
17
packages/foundation/flow/retry/src/types/index.ts
Normal 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";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
32
packages/foundation/flow/retry/src/types/retry.schemas.ts
Normal file
32
packages/foundation/flow/retry/src/types/retry.schemas.ts
Normal 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(),
|
||||
});
|
||||
75
packages/foundation/flow/retry/src/types/retry.types.md
Normal file
75
packages/foundation/flow/retry/src/types/retry.types.md
Normal 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.
|
||||
82
packages/foundation/flow/retry/src/types/retry.types.ts
Normal file
82
packages/foundation/flow/retry/src/types/retry.types.ts
Normal 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;
|
||||
}
|
||||
28
packages/foundation/flow/retry/tests/dep-graph.test.ts
Normal file
28
packages/foundation/flow/retry/tests/dep-graph.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
38
packages/foundation/flow/retry/tests/no-disallowed.test.ts
Normal file
38
packages/foundation/flow/retry/tests/no-disallowed.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
8
packages/foundation/flow/retry/tests/setup/polyfills.ts
Normal file
8
packages/foundation/flow/retry/tests/setup/polyfills.ts
Normal 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;
|
||||
}
|
||||
12
packages/foundation/flow/retry/tests/tree-shake.test.ts
Normal file
12
packages/foundation/flow/retry/tests/tree-shake.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
7
packages/foundation/flow/retry/tsconfig.lib.json
Normal file
7
packages/foundation/flow/retry/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
38
packages/foundation/flow/retry/vite.config.ts
Normal file
38
packages/foundation/flow/retry/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
37
packages/foundation/flow/retry/vitest.config.ts
Normal file
37
packages/foundation/flow/retry/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user