@zerothrow/resilience
TypeScript icon, indicating that this package has built-in type declarations

0.2.1 • Public • Published

@zerothrow/resilience

🧠 ZeroThrow Layers
ZT – primitives (try, tryAsync, ok, err)
Result – combinators (map, andThen, match)
ZeroThrow – utilities (collect, enhanceAsync)
@zerothrow/* – ecosystem packages (resilience, jest, etc)

ZeroThrow Ecosystem · Packages ⇢

CI npm types ecosystem

Production-grade resilience patterns for ZeroThrow: retry policies, circuit breakers, and timeouts with full Result<T,E> integration.

What's New in v0.2.0 🎉

Breaking Changes:

  • Policy Hierarchy: Policy is now the base type with specific subtypes:
    • RetryPolicy for retry operations
    • CircuitBreakerPolicy for circuit breaker patterns
    • TimeoutPolicy for timeout handling
  • Factory Renamed: Policy static methods are now on PolicyFactory
  • New Callbacks: Added onRetry and onCircuitStateChange for better observability
// Old (v0.1.x)
import { Policy } from '@zerothrow/resilience';
const retry = Policy.retry(3);

// New (v0.2.0)
import { PolicyFactory } from '@zerothrow/resilience';
const retry = PolicyFactory.retry(3);

Installation

npm install @zerothrow/resilience @zerothrow/core
# or: pnpm add @zerothrow/resilience @zerothrow/core

Quick Start

import { PolicyFactory } from '@zerothrow/resilience';
import { ZT } from '@zerothrow/core';

// Create a resilient API call with retry, circuit breaker, and timeout
const resilientFetch = PolicyFactory.compose(
  PolicyFactory.retry(3, { backoff: 'exponential', delay: 1000 }),
  PolicyFactory.circuitBreaker({ threshold: 5, duration: 60000 }),
  PolicyFactory.timeout(5000)
);

const result = await resilientFetch.execute(async () => {
  const response = await fetch('https://api.example.com/data');
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return response.json();
});

// Using combinators for elegant error handling
const processed = result
  .map(data => ({ ...data, timestamp: Date.now() }))
  .tap(data => console.log('Success:', data))
  .tapErr(error => console.error('Failed after retries:', error))
  .unwrapOr({ fallback: true });

API

Retry Policy

Automatically retry failed operations with configurable backoff strategies.

// Basic retry - 3 attempts with constant 1s delay
const retry = PolicyFactory.retry(3);

// Exponential backoff: 1s, 2s, 4s, 8s...
const retryExp = PolicyFactory.retry(5, { 
  backoff: 'exponential',
  delay: 1000,      // Base delay
  maxDelay: 30000   // Cap at 30 seconds
});

// Linear backoff: 1s, 2s, 3s, 4s...
const retryLinear = PolicyFactory.retry(4, { 
  backoff: 'linear',
  delay: 1000 
});

// Selective retry - only retry specific errors
const retryNetwork = PolicyFactory.retry(3, {
  handle: (error) => error.message.includes('ECONNREFUSED')
});

Circuit Breaker Policy

Fail fast when a service is down to prevent cascading failures.

// Open circuit after 5 failures, stay open for 60 seconds
const breaker = PolicyFactory.circuitBreaker({
  threshold: 5,        // Failures before opening
  duration: 60000,     // Stay open for 1 minute
  onOpen: () => console.log('Circuit opened!'),
  onClose: () => console.log('Circuit closed!')
});

// The circuit breaker has three states:
// - Closed: Normal operation, requests pass through
// - Open: All requests fail immediately with CircuitOpenError
// - Half-Open: After duration, one request is allowed to test recovery

Timeout Policy

Prevent operations from hanging indefinitely.

// Timeout after 5 seconds
const timeout = PolicyFactory.timeout(5000);

// Or with options object
const timeout = PolicyFactory.timeout({ timeout: 5000 });

// Operations that exceed the timeout will fail with TimeoutError
const result = await timeout.execute(async () => {
  await slowDatabaseQuery(); // Fails if > 5s
});

Policy Composition

Combine multiple policies for defense in depth. Policies compose from left to right (leftmost is outermost).

// Method 1: Using compose
const resilient = PolicyFactory.compose(
  PolicyFactory.retry(3, { backoff: 'exponential' }),
  PolicyFactory.circuitBreaker({ threshold: 5, duration: 60000 }),
  PolicyFactory.timeout(5000)
);

// Method 2: Using wrap (for two policies)
const retryWithTimeout = PolicyFactory.wrap(
  PolicyFactory.retry(3),
  PolicyFactory.timeout(5000)
);

// Execution order (for compose example):
// 1. Retry policy executes
// 2. For each retry attempt:
//    - Circuit breaker checks if open
//    - If closed, timeout policy executes
//    - If timeout succeeds, operation runs

Error Types

All policies return typed errors that provide context about failures:

import { 
  RetryExhaustedError, 
  CircuitOpenError, 
  TimeoutError 
} from '@zerothrow/resilience';

const result = await policy.execute(operation);

if (!result.ok) {
  if (result.error instanceof RetryExhaustedError) {
    console.log(`Failed after ${result.error.attempts} attempts`);
    console.log(`Last error: ${result.error.lastError.message}`);
  } else if (result.error instanceof CircuitOpenError) {
    console.log(`Circuit opened at ${result.error.openedAt}`);
    console.log(`Failure count: ${result.error.failureCount}`);
  } else if (result.error instanceof TimeoutError) {
    console.log(`Timed out after ${result.error.elapsed}ms`);
  }
}

Examples

Resilient HTTP Client

import { PolicyFactory } from '@zerothrow/resilience';

// Create a reusable HTTP client with resilience
class ResilientHttpClient {
  private policy = PolicyFactory.compose(
    PolicyFactory.retry(3, { 
      backoff: 'exponential',
      handle: (error) => {
        // Only retry network and 5xx errors
        return error.code === 'ECONNREFUSED' || 
               (error.status >= 500 && error.status < 600);
      }
    }),
    PolicyFactory.circuitBreaker({ 
      threshold: 10, 
      duration: 30000 
    }),
    PolicyFactory.timeout(10000)
  );

  async get<T>(url: string): Promise<Result<T, Error>> {
    return this.policy.execute(async () => {
      const response = await fetch(url);
      if (!response.ok) {
        const error = new Error(`HTTP ${response.status}`);
        (error as any).status = response.status;
        throw error;
      }
      return response.json() as T;
    });
  }
}

Database Operations

// Resilient database connection with retry and timeout
const dbPolicy = PolicyFactory.compose(
  PolicyFactory.retry(5, { 
    backoff: 'linear',
    delay: 500,
    handle: (error) => error.code === 'ECONNREFUSED'
  }),
  PolicyFactory.timeout(30000)
);

async function queryDatabase(sql: string) {
  const result = await dbPolicyFactory.execute(async () => {
    const conn = await getConnection();
    return conn.query(sql);
  });
  
  return result
    .tap(rows => logger.debug(`Query returned ${rows.length} rows`))
    .tapErr(error => logger.error('Database query failed', { sql, error }))
    .map(rows => rows.filter(row => row.active))
    .unwrapOr([]);
}

Microservice Communication

// Different policies for different service criticality
const criticalServicePolicy = PolicyFactory.compose(
  PolicyFactory.retry(5, { backoff: 'exponential', maxDelay: 10000 }),
  PolicyFactory.circuitBreaker({ threshold: 3, duration: 60000 }),
  PolicyFactory.timeout(5000)
);

const nonCriticalServicePolicy = PolicyFactory.compose(
  PolicyFactory.retry(1),  // Only one retry
  PolicyFactory.timeout(2000)  // Shorter timeout
);

// Use appropriate policy based on service with result chaining
async function callService(name: string, request: any) {
  const policy = name === 'payment' 
    ? criticalServicePolicy 
    : nonCriticalServicePolicy;
    
  return policy.execute(() => serviceClient.call(name, request))
    .then(result => result
      .tap(response => metrics.recordLatency(name, response.latency))
      .map(response => response.data)
      .mapErr(error => {
        telemetry.recordError(name, error);
        return new ServiceError(name, error);
      })
    );
}

Testing with TestClock

import { Policy, TestClock } from '@zerothrow/resilience';

// Use TestClock for deterministic tests
test('retry with exponential backoff', async () => {
  const clock = new TestClock();
  const retry = PolicyFactory.retry(3, { 
    backoff: 'exponential',
    delay: 1000 
  }, clock);
  
  let attempts = 0;
  const operation = jest.fn(async () => {
    attempts++;
    if (attempts < 3) throw new Error('Failed');
    return 'success';
  });
  
  const promise = retry.execute(operation);
  
  // First attempt fails immediately
  await clock.advance(0);
  expect(operation).toHaveBeenCalledTimes(1);
  
  // Second attempt after 1s
  await clock.advance(1000);
  expect(operation).toHaveBeenCalledTimes(2);
  
  // Third attempt after 2s (exponential)
  await clock.advance(2000);
  expect(operation).toHaveBeenCalledTimes(3);
  
  const result = await promise;
  expect(result.ok).toBe(true);
});

Integration with ZeroThrow Core

All policies work seamlessly with ZeroThrow's Result type and combinators:

import { ZT, Result, ZeroThrow } from '@zerothrow/core';
import { PolicyFactory } from '@zerothrow/resilience';

// Policies always return Result<T, Error>
const policy = PolicyFactory.retry(3);
const result: Result<Data, Error> = await policy.execute(fetchData);

// Chain multiple transformations
const processed = result
  .map(data => transform(data))
  .andThen(data => validate(data))
  .map(data => enrichData(data))
  .tap(data => logger.info('Processing complete', { data }))
  .tapErr(error => {
    if (error instanceof RetryExhaustedError) {
      metrics.increment('retry.exhausted');
    }
  })
  .orElse(error => {
    if (error instanceof RetryExhaustedError) {
      return ZT.ok(fallbackData);
    }
    return ZT.err(error);
  })
  .unwrapOr(defaultData);

// Use with ZT utilities and combinators
const results = await Promise.all(
  urls.map(url => 
    policy.execute(() => fetch(url).then(r => r.json()))
  )
);

// Process all results with combinators
const processed = ZeroThrow.collect(results)
  .map(dataArray => dataArray.filter(d => d.valid))
  .tap(valid => console.log(`Processed ${valid.length} valid items`))
  .unwrapOr([]);

Contributing

See the main repository for contribution guidelines.

License

MIT

Package Sidebar

Install

npm i @zerothrow/resilience

Weekly Downloads

39

Version

0.2.1

License

MIT

Unpacked Size

87.2 kB

Total Files

10

Last publish

Collaborators

  • flyingrobots