Skip to content

laurells/redeye

Repository files navigation

redeye

CI npm version license

A circuit breaker for Node.js with an optional distributed mode: instead of tracking failures only in the memory of one process, breaker state lives in Redis (or any store you plug in), so a failure burst against a downstream dependency trips the breaker for every instance sharing that store — not just the one that saw the failures.

When paired with RedisStore, redeye is a reliable distributed circuit breaker, not just a best-effort one: failure counting is atomic (a Redis Lua script, not a racy get-then-set), and recovery goes through a real half-open state where exactly one instance gets to try the dependency again while everyone else stays blocked — the two properties most local-only or naively-distributed circuit breakers skip.

Read Reliability model & limitations before using this for anything load-bearing. A handful of tradeoffs are fundamental to any distributed system (trusting your store, tolerating clock skew) and no library can engineer those away — that section is honest about exactly which ones those are, and which ones redeye actually solves.

Install

npm install redeye-breaker

Redis support is an optional peer dependency — only needed if you use RedisStore:

npm install ioredis

Usage

Local mode (default, no dependencies)

import { CircuitBreaker } from 'redeye-breaker';

const breaker = new CircuitBreaker({
  failureThreshold: 5,
  resetTimeout: 60_000,
});

const result = await breaker.execute('payment-gateway', () => callPaymentGateway());

Local mode gets real half-open (single in-flight trial) and backoff+jitter too — it's single-process, so there's no atomicity concern to begin with.

Local mode vs. distributed mode: which do you need?

Local mode is not the "lesser" option — for a lot of services, N independent per-instance breakers is the right answer, not a compromise:

  • Use local mode when each instance tripping independently is fine, or even preferable: instances see meaningfully different traffic, the dependency is instance-local anyway (a sidecar, a per-instance cache), or you'd rather each instance protect itself than take on a new piece of shared infrastructure (the store) that can itself fail. Zero dependencies, zero added latency per call, nothing extra to operate.
  • Use distributed mode when a failure burst against a shared downstream dependency should trip the breaker for the whole fleet at once — e.g. a payment gateway or third-party API, where 5 of 6 instances continuing to hammer a dependency the 6th just found dead is actively harmful (wasted capacity, a worse incident, a slower recovery signal). This costs something real in return: a store round trip on every gated decision (see item 1 below), and a new dependency whose own outages now shape the breaker's behavior (see Total coordination-layer outage).

If you're not sure, start local — it's strictly cheaper, and nothing about switching to distributed mode later changes your call sites.

Two tripping strategies

// Default: trips after N failures in a row. Good for hard drops (a
// dependency going fully down). Resets to zero on any success, so it
// will not catch a dependency that's merely degraded.
new CircuitBreaker({ strategy: 'consecutive', failureThreshold: 5 });

// Trips when an EWMA-smoothed failure rate crosses a threshold, once
// enough samples have been seen. Catches flapping/degrading dependencies
// a consecutive-failure breaker structurally cannot: e.g. an API that
// fails 4 out of every 5 requests (80% failure rate) never fails twice
// in a row in that exact pattern, so 'consecutive' never trips — but
// 'errorRate' does, because it looks at the rate, not the streak.
new CircuitBreaker({
  strategy: 'errorRate',
  errorRateThreshold: 0.5, // open at >= 50% failure rate
  minimumCalls: 10,        // ...but only once we've seen at least 10 calls
  errorRateDecay: 0.9,     // how much weight recent calls get vs. history
});

Pick consecutive when you mainly care about clean outages, errorRate when the dependency is more likely to degrade than to go fully dark. You can run two breaker instances with different strategies over the same logical operation for both kinds of protection at once — in distributed mode, give each its own RedisStore keyPrefix (or the two breakers will read/write the same Redis key with incompatible state shapes and corrupt each other).

Distributed mode (Redis-backed, fully atomic)

import { CircuitBreaker } from 'redeye-breaker';
import { RedisStore } from 'redeye-breaker/redis-store';
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);
const store = new RedisStore(redis, { keyPrefix: 'myapp:' });

const breaker = new CircuitBreaker({
  failureThreshold: 5,
  resetTimeout: 60_000,
  store, // <- presence of a store is what enables distributed mode
  onStateChange: (state, operation) => {
    console.warn(`circuit breaker for ${operation} is now ${state}`);
  },
  onStoreError: (error, operation) => {
    // Redis unreachable, timed out, etc. — see "Store unavailability" below.
    console.error(`circuit breaker store error for ${operation}`, error);
  },
});

await breaker.execute('payment-gateway', () => callPaymentGateway());

