obviate
v0.1.2
Published
Minimal promise circuit breaker — trip after N failures, fail fast during cooldown, probe once before reopening. Zero deps.
Maintainers
Readme
obviate
Minimal promise circuit breaker — trip after N failures, fail fast during cooldown, probe once before reopening. Zero deps.
Overview
A circuit breaker protects distributed systems from cascading failures by failing fast when a dependency is experiencing issues. When wrapped in a circuit breaker, function calls are automatically blocked after consecutive failures, allowing the downstream service time to recover.
Installation
npm install obviate
# or
pnpm add obviate
# or
yarn add obviateQuick Start
import obviate from "obviate";
// Wrap an async function
const fetchUser = obviate(async (id: string) => {
const response = await fetch(`/api/users/${id}`);
return response.json();
}, {
threshold: 5, // trip after 5 consecutive failures
cooldownMs: 30000 // stay open for 30 seconds
});
// Use normally
try {
const user = await fetchUser("42");
console.log(user);
} catch (error) {
if (error.message === "Circuit breaker is open") {
console.log("Service temporarily unavailable");
}
}How It Works
The circuit breaker has three states:
Closed (default): Calls pass through normally. Consecutive failures increment a counter.
Open: After threshold failures, the breaker trips. All calls fail immediately with
BreakerOpenerror without invoking the wrapped function.Half-open: After cooldown period, the next call is allowed through as a "probe". If successful, the breaker closes and resets. If it fails, the breaker opens again.
API
obviate(fn, options)
Creates a circuit breaker that wraps an async function.
Parameters:
fn: The async function to wrapoptions: Configuration options (optional)
Returns: A circuit breaker function with state and methods
Options
interface BreakerOptions {
/** Consecutive failures to trip the breaker. Default: 5 */
threshold?: number;
/** Duration in milliseconds before attempting half-open probe. Default: 30000 */
cooldownMs?: number;
/** Determine if an error should count as a failure. Default: any throw counts */
isFailure?: (error: unknown) => boolean;
/** Callback fired when breaker state changes */
onStateChange?: (from: BreakerState, to: BreakerState) => void;
/** Test seam for time injection. Default: Date.now */
clock?: () => number;
}Circuit Breaker Interface
The returned circuit breaker has the following properties and methods:
interface Breaker<A extends unknown[], R> {
/** Execute the wrapped function with circuit breaker logic */
(...args: A): Promise<R>;
/** Current state: "closed" | "open" | "half-open" */
readonly state: BreakerState;
/** Number of consecutive failures counted */
readonly failures: number;
/** Manually trip the breaker to open state */
trip(): void;
/** Manually reset the breaker to closed state */
reset(): void;
}BreakerOpen Error
Thrown when the circuit breaker is in open state:
import { BreakerOpen } from "obviate";
try {
await breaker();
} catch (error) {
if (error instanceof BreakerOpen) {
console.log("Circuit breaker is open");
}
}Examples
Basic Usage
import obviate from "obviate";
const apiCall = obviate(async (endpoint: string) => {
const response = await fetch(endpoint);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}, {
threshold: 3,
cooldownMs: 10000
});
// Normal operation
const data = await apiCall("/api/data");
// After 3 failures, circuit opens
// All subsequent calls fail immediately with BreakerOpenSelective Failure Counting
Ignore certain errors (like 404) from tripping the breaker:
const smartBreaker = obviate(async (url: string) => {
const response = await fetch(url);
if (response.status === 404) {
const error = new Error("Not found");
(error as any).statusCode = 404;
throw error;
}
return response.json();
}, {
threshold: 5,
isFailure: (error) => {
// Don't count 404 errors toward tripping
const err = error as { statusCode?: number };
return err.statusCode !== 404;
}
});Manual Control
Force the breaker open based on external health checks:
const breaker = obviate(fetchUserData, { threshold: 3 });
// External health check fails
breaker.trip();
// Later, when service recovers
breaker.reset();State Monitoring
Track state changes for monitoring and alerting:
const breaker = obviate(apiCall, {
threshold: 3,
cooldownMs: 30000,
onStateChange: (from, to) => {
console.log(`Circuit breaker: ${from} → ${to}`);
if (to === "open") {
// Send alert to monitoring system
alertService.notify("Circuit breaker opened");
}
}
});Deterministic Testing
Inject a custom clock for predictable testing:
let testClock = 0;
const breaker = obviate(failingFunction, {
threshold: 2,
cooldownMs: 1000,
clock: () => testClock
});
// Control time for testing
testClock = 0;
await breaker(); // failure 1
await breaker(); // failure 2, trips to open
testClock = 1001; // advance past cooldown
await breaker(); // half-open probeUse Cases
Protecting External APIs
const paymentApi = obviate(async (paymentData) => {
return await fetch("/api/payments", {
method: "POST",
body: JSON.stringify(paymentData)
});
}, {
threshold: 3,
cooldownMs: 60000, // 1 minute cooldown
onStateChange: (from, to) => {
if (to === "open") {
logger.error("Payment API circuit opened");
}
}
});Database Connection Protection
const dbQuery = obviate(async (sql) => {
return await database.query(sql);
}, {
threshold: 5,
cooldownMs: 30000,
isFailure: (error) => {
// Only count connection errors, not query errors
return error.message.includes("connection");
}
});Microservice Resilience
// Different thresholds for different service tiers
const criticalService = obviate(apiCall, {
threshold: 2, // low tolerance
cooldownMs: 10000
});
const backgroundService = obviate(apiCall, {
threshold: 10, // high tolerance
cooldownMs: 60000
});Best Practices
Set Appropriate Thresholds
- Critical services: Lower threshold (2-3 failures)
- Non-critical services: Higher threshold (5-10 failures)
- Consider your service's typical failure patterns
Configure Cooldown Based on Recovery Time
- Set cooldown longer than typical service recovery time
- Too short: May overwhelm recovering service
- Too long: Unnecessary delays in attempting recovery
Use isFailure Selectively
- Don't count client errors (400, 404) toward tripping
- Focus on server errors (500, 503) that indicate service issues
- Consider network errors vs. application errors
Monitor State Changes
- Log all state transitions for observability
- Alert on circuit opens for critical services
- Track how often circuits trip to identify systemic issues
Test Failure Scenarios
- Use clock injection for deterministic testing
- Test half-open recovery behavior
- Verify concurrent call handling
Limitations
This is a minimal circuit breaker implementation focused on core functionality. It does not include:
- Request queuing: Calls during open state fail immediately; no queueing
- Metrics collection: No built-in metrics (use external monitoring)
- Automatic retry: No retry logic (use with a retry library)
- Timeout handling: Does not add timeouts to wrapped functions
- Rolling windows: Uses consecutive failure count, not time-based windows
- Distributed coordination: Each breaker instance is independent
Error Handling
The circuit breaker preserves error semantics:
- Closed state: Original errors are re-thrown
- Open state:
BreakerOpenerror is thrown immediately - Half-open state: Original errors are re-thrown, breaker re-opens
try {
await breaker();
} catch (error) {
if (error instanceof BreakerOpen) {
// Circuit is open, call blocked
handleCircuitOpen();
} else {
// Original error from wrapped function
handleOriginalError(error);
}
}Testing
The library supports deterministic testing through clock injection:
describe("my circuit breaker", () => {
it("should trip after threshold failures", async () => {
const clock = { now: 0 };
const breaker = obviate(async () => {
throw new Error("fail");
}, {
threshold: 3,
clock: () => clock.now
});
await breaker().catch(() => {}); // failure 1
await breaker().catch(() => {}); // failure 2
await breaker().catch(() => {}); // failure 3
expect(breaker.state).toBe("open");
});
});License
MIT
Related Packages
Caching & Concurrency:
- @azghr/filterkit — Framework-agnostic, type-safe filtering for TypeScript
- @azghr/singlet — Deduplicate concurrent async calls
- staleness — Stale-while-revalidate caching for async functions
Text Processing:
- @azghr/shorn — Truncate strings by byte budget without breaking graphemes
- seriatim — Sequential processing utilities
HTTP & Network:
- forbear — Read server rate-limit instructions from HTTP responses
- forestall — Delay execution until a condition is met
System & Process:
- quiesce — Ordered, timeboxed graceful shutdown for Node
- sortition — Deterministic percentage rollouts and A/B bucketing
- stanch — Stop flows or operations based on conditions
Utilities:
- expunge — Remove or exclude items from collections
- occlude — Hide or mask data and functionality
- placemark — Geographic location and mapping utilities
- specie — Currency and financial calculations
Contributing
Contributions are welcome! Please ensure all tests pass and maintain the minimalist philosophy of this package.
Keywords
circuit-breaker, resilience, retry, fault-tolerance, fail-fast, promise, async, breaker, recovery, threshold
