Circuit Breaker#
Fail-fast resilience pattern for protecting against cascading failures. When a downstream service starts failing, the circuit breaker opens and rejects calls immediately - avoiding slow timeouts and resource exhaustion. Based on patterns from Resilience4j, Polly-TS, and Fiber.
States#
| State | Behavior |
|---|---|
| CLOSED | Normal operation. Calls pass through. Failures are counted. |
| OPEN | All calls rejected fast (CircuitOpenError or fallback). After timeout → HALF-OPEN. |
| HALF-OPEN | Limited trial calls. If enough succeed → CLOSED. Any failure → OPEN. |
| DISABLED | Manual override. All calls pass through, no tracking. |
Installation#
import { CircuitBreaker, CircuitOpenError } from "@buntok/core";
import { circuitBreaker, getCircuitBreakers } from "@buntok/core/middlewares";As Middleware#
The simplest way to use the circuit breaker. Wrap specific routes or mount globally to protect all routes.
import { Buntok } from "@buntok/core";
import { circuitBreaker } from "@buntok/core/middlewares";
const app = new Buntok();
// Protect a specific route
app.post("/pay",
circuitBreaker("payment", {
failureThreshold: 5, // consecutive failures to trip
successThreshold: 2, // successes in half-open to close
timeout: 30000, // ms in open before half-open
slidingWindowType: "count", // "count" | "time"
slidingWindowSize: 10, // calls or seconds
minimumNumberOfCalls: 5, // min calls before failure rate applies
failureRateThreshold: 50, // % to trip
halfOpenMaxCalls: 1, // concurrent trial calls in half-open
slowCallDurationThreshold: 5000,
slowCallRateThreshold: 100, // 100 = disabled
onOpen: (ctx) => ctx.json(
{ error: "Service temporarily unavailable" },
503,
),
}),
async (ctx) => {
const result = await processPayment();
return ctx.json(result);
},
);
// Or protect all routes globally
app.use(circuitBreaker("global", { failureThreshold: 10 }));Options#
| Option | Default | Description |
|---|---|---|
| failureThreshold | 5 | Consecutive failures to open circuit |
| successThreshold | 2 | Successes in half-open to close circuit |
| timeout | 30000 | Ms in open state before half-open |
| slidingWindowType | "count" | Window type: count-based or time-based |
| slidingWindowSize | 10 | Window size (calls or seconds) |
| minimumNumberOfCalls | 10 | Min calls before failure rate applies |
| failureRateThreshold | 50 | Failure rate % to trip circuit |
| slowCallDurationThreshold | 5000 | Ms to consider a call slow |
| slowCallRateThreshold | 100 | Slow call rate % to trip (100=disabled) |
| halfOpenMaxCalls | 1 | Max concurrent calls in half-open |
| initialState | "closed" | Initial circuit state |
| onOpen | 503 JSON | Custom response when circuit is open (middleware only) |
Programmatic Usage#
Use CircuitBreaker directly for more control. The fire() method wraps any async function with circuit protection.
import { CircuitBreaker, CircuitOpenError } from "@buntok/core";
const breaker = new CircuitBreaker("payment", {
failureThreshold: 5,
timeout: 30000,
});
// Basic usage
try {
const result = await breaker.fire(() => processPayment());
console.log("Payment succeeded:", result);
} catch (err) {
if (err instanceof CircuitOpenError) {
console.log(`Circuit open, retry after: ${err.retryAfter}`);
}
}
// With fallback
const result = await breaker.fire(
() => processPayment(),
{ fallback: () => cachedPaymentResult },
);Fallback#
Provide a fallback function to return a default value when the circuit is open or the call fails. This prevents errors from propagating to the caller.
const breaker = new CircuitBreaker("weather-api", {
failureThreshold: 3,
timeout: 60000,
});
const weather = await breaker.fire(
() => fetchWeatherFromAPI(),
{
fallback: () => ({
temperature: 25,
condition: "cached",
message: "Using cached data",
}),
},
);Manual Control#
breaker.reset(); // Force back to closed state
breaker.disable(); // All calls pass through (no tracking)
breaker.enable(); // Re-enable after disableEvents#
Subscribe to state changes, successes, and failures. Useful for logging, alerting, and monitoring.
breaker.on("open", () => logAlert("Circuit opened!"));
breaker.on("close", () => logInfo("Circuit closed - recovered"));
breaker.on("halfOpen", () => logInfo("Testing recovery..."));
breaker.on("success", (e) => logInfo(`Success in ${e.duration}ms`));
breaker.on("failure", (e) => logError(`Failed: ${e.error.message}`));
breaker.on("stateChange", (e) => logInfo(`${e.from} → ${e.to}`));
breaker.on("callNotPermitted", () => logWarn("Call rejected - circuit open"));
// Unsubscribe
const unsub = breaker.on("failure", handler);
unsub();Sliding Windows#
The circuit breaker uses a sliding window to track call outcomes. Two strategies are available:
| Type | Size | Best For |
|---|---|---|
| count | Number of calls (e.g., 10) | Steady traffic, simple thresholding |
| time | Seconds (e.g., 60) | Variable traffic, time-based decay |
// Count-based (default)
new CircuitBreaker("api", {
slidingWindowType: "count",
slidingWindowSize: 20, // last 20 calls
minimumNumberOfCalls: 5, // need at least 5 calls
failureRateThreshold: 50, // 50% failure rate trips
});
// Time-based
new CircuitBreaker("api", {
slidingWindowType: "time",
slidingWindowSize: 60, // last 60 seconds
minimumNumberOfCalls: 5,
failureRateThreshold: 50,
});Health Checks#
Expose circuit breaker state in your health endpoint. Use getCircuitBreakers() to get all registered instances.
import { getCircuitBreakers } from "@buntok/core/middlewares";
app.get("/health", (ctx) => {
const breakers = getCircuitBreakers();
const status = Object.fromEntries(
[...breakers.entries()].map(([name, b]) => [
name,
{
state: b.getState(),
metrics: b.getMetrics(),
},
]),
);
const allHealthy = Object.values(status).every(
(s: any) => s.state !== "open",
);
return ctx.json(
{ status: allHealthy ? "healthy" : "degraded", circuits: status },
allHealthy ? 200 : 503,
);
});Metrics#
const metrics = breaker.getMetrics();
// {
// successes: 45,
// failures: 3,
// totalCalls: 48,
// failureRate: 6.25,
// slowCallRate: 0,
// notPermittedCalls: 12,
// }Example: Payment Service#
import { Buntok } from "@buntok/core";
import { circuitBreaker } from "@buntok/core/middlewares";
const app = new Buntok();
const paymentBreaker = circuitBreaker("payment", {
failureThreshold: 3,
successThreshold: 2,
timeout: 30000,
slidingWindowSize: 10,
minimumNumberOfCalls: 3,
failureRateThreshold: 50,
onOpen: (ctx) =>
ctx.json(
{
error: "Payment service temporarily unavailable",
message: "Please try again later",
retryAfter: 30,
},
503,
),
});
app.post("/checkout", paymentBreaker, async (ctx) => {
const { items, userId } = ctx.body();
const payment = await processPayment(userId, items);
return ctx.json({
success: true,
transactionId: payment.id,
});
});
app.get("/payment-status", async (ctx) => {
// Access the breaker directly for status
const { getCircuitBreakers } = await import("@buntok/core/middlewares");
const breakers = getCircuitBreakers();
const payment = breakers.get("payment");
return ctx.json({
state: payment?.getState(),
metrics: payment?.getMetrics(),
});
});circuitBreaker("payment", ...) in multiple routes, they all share the same circuit state.