Every process pointed at the same Redis instance (and using the same operation name / key prefix) shares breaker state, with atomic counting and single-trial recovery.

RedisStore stores state under {keyPrefix}circuit_breaker:{operation}, plus {keyPrefix}circuit_breaker:{operation}:trial while a half-open trial is in flight. keyPrefix defaults to '' (no prefix) — set one (as above) if you share a Redis instance/DB across services and want your keys namespaced.

Bring your own store

RedisStore implements a Store interface with two required methods and seven optional ones:

export interface Store {
  get<T>(key: string): Promise<T | null>;
  set<T>(key: string, value: T, ttlSeconds: number): Promise<void>;
  del(key: string): Promise<void>;

  // Optional — implement these for the reliability guarantees below.
  // Without them, redeye falls back to best-effort semantics and logs a
  // one-time warning telling you exactly what's degraded.
  recordFailureAtomic?(key: string, opts: { ttlSeconds: number; failureThreshold: number; now: number; operation: string }): Promise<CircuitBreakerState>;
  recordOutcomeAtomic?(key: string, opts: { success: boolean; decay: number; minimumCalls: number; errorRateThreshold: number; ttlSeconds: number; now: number; operation: string }): Promise<CircuitBreakerState & { openedNow: boolean }>;
  claimTrial?(key: string, ttlSeconds: number): Promise<string | null>;
  releaseTrial?(key: string, token: string): Promise<void>;

  // Optional — only needed for the closed-state local cache (`localCache`, see below).
  closeAtomic?(key: string, eventsKey: string, opts: { ttlSeconds: number; operation: string }): Promise<CircuitBreakerState>;
  reopenTrialFailureAtomic?(key: string, eventsKey: string, opts: { ttlSeconds: number; failureThreshold: number; now: number; operation: string }): Promise<CircuitBreakerState>;
  subscribeTransitions?(eventsKey: string, handler: (event: TransitionEvent | null) => void): Promise<() => void>;
}
  • recordFailureAtomic (strategy: 'consecutive') should increment the failure count and decide isOpen in one atomic round trip (a Lua script in Redis, a conditional update in DynamoDB, etc.). Should also bump a monotonic version counter and publish a transition event to eventsKey (see below) when — and only when — this call flips isOpen; a plain sub-threshold increment isn't a transition and shouldn't publish one.
  • recordOutcomeAtomic (strategy: 'errorRate') should fold one call's outcome into the EWMA rate and decide isOpen in one atomic round trip — called on every closed-phase call, not just failures. Same version/publish rule as above, keyed off an isOpen flip.
  • claimTrial should be a conditional "create if absent" write (SET key token NX EX ttl in Redis, with token a fresh unique value per call, e.g. a UUID) — it's what makes half-open recovery exclusive to one caller instead of a free-for-all. Return the token you wrote if this call won the claim, or null if the key was already taken.
  • releaseTrial should be a compare-and-delete (if GET key == token then DEL key in Redis, a conditional delete elsewhere), not an unconditional delete. This is what stops a trial that outran its TTL (see item 11) from deleting a different instance's newer claim on the same key when it finally gets around to releasing its own now-stale one. If omitted (or if a call has no confirmed ownership token to release at all — e.g. claimTrial itself errored), redeye logs a one-time warning and leaves the claim for its TTL to expire naturally, rather than risk an unconditional delete that could destroy a claim it never confirmed was its own.
  • closeAtomic should close key, bumping version and publishing a transition, but only if there was anything to clear (it was open, or had accumulated sub-threshold failures) — a true no-op, no write at all, when the state was already clean. This is what lets the healthy-path write-skip (see limitation item 1) happen atomically server-side instead of relying on the caller's own possibly-stale view.
  • reopenTrialFailureAtomic should reopen key after a failed half-open trial — isOpen: true, openCount + 1, failures raised to at least failureThreshold, lastFailure: now, version + 1 — and always publish a transition (a failed trial is always a real transition, unlike closeAtomic).
  • subscribeTransitions should subscribe to a stream of transition events at eventsKeyone shared stream per store/prefix, not one per operation — and invoke handler with each decoded event, or with null whenever the underlying subscription drops or errors (callers must then distrust every locally cached entry until it's re-verified by a real read; implementations should retry their own subscription with backoff, not expect the caller to re-subscribe). Returns an unsubscribe function. Must support more than one concurrent subscriber on the same store instance — multiple CircuitBreakers sharing one store, all with localCache set, each call this independently, and all of them need events, not just the first; RedisStore fans one shared connection out to every registered handler for exactly this reason.

Implement the two required methods against Memcached, DynamoDB, your own cache wrapper, etc., and you have a working (best-effort) distributed breaker. Add recordFailureAtomic/recordOutcomeAtomic/claimTrial/releaseTrial when that store supports a real atomic increment and a real conditional write, and you get the full reliability guarantees. Add closeAtomic/reopenTrialFailureAtomic/subscribeTransitions on top of that if you also want the closed-state local cache — every capability is independently optional, so skip any subset that doesn't fit your store.

Local caching (hybrid mode)

Everything up to this point reads the store on every gate check (a genuine read every closed-path call; a cached, 0-read rejection once open — see limitation item 1). localCache goes one step further, opt-in, only for RedisStore (or any store implementing subscribeTransitions): it caches the closed state locally too, invalidated push-style instead of polling, so a healthy call can skip the store read entirely, not just the write.

const breaker = new CircuitBreaker({
  failureThreshold: 5,
  resetTimeout: 60_000,
  store,
  localCache: { staleToleranceMs: 100 }, // opt-in; default staleToleranceMs is 100 once the object is present
});

What's cached, and how it's invalidated. Per operation, redeye keeps { state, version, fetchedAt, trusted } in memory. A gate check allows immediately, with zero store reads, when the entry is trusted, closed (!state.isOpen), and fresher than staleToleranceMs. Three independent mechanisms keep this from going stale silently:

  • Push invalidation. subscribeTransitions delivers a decoded event the instant any instance's write flips isOpen or clears real accumulated state (never on a plain sub-threshold counter update — those aren't transitions and don't invalidate the cache, by design, since they can't affect the closed fast path this cache serves). A higher version than what's cached overwrites the entry immediately.
  • Fallback poll. The existing local-mode monitor interval doubles as a missed-message safety net in distributed mode: any cached entry older than 5s gets one authoritative get and is reconciled by version. In steady state (no missed messages) this is the only background traffic the cache generates — one GET per stale operation per sweep, not per call.
  • Your own writes are free. The state recordFailureAtomic/recordOutcomeAtomic/closeAtomic/reopenTrialFailureAtomic hand back already updates this instance's own cache — the instance that causes a transition never has a stale window on it, regardless of stream or poll timing.

