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#

StateBehavior
CLOSEDNormal operation. Calls pass through. Failures are counted.
OPENAll calls rejected fast (CircuitOpenError or fallback). After timeout → HALF-OPEN.
HALF-OPENLimited trial calls. If enough succeed → CLOSED. Any failure → OPEN.
DISABLEDManual override. All calls pass through, no tracking.

Installation#

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

typescript
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#

OptionDefaultDescription
failureThreshold5Consecutive failures to open circuit
successThreshold2Successes in half-open to close circuit
timeout30000Ms in open state before half-open
slidingWindowType"count"Window type: count-based or time-based
slidingWindowSize10Window size (calls or seconds)
minimumNumberOfCalls10Min calls before failure rate applies
failureRateThreshold50Failure rate % to trip circuit
slowCallDurationThreshold5000Ms to consider a call slow
slowCallRateThreshold100Slow call rate % to trip (100=disabled)
halfOpenMaxCalls1Max concurrent calls in half-open
initialState"closed"Initial circuit state
onOpen503 JSONCustom 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.

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

typescript
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#

typescript
breaker.reset();      // Force back to closed state
breaker.disable();    // All calls pass through (no tracking)
breaker.enable();     // Re-enable after disable

Events#

Subscribe to state changes, successes, and failures. Useful for logging, alerting, and monitoring.

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

TypeSizeBest For
countNumber of calls (e.g., 10)Steady traffic, simple thresholding
timeSeconds (e.g., 60)Variable traffic, time-based decay
typescript
// 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.

typescript
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#

typescript
const metrics = breaker.getMetrics();
// {
//   successes: 45,
//   failures: 3,
//   totalCalls: 48,
//   failureRate: 6.25,
//   slowCallRate: 0,
//   notPermittedCalls: 12,
// }

Example: Payment Service#

typescript
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(),
  });
});
Info
Circuit breaker instances are shared by name. If you use circuitBreaker("payment", ...) in multiple routes, they all share the same circuit state.