npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

bunbreaker

v0.1.2

Published

Bun-native fault isolation & circuit breaker library. Zero dependencies, Redis + SQLite + Memory stores, Bun.cron health probes, framework adapters.

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 FetchfetchWithBreaker() cancels TCP connections on timeout via AbortController
  • 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 ProbesBun.cron in-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)
  • Disposableawait using cb = await createBreaker(...) with Symbol.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 failureThreshold OR percentageThreshold, 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