@git-stunts/alfred
v0.7.0
Published
Production-grade resilience patterns for async ops: retry/backoff+jitter, circuit breaker, bulkhead, timeout.
Maintainers
Readme
@git-stunts/alfred
.o. oooo .o88o. .o8
.888. `888 888 `" "888
.8"888. 888 o888oo oooo d8b .ooooo. .oooo888
.8' `888. 888 888 `888""8P d88' `88b d88' `888
.88ooo8888. 888 888 888 888ooo888 888 888
.8' `888. 888 888 888 888 .o 888 888
o88o o8888o o888o o888o d888b `Y8bod8P' `Y8bod88P""Why do we fall, Bruce?"
"So we can
retry({ backoff: 'exponential', jitter: 'decorrelated' })."
Resilience patterns for async operations. Tuff 'nuff for most stuff.
Includes: retry - circuit breaker - bulkhead - timeout - hedge - composition - TestClock - telemetry sinks
Install
npm
npm install @git-stunts/alfredJSR (Deno, Bun, Node)
npx jsr add @git-stunts/alfred20-second win
A realistic stack you'll actually ship: total timeout + retry (decorrelated jitter) + circuit breaker + bulkhead.
import { Policy } from '@git-stunts/alfred';
const resilient = Policy.timeout(5_000)
.wrap(
Policy.retry({
retries: 3,
backoff: 'exponential',
jitter: 'decorrelated',
delay: 150,
maxDelay: 3_000,
shouldRetry: (err) =>
err?.name === 'TimeoutError' || err?.code === 'ECONNRESET' || err?.code === 'ETIMEDOUT',
})
)
.wrap(
Policy.circuitBreaker({
threshold: 5,
duration: 60_000,
})
)
.wrap(Policy.bulkhead({ limit: 10, queueLimit: 20 }));
const data = await resilient.execute(async () => {
const res = await fetch('https://api.example.com/data');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
});Multi-runtime support
Alfred is designed to be platform-agnostic and tested against:
- Node.js (>= 20)
- Bun (>= 1)
- Deno (>= 1.35)
- Browsers (Chrome 85+, Firefox 79+, Safari 14+, Edge 85+)
- Cloudflare Workers
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:
npm run demo:web:install && npm run demo:webOr run the Playwright browser tests:
npm run demo:web:install && npm run test:browserQuick start (functional helpers)
import { retry, circuitBreaker, bulkhead, timeout } from '@git-stunts/alfred';
// 1) Simple retry with exponential backoff
const data = await retry(() => fetch('https://api.example.com/data'), {
retries: 3,
backoff: 'exponential',
delay: 100,
});
// 2) Circuit breaker — fail fast when a service is down
const breaker = circuitBreaker({ threshold: 5, duration: 60_000 });
const result = await breaker.execute(() => callFlakeyService());
// 3) Bulkhead — limit concurrent executions
const limiter = bulkhead({ limit: 10, queueLimit: 20 });
await limiter.execute(() => heavyOperation());
// 4) Timeout — prevent operations from hanging
const fast = await timeout(5_000, () => slowOperation());Policy Algebra
Alfred provides three composition operators for building complex resilience strategies:
| Operator | Fluent | Functional | Semantics |
| -------- | --------------- | ---------------- | ------------------------------------- |
| wrap | .wrap(policy) | compose(a, b) | Sequential: A wraps B (outer → inner) |
| or | .or(policy) | fallback(a, b) | Fallback: try A, if fails try B |
| race | .race(policy) | race(a, b) | Concurrent: first success wins |
Example 1: Production Stack (timeout + retry + circuit + bulkhead)
The classic resilience stack. Read execution order from outside-in: timeout wraps retry wraps circuit breaker wraps bulkhead.
import { Policy } from '@git-stunts/alfred';
const resilient = Policy.timeout(5_000) // 1. Total deadline
.wrap(
Policy.retry({
// 2. Retry transient failures
retries: 3,
backoff: 'exponential',
jitter: 'decorrelated',
delay: 100,
})
)
.wrap(
Policy.circuitBreaker({
// 3. Fail fast when broken
threshold: 5,
duration: 30_000,
})
)
.wrap(Policy.bulkhead({ limit: 10, queueLimit: 20 })); // 4. Limit concurrency
await resilient.execute(() => fetch('https://api.example.com/data'));Example 2: Fast/Slow Fallback
Try a fast strategy first; if it fails (or times out), fall back to a slower but more reliable approach.
import { Policy } from '@git-stunts/alfred';
// Fast path: short timeout, no retries
const fast = Policy.timeout(500);
// Slow path: longer timeout with retries
const slow = Policy.timeout(5_000).wrap(
Policy.retry({ retries: 3, backoff: 'exponential', delay: 200 })
);
// Try fast first, fall back to slow
const resilient = fast.or(slow);
await resilient.execute(() => fetch('https://api.example.com/data'));Example 3: Hedged Requests (Race Pattern)
Spawn parallel "hedge" requests to reduce tail latency. First success wins; losers are cancelled.
import { Policy } from '@git-stunts/alfred';
// Hedge: if primary is slow, spawn backup attempts
const hedged = Policy.hedge({ delay: 100, maxHedges: 2 });
// Combine with bulkhead to prevent self-DDOS
const safe = hedged.wrap(Policy.bulkhead({ limit: 5 }));
// The operation receives an AbortSignal to enable cancellation
await safe.execute((signal) => fetch('https://api.example.com/data', { signal }));Tip: Only hedge idempotent operations. Non-idempotent operations (writes, payments) should not be hedged.
Fluent vs Functional
Both styles produce equivalent results:
// Fluent API
const policy1 = Policy.timeout(5_000)
.wrap(Policy.retry({ retries: 3 }))
.wrap(Policy.circuitBreaker({ threshold: 5, duration: 60_000 }));
// Functional API
const policy2 = compose(
Policy.timeout(5_000),
Policy.retry({ retries: 3 }),
circuitBreaker({ threshold: 5, duration: 60_000 }) // functional returns policy object
);The fluent API is recommended for readability. Use functional compose() when building policies dynamically.
API
- retry(fn, options)
- circuitBreaker(options)
- bulkhead(options)
- rateLimit(options)
- timeout(ms, fn, options)
- hedge(options)
- Policy Algebra
- Policy (fluent API)
- compose(...policies)
- fallback(primary, secondary)
- race(primary, secondary)
- Telemetry & Observability
- Testing
- Error Types
- Resolution Timing
retry(fn, options)
Retries a failed operation with configurable backoff.
import { retry } from '@git-stunts/alfred';
// Basic retry
await retry(() => mightFail(), { retries: 3 });
// Exponential backoff: 100ms, 200ms, 400ms...
await retry(() => mightFail(), {
retries: 5,
backoff: 'exponential',
delay: 100,
maxDelay: 10_000,
});
// Only retry specific errors
await retry(() => mightFail(), {
retries: 3,
shouldRetry: (err) => err?.code === 'ECONNREFUSED',
});
// With jitter to prevent thundering herd
await retry(() => mightFail(), {
retries: 3,
backoff: 'exponential',
delay: 100,
jitter: 'full', // or "equal" or "decorrelated"
});
// Abort retries early
const controller = new AbortController();
const promise = retry((signal) => fetch('https://api.example.com/data', { signal }), {
retries: 3,
backoff: 'exponential',
delay: 100,
signal: controller.signal,
});
controller.abort();Options
| Option | Type | Default | Description |
| ------------- | ----------------------------------------------- | ------------ | -------------------------------- |
| retries | number | 3 | Maximum retry attempts |
| delay | number | 1000 | Base delay (ms) |
| maxDelay | number | 30000 | Maximum delay cap (ms) |
| backoff | "constant" \| "linear" \| "exponential" | "constant" | Backoff strategy |
| jitter | "none" \| "full" \| "equal" \| "decorrelated" | "none" | Jitter strategy |
| shouldRetry | (error) => boolean | () => true | Filter retryable errors |
| onRetry | (error, attempt, delay) => void | - | Callback on each retry |
| signal | AbortSignal | - | Abort retries and backoff sleeps |
circuitBreaker(options)
Fails fast when a service is degraded, preventing cascade failures.
import { circuitBreaker } from '@git-stunts/alfred';
const breaker = circuitBreaker({
threshold: 5, // Open after 5 failures
duration: 60_000, // Stay open for 60 seconds
onOpen: () => console.log('Circuit opened!'),
onClose: () => console.log('Circuit closed!'),
onHalfOpen: () => console.log('Testing recovery...'),
});
try {
await breaker.execute(() => callService());
} catch (err) {
if (err?.name === 'CircuitOpenError') {
console.log('Service is down, failing fast');
}
}Options
| Option | Type | Default | Description |
| ------------------ | -------------------- | ------------ | --------------------------------- |
| threshold | number | required | Failures before opening |
| duration | number | required | How long to stay open (ms) |
| successThreshold | number | 1 | Successes to close from half-open |
| shouldTrip | (error) => boolean | () => true | Which errors count as failures |
| onOpen | () => void | - | Called when circuit opens |
| onClose | () => void | - | Called when circuit closes |
| onHalfOpen | () => void | - | Called when entering half-open |
bulkhead(options)
Limits the number of concurrent executions to prevent resource exhaustion.
import { bulkhead } from '@git-stunts/alfred';
const limiter = bulkhead({
limit: 10, // Max 10 concurrent executions
queueLimit: 20, // Max 20 pending requests in queue
});
try {
await limiter.execute(() => heavyOperation());
} catch (err) {
if (err?.name === 'BulkheadRejectedError') {
console.log('Too many concurrent requests, failing fast');
}
}
console.log(`Load: ${limiter.stats.active} active`);Options
| Option | Type | Default | Description |
| ------------ | -------- | -------- | --------------------------------- |
| limit | number | required | Maximum concurrent executions |
| queueLimit | number | 0 | Maximum pending requests in queue |
rateLimit(options)
Token bucket rate limiter for throughput control. Unlike bulkhead (which limits concurrency), rate limit controls requests per second.
import { rateLimit } from '@git-stunts/alfred';
const limiter = rateLimit({ rate: 100 }); // 100 req/sec
await limiter.execute(() => fetch('/api'));With Burst Capacity
Allow temporary bursts above the base rate:
const limiter = rateLimit({ rate: 100, burst: 150 });With Queueing
Queue excess requests instead of rejecting immediately:
const limiter = rateLimit({ rate: 10, queueLimit: 50 });Policy Fluent API
const policy = Policy.rateLimit({ rate: 100 }).wrap(Policy.retry({ retries: 3 }));Options
| Option | Type | Default | Description |
| ------------ | --------------- | -------- | ----------------------------------------- |
| rate | number | required | Maximum requests per second |
| burst | number | rate | Maximum bucket capacity for burst traffic |
| queueLimit | number | 0 | Maximum pending requests in queue |
| clock | Clock | - | Custom clock for testing |
| telemetry | TelemetrySink | - | Sink for observability events |
Error Handling
When the rate limit is exceeded and no queue space is available, a RateLimitExceededError is thrown:
import { rateLimit, RateLimitExceededError } from '@git-stunts/alfred';
const limiter = rateLimit({ rate: 10 });
try {
await limiter.execute(() => fetch('/api'));
} catch (err) {
if (err instanceof RateLimitExceededError) {
console.log(`Rate limit exceeded: ${err.rate} req/sec`);
console.log(`Retry after: ${err.retryAfter}ms`);
}
}| Error | Thrown When | Properties |
| ------------------------ | ---------------------------------- | -------------------- |
| RateLimitExceededError | Rate limit exceeded and queue full | rate, retryAfter |
timeout(ms, fn, options)
Prevents operations from hanging indefinitely.
import { timeout } from '@git-stunts/alfred';
// Simple timeout
const result = await timeout(5_000, () => slowOperation());
// With callback
const result2 = await timeout(5_000, () => slowOperation(), {
onTimeout: (elapsed) => console.log(`Timed out after ${elapsed}ms`),
});Throws TimeoutError if the operation exceeds the time limit.
hedge(options)
Speculative execution: if the primary request is slow, spawn parallel "hedge" requests to race for the fastest response.
import { hedge } from '@git-stunts/alfred';
const hedger = hedge({
delay: 100, // Wait 100ms before spawning a hedge
maxHedges: 2, // Spawn up to 2 additional requests
});
// If the first request takes > 100ms, a second request starts.
// If still slow after another 100ms, a third starts.
// First successful response wins; others are aborted.
const result = await hedger.execute((signal) => fetch('https://api.example.com/data', { signal }));Options
| Option | Type | Default | Description |
| ----------- | -------- | -------- | -------------------------------------------- |
| delay | number | required | Milliseconds to wait before spawning a hedge |
| maxHedges | number | 1 | Maximum number of hedge requests to spawn |
Safety Guardrails
Warning: Hedging spawns parallel requests. Use responsibly to avoid overloading backends.
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.
Always use AbortSignal. Your operation receives an
AbortSignalthat fires when a faster hedge wins. Honor it to cancel in-flight work (fetch, database queries, etc.).Combine with bulkhead + circuit breaker. Prevent self-DDOS and cascading failures:
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 }));- Set reasonable delays. The
delayshould 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:
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):
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+)
- Cloudflare Workers
For older runtimes, use a polyfill like core-js or promise.any.
Policy (fluent API)
Building complex policies is easier with the chainable Policy class.
import { Policy, ConsoleSink } from '@git-stunts/alfred';
const telemetry = new ConsoleSink();
const resilient = Policy.timeout(30_000)
.wrap(Policy.retry({ retries: 3, backoff: 'exponential', telemetry }))
.wrap(Policy.circuitBreaker({ threshold: 5, duration: 60_000, telemetry }))
.wrap(Policy.bulkhead({ limit: 5, queueLimit: 10, telemetry }));
await resilient.execute(() => riskyOperation());Static Methods
| Method | Description |
| -------------------------------- | ------------------------------------ |
| Policy.retry(options) | Create a retry policy |
| Policy.circuitBreaker(options) | Create a circuit breaker policy |
| Policy.timeout(ms, options) | Create a timeout policy |
| Policy.bulkhead(options) | Create a bulkhead policy |
| Policy.rateLimit(options) | Create a rate limit policy |
| Policy.hedge(options) | Create a hedge policy |
| Policy.noop() | Create a pass-through (no-op) policy |
Instance Methods
| Method | Description |
| --------------- | ------------------------------------------------- |
| .wrap(policy) | Wrap with another policy (sequential composition) |
| .or(policy) | Fall back to another policy if this one fails |
| .race(policy) | Race this policy against another |
| .execute(fn) | Execute the policy chain |
// Fallback example: try primary, fall back to cache on failure
const withFallback = Policy.retry({ retries: 2 }).or(Policy.noop()); // fallback policy
// Race example: use whichever responds first
const racing = Policy.timeout(1_000).race(Policy.timeout(2_000));compose(...policies)
Combines multiple policy objects. Policies execute left -> right (outermost -> innermost).
Policy objects must have an .execute(fn) method. Use circuitBreaker() and bulkhead() directly, or use the Policy class for retry() and timeout().
import { compose, circuitBreaker, bulkhead, Policy } from '@git-stunts/alfred';
const resilient = compose(
Policy.timeout(30_000), // Total timeout
Policy.retry({ retries: 3, backoff: 'exponential' }), // Retry failures
circuitBreaker({ threshold: 5, duration: 60_000 }), // Fail fast if broken
bulkhead({ limit: 5, queueLimit: 10 }) // Limit concurrency
);
await resilient.execute(() => riskyOperation());fallback(primary, secondary)
Executes the primary policy; if it fails, executes the secondary.
import { fallback, circuitBreaker, Policy } from '@git-stunts/alfred';
const withFallback = fallback(
Policy.retry({ retries: 3 }),
circuitBreaker({ threshold: 5, duration: 60_000 })
);
await withFallback.execute(() => riskyOperation());race(primary, secondary)
Executes both policies concurrently; the first to succeed wins.
import { race, Policy } from '@git-stunts/alfred';
const racing = race(Policy.timeout(1_000), Policy.timeout(2_000));
// Whichever completes first wins
await racing.execute(() => fetchFromMultipleSources());Telemetry & Observability
Alfred provides composable telemetry sinks to monitor policy behavior.
import { Policy, ConsoleSink, InMemorySink, MultiSink } from '@git-stunts/alfred';
const sink = new MultiSink([new ConsoleSink(), new InMemorySink()]);
await Policy.retry({
retries: 3,
telemetry: sink,
}).execute(() => doSomething());Available Sinks
| Sink | Description |
| -------------- | ---------------------------------------------- |
| ConsoleSink | Logs events to stdout |
| InMemorySink | Stores events in an array (useful for testing) |
| MetricsSink | Aggregates metrics (counters, latency stats) |
| MultiSink | Broadcasts to multiple sinks |
| NoopSink | Discards all events (disables telemetry) |
MetricsSink
Aggregates metrics for production monitoring.
import { Policy, MetricsSink } from '@git-stunts/alfred';
const metrics = new MetricsSink();
const policy = Policy.retry({ retries: 3, telemetry: metrics }).wrap(
Policy.circuitBreaker({ threshold: 5, duration: 60_000, telemetry: metrics })
);
await policy.execute(() => doSomething());
console.log(metrics.stats);
// {
// retries: 2,
// failures: 1,
// successes: 1,
// circuitBreaks: 0,
// circuitRejections: 0,
// bulkheadRejections: 0,
// timeouts: 0,
// hedges: 0,
// latency: { count: 1, sum: 150, min: 150, max: 150, avg: 150 }
// }
metrics.clear(); // Reset all metricsEvents
All policies emit events:
- retry: success, failure, scheduled, exhausted
- circuit: open, close, half-open, success, failure, reject
- bulkhead: execute, complete, queued, reject
- timeout: timeout
- hedge: spawn, success, failure
Testing
Use TestClock for deterministic tests without real delays. All time-based policies (retry, timeout, hedge) support clock injection.
import { retry, timeout, TestClock, TimeoutError } from '@git-stunts/alfred';
test('retries with exponential backoff', async () => {
const clock = new TestClock();
let attempts = 0;
const operation = async () => {
attempts++;
if (attempts < 3) throw new Error('fail');
return 'success';
};
const promise = retry(operation, {
retries: 3,
backoff: 'exponential',
delay: 1_000,
clock,
});
await clock.tick(0);
expect(attempts).toBe(1);
await clock.advance(1_000);
expect(attempts).toBe(2);
await clock.advance(2_000);
expect(attempts).toBe(3);
expect(await promise).toBe('success');
});
test('timeout triggers after virtual time', async () => {
const clock = new TestClock();
const slowOp = () => clock.sleep(10_000).then(() => 'done');
const promise = timeout(5_000, slowOp, { clock });
await clock.advance(5_000);
await expect(promise).rejects.toThrow(TimeoutError);
});Error Types
import {
RetryExhaustedError,
CircuitOpenError,
TimeoutError,
BulkheadRejectedError,
RateLimitExceededError,
} from '@git-stunts/alfred';
try {
await resilientOperation();
} catch (err) {
if (err instanceof RetryExhaustedError) {
console.log(`Failed after ${err.attempts} attempts`);
console.log(`Last error: ${err.cause.message}`);
} else if (err instanceof CircuitOpenError) {
console.log(`Circuit open since ${err.openedAt}`);
} else if (err instanceof TimeoutError) {
console.log(`Timed out after ${err.elapsed}ms`);
} else if (err instanceof BulkheadRejectedError) {
console.log(`Bulkhead full: ${err.limit} active, ${err.queueLimit} queued`);
} else if (err instanceof RateLimitExceededError) {
console.log(`Rate limited: ${err.rate} req/sec, retry after ${err.retryAfter}ms`);
}
}| Error | Thrown When | Properties |
| ------------------------ | ---------------------------------- | -------------------------- |
| RetryExhaustedError | All retry attempts failed | attempts, cause |
| CircuitOpenError | Circuit breaker is open | openedAt, failureCount |
| TimeoutError | Operation exceeded time limit | timeout, elapsed |
| BulkheadRejectedError | Bulkhead limit and queue are full | limit, queueLimit |
| RateLimitExceededError | Rate limit exceeded and queue full | rate, retryAfter |
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
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 limitExample: Dynamic Bulkhead Limit
let concurrencyLimit = 10;
const bh = bulkhead({
limit: () => concurrencyLimit, // Resolved per admission
queueLimit: 20,
});
// Later, reduce concurrency (takes effect on next admission)
concurrencyLimit = 5;License
Apache-2.0 © 2026 by James Ross