The fail-open window, as a formula, not an adjective. This is the one place in redeye that's biased toward availability over freshness on the closed side (everywhere else — the open-state cache, failOpenOnStoreError: false, etc. — is fail-closed or symmetric by default). If the breaker trips elsewhere in the fleet, a call landing on this instance's still-fresh cached-closed entry can pass through before that instance learns about it. The bound is exactly:

worst case:    staleToleranceMs
typical case:  one stream delivery (usually much shorter than staleToleranceMs)

staleToleranceMs is the hard ceiling, full stop, not one term in a larger sum — the cache stops trusting itself the instant it elapses, regardless of whether the transition event ever arrives (that's what makes the fallback poll a correctness backstop and not just a nice-to-have: a dropped or delayed event can never push the window past this bound, only fail to shorten it). Delivery latency only ever makes the actual window tighter than the ceiling, never looser — in the common case it's one Redis stream round trip, resolving the window well before staleToleranceMs would have on its own. Set staleToleranceMs to whatever window you're comfortable a downstream outage could go un-noticed on this one path — 100ms (the default) is a reasonable starting point for most services; push it lower if even a hundred milliseconds of continued traffic against a freshly-tripped dependency is unacceptable, at the cost of the cache being useful for a shorter fraction of the healthy path.

Compare this to the open-state cache deliberately. openCacheRefreshMs never risks an extra call through — staleness there can only make a rejection late, not wrong, because it always falls through to a real read right at the real eligibility instant (see limitation item 1). localCache's closed-side cache is the opposite shape: staleness there can make an allow wrong for a bounded window, in exchange for the healthy path's read too. That's the real tradeoff you're opting into, not a bug to route around — the same one Envoy's outlier detection makes by default (cache state locally with a bounded TTL, accept staleness, skip the round trip), just off by default here and explicit about the bound when you turn it on.

The version/absence rule, and mixed-fleet rolling deploys. CircuitBreakerState.version is a monotonic counter that increments only on a real transition — its absence (not 0) means "written by a library version that predates localCache, provenance unknown." A cache entry built from a version-less read is marked trusted: false and never used for the fast path; the very next call for that operation performs a real read instead, forever, until a version-bearing write appears. This is what makes a rolling deploy safe: instances running an old version keep working exactly as before (they don't publish transitions, and their writes lack version), and new instances simply never trust a versionless observation rather than guessing. Nothing about this requires deploying in any particular order.

Strategy asymmetry, stated plainly. consecutive is the strategy this cache is built for: combined with the healthy-path write skip, a run of successes against a warm cache is a genuine 0-store-op healthy path. errorRate cannot get the same benefit — recordOutcomeAtomic must fold every outcome (success or failure) into the EWMA to keep the rate meaningful, so it floors at 1 write per call no matter what's cached. localCache still saves the read for errorRate the same way it does for consecutive; it just can't save the write, structurally, not as an oversight.

Read "0-store-op" as decoupled from request volume, not as a literal constant: under sustained load it's at most one read per staleToleranceMs window (a real read resets that window's clock), plus one poll per idle operation roughly every 5s if nothing else refreshes it sooner. A short benchmark run's literal op count is partly an artifact of the run fitting inside a handful of those windows — see BENCHMARK.md.

Requires subscribeTransitions. Without it (a custom Store that doesn't implement the capability), localCache is a no-op: one-time warning, and behavior is identical to not setting it at all. RedisStore implements it via a dedicated (duplicate()d) blocking connection running XREAD BLOCK against the shared circuit_breaker:events stream, reconnecting with backoff on its own if that connection drops — see the Store interface docs above for the reconnect contract. If multiple CircuitBreakers share one RedisStore instance, each with localCache set, they all fan out onto that same connection rather than fighting over it — the second-and-later subscriber isn't silently left without a working cache. Use isLocalCacheActive() (see the API section) to confirm any given breaker's subscription actually established.

API

  • execute(operation, fn) — runs fn if the breaker allows it (closed, or this call won the half-open trial); throws immediately without calling fn otherwise. Records the outcome automatically.
  • canExecute(operation) — synchronous, read-only, best-effort check. In distributed mode it can't await the store, so it can only consult the local open-state cache (see openCacheRefreshMs below): a false is a real, cached-open result, but a true is still only advisory — it means either nothing is cached open, or nothing has been cached yet, not a confirmed closed state — use canExecuteAsync for that. Never claims a trial slot. Logs a one-time warning the first time it's called in distributed mode, explaining that asymmetry.
  • canExecuteAsync(operation) — async, store-aware, read-only check. Honors failOpenOnStoreError. Never claims a trial slot — see the TOCTOU note below.

The names are a deliberate sync/async pair, not a typo or an inconsistency to fix later: canExecute is the name a plain, synchronous, best-effort check earns in this codebase (matching execute's own naming), and canExecuteAsync is the same check with the word that describes what actually changed — it awaits the store. Read both entries above together before reaching for either one; the sync variant's true carries materially less confidence than the async variant's, and that's the whole reason two methods exist instead of one.

  • recordFailure(operation) / recordSuccess(operation) — manually record an outcome without routing the call through execute. Note: these bypass the half-open trial-claim mechanism entirely (see limitations) — prefer execute.
  • getState(operation) — returns { failures, lastFailure, isOpen, openCount, errorRate, sampleCount }. openCount is how many consecutive half-open trials have failed since the breaker last fully closed (drives backoff). errorRate/sampleCount are only meaningful under strategy: 'errorRate'.
  • getMetrics(operation) / getAllMetrics() — per-instance counters: { totalCalls, totalSuccesses, totalFailures, totalRejections, totalStoreErrors }.
  • reset(operation) — manually closes the breaker for an operation.
  • destroy() — stops the internal monitor interval. Call this when a breaker instance is no longer needed (e.g. in tests, or on service shutdown) to avoid leaking a timer.
  • isLocalCacheActive() — whether the closed-state local cache is actually active right now: false if localCache was never set, if the store doesn't implement subscribeTransitions, or if the initial subscribe attempt itself failed. Setting localCache never throws (subscribing happens fire-and-forget after construction), so this is the way to confirm an opt-in feature actually opted in — check it from a health check or a startup assertion rather than relying on the one-time warning log.

operation should be a small, fixed set of names ('payment-gateway', 'fraud-api', one per dependency) — not something you interpolate per-request values into (a tenant ID, a URL, a user ID). Every distinct operation string gets its own entry in several per-instance maps (metrics, local-mode failure/backoff state, an internal jitter cache) that are never evicted, so a dynamic operation name is an unbounded memory leak, the same footgun as labeling a Prometheus metric with a high-cardinality value.

redeye can't stop you from doing this, but it can flag it: execute/recordFailure/recordSuccess log a one-time warning the first time they see an operation name over 100 characters (a common sign of an interpolated URL or ID), and — if you set maxOperations — a second one-time warning once the breaker has seen more distinct operation names than that. Neither check ever rejects a call; they're a smoke alarm, not enforcement. Deliberately length-only, not also keyed on e.g. containing /: the length check is one-time, so a false positive doesn't just annoy — it permanently consumes the one warning this breaker will ever give, silencing it for a genuine leak introduced later. / is too weak a signal to risk that on (a namespaced fixed name like "myapp/payment-gateway" is common and legitimate); maxOperations is what actually catches a cardinality leak, independent of what the names look like.

Options

Option Default Description
strategy 'consecutive' 'consecutive' or 'errorRate' — see Two tripping strategies
failureThreshold 5 'consecutive' only: failures in a row before the breaker opens
errorRateThreshold 0.5 'errorRate' only: failure rate (0-1) at or above which the breaker opens
minimumCalls 10 'errorRate' only: minimum samples before the rate can trip the breaker
errorRateDecay 0.9 'errorRate' only: EWMA decay factor — closer to 1 weighs history more heavily
resetTimeout 60000 ms to stay open before allowing a half-open trial
backoffMultiplier 2 Multiplies resetTimeout on each failed trial (1 disables backoff)
maxResetTimeout resetTimeout * 8 Ceiling for the backed-off reset timeout
jitter 0.1 Randomizes the effective reset timeout by ±this fraction, so instances don't all retry in lockstep
trialTimeout min(timeout ?? 10000, resetTimeout) Max ms a claimed half-open trial may run before its claim is released/expires
monitorInterval 5000 ms between local-mode sweeps for a trial that never settled (safety net)
timeout none optional per-call timeout in ms
store none enables distributed mode when provided
failOpenOnStoreError true see Store unavailability
onStateChange none (state: 'open' | 'half-open' | 'closed', operation: string) => void'half-open' fires exactly when a caller claims the single half-open trial slot. In distributed mode this is a per-process callback, not a fleet-wide broadcast — see Observability below for which instance actually sees it.
onStoreError none (error: unknown, operation: string) => void — fired whenever a store read/write fails, in addition to being logged
logger no-op { warn(msg), log(msg) } — plug in your own logger
maxOperations none Logs a one-time warning once the breaker has seen more than this many distinct operation names — see the cardinality note above. Unset: no limit, no warning.
openCacheRefreshMs 2000 Distributed mode only: while operation is known-open, reject locally without a store read, re-verifying against the store at most once per this many ms (to catch an early close by another instance's trial, or a manual reset()). Never delays trial eligibility — see limitation item 1. 0 disables the cache (a store read on every call, the pre-cache behavior).
localCache none (disabled) Distributed mode, opt-in, requires a store implementing subscribeTransitions: { staleToleranceMs?: number } (default 100 once the object is present) — caches CLOSED state locally, invalidated push-style, so a healthy call can skip the store read too, not just the write. See Local caching (hybrid mode) for the fail-open window this trades for that.

Store unavailability: fail-open vs fail-closed

If the store itself throws (Redis is down, times out, network partition, etc.), redeye does not treat that the same as the protected operation failing — it's a distinct condition, controlled by failOpenOnStoreError:

  • true (default) — treat the breaker as closed and let the call through. A store outage degrades you to "no circuit-breaker protection," not "every call blocked."
  • false — throw StoreUnavailableError (exported) without calling the wrapped function at all. Appropriate when the protected operation is expensive, dangerous to retry blindly, or would itself add load to whatever's already causing the outage.

Writes back to the store are always best-effort — a write failure is logged and reported via onStoreError, but never thrown, and never overrides the real result of a call that already happened.

Total coordination-layer outage (the store itself is down)

Same failOpenOnStoreError branch as above, just hit by every instance at once instead of one partitioned instance — redeye doesn't promote a backup coordinator or fall back to per-instance local tracking as a substitute:

  • true (default): every instance lets every call through — no breaking happens anywhere, fleet-wide, for as long as the outage lasts.
  • false: every instance blocks every call with StoreUnavailableError, fleet-wide, including requests that would have succeeded.

Nothing is buffered or replayed: outcomes during the outage are never recorded, and the breaker has no memory of the blackout once the store recovers. There's also no back-off from hitting a store known to be down — every guarded call keeps retrying it, so pair failOpenOnStoreError: false with fail-fast client options (ioredis's maxRetriesPerRequest, enableOfflineQueue: false) if you don't want calls queuing on the client's own reconnect logic. Recovery is instant and automatic on the next successful read — no warm-up, no manual reset.

High availability of the store itself (Sentinel, Cluster) is your responsibility, configured on the client you hand to RedisStore — whatever failover consistency that setup provides is inherited as-is (see Split-brain).

Reliability model & limitations

What redeye actually solves (with RedisStore, or any store implementing the matching optional methods)

  • Exact counting under concurrent failure, for both strategies. recordFailureAtomic (consecutive) and recordOutcomeAtomic (errorRate) each run as a single Lua script inside Redis's single-threaded execution — two instances updating at the same instant cannot race and lose an update the way a plain get-then-set would.
  • Catches flapping, not just hard drops. strategy: 'errorRate' trips on a smoothed failure rate once enough samples are seen, so a dependency that succeeds 1 in 5 requests (an 80% failure rate) still trips the breaker even though it never fails N times consecutively — a case 'consecutive' structurally cannot catch, by design (see Two tripping strategies).
  • Real half-open, single-trial recovery. When the reset window elapses, callers don't all rush in — one caller atomically claims the trial slot (claimTrial, a conditional write) and the rest stay blocked (Circuit breaker is open) until that trial resolves. This is the classic Hystrix-style half-open state, not "everyone tries at once." Applies to both strategies. A single successful trial fully closes the breaker — redeye does not support requiring N consecutive successful trials before closing; that's intentionally out of scope for now, not an oversight.
  • Exponential backoff with jitter. A dependency that keeps failing its trial is retried less often each time (resetTimeout * backoffMultiplier ^ openCount, capped at maxResetTimeout), and the exact retry moment is jittered per-instance so a cluster doesn't hammer a recovering dependency in lockstep.
  • No hard dependency on precise TTL timing for correctness. The gating decision is based on elapsed time since the last recorded failure, not "did the key vanish yet" — the store's TTL is a generous safety-net for cleanup, not the primary mechanism. (Early eviction is still possible — see below — but it degrades gracefully instead of being load-bearing.)
  • Store outages are a distinct, handled condition, not silently misattributed as the protected operation failing (see fail-open/fail-closed above).
  • Observability: per-instance call/success/failure/rejection/store-error counters via getMetrics, plus onStateChange and onStoreError hooks. In distributed mode, onStateChange is invoked locally by whichever instance's own call happens to trigger a given transition — not broadcast to the rest of the fleet. Concretely: 'open' fires only on the instance whose write is the one that crosses the threshold (or whose failed trial reopens it); 'half-open' fires only on the instance that wins the trial claim; 'closed' fires only on the instance that closes it (via a successful trial, or a manual recordSuccess()/reset() call). Every other instance sharing that operation still sees the new state reflected in their own gating decisions immediately — they just never get their own onStateChange call for a transition they didn't personally cause. If you need a fleet-wide notification (e.g. to page on-call once, not once per instance that happens to notice), de-duplicate downstream of the hook, or drive alerting off the store directly instead.

If your store doesn't implement the atomic method your chosen strategy needs (recordFailureAtomic or recordOutcomeAtomic) or claimTrial, redeye logs a one-time warning per missing capability and falls back to a non-atomic get-then-set — it still works, just without the atomicity/exclusivity guarantee for that piece.

Split-brain: what's prevented, and what isn't

There's no split-brain among your app instances in the classic sense, because instances never hold independent authoritative state to write from — every state-changing decision goes through the store, and the two local caches (openCacheRefreshMs, localCache) exist only to skip reading it before an already-known-safe decision, never to substitute for it. The store is the only "brain"; instances are just readers/writers of it, some of the time from a recent, bounded-staleness memory of what they last read instead of asking again. Two mechanisms enforce agreement on the common path:

  • Atomic writes. recordFailureAtomic/recordOutcomeAtomic run as one Lua script — GET, decode, increment, decide, SET — inside Redis's single-threaded execution, so two instances failing at the same instant can't both read the same count and both write the same increment.
  • Exclusive trial claim. claimTrial is a SET ... NX, which Redis resolves atomically, so exactly one instance ever runs the half-open recovery probe — never two instances simultaneously deciding they're the one testing recovery.

So the design doesn't eliminate split-brain so much as route around it: it pushes the single-arbiter requirement onto the store and never lets an instance act on stale local state instead of asking the store. That said, there are real, narrow windows where instances can still disagree — each one is a deliberate availability-over-consistency choice, not a hidden gap:

  • An instance partitioned from the store fails open independently (item 9 below) — during the partition, that one instance's view of the breaker can genuinely diverge from the rest of the fleet's.
  • A claimTrial error can rarely produce two "winners" (item 10 below) — a narrow window traded for never permanently wedging the breaker open.
  • A slow trial outliving its claim's TTL is the more likely way to get two concurrent trials (item 11 below) — routine for a slowly-recovering dependency, not rare like item 10.
  • The store's own replication/failover consistency is inherited, not solved (item 12 below) — this library adds no consensus layer on top of whatever your Redis deployment already guarantees.
  • Clock skew causes timing disagreement, not state disagreement (item 3 below) — it can shift exactly when a trial becomes eligible, but it cannot cause a double-trial, since that's arbitrated by the store, not by comparing clocks.

What's still a fundamental tradeoff, not a bug

These are true of any distributed circuit breaker, rate limiter, or lock built on a shared store — not gaps specific to redeye:

  1. Distributed mode's per-call store cost is opt-in-able down to a bound you choose, not fixed — but "zero" always means "zero, at a stated staleness price," never "zero, for free." By default, every execute() call does a gate read (store.get) before deciding whether to proceed; what happens after differs by strategy — errorRate writes (recordOutcomeAtomic) on every closed-phase call, success or failure, since the rate needs both to be meaningful (this never goes away, at any cache setting — see below), while consecutive only writes when the gate's own read observed accumulated state to clear (an open breaker, or failures > 0), making a run of healthy successes 1 read and 0 writes per call by default, not 1-and-1. Once the breaker is open, rejections stop costing a read at all, unconditionally, no opt-in required: the gate caches the open state locally (lastFailure, openCount, and the same jittered expiresAt the eligibility check itself uses) and rejects straight from that cache, re-verifying against the store only once per openCacheRefreshMs (default 2000ms), and always falling through to a real read right at expiresAt — so this cache can delay a rejection's freshness but can never delay a trial's eligibility, and never risks letting an extra call through. That's the fail-closed-biased half of the story: staleness there can only make things more conservative. The closed, healthy path is the other half, and it's where a real tradeoff lives: localCache (opt-in) is redeye's answer to exactly the caching-with-bounded-staleness tradeoff Envoy's outlier detection makes by default — cache state locally, accept a bounded staleness window, skip the round trip. Off by default (redeye chooses per-call freshness there until you say otherwise), and explicit about the bound when you turn it on: worst case staleToleranceMs, full stop — the cache stops trusting itself at that bound regardless of whether a transition event ever arrives; typical case one stream delivery, usually well under it — a formula, not an adjective — see Local caching (hybrid mode) for exactly what that means and what it doesn't cover (errorRate's write floor, in particular, which localCache cannot remove — only consecutive reaches a genuine 0-store-op healthy path). A resolving half-open trial always adds a round trip to claim it and another to release its claim, at any cache setting, on either strategy — that path is never cacheable, and rightly so. Local mode has none of this cost at all — see Local mode vs. distributed mode if you're not sure you need shared state to begin with.
  2. Correctness depends on trusting your store. If Redis evicts a breaker key early under memory pressure (e.g. maxmemory-policy allkeys-lru with circuit_breaker:* competing for space with everything else), the breaker can reset earlier than intended. Mitigate by giving circuit-breaker keys their own keyspace/DB with a policy that doesn't evict them, or by monitoring onStoreError and Redis memory pressure directly. This is not fixable in-library — it's an operational configuration matter.
  3. Distributed timing is sensitive to clock skew. Backoff and jitter windows are computed by comparing each instance's local Date.now() against a lastFailure timestamp written by whichever instance recorded it. With reasonably synchronized clocks (standard NTP) this is a non-issue; on a fleet with significant clock drift, instances can disagree by that drift amount about exactly when a trial becomes eligible. This affects timing only — the half-open claim mechanism (claimTrial) still guarantees exactly one trial runs regardless of skew, so skew cannot cause a thundering herd, only a slightly early or late trial attempt.
  4. recordFailure/recordSuccess/canExecuteAsync don't participate in trial claiming. Only execute() claims and releases the half-open trial slot. If you build your own call flow around the manual API instead of execute(), you lose the single-trial guarantee and get the old "everyone retries once elapsed" behavior for that flow. Prefer execute().
  5. One strategy/threshold/backoff policy per breaker instance, applied uniformly to every operation string passed to it. Use separate CircuitBreaker instances for dependencies that need different policies — and separate RedisStore key prefixes if two breakers share an operation name with different strategies (their state shapes are incompatible and will corrupt each other under the same key).
  6. Local mode has no cross-restart persistence, by design — it's explicitly the zero-dependency option. Use distributed mode if breaker state needs to survive a process restart.
  7. Metrics are per-instance, not automatically aggregated across your fleet — the same as any Prometheus counter you'd scrape per-pod. Aggregate them in your own metrics backend if you need a fleet-wide view.
  8. errorRate is EWMA-smoothed, not a precise sliding window. It approximates "failure rate over roughly the last ~1/(1-decay) calls," not an exact count over an exact window — good enough to catch flapping dependencies, but the exact trip point for a given call sequence is a function of the decay math, not literally "N of the last M calls."
  9. A partitioned instance fails open on its own, independently of the rest of the fleet. If an instance's own call to the store errors (that instance can't reach Redis, but others can), it doesn't consult anyone else — it applies failOpenOnStoreError locally (default true). For the duration of that partition, the isolated instance treats the breaker as closed while the rest of the fleet, still able to reach the store, correctly sees it open. This is scoped to exactly the partitioned instance for exactly the duration of the partition, and it's a deliberate choice: favoring that one instance staying available over it blocking calls based on a store it can't even confirm the state of.
  10. A claimTrial error can rarely let two instances both believe they won the trial. If the claimTrial call itself throws (not "no key", an actual error), the gate does not block the caller — it fails open on the trial too (allowed: true, isTrial: true), because refusing here would mean a single store blip at exactly the wrong moment could permanently wedge the breaker open (no one could ever claim the trial again). If two instances hit that specific error in the same narrow window, both can proceed with a trial call. This trades a rare double-trial for never risking permanent lockout.
  11. The trial-claim TTL expiring mid-flight is a more likely double-trial window than item 10, and it's by design. claimTrial's key has a TTL of trialTimeout (default min(timeout ?? 10000, resetTimeout)). If the trial call itself runs longer than that TTL, the claim expires while the call is still in flight, and another instance can then claim a second trial against the same recovering dependency — this is the intended anti-wedging mechanism (nothing should be able to hold the exclusive slot forever), not a bug. It's also the routine case, not the rare one: a slowly-recovering dependency whose first trial takes 12 seconds against a 10-second default trialTimeout will hit this on every recovery attempt, not occasionally. Set trialTimeout comfortably above your dependency's real p99 recovery-check latency if avoiding this matters to you. What this does not do, strictly bounded by the release mechanism: cascade into a third concurrent trial. claimTrial returns a per-call ownership token, and releasing a claim is always either a compare-and-delete against that exact token or nothing at all — never an unconditional delete — so the original caller's own expired claim, once it finally gets released, can never destroy whichever other instance's claim now legitimately occupies the key. That makes this limitation "double trial, strictly bounded to two" rather than "double trial, possibly cascading further."
  12. RedisStore inherits Redis's own replication/failover consistency — it doesn't add a consensus layer on top. A single Redis instance has nothing to split-brain over. But if you run Sentinel or Cluster behind it, a failover with asynchronous replication can lose the last few writes, and reading from a lagging replica can return stale state. RedisStore doesn't issue WAIT or otherwise wait for replica acknowledgment — it trusts whatever consistency guarantees your Redis deployment itself provides.

What redeye deliberately does not try to be

redeye is scoped to "circuit breaking, done correctly, optionally shared via a store." It does not do retries, bulkheading, or request hedging. If you need those, compose redeye with a separate retry library, and think carefully about ordering: retries should generally happen inside what the breaker counts as a single call, not wrapped around it — otherwise a retry storm can trip the breaker faster than intended, or mask real failures from it entirely.

Testing

npm test runs the unit suite (test/*.spec.ts) against in-memory fakes — no external services required.

npm run test:integration runs test/*.integration.spec.ts against a real Redis, exercising RedisStore's actual Lua scripts and SET ... NX trial-claim logic instead of a reimplementation of them. Start Redis first:

docker compose up -d
npm run test:integration
docker compose down

It connects to REDIS_URL (default redis://localhost:6379).

npm run bench runs bench/index.js against a real Redis, comparing total store round trips and p50/p99 latency across three configurations: no caching, today's defaults (openCacheRefreshMs), and localCache on top. Same REDIS_URL; start Redis first the same way as the integration suite. See BENCHMARK.md for a full run's results and how to read them.

Contributing

See CONTRIBUTING.md. Changes are tracked in CHANGELOG.md.

License

MIT

About

A circuit breaker for Node.js with an optional Redis-backed distributed mode, so breaker state can be shared across multiple app instances instead of being tracked per-process.

Topics

Resources

License

Contributing

Stars

1 star

Watchers

0 watching

Forks

Packages

 
 
 

Contributors