bunbreaker
v0.1.2
Published
Bun-native fault isolation & circuit breaker library. Zero dependencies, Redis + SQLite + Memory stores, Bun.cron health probes, framework adapters.
Maintainers
Readme
bunbreaker
Bun-native circuit breaker with built-in retry, abort-aware fetch, error mapping, and diagnostics. Zero dependencies — Redis + SQLite + Memory tiered storage, Bun.cron health probes, ElysiaJS & Hono adapters.
Minimum runtime: Bun >= 1.3.12
Features
- Circuit Breaker — CLOSED → OPEN → HALF_OPEN state machine with configurable thresholds
- Capacity Limiter — Per-breaker concurrent execution semaphore to prevent overwhelming upstream
- Built-in Retry — Exponential backoff with jitter, per-error retryability, total time budgets
- Abort-Aware Fetch —
fetchWithBreaker()cancels TCP connections on timeout viaAbortController - Dual Threshold Modes — Absolute failure count or percentage-based (like Opossum's
errorThresholdPercentage) - Error Classification — Built-in classifier for HTTP status, network errors, timeouts. Fully overridable
- Error Mapping — Transform
CircuitOpenError→ your domain errors before they leave the breaker - Three-Tier Storage — Redis (primary) → SQLite (fallback + audit) → Memory (last resort)
- Auto-Failover — Redis meta-breaker detects failures and switches to SQLite transparently
- Sliding Window — True sliding window via Redis sorted sets + atomic Lua scripts
- Health Probes —
Bun.cronin-process scheduler probes OPEN circuits automatically - Fallback Queue — SQLite-backed outbox replays events when services recover
- Diagnostics — Per-breaker stats + aggregate snapshot for health endpoints
- Alert Adapters — Resend, Telegram, Webhook (pure functions, zero coupling)
- Framework Adapters — ElysiaJS and Hono (optional, thin wrappers)
- Disposable —
await using cb = await createBreaker(...)withSymbol.asyncDispose - Zero npm dependencies — Built entirely on Bun primitives
Quick Start
import { createBreaker, telegramAlert } from "bunbreaker";
const cb = await createBreaker({
redisUrl: process.env.REDIS_URL,
sqlite: { path: "./bunbreaker.db" },
});
// Create a named circuit breaker
const paymentBreaker = cb.for("payment-api", {
failureThreshold: 5,
windowSecs: 60,
resetTimeoutSecs: 30,
timeoutMs: 8000,
fallback: async () => ({ status: "queued" }),
});
// Execute a protected call
const result = await paymentBreaker.execute(
() => fetch("https://payments.example.com/charge", {
method: "POST",
body: JSON.stringify(payload),
}),
payload // optional — enqueued when circuit is OPEN
);
// Subscribe to events
cb.events
.on("opened", telegramAlert(process.env.TG_TOKEN!, process.env.TG_CHAT!))
.on("closed", (e) => console.log(`${e.name} recovered`))
.on("*", (e) => metrics.increment(`breaker.${e.type}`));
// Health status
const health = cb.health();
// → { currentLayer: "redis", redis: { open: false, failures: 0, recoversAt: null } }
// Diagnostics
const snap = await cb.diagnostics();
// → { summary: { openBreakers: 0, totalRequests: 42, ... }, breakers: [...] }
// Graceful shutdown
await cb.shutdown();Retry
Built-in retry with exponential backoff, jitter, and total time budgets. Each retry attempt gets its own timeout — retries don't eat into a shared budget.
const breaker = cb.for("flaky-api", {
failureThreshold: 5,
windowSecs: 60,
resetTimeoutSecs: 30,
timeoutMs: 3000,
retry: {
retries: 3,
factor: 2, // exponential backoff factor (default: 2)
minTimeoutMs: 250, // minimum delay between retries
maxTimeoutMs: 5000, // maximum delay between retries
maxRetryTimeMs: 10000, // total wall-clock budget for all retries
shouldRetry: (err) => {
// Override per-error retryability (default: uses error classifier)
return !(err instanceof PaymentError);
},
onRetry: (err, attempt, retriesLeft) => {
logger.warn(`Retry ${attempt}, ${retriesLeft} left`, err);
},
},
});
// Only the FINAL error (after all retries) counts toward the breaker
const result = await breaker.execute(() => callFlakyService());Abort-Aware Fetch
fetchWithBreaker() creates a per-attempt AbortController that actually cancels the TCP connection on timeout — unlike execute(() => fetch(...)) which just races the promise.
import { fetchWithBreaker } from "bunbreaker";
const breaker = cb.for("external-api", {
failureThreshold: 5,
windowSecs: 60,
resetTimeoutSecs: 30,
timeoutMs: 5000,
});
// Basic usage — abort on timeout, classify 5xx responses automatically
const response = await fetchWithBreaker(breaker, "https://api.example.com/data");
// With per-fetch retry (independent from breaker's retry config)
const response = await fetchWithBreaker(
breaker,
"https://api.example.com/data",
{ method: "POST", body: JSON.stringify(data) },
{
timeoutMs: 3000, // override breaker's timeout for this call
retry: { retries: 2, minTimeoutMs: 100 },
}
);You can also use executeWithAbort() directly for non-fetch workloads that support cancellation:
const result = await breaker.executeWithAbort(async (signal) => {
const response = await fetch("https://api.example.com/stream", { signal });
return response.json();
});Percentage-Based Thresholding
Instead of a fixed failure count, trip the circuit when the error rate exceeds a percentage. Requires a minimum request volume to prevent false positives on low traffic.
const breaker = cb.for("high-traffic-api", {
percentageThreshold: 50, // trip at 50% error rate
volumeThreshold: 20, // need at least 20 requests before evaluating
windowSecs: 60,
resetTimeoutSecs: 30,
timeoutMs: 5000,
});Note: Use
failureThresholdORpercentageThreshold, not both.
Capacity Limiter
Limit the number of concurrent in-flight executions per breaker. When the limit is reached, new requests are rejected immediately (via fallback or CircuitOpenError) — even if the circuit is CLOSED.
This prevents overwhelming a slow or degraded upstream service with unbounded concurrency.
const breaker = cb.for("payment-api", {
failureThreshold: 5,
windowSecs: 60,
resetTimeoutSecs: 30,
timeoutMs: 8000,
capacity: 40, // max 40 concurrent requests
});
// If 40 requests are already in-flight, this rejects immediately
const result = await breaker.execute(() => paymentService.charge(body));Enabled Kill-Switch
Disable a circuit breaker at runtime without removing it. When enabled is false, all calls pass straight through to the wrapped function with no circuit breaker logic — no state checks, no failure counting, no timeout racing. Stats are still tracked for observability.
const breaker = cb.for("payment-api", {
failureThreshold: 5,
windowSecs: 60,
resetTimeoutSecs: 30,
timeoutMs: 8000,
enabled: process.env.PAYMENT_BREAKER_ENABLED !== "false", // runtime kill-switch
});Error Classification
The built-in classifier decides which errors count toward the threshold and which are retryable:
| Error Type | Counts? | Retries? | Trips? | |-----------|---------|----------|--------| | 5xx Server | ✅ | ✅ | ✅ | | 429 Rate Limited | ✅ | ✅ | ✅ | | 4xx Client | ❌ | ❌ | — | | Network failure | ✅ | ✅ | ✅ | | Timeout | ✅ | ✅ | ✅ | | Validation/Business | ❌ | ❌ | — |
Custom Error Classifier
const breaker = cb.for("service", {
failureThreshold: 5,
windowSecs: 60,
resetTimeoutSecs: 30,
timeoutMs: 5000,
errorClassifier: (err) => ({
shouldCount: true, // count toward failure threshold
shouldRetry: true, // eligible for retry
shouldTrip: false, // count for health metrics, but don't trip the circuit
}),
});The shouldTrip field lets you separate "count for health metrics" from "trigger OPEN". For example, you might want to track 429s in failure stats but not trip the circuit for rate limiting.
Error Mapping
Map breaker errors to your application's domain errors before they leave the library:
import { CircuitOpenError, BreakerTimeoutError } from "bunbreaker";
const breaker = cb.for("payment-api", {
failureThreshold: 5,
windowSecs: 60,
resetTimeoutSecs: 30,
timeoutMs: 5000,
errorMapper: (err, ctx) => {
if (err instanceof CircuitOpenError) {
return new ThirdPartyCircuitOpenError(ctx.name);
}
if (err instanceof BreakerTimeoutError) {
return new ThirdPartyTimeoutError(ctx.name);
}
return err instanceof Error ? err : new Error(String(err));
},
});The ctx parameter includes { name, state, config } for context-aware mapping.
Diagnostics
Get runtime stats for all registered breakers:
const snapshot = await cb.diagnostics();
// {
// generatedAt: "2024-01-15T10:30:00.000Z",
// storeHealth: { currentLayer: "redis", ... },
// summary: {
// registeredBreakers: 3,
// openBreakers: 1,
// halfOpenBreakers: 0,
// closedBreakers: 2,
// totalRequests: 1542,
// totalFailures: 23,
// totalTimeouts: 5,
// totalRejects: 12,
// },
// breakers: [
// {
// name: "payment-api",
// state: "CLOSED",
// config: { ... },
// stats: {
// createdAt: 1705312200000,
// useCount: 500,
// successCount: 487,
// failureCount: 13,
// rejectCount: 0,
// timeoutCount: 3,
// retryCount: 8,
// lastUsedAt: 1705312500000,
// lastOpenedAt: 1705312100000,
// lastClosedAt: 1705312150000,
// },
// },
// ...
// ],
// }Per-breaker stats are also available directly:
const stats = breaker.getStats();Framework Adapters
ElysiaJS
import { Elysia } from "elysia";
import { createBreaker } from "bunbreaker";
import { elysiaBreaker } from "bunbreaker/elysia";
const cb = await createBreaker({ redisUrl: process.env.REDIS_URL });
const app = new Elysia()
.use(elysiaBreaker(cb))
.post("/checkout", async ({ breaker, body }) => {
return await breaker
.for("payment-api", {
failureThreshold: 5,
windowSecs: 60,
resetTimeoutSecs: 30,
timeoutMs: 8000,
})
.execute(() => paymentService.charge(body));
});Hono
import { Hono } from "hono";
import { createBreaker } from "bunbreaker";
import { honoBreaker } from "bunbreaker/hono";
const cb = await createBreaker({ redisUrl: process.env.REDIS_URL });
const app = new Hono();
app.use("*", honoBreaker(cb));
app.get("/resource", async (c) => {
const result = await c.var.breaker
.for("upstream", {
failureThreshold: 5,
windowSecs: 60,
resetTimeoutSecs: 30,
timeoutMs: 5000,
})
.execute(() => fetchUpstream());
return c.json(result);
});Bun.serve (Standalone)
import { createBreaker } from "bunbreaker";
await using cb = await createBreaker({
sqlite: { path: "./bunbreaker.db" },
});
const apiBreaker = cb.for("external-api", {
failureThreshold: 3,
windowSecs: 30,
resetTimeoutSecs: 15,
timeoutMs: 5000,
});
Bun.serve({
async fetch(req) {
const url = new URL(req.url);
if (url.pathname === "/health") {
return Response.json(cb.health());
}
if (url.pathname === "/diagnostics") {
return Response.json(await cb.diagnostics());
}
if (url.pathname === "/api/data") {
try {
const data = await apiBreaker.execute(() =>
fetch("https://api.example.com/data").then((r) => r.json())
);
return Response.json(data);
} catch (err) {
return Response.json({ error: "Service unavailable" }, { status: 503 });
}
}
return new Response("Not found", { status: 404 });
},
port: 3000,
});Configuration
createBreaker(config)
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| redisUrl | string? | — | Redis connection URL. Omit for SQLite/Memory only |
| sqlite.path | string | ./bunbreaker.db | SQLite database file path |
| sqlite.deliveredRetentionSecs | number | 604800 (7d) | Retain delivered events |
| sqlite.deadRetentionSecs | number | 2592000 (30d) | Retain dead events |
| sqlite.autoPurge | boolean | true | Auto-purge old events |
| sqlite.purgeSchedule | string | 0 3 * * * | Purge cron (UTC) |
| probeSchedule | string | * * * * * | Health probe cron (UTC) |
| memoryCacheTtlMs | number | 7000 | Memory cache TTL in ms |
.for(name, config) — Breaker Config
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| failureThreshold | number? | — | Absolute failure count to trigger OPEN |
| percentageThreshold | number? | — | Error % (0–100) to trigger OPEN |
| volumeThreshold | number? | — | Minimum requests before percentage check |
| windowSecs | number | — | Sliding window duration in seconds |
| resetTimeoutSecs | number | — | Seconds in OPEN before HALF_OPEN |
| timeoutMs | number | — | Max ms to wait for fn() |
| capacity | number? | — | Max concurrent in-flight executions |
| enabled | boolean? | true | Set false to bypass all breaker logic |
| retry | RetryConfig? | — | Retry configuration (see below) |
| errorMapper | ErrorMapper? | — | Map errors to domain types |
| errorClassifier | function? | — | Override default classification |
| fallback | function? | — | Called when OPEN instead of throwing |
| queueOnOpen | boolean? | true | Enqueue payloads when OPEN |
RetryConfig
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| retries | number | — | Number of retry attempts |
| factor | number | 2 | Exponential backoff factor |
| minTimeoutMs | number | 250 | Minimum delay between retries |
| maxTimeoutMs | number | 5000 | Maximum delay between retries |
| maxRetryTimeMs | number | Infinity | Total wall-clock budget |
| shouldRetry | function? | — | Override per-error retryability |
| onRetry | function? | — | Called on each retry attempt |
Architecture
┌─────────────────────────────────────────────────────┐
│ BunbreakerInstance │
│ .for() .events .health() .diagnostics() │
├─────────────────────────────────────────────────────┤
│ CircuitBreaker │
│ execute() → raceWithTimeout → classify → threshold │
│ executeWithAbort() → AbortController → classify │
│ retry integration → only final error counts │
├─────────────────────────────────────────────────────┤
│ StoreManager │
│ Redis → SQLite → Memory (fallback) │
│ Meta-breaker on Redis itself │
├──────────┬──────────────┬───────────────────────────┤
│ RedisStore│ SQLiteStore │ MemoryStore │
│ Sorted set│ WAL mode │ Map + TTL │
│ Lua atomic│ Audit log │ Last resort │
│ Pub/Sub │ Event queue │ │
└──────────┴──────────────┴───────────────────────────┘API Reference
CircuitBreaker
| Method | Description |
|--------|-------------|
| execute(fn, payload?) | Execute with timeout race + optional retry |
| executeWithAbort(fn, payload?) | Execute with AbortSignal on timeout |
| executeSelfTimed(fn, payload?) | Execute without timeout (caller manages timeout) |
| getState() | Get current circuit state |
| getStats() | Get diagnostics stats snapshot |
BunbreakerInstance
| Method | Description |
|--------|-------------|
| for(name, config) | Create or retrieve a named breaker |
| events | Typed event emitter |
| probe(name, config) | Register a health probe |
| health() | Get store health status |
| queue | Access the local event queue |
| replayer(config?) | Create an event replayer |
| diagnostics() | Get full diagnostics snapshot |
| maintenance() | Manual SQLite VACUUM |
| shutdown() | Graceful shutdown |
Standalone Functions
| Function | Description |
|----------|-------------|
| fetchWithBreaker(breaker, input, init?, options?) | Abort-aware fetch with circuit breaker |
| executeWithRetry(fn, ctx) | Pure retry engine (no breaker dependency) |
| classifyError(err) | Default error classifier |
Events
cb.events
.on("opened", (e) => { /* e.name, e.failures, e.ts */ })
.on("closed", (e) => { /* e.name, e.ts */ })
.on("half_open", (e) => { /* e.name, e.ts */ })
.on("rejected", (e) => { /* e.name, e.ts */ })
.on("fallback", (e) => { /* e.name, e.ts */ })
.on("ignored_error", (e) => { /* e.name, e.reason, e.ts */ })
.on("queue_error", (e) => { /* e.name, e.reason, e.ts */ })
.on("queue_purge_warning", (e) => { /* e.name, e.deadCount, e.ts */ })
.on("*", (e) => { /* wildcard — all events */ });License
MIT
