@zerothrow/resilience
Version:
Production-grade resilience patterns for ZeroThrow
382 lines (306 loc) • 11.1 kB
Markdown
# @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 ⇢](https://github.com/zerothrow/zerothrow/blob/main/ECOSYSTEM.md)
[](https://github.com/zerothrow/zerothrow/actions)



<div align="center">
<img src="https://raw.githubusercontent.com/flyingrobots/image-dump/refs/heads/main/optimized/marketing/brand/zerothrow-resilience.webp" height="300" />
</div>
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
```typescript
// 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
```bash
npm install @zerothrow/resilience @zerothrow/core
# or: pnpm add @zerothrow/resilience @zerothrow/core
```
## Quick Start
```typescript
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.
```typescript
// 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.
```typescript
// 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.
```typescript
// 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).
```typescript
// 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:
```typescript
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
```typescript
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
```typescript
// 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
```typescript
// 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
```typescript
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:
```typescript
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](https://github.com/zerothrow/zerothrow) for contribution guidelines.
## License
MIT