@git-stunts/alfred
Advanced tools
+24
-0
@@ -8,2 +8,26 @@ # Changelog | ||
| ## [0.6.0] - 2026-02-02 | ||
| ### Added | ||
| - **Browser Support**: Alfred now officially supports modern browsers (Chrome 85+, Firefox 79+, Safari 14+, Edge 85+). | ||
| - **Browser Demo**: Interactive "Flaky Fetch Lab" (`npm run demo:web`) demonstrates resilience policies running in a browser. | ||
| - **Playwright Tests**: Browser compatibility tests verify retry, timeout, bulkhead, and circuit breaker work in Chromium. | ||
| - **Resolution Timing Documentation**: New README section documenting when dynamic options (functions) are resolved for each policy—per attempt, per admission, per event, or per execute. | ||
| - **Resolution Timing Tests**: Comprehensive test suite verifying option resolution timing with call counters. | ||
| - **Hedge Safety Guardrails**: Documentation for safe hedge usage—idempotent operations only, AbortSignal handling, bulkhead composition. | ||
| - **Hedge Recipes**: `hedgeRead` pattern for database/cache operations, `happyEyeballsFetch` for multi-endpoint racing. | ||
| - **Full JSDoc Coverage**: All source files and TypeScript declarations now have complete documentation. | ||
| ### Fixed | ||
| - **JSR Module Docs**: Entrypoints now use `@module` tag with examples for proper JSR documentation. | ||
| - **JSR Publish Config**: Fixed exclude list to only publish required files (was including examples, scripts, etc.). | ||
| - **TypeScript Declarations**: `testing.d.ts` updated with missing types (`HedgeOptions`, `MetricsSink`, `Resolvable`, `Policy.hedge`). | ||
| - **Example in index.d.ts**: Fixed incorrect compose example to use proper Policy fluent API. | ||
| ### Changed | ||
| - **ROADMAP.md**: v0.5 and v0.6 milestones marked complete. | ||
| ## [0.5.0] - 2026-02-02 | ||
@@ -10,0 +34,0 @@ |
+12
-9
| { | ||
| "name": "@git-stunts/alfred", | ||
| "version": "0.5.0", | ||
| "version": "0.6.0", | ||
| "description": "Production-grade resilience patterns for async ops: retry/backoff+jitter, circuit breaker, bulkhead, timeout.", | ||
@@ -13,13 +13,16 @@ | ||
| "exclude": [ | ||
| "!src/**/*.js", | ||
| "!index.d.ts", | ||
| "!README.md", | ||
| "!LICENSE", | ||
| "!CHANGELOG.md", | ||
| ".github", | ||
| ".devcontainer", | ||
| "docker", | ||
| "examples", | ||
| "scripts", | ||
| "test", | ||
| "node_modules", | ||
| "test", | ||
| "docker", | ||
| ".devcontainer" | ||
| ".editorconfig", | ||
| "eslint.config.js", | ||
| "package-lock.json", | ||
| "ROADMAP.md", | ||
| "setup-hooks.sh" | ||
| ] | ||
| } | ||
| } |
+7
-3
| { | ||
| "name": "@git-stunts/alfred", | ||
| "version": "0.5.0", | ||
| "version": "0.6.0", | ||
| "description": "Production-grade resilience patterns for async ops: retry/backoff+jitter, circuit breaker, bulkhead, timeout.", | ||
@@ -26,4 +26,7 @@ "type": "module", | ||
| "test:watch": "vitest", | ||
| "test:browser": "npm --prefix examples/web run test", | ||
| "lint": "eslint .", | ||
| "format": "prettier --write ." | ||
| "format": "prettier --write .", | ||
| "demo:web": "npm --prefix examples/web run dev", | ||
| "demo:web:install": "npm --prefix examples/web install" | ||
| }, | ||
@@ -57,3 +60,4 @@ "author": "James Ross <james@flyingrobots.dev>", | ||
| "fault-tolerance", | ||
| "async" | ||
| "async", | ||
| "browser" | ||
| ], | ||
@@ -60,0 +64,0 @@ "devDependencies": { |
+159
-1
@@ -86,5 +86,20 @@ # @git-stunts/alfred | ||
| - **Deno** (>= 1.35) | ||
| - **Browsers** (Chrome 85+, Firefox 79+, Safari 14+, Edge 85+) | ||
| Uses standard Web APIs (AbortController, AbortSignal) and runtime-aware clock management to ensure clean process exits (e.g. timer unref where applicable). | ||
| Uses standard Web APIs (AbortController, AbortSignal, Promise.any) with no Node-specific dependencies. Runtime-aware clock management ensures clean process exits in server environments. | ||
| ### Browser Demo | ||
| Run the interactive "Flaky Fetch Lab" to see resilience policies in action: | ||
| ```bash | ||
| npm run demo:web:install && npm run demo:web | ||
| ``` | ||
| Or run the Playwright browser tests: | ||
| ```bash | ||
| npm run demo:web:install && npm run test:browser | ||
| ``` | ||
| --- | ||
@@ -234,2 +249,3 @@ | ||
| - [Error Types](#error-types) | ||
| - [Resolution Timing](#resolution-timing-dynamic-options) | ||
@@ -411,2 +427,86 @@ --- | ||
| ### Safety Guardrails | ||
| > **Warning:** Hedging spawns parallel requests. Use responsibly to avoid overloading backends. | ||
| 1. **Only hedge idempotent operations.** Reads, lookups, and GET requests are safe. Writes, payments, and state mutations are not — you may end up with duplicate side effects. | ||
| 2. **Always use AbortSignal.** Your operation receives an `AbortSignal` that fires when a faster hedge wins. Honor it to cancel in-flight work (fetch, database queries, etc.). | ||
| 3. **Combine with bulkhead + circuit breaker.** Prevent self-DDOS and cascading failures: | ||
| ```javascript | ||
| import { Policy } from '@git-stunts/alfred'; | ||
| // Safe hedging: bulkhead limits total concurrency, circuit breaker fails fast | ||
| const safeHedge = Policy.hedge({ delay: 100, maxHedges: 2 }) | ||
| .wrap(Policy.bulkhead({ limit: 10 })) | ||
| .wrap(Policy.circuitBreaker({ threshold: 5, duration: 30_000 })); | ||
| await safeHedge.execute((signal) => fetch(url, { signal })); | ||
| ``` | ||
| 4. **Set reasonable delays.** The `delay` should be based on your P50/P90 latency. Too short = excessive load. Too long = no benefit. | ||
| ### Recipe: hedgeRead (Read-Only Operations) | ||
| A reusable pattern for hedging database reads or cache lookups: | ||
| ```javascript | ||
| import { Policy } from '@git-stunts/alfred'; | ||
| function createHedgedReader(options = {}) { | ||
| const { delay = 50, maxHedges = 1, concurrencyLimit = 5 } = options; | ||
| return Policy.hedge({ delay, maxHedges }).wrap(Policy.bulkhead({ limit: concurrencyLimit })); | ||
| } | ||
| const hedgedRead = createHedgedReader({ delay: 50, maxHedges: 1 }); | ||
| // Use for any read-only operation | ||
| const user = await hedgedRead.execute((signal) => db.users.findById(id, { signal })); | ||
| const cached = await hedgedRead.execute((signal) => cache.get(key, { signal })); | ||
| ``` | ||
| ### Recipe: Happy Eyeballs (Parallel Endpoints) | ||
| Race requests to multiple endpoints (e.g., IPv4 vs IPv6, primary vs replica): | ||
| ```javascript | ||
| import { Policy } from '@git-stunts/alfred'; | ||
| async function happyEyeballsFetch(urls, options = {}) { | ||
| const { delay = 50 } = options; | ||
| // Create a hedge policy that spawns one hedge per additional URL | ||
| const racer = Policy.hedge({ delay, maxHedges: urls.length - 1 }); | ||
| let urlIndex = 0; | ||
| return racer.execute((signal) => { | ||
| const url = urls[urlIndex++ % urls.length]; | ||
| return fetch(url, { signal }).then((res) => { | ||
| if (!res.ok) throw new Error(`HTTP ${res.status}`); | ||
| return res; | ||
| }); | ||
| }); | ||
| } | ||
| // First successful response wins | ||
| const response = await happyEyeballsFetch([ | ||
| 'https://api-primary.example.com/data', | ||
| 'https://api-replica.example.com/data', | ||
| ]); | ||
| ``` | ||
| ### Runtime Requirements | ||
| Hedge uses `Promise.any()` internally. This is available in: | ||
| - Node.js >= 15.0.0 | ||
| - Deno >= 1.2 | ||
| - Bun >= 1.0 | ||
| - Modern browsers (Chrome 85+, Firefox 79+, Safari 14+) | ||
| For older runtimes, use a polyfill like [core-js](https://github.com/zloirock/core-js#promiseany) or [promise.any](https://www.npmjs.com/package/promise.any). | ||
| --- | ||
@@ -667,2 +767,60 @@ | ||
| ## Resolution Timing (Dynamic Options) | ||
| All policy options can be passed as **functions** for dynamic/live-tunable behavior. This table documents **when** each option is resolved: | ||
| | Policy | Option | Resolution Timing | Description | | ||
| | ------------------ | ------------------ | ----------------- | --------------------------------------------- | | ||
| | **retry** | `retries` | per attempt | Checked after each failure | | ||
| | **retry** | `delay` | per attempt | Calculated before each backoff sleep | | ||
| | **retry** | `maxDelay` | per attempt | Applied when calculating delay | | ||
| | **retry** | `backoff` | per attempt | Strategy resolved per delay calculation | | ||
| | **retry** | `jitter` | per attempt | Jitter type resolved per delay calculation | | ||
| | **bulkhead** | `limit` | per admission | Checked when request tries to execute | | ||
| | **bulkhead** | `queueLimit` | per admission | Checked when request tries to queue | | ||
| | **circuitBreaker** | `threshold` | per event | Checked on each failure | | ||
| | **circuitBreaker** | `duration` | per event | Checked when testing for half-open transition | | ||
| | **circuitBreaker** | `successThreshold` | per event | Checked on each success in half-open state | | ||
| | **timeout** | `ms` | per execute | Resolved once at start of timeout | | ||
| | **hedge** | `delay` | per execute | Resolved once at start of execute | | ||
| | **hedge** | `maxHedges` | per execute | Resolved once at start of execute | | ||
| ### Resolution Timing Semantics | ||
| - **per execute**: Option is resolved once when `execute()` is called. Changes during execution have no effect. | ||
| - **per attempt**: Option is resolved each time an attempt/retry occurs. Allows mid-execution tuning. | ||
| - **per admission**: Option is resolved each time a request attempts to enter the bulkhead. | ||
| - **per event**: Option is resolved when the relevant event (failure, success, state check) occurs. | ||
| ### Example: Dynamic Retry Limit | ||
| ```javascript | ||
| let maxRetries = 2; | ||
| // Pass a function to make it dynamic | ||
| await retry(operation, { | ||
| retries: () => maxRetries, // Resolved per attempt | ||
| delay: 100, | ||
| }); | ||
| // In another part of your code, you can adjust: | ||
| maxRetries = 5; // Future failures will see the new limit | ||
| ``` | ||
| ### Example: Dynamic Bulkhead Limit | ||
| ```javascript | ||
| let concurrencyLimit = 10; | ||
| const bh = bulkhead({ | ||
| limit: () => concurrencyLimit, // Resolved per admission | ||
| queueLimit: 20, | ||
| }); | ||
| // Later, reduce concurrency (takes effect on next admission) | ||
| concurrencyLimit = 5; | ||
| ``` | ||
| --- | ||
| ## License | ||
@@ -669,0 +827,0 @@ |
+2
-2
@@ -29,4 +29,4 @@ <!-- SPDX-License-Identifier: Apache-2.0 --> | ||
| - [ ] v0.5 — Correctness & Coherence | ||
| - [ ] v0.6 — Typed Knobs & Stable Semantics | ||
| - [x] v0.5 — Correctness & Coherence | ||
| - [x] v0.6 — Typed Knobs & Stable Semantics | ||
| - [ ] v0.7 — Rate Limiting (Throughput) Policy | ||
@@ -33,0 +33,0 @@ - [ ] v0.8 — Control Plane Core (In-Memory) |
+9
-0
| /** | ||
| * @fileoverview Custom error types for resilience policy failures. | ||
| * | ||
| * Each error includes contextual metadata to aid debugging and | ||
| * error handling in application code. | ||
| * | ||
| * @module @git-stunts/alfred/errors | ||
| */ | ||
| /** | ||
| * Error thrown when all retry attempts are exhausted. | ||
@@ -3,0 +12,0 @@ */ |
+28
-9
| /** | ||
| * @module @git-stunts/alfred | ||
| * @description Production-grade resilience patterns for async operations. | ||
| * Includes Retry, Circuit Breaker, Timeout, and Bulkhead policies. | ||
| * Includes Retry, Circuit Breaker, Timeout, Bulkhead, and Hedge policies. | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * import { compose, retry, circuitBreaker, timeout } from "@git-stunts/alfred"; | ||
| * import { Policy } from "@git-stunts/alfred"; | ||
| * | ||
| * const policy = compose( | ||
| * retry({ retries: 3 }), | ||
| * circuitBreaker({ threshold: 5, duration: 60000 }), | ||
| * timeout(5000) | ||
| * ); | ||
| * const resilient = Policy.timeout(5_000) | ||
| * .wrap(Policy.retry({ retries: 3, backoff: "exponential" })) | ||
| * .wrap(Policy.circuitBreaker({ threshold: 5, duration: 60_000 })) | ||
| * .wrap(Policy.bulkhead({ limit: 10 })); | ||
| * | ||
| * await policy.execute(() => fetch("https://api.example.com")); | ||
| * const data = await resilient.execute(() => fetch("https://api.example.com")); | ||
| * ``` | ||
@@ -167,3 +166,3 @@ */ | ||
| export class MultiSink implements TelemetrySink { | ||
| constructor(sinks: TelemetrySink[]); | ||
| constructor(sinks?: TelemetrySink[]); | ||
| emit(event: TelemetryEvent): void; | ||
@@ -369,12 +368,32 @@ } | ||
| /** | ||
| * System clock using real time. | ||
| * Uses runtime-aware timer management for clean process exits. | ||
| */ | ||
| export class SystemClock { | ||
| /** Returns current time in milliseconds since Unix epoch. */ | ||
| now(): number; | ||
| /** Sleeps for the specified duration. */ | ||
| sleep(ms: number): Promise<void>; | ||
| } | ||
| /** | ||
| * Test clock for deterministic tests. | ||
| * Allows manual control of time progression without real delays. | ||
| */ | ||
| export class TestClock { | ||
| /** Returns current virtual time in milliseconds. */ | ||
| now(): number; | ||
| /** Creates a sleep promise that resolves when time is advanced. */ | ||
| sleep(ms: number): Promise<void>; | ||
| /** Process any timers ready at current time. */ | ||
| tick(ms?: number): Promise<void>; | ||
| /** Advances time and resolves any pending timers. */ | ||
| advance(ms: number): Promise<void>; | ||
| /** Sets absolute time. */ | ||
| setTime(time: number): void; | ||
| /** Returns number of pending timers. */ | ||
| readonly pendingCount: number; | ||
| /** Clears all pending timers and resets time to 0. */ | ||
| reset(): void; | ||
| } |
+19
-2
| /** | ||
| * @fileoverview Main entry point for @git-stunts/alfred resilience library. | ||
| * Exports all public APIs for building resilient applications. | ||
| * Production-grade resilience patterns for async operations. | ||
| * | ||
| * Alfred provides composable policies for retry, circuit breaker, bulkhead, | ||
| * timeout, and hedge patterns with telemetry support and TestClock for | ||
| * deterministic testing. | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * import { Policy } from "@git-stunts/alfred"; | ||
| * | ||
| * const resilient = Policy.timeout(5_000) | ||
| * .wrap(Policy.retry({ retries: 3, backoff: "exponential" })) | ||
| * .wrap(Policy.circuitBreaker({ threshold: 5, duration: 60_000 })) | ||
| * .wrap(Policy.bulkhead({ limit: 10 })); | ||
| * | ||
| * const data = await resilient.execute(() => fetch("https://api.example.com")); | ||
| * ``` | ||
| * | ||
| * @module | ||
| */ | ||
@@ -5,0 +22,0 @@ |
@@ -0,1 +1,11 @@ | ||
| /** | ||
| * @fileoverview Circuit breaker policy for fail-fast behavior. | ||
| * | ||
| * Prevents cascading failures by tracking error rates and "opening" | ||
| * the circuit when a threshold is exceeded. Supports half-open state | ||
| * for automatic recovery testing. | ||
| * | ||
| * @module @git-stunts/alfred/policies/circuit-breaker | ||
| */ | ||
| import { CircuitOpenError } from '../errors.js'; | ||
@@ -2,0 +12,0 @@ import { SystemClock } from '../utils/clock.js'; |
+10
-0
| /** | ||
| * @fileoverview Fluent Policy API for composing resilience strategies. | ||
| * | ||
| * The Policy class provides a chainable interface for building complex | ||
| * resilience stacks using wrap (sequential), or (fallback), and race | ||
| * (concurrent) composition operators. | ||
| * | ||
| * @module @git-stunts/alfred/policy | ||
| */ | ||
| /** | ||
| * @typedef {(fn: () => Promise<T>) => Promise<T>} Executor | ||
@@ -3,0 +13,0 @@ * @template T |
+29
-4
| /** | ||
| * @fileoverview Telemetry system for observing resilience policy behavior. | ||
| * Provides composable sinks for capturing events. | ||
| * Telemetry system for observing resilience policy behavior. | ||
| * | ||
| * Provides composable sinks for capturing, logging, and aggregating | ||
| * events emitted by resilience policies. | ||
| * | ||
| * @module | ||
| */ | ||
@@ -25,5 +29,10 @@ | ||
| constructor() { | ||
| /** @type {TelemetryEvent[]} */ | ||
| this.events = []; | ||
| } | ||
| /** | ||
| * Records an event to the in-memory array. | ||
| * @param {TelemetryEvent} event | ||
| */ | ||
| emit(event) { | ||
@@ -33,2 +42,5 @@ this.events.push(event); | ||
| /** | ||
| * Clears all recorded events. | ||
| */ | ||
| clear() { | ||
@@ -44,2 +56,6 @@ this.events = []; | ||
| export class ConsoleSink { | ||
| /** | ||
| * Logs an event to the console. | ||
| * @param {TelemetryEvent} event | ||
| */ | ||
| emit(event) { | ||
@@ -53,6 +69,10 @@ const { type, timestamp = Date.now(), ...rest } = event; | ||
| /** | ||
| * Sink that does nothing. Default. | ||
| * Sink that does nothing. Default when telemetry is not needed. | ||
| * @implements {TelemetrySink} | ||
| */ | ||
| export class NoopSink { | ||
| /** | ||
| * Discards the event (no-op). | ||
| * @param {TelemetryEvent} _event | ||
| */ | ||
| emit(_event) { | ||
@@ -69,3 +89,4 @@ // No-op | ||
| /** | ||
| * @param {TelemetrySink[]} sinks | ||
| * Creates a MultiSink that broadcasts to all provided sinks. | ||
| * @param {TelemetrySink[]} sinks - Array of sinks to broadcast to. | ||
| */ | ||
@@ -76,2 +97,6 @@ constructor(sinks = []) { | ||
| /** | ||
| * Broadcasts an event to all child sinks. | ||
| * @param {TelemetryEvent} event | ||
| */ | ||
| emit(event) { | ||
@@ -78,0 +103,0 @@ for (const sink of this.sinks) { |
+287
-16
@@ -0,70 +1,225 @@ | ||
| /** | ||
| * @module @git-stunts/alfred/testing | ||
| * @description Testing utilities for Alfred resilience policies. | ||
| * | ||
| * Provides TestClock for deterministic time control and re-exports | ||
| * telemetry sinks useful for test assertions. | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * import { TestClock, InMemorySink } from "@git-stunts/alfred/testing"; | ||
| * import { retry } from "@git-stunts/alfred"; | ||
| * | ||
| * const clock = new TestClock(); | ||
| * const sink = new InMemorySink(); | ||
| * | ||
| * const promise = retry(() => mightFail(), { | ||
| * retries: 3, | ||
| * delay: 1000, | ||
| * clock, | ||
| * telemetry: sink, | ||
| * }); | ||
| * | ||
| * await clock.advance(1000); // Advance virtual time | ||
| * expect(sink.events).toContainEqual(expect.objectContaining({ type: "retry.scheduled" })); | ||
| * ``` | ||
| */ | ||
| /** | ||
| * A value that can be either static or resolved dynamically via a function. | ||
| * Enables live-tunable policy options. | ||
| */ | ||
| export type Resolvable<T> = T | (() => T); | ||
| /** | ||
| * Options for the Retry policy. | ||
| */ | ||
| export interface RetryOptions { | ||
| retries?: number; | ||
| delay?: number; | ||
| maxDelay?: number; | ||
| backoff?: 'constant' | 'linear' | 'exponential'; | ||
| jitter?: 'none' | 'full' | 'equal' | 'decorrelated'; | ||
| /** Maximum number of retry attempts. Default: 3 */ | ||
| retries?: Resolvable<number>; | ||
| /** Base delay in milliseconds. Default: 1000 */ | ||
| delay?: Resolvable<number>; | ||
| /** Maximum delay cap in milliseconds. Default: 30000 */ | ||
| maxDelay?: Resolvable<number>; | ||
| /** Backoff strategy. Default: 'constant' */ | ||
| backoff?: Resolvable<'constant' | 'linear' | 'exponential'>; | ||
| /** Jitter strategy to prevent thundering herd. Default: 'none' */ | ||
| jitter?: Resolvable<'none' | 'full' | 'equal' | 'decorrelated'>; | ||
| /** Predicate to determine if an error is retryable. Default: always true */ | ||
| shouldRetry?: (error: Error) => boolean; | ||
| /** Callback invoked before each retry. */ | ||
| onRetry?: (error: Error, attempt: number, delay: number) => void; | ||
| /** Telemetry sink for observability. */ | ||
| telemetry?: TelemetrySink; | ||
| clock?: any; | ||
| /** Clock implementation for testing. */ | ||
| clock?: TestClock | SystemClock; | ||
| /** Abort signal to cancel retries. */ | ||
| signal?: AbortSignal; | ||
| } | ||
| /** | ||
| * Options for the Circuit Breaker policy. | ||
| */ | ||
| export interface CircuitBreakerOptions { | ||
| threshold: number; | ||
| duration: number; | ||
| successThreshold?: number; | ||
| /** Number of failures before opening the circuit. */ | ||
| threshold: Resolvable<number>; | ||
| /** Milliseconds to stay open before transitioning to half-open. */ | ||
| duration: Resolvable<number>; | ||
| /** Consecutive successes required to close the circuit from half-open. Default: 1 */ | ||
| successThreshold?: Resolvable<number>; | ||
| /** Predicate to determine if an error counts as a failure. Default: always true */ | ||
| shouldTrip?: (error: Error) => boolean; | ||
| /** Callback when circuit opens. */ | ||
| onOpen?: () => void; | ||
| /** Callback when circuit closes. */ | ||
| onClose?: () => void; | ||
| /** Callback when circuit transitions to half-open. */ | ||
| onHalfOpen?: () => void; | ||
| /** Telemetry sink for observability. */ | ||
| telemetry?: TelemetrySink; | ||
| clock?: any; | ||
| /** Clock implementation for testing. */ | ||
| clock?: TestClock | SystemClock; | ||
| } | ||
| /** | ||
| * Options for the Timeout policy. | ||
| */ | ||
| export interface TimeoutOptions { | ||
| /** Callback invoked when timeout occurs. */ | ||
| onTimeout?: (elapsed: number) => void; | ||
| /** Telemetry sink for observability. */ | ||
| telemetry?: TelemetrySink; | ||
| /** Clock implementation for testing. */ | ||
| clock?: TestClock | SystemClock; | ||
| } | ||
| /** | ||
| * Options for the Bulkhead policy. | ||
| */ | ||
| export interface BulkheadOptions { | ||
| limit: number; | ||
| queueLimit?: number; | ||
| /** Maximum concurrent executions. */ | ||
| limit: Resolvable<number>; | ||
| /** Maximum pending requests in queue. Default: 0 */ | ||
| queueLimit?: Resolvable<number>; | ||
| /** Telemetry sink for observability. */ | ||
| telemetry?: TelemetrySink; | ||
| clock?: any; | ||
| /** Clock implementation for testing. */ | ||
| clock?: TestClock | SystemClock; | ||
| } | ||
| /** | ||
| * Options for the Hedge policy. | ||
| */ | ||
| export interface HedgeOptions { | ||
| /** Milliseconds to wait before spawning a hedge. */ | ||
| delay: Resolvable<number>; | ||
| /** Maximum number of hedged attempts to spawn. Default: 1 */ | ||
| maxHedges?: Resolvable<number>; | ||
| /** Telemetry sink for observability. */ | ||
| telemetry?: TelemetrySink; | ||
| /** Clock implementation for testing. */ | ||
| clock?: TestClock | SystemClock; | ||
| } | ||
| /** | ||
| * A structured event emitted by the telemetry system. | ||
| */ | ||
| export interface TelemetryEvent { | ||
| /** The type of event (e.g., 'retry.failure', 'circuit.open'). */ | ||
| type: string; | ||
| /** Unix timestamp of the event. */ | ||
| timestamp: number; | ||
| /** Metric increments (counters) to be aggregated by MetricsSink. */ | ||
| metrics?: Record<string, number>; | ||
| /** Additional metadata (error, duration, attempts, etc.). */ | ||
| [key: string]: any; | ||
| } | ||
| /** | ||
| * Interface for receiving telemetry events. | ||
| */ | ||
| export interface TelemetrySink { | ||
| /** | ||
| * Records a telemetry event. | ||
| * @param event The structured event. | ||
| */ | ||
| emit(event: TelemetryEvent): void; | ||
| } | ||
| /** | ||
| * Stores telemetry events in an in-memory array. Useful for testing. | ||
| */ | ||
| export class InMemorySink implements TelemetrySink { | ||
| /** Array of recorded events. */ | ||
| events: TelemetryEvent[]; | ||
| /** Records a telemetry event. */ | ||
| emit(event: TelemetryEvent): void; | ||
| /** Clears all recorded events. */ | ||
| clear(): void; | ||
| } | ||
| /** | ||
| * Logs telemetry events to the console (stdout). | ||
| */ | ||
| export class ConsoleSink implements TelemetrySink { | ||
| /** Records a telemetry event by logging to console. */ | ||
| emit(event: TelemetryEvent): void; | ||
| } | ||
| /** | ||
| * Discards all telemetry events. Useful for disabling telemetry. | ||
| */ | ||
| export class NoopSink implements TelemetrySink { | ||
| /** Discards the event (no-op). */ | ||
| emit(event: TelemetryEvent): void; | ||
| } | ||
| /** | ||
| * Broadcasts telemetry events to multiple other sinks. | ||
| */ | ||
| export class MultiSink implements TelemetrySink { | ||
| constructor(sinks: TelemetrySink[]); | ||
| /** | ||
| * @param sinks Array of sinks to broadcast to. | ||
| */ | ||
| constructor(sinks?: TelemetrySink[]); | ||
| /** Broadcasts event to all child sinks. */ | ||
| emit(event: TelemetryEvent): void; | ||
| } | ||
| /** | ||
| * Sink that aggregates metrics in memory. Useful for testing and monitoring. | ||
| */ | ||
| export class MetricsSink implements TelemetrySink { | ||
| /** Records a telemetry event and updates metrics. */ | ||
| emit(event: TelemetryEvent): void; | ||
| /** Returns a snapshot of the current metrics. */ | ||
| get stats(): { | ||
| retries: number; | ||
| failures: number; | ||
| successes: number; | ||
| circuitBreaks: number; | ||
| circuitRejections: number; | ||
| bulkheadRejections: number; | ||
| timeouts: number; | ||
| hedges: number; | ||
| latency: { | ||
| count: number; | ||
| sum: number; | ||
| min: number; | ||
| max: number; | ||
| avg: number; | ||
| }; | ||
| [key: string]: number | { count: number; sum: number; min: number; max: number; avg: number }; | ||
| }; | ||
| /** Resets all metrics to zero. */ | ||
| clear(): void; | ||
| } | ||
| /** | ||
| * Error thrown when all retry attempts are exhausted. | ||
| */ | ||
| export class RetryExhaustedError extends Error { | ||
| /** Total number of attempts made. */ | ||
| attempts: number; | ||
| /** The last error that caused the failure. */ | ||
| cause: Error; | ||
@@ -74,4 +229,9 @@ constructor(attempts: number, cause: Error); | ||
| /** | ||
| * Error thrown when the circuit breaker is open (OPEN state). | ||
| */ | ||
| export class CircuitOpenError extends Error { | ||
| /** When the circuit was opened. */ | ||
| openedAt: Date; | ||
| /** Number of failures that triggered the open state. */ | ||
| failureCount: number; | ||
@@ -81,4 +241,9 @@ constructor(openedAt: Date, failureCount: number); | ||
| /** | ||
| * Error thrown when an operation exceeds its time limit. | ||
| */ | ||
| export class TimeoutError extends Error { | ||
| /** The configured timeout in milliseconds. */ | ||
| timeout: number; | ||
| /** Actual elapsed time in milliseconds. */ | ||
| elapsed: number; | ||
@@ -88,4 +253,9 @@ constructor(timeout: number, elapsed: number); | ||
| /** | ||
| * Error thrown when the bulkhead limit and queue are both full. | ||
| */ | ||
| export class BulkheadRejectedError extends Error { | ||
| /** The configured concurrency limit. */ | ||
| limit: number; | ||
| /** The configured queue limit. */ | ||
| queueLimit: number; | ||
@@ -95,2 +265,5 @@ constructor(limit: number, queueLimit: number); | ||
| /** | ||
| * Executes an async function with configurable retry logic. | ||
| */ | ||
| export function retry<T>( | ||
@@ -101,11 +274,22 @@ fn: (signal?: AbortSignal) => Promise<T>, | ||
| /** | ||
| * Represents a Circuit Breaker instance. | ||
| */ | ||
| export interface CircuitBreaker { | ||
| /** Executes a function with circuit breaker protection. */ | ||
| execute<T>(fn: () => Promise<T>): Promise<T>; | ||
| /** Current state of the circuit. */ | ||
| readonly state: 'CLOSED' | 'OPEN' | 'HALF_OPEN'; | ||
| } | ||
| /** | ||
| * Creates a Circuit Breaker policy. | ||
| */ | ||
| export function circuitBreaker(options: CircuitBreakerOptions): CircuitBreaker; | ||
| /** | ||
| * Executes a function with a time limit. | ||
| */ | ||
| export function timeout<T>( | ||
| ms: number, | ||
| ms: Resolvable<number>, | ||
| fn: ((signal: AbortSignal) => Promise<T>) | (() => Promise<T>), | ||
@@ -115,10 +299,38 @@ options?: TimeoutOptions | ||
| /** | ||
| * Represents a Bulkhead instance. | ||
| */ | ||
| export interface Bulkhead { | ||
| /** Executes a function with concurrency limiting. */ | ||
| execute<T>(fn: () => Promise<T>): Promise<T>; | ||
| /** Current load statistics. */ | ||
| readonly stats: { active: number; pending: number; available: number }; | ||
| } | ||
| /** | ||
| * Creates a Bulkhead policy for concurrency limiting. | ||
| */ | ||
| export function bulkhead(options: BulkheadOptions): Bulkhead; | ||
| /** | ||
| * Represents a Hedge policy instance. | ||
| */ | ||
| export interface Hedge { | ||
| /** Executes with speculative hedging. */ | ||
| execute<T>(fn: (signal?: AbortSignal) => Promise<T>): Promise<T>; | ||
| } | ||
| /** | ||
| * Creates a Hedge policy for speculative execution. | ||
| */ | ||
| export function hedge(options: HedgeOptions): Hedge; | ||
| /** | ||
| * Composes multiple policies into a single executable policy. | ||
| */ | ||
| export function compose(...policies: any[]): { execute<T>(fn: () => Promise<T>): Promise<T> }; | ||
| /** | ||
| * Creates a fallback policy. If the primary policy fails, the secondary is executed. | ||
| */ | ||
| export function fallback( | ||
@@ -128,2 +340,6 @@ primary: any, | ||
| ): { execute<T>(fn: () => Promise<T>): Promise<T> }; | ||
| /** | ||
| * Creates a race policy. Executes both policies concurrently; the first to succeed wins. | ||
| */ | ||
| export function race( | ||
@@ -134,26 +350,81 @@ primary: any, | ||
| /** | ||
| * Fluent API for building resilience policies. | ||
| */ | ||
| export class Policy { | ||
| constructor(executor: (fn: () => Promise<any>) => Promise<any>); | ||
| /** Creates a Retry policy wrapper. */ | ||
| static retry(options?: RetryOptions): Policy; | ||
| /** Creates a Circuit Breaker policy wrapper. */ | ||
| static circuitBreaker(options: CircuitBreakerOptions): Policy; | ||
| static timeout(ms: number, options?: TimeoutOptions): Policy; | ||
| /** Creates a Timeout policy wrapper. */ | ||
| static timeout(ms: Resolvable<number>, options?: TimeoutOptions): Policy; | ||
| /** Creates a Bulkhead policy wrapper. */ | ||
| static bulkhead(options: BulkheadOptions): Policy; | ||
| /** Creates a Hedge policy wrapper. */ | ||
| static hedge(options: HedgeOptions): Policy; | ||
| /** Creates a pass-through (no-op) policy. */ | ||
| static noop(): Policy; | ||
| /** Wraps this policy with another (sequential composition). */ | ||
| wrap(otherPolicy: Policy): Policy; | ||
| /** Falls back to another policy if this one fails. */ | ||
| or(otherPolicy: Policy): Policy; | ||
| /** Races this policy against another. */ | ||
| race(otherPolicy: Policy): Policy; | ||
| /** Executes the policy chain. */ | ||
| execute<T>(fn: () => Promise<T>): Promise<T>; | ||
| } | ||
| /** | ||
| * System clock using real time. | ||
| * Uses runtime-aware timer management for clean process exits. | ||
| */ | ||
| export class SystemClock { | ||
| /** Returns current time in milliseconds since Unix epoch. */ | ||
| now(): number; | ||
| /** Sleeps for the specified duration. */ | ||
| sleep(ms: number): Promise<void>; | ||
| } | ||
| /** | ||
| * Test clock for deterministic tests. | ||
| * Allows manual control of time progression without real delays. | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * const clock = new TestClock(); | ||
| * const promise = retry(fn, { delay: 1000, clock }); | ||
| * | ||
| * await clock.advance(1000); // Triggers first retry | ||
| * await clock.advance(1000); // Triggers second retry | ||
| * ``` | ||
| */ | ||
| export class TestClock { | ||
| /** Returns current virtual time in milliseconds. */ | ||
| now(): number; | ||
| /** | ||
| * Creates a sleep promise that resolves when time is advanced. | ||
| * @param ms Milliseconds to sleep. | ||
| */ | ||
| sleep(ms: number): Promise<void>; | ||
| /** | ||
| * Process any timers ready at current time. | ||
| * @param ms Optional additional time to add. | ||
| */ | ||
| tick(ms?: number): Promise<void>; | ||
| /** | ||
| * Advances time and resolves any pending timers. | ||
| * @param ms Milliseconds to advance. | ||
| */ | ||
| advance(ms: number): Promise<void>; | ||
| /** | ||
| * Sets absolute time. | ||
| * @param time Time in milliseconds. | ||
| */ | ||
| setTime(time: number): void; | ||
| /** Returns number of pending timers. */ | ||
| readonly pendingCount: number; | ||
| /** Clears all pending timers and resets time to 0. */ | ||
| reset(): void; | ||
| } |
+18
-2
| /** | ||
| * @fileoverview Testing utilities for @git-stunts/alfred. | ||
| * Provides tools for deterministic testing of resilience policies. | ||
| * Testing utilities for deterministic resilience policy tests. | ||
| * | ||
| * Provides TestClock for controlling time progression without real delays, | ||
| * enabling fast and reliable tests for retry, timeout, and hedge policies. | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * import { TestClock } from "@git-stunts/alfred/testing"; | ||
| * import { retry } from "@git-stunts/alfred"; | ||
| * | ||
| * const clock = new TestClock(); | ||
| * const promise = retry(() => mightFail(), { retries: 3, delay: 1000, clock }); | ||
| * | ||
| * await clock.advance(1000); // Triggers first retry instantly | ||
| * await clock.advance(1000); // Triggers second retry | ||
| * ``` | ||
| * | ||
| * @module | ||
| */ | ||
@@ -5,0 +21,0 @@ |
+27
-1
| /** | ||
| * @fileoverview Clock abstractions for time-based operations. | ||
| * | ||
| * Provides SystemClock for production use and TestClock for deterministic testing. | ||
| * All time-based policies accept a clock option for testability. | ||
| * | ||
| * @module @git-stunts/alfred/utils/clock | ||
| */ | ||
| /** | ||
| * System clock using real time. | ||
| * Uses runtime-aware timer management (unref) to allow clean process exits. | ||
| */ | ||
| export class SystemClock { | ||
| /** | ||
| * Returns the current time in milliseconds since Unix epoch. | ||
| * @returns {number} | ||
| */ | ||
| now() { | ||
@@ -9,2 +23,8 @@ return Date.now(); | ||
| /** | ||
| * Sleeps for the specified duration. | ||
| * Timer is unref'd to prevent blocking process exit. | ||
| * @param {number} ms - Milliseconds to sleep | ||
| * @returns {Promise<void>} | ||
| */ | ||
| async sleep(ms) { | ||
@@ -25,10 +45,16 @@ return new Promise((resolve) => { | ||
| * Test clock for deterministic tests. | ||
| * Allows manual control of time progression. | ||
| * Allows manual control of time progression without real delays. | ||
| */ | ||
| export class TestClock { | ||
| constructor() { | ||
| /** @type {number} */ | ||
| this._time = 0; | ||
| /** @type {Array<{triggerAt: number, resolve: () => void}>} */ | ||
| this._pendingTimers = []; | ||
| } | ||
| /** | ||
| * Returns the current virtual time in milliseconds. | ||
| * @returns {number} | ||
| */ | ||
| now() { | ||
@@ -35,0 +61,0 @@ return this._time; |
| /** | ||
| * @fileoverview Utility for resolving static or dynamic configuration values. | ||
| * | ||
| * Enables live-tunable policy options by accepting either a value or a | ||
| * function that returns a value. See "Resolution Timing" in README. | ||
| * | ||
| * @module @git-stunts/alfred/utils/resolvable | ||
| */ | ||
| /** | ||
| * Resolves a value that might be dynamic. | ||
@@ -3,0 +12,0 @@ * |
135372
18.22%2442
19.71%829
23.55%