Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@git-stunts/alfred

Package Overview
Dependencies
Maintainers
1
Versions
14
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@git-stunts/alfred

Production-grade resilience patterns for async ops: retry/backoff+jitter, circuit breaker, bulkhead, timeout.

latest
Source
npmnpm
Version
0.10.3
Version published
Maintainers
1
Created
Source

@git-stunts/alfred

JSR NPM Version CI

      .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/alfred

JSR (Deno, Bun, Node)

npx jsr add @git-stunts/alfred

Roadmap

See the ecosystem roadmap at ROADMAP.md.

Live Control Plane

Need to tune policies without redeploy? Use @git-stunts/alfred-live for LivePolicyPlan + ControlPlane bindings that drive Alfred at runtime.

20-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:web

Or run the Playwright browser tests:

npm run demo:web:install && npm run test:browser

Quick 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:

OperatorFluentFunctionalSemantics
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)

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

OptionTypeDefaultDescription
retriesnumber3Maximum retry attempts
delaynumber1000Base delay (ms)
maxDelaynumber30000Maximum delay cap (ms)
backoff"constant" | "linear" | "exponential""constant"Backoff strategy
jitter"none" | "full" | "equal" | "decorrelated""none"Jitter strategy
shouldRetry(error) => boolean() => trueFilter retryable errors
onRetry(error, attempt, delay) => void-Callback on each retry
signalAbortSignal-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

OptionTypeDefaultDescription
thresholdnumberrequiredFailures before opening
durationnumberrequiredHow long to stay open (ms)
successThresholdnumber1Successes to close from half-open
shouldTrip(error) => boolean() => trueWhich 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

OptionTypeDefaultDescription
limitnumberrequiredMaximum concurrent executions
queueLimitnumber0Maximum 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

OptionTypeDefaultDescription
ratenumberrequiredMaximum requests per second
burstnumberrateMaximum bucket capacity for burst traffic
queueLimitnumber0Maximum pending requests in queue
clockClock-Custom clock for testing
telemetryTelemetrySink-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`);
  }
}
ErrorThrown WhenProperties
RateLimitExceededErrorRate limit exceeded and queue fullrate, 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

OptionTypeDefaultDescription
delaynumberrequiredMilliseconds to wait before spawning a hedge
maxHedgesnumber1Maximum 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 AbortSignal that 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 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:

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

MethodDescription
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

MethodDescription
.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

SinkDescription
ConsoleSinkLogs events to stdout
InMemorySinkStores events in an array (useful for testing)
MetricsSinkAggregates metrics (counters, latency stats)
MultiSinkBroadcasts to multiple sinks
NoopSinkDiscards 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 metrics

Events

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`);
  }
}
ErrorThrown WhenProperties
RetryExhaustedErrorAll retry attempts failedattempts, cause
CircuitOpenErrorCircuit breaker is openopenedAt, failureCount
TimeoutErrorOperation exceeded time limittimeout, elapsed
BulkheadRejectedErrorBulkhead limit and queue are fulllimit, queueLimit
RateLimitExceededErrorRate limit exceeded and queue fullrate, 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:

PolicyOptionResolution TimingDescription
retryretriesper attemptChecked after each failure
retrydelayper attemptCalculated before each backoff sleep
retrymaxDelayper attemptApplied when calculating delay
retrybackoffper attemptStrategy resolved per delay calculation
retryjitterper attemptJitter type resolved per delay calculation
bulkheadlimitper admissionChecked when request tries to execute
bulkheadqueueLimitper admissionChecked when request tries to queue
circuitBreakerthresholdper eventChecked on each failure
circuitBreakerdurationper eventChecked when testing for half-open transition
circuitBreakersuccessThresholdper eventChecked on each success in half-open state
timeoutmsper executeResolved once at start of timeout
hedgedelayper executeResolved once at start of execute
hedgemaxHedgesper executeResolved 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 limit

Example: 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

Built by FLYING•ROBOTS

Keywords

resilience

FAQs

Package last updated on 08 Feb 2026

Did you know?

Socket

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Install

Related posts