redeye-breaker 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Laurels Ozy Echichinwo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,203 @@
1
+ # redeye
2
+
3
+ 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.
4
+
5
+ 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.
6
+
7
+ **Read [Reliability model & limitations](#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.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install redeye-breaker
13
+ ```
14
+
15
+ Redis support is an optional peer dependency — only needed if you use `RedisStore`:
16
+
17
+ ```bash
18
+ npm install ioredis
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ### Local mode (default, no dependencies)
24
+
25
+ ```ts
26
+ import { CircuitBreaker } from 'redeye-breaker';
27
+
28
+ const breaker = new CircuitBreaker({
29
+ failureThreshold: 5,
30
+ resetTimeout: 60_000,
31
+ });
32
+
33
+ const result = await breaker.execute('payment-gateway', () => callPaymentGateway());
34
+ ```
35
+
36
+ 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.
37
+
38
+ ### Two tripping strategies
39
+
40
+ ```ts
41
+ // Default: trips after N failures in a row. Good for hard drops (a
42
+ // dependency going fully down). Resets to zero on any success, so it
43
+ // will not catch a dependency that's merely degraded.
44
+ new CircuitBreaker({ strategy: 'consecutive', failureThreshold: 5 });
45
+
46
+ // Trips when an EWMA-smoothed failure rate crosses a threshold, once
47
+ // enough samples have been seen. Catches flapping/degrading dependencies
48
+ // a consecutive-failure breaker structurally cannot: e.g. an API that
49
+ // fails 4 out of every 5 requests (80% failure rate) never fails twice
50
+ // in a row in that exact pattern, so 'consecutive' never trips — but
51
+ // 'errorRate' does, because it looks at the rate, not the streak.
52
+ new CircuitBreaker({
53
+ strategy: 'errorRate',
54
+ errorRateThreshold: 0.5, // open at >= 50% failure rate
55
+ minimumCalls: 10, // ...but only once we've seen at least 10 calls
56
+ errorRateDecay: 0.9, // how much weight recent calls get vs. history
57
+ });
58
+ ```
59
+
60
+ 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).
61
+
62
+ ### Distributed mode (Redis-backed, fully atomic)
63
+
64
+ ```ts
65
+ import { CircuitBreaker } from 'redeye-breaker';
66
+ import { RedisStore } from 'redeye-breaker/redis-store';
67
+ import Redis from 'ioredis';
68
+
69
+ const redis = new Redis(process.env.REDIS_URL);
70
+ const store = new RedisStore(redis, { keyPrefix: 'myapp:' });
71
+
72
+ const breaker = new CircuitBreaker({
73
+ failureThreshold: 5,
74
+ resetTimeout: 60_000,
75
+ store, // <- presence of a store is what enables distributed mode
76
+ onStateChange: (state, operation) => {
77
+ console.warn(`circuit breaker for ${operation} is now ${state}`);
78
+ },
79
+ onStoreError: (error, operation) => {
80
+ // Redis unreachable, timed out, etc. — see "Store unavailability" below.
81
+ console.error(`circuit breaker store error for ${operation}`, error);
82
+ },
83
+ });
84
+
85
+ await breaker.execute('payment-gateway', () => callPaymentGateway());
86
+ ```
87
+
88
+ 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.
89
+
90
+ ### Bring your own store
91
+
92
+ `RedisStore` implements a `Store` interface with two required methods and three *optional* ones:
93
+
94
+ ```ts
95
+ export interface Store {
96
+ get<T>(key: string): Promise<T | null>;
97
+ set<T>(key: string, value: T, ttlSeconds: number): Promise<void>;
98
+ del(key: string): Promise<void>;
99
+
100
+ // Optional — implement these for the reliability guarantees below.
101
+ // Without them, redeye falls back to best-effort semantics and logs a
102
+ // one-time warning telling you exactly what's degraded.
103
+ recordFailureAtomic?(key: string, opts: { ttlSeconds: number; failureThreshold: number; now: number }): Promise<CircuitBreakerState>;
104
+ recordOutcomeAtomic?(key: string, opts: { success: boolean; decay: number; minimumCalls: number; errorRateThreshold: number; ttlSeconds: number; now: number }): Promise<CircuitBreakerState & { openedNow: boolean }>;
105
+ claimTrial?(key: string, ttlSeconds: number): Promise<boolean>;
106
+ }
107
+ ```
108
+
109
+ - `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.).
110
+ - `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.
111
+ - `claimTrial` should be a conditional "create if absent" write (`SET key val NX EX ttl` in Redis) — it's what makes half-open recovery exclusive to one caller instead of a free-for-all.
112
+
113
+ Implement the two required methods against Memcached, DynamoDB, your own cache wrapper, etc., and you have a working (best-effort) distributed breaker. Add the optional methods relevant to the strategy you use when that store supports a real atomic increment and a real conditional write, and you get the full reliability guarantees.
114
+
115
+ ## API
116
+
117
+ - `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.
118
+ - `canExecute(operation)` — synchronous, read-only, best-effort check. **Always returns `true` in distributed mode** (it can't await the store) — use `canExecuteAsync` there. Never claims a trial slot.
119
+ - `canExecuteAsync(operation)` — async, store-aware, read-only check. Honors `failOpenOnStoreError`. Never claims a trial slot — see the TOCTOU note below.
120
+ - `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](#reliability-model--limitations)) — prefer `execute`.
121
+ - `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'`.
122
+ - `getMetrics(operation)` / `getAllMetrics()` — per-instance counters: `{ totalCalls, totalSuccesses, totalFailures, totalRejections, totalStoreErrors }`.
123
+ - `reset(operation)` — manually closes the breaker for an operation.
124
+ - `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.
125
+
126
+ ## Options
127
+
128
+ | Option | Default | Description |
129
+ |---|---|---|
130
+ | `strategy` | `'consecutive'` | `'consecutive'` or `'errorRate'` — see [Two tripping strategies](#two-tripping-strategies) |
131
+ | `failureThreshold` | `5` | `'consecutive'` only: failures in a row before the breaker opens |
132
+ | `errorRateThreshold` | `0.5` | `'errorRate'` only: failure rate (0-1) at or above which the breaker opens |
133
+ | `minimumCalls` | `10` | `'errorRate'` only: minimum samples before the rate can trip the breaker |
134
+ | `errorRateDecay` | `0.9` | `'errorRate'` only: EWMA decay factor — closer to 1 weighs history more heavily |
135
+ | `resetTimeout` | `60000` | ms to stay open before allowing a half-open trial |
136
+ | `backoffMultiplier` | `2` | Multiplies `resetTimeout` on each failed trial (`1` disables backoff) |
137
+ | `maxResetTimeout` | `resetTimeout * 8` | Ceiling for the backed-off reset timeout |
138
+ | `jitter` | `0.1` | Randomizes the effective reset timeout by ±this fraction, so instances don't all retry in lockstep |
139
+ | `trialTimeout` | `min(timeout ?? 10000, resetTimeout)` | Max ms a claimed half-open trial may run before its claim is released/expires |
140
+ | `monitorInterval` | `5000` | ms between local-mode sweeps for a trial that never settled (safety net) |
141
+ | `timeout` | none | optional per-call timeout in ms |
142
+ | `store` | none | enables distributed mode when provided |
143
+ | `failOpenOnStoreError` | `true` | see [Store unavailability](#store-unavailability-fail-open-vs-fail-closed) |
144
+ | `onStateChange` | none | `(state: 'open' \| 'closed', operation: string) => void` |
145
+ | `onStoreError` | none | `(error: unknown, operation: string) => void` — fired whenever a store read/write fails, in addition to being logged |
146
+ | `logger` | no-op | `{ warn(msg), log(msg) }` — plug in your own logger |
147
+
148
+ ### Store unavailability: fail-open vs fail-closed
149
+
150
+ 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`:
151
+
152
+ - **`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."
153
+ - **`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.
154
+
155
+ 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.
156
+
157
+ ## Reliability model & limitations
158
+
159
+ ### What redeye actually solves (with `RedisStore`, or any store implementing the matching optional methods)
160
+
161
+ - **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.
162
+ - **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](#two-tripping-strategies)).
163
+ - **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.
164
+ - **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.
165
+ - **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.)
166
+ - **Store outages are a distinct, handled condition**, not silently misattributed as the protected operation failing (see fail-open/fail-closed above).
167
+ - **Observability**: per-instance call/success/failure/rejection/store-error counters via `getMetrics`, plus `onStateChange` and `onStoreError` hooks.
168
+
169
+ 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.
170
+
171
+ ### What's still a fundamental tradeoff, not a bug
172
+
173
+ These are true of any distributed circuit breaker, rate limiter, or lock built on a shared store — not gaps specific to redeye:
174
+
175
+ 1. **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.
176
+ 2. **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.
177
+ 3. **`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()`.
178
+ 4. **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).
179
+ 5. **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.
180
+ 6. **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.
181
+ 7. **`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."
182
+
183
+ ### What redeye deliberately does not try to be
184
+
185
+ 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.
186
+
187
+ ## Testing
188
+
189
+ `npm test` runs the unit suite (`test/*.spec.ts`) against in-memory fakes — no external services required.
190
+
191
+ `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:
192
+
193
+ ```sh
194
+ docker compose up -d
195
+ npm run test:integration
196
+ docker compose down
197
+ ```
198
+
199
+ It connects to `REDIS_URL` (default `redis://localhost:6379`).
200
+
201
+ ## License
202
+
203
+ MIT
@@ -0,0 +1,211 @@
1
+ import { Store } from './store';
2
+ import { CircuitBreakerState, CircuitMetrics } from './types';
3
+ export type CircuitState = 'closed' | 'open';
4
+ export interface BreakerLogger {
5
+ warn(message: string): void;
6
+ log(message: string): void;
7
+ }
8
+ export type { CircuitBreakerState, CircuitMetrics };
9
+ /**
10
+ * Thrown in distributed mode when the backing store is unreachable and
11
+ * `failOpenOnStoreError` is `false`. Distinguishable from a downstream
12
+ * failure so callers (and metrics) can tell "the dependency failed" apart
13
+ * from "we couldn't tell whether the dependency is healthy."
14
+ */
15
+ export declare class StoreUnavailableError extends Error {
16
+ readonly cause: unknown;
17
+ constructor(operation: string, cause: unknown);
18
+ }
19
+ export interface CircuitBreakerOptions {
20
+ /**
21
+ * `'consecutive'` (default): trips after `failureThreshold` failures in a
22
+ * row; any success resets the count to zero. Good for hard drops (a
23
+ * dependency going fully down) — bad for flapping/degrading dependencies,
24
+ * since a single interspersed success resets the streak no matter how bad
25
+ * the overall failure rate is.
26
+ *
27
+ * `'errorRate'`: trips when an EWMA-smoothed failure rate crosses
28
+ * `errorRateThreshold`, once at least `minimumCalls` samples have been
29
+ * observed. Tracks both successes and failures, so a dependency that
30
+ * fails most — but not all — of the time still trips the breaker.
31
+ */
32
+ strategy?: 'consecutive' | 'errorRate';
33
+ /** Consecutive failures before the breaker opens. Only used by `strategy: 'consecutive'`. Default: 5 */
34
+ failureThreshold?: number;
35
+ /**
36
+ * Failure rate (0-1) at or above which the breaker opens. Only used by
37
+ * `strategy: 'errorRate'`. Default: 0.5 (50%).
38
+ */
39
+ errorRateThreshold?: number;
40
+ /**
41
+ * Minimum number of samples before `errorRate` can trip the breaker, so a
42
+ * couple of early failures in a cold start don't trip it on a tiny
43
+ * sample size. Only used by `strategy: 'errorRate'`. Default: 10.
44
+ */
45
+ minimumCalls?: number;
46
+ /**
47
+ * EWMA decay factor (0-1) for the `errorRate` strategy: closer to 1
48
+ * weighs history more heavily (slow to react, resistant to noise);
49
+ * closer to 0 reacts faster to recent calls. Default: 0.9.
50
+ */
51
+ errorRateDecay?: number;
52
+ /** Milliseconds to keep the breaker open before allowing a single trial request. Default: 60000 */
53
+ resetTimeout?: number;
54
+ /** How often the local-mode monitor sweeps for stuck trial claims, in ms. Default: 5000 */
55
+ monitorInterval?: number;
56
+ /** Optional per-call timeout in milliseconds. Unset = no timeout (also used to size the trial claim window — see `trialTimeout`). */
57
+ timeout?: number;
58
+ /**
59
+ * Upper bound in ms on how long a claimed half-open trial is allowed to
60
+ * run before its claim is released (local mode) or expires (distributed
61
+ * mode, via the store's TTL). Defaults to `min(timeout ?? 10000, resetTimeout)`.
62
+ * This is what prevents a trial call that never settles from permanently
63
+ * wedging the breaker open.
64
+ */
65
+ trialTimeout?: number;
66
+ /**
67
+ * Multiplier applied to `resetTimeout` each time a half-open trial fails,
68
+ * so a persistently unhealthy dependency is retried less and less often
69
+ * instead of at a fixed cadence forever. Default: 2. Set to 1 to disable backoff.
70
+ */
71
+ backoffMultiplier?: number;
72
+ /** Ceiling for the backed-off reset timeout, in ms. Default: `resetTimeout * 8`. */
73
+ maxResetTimeout?: number;
74
+ /**
75
+ * Randomizes the effective reset timeout by up to this fraction (0-1) so
76
+ * multiple instances don't all attempt their trial at the exact same
77
+ * moment. Default: 0.1 (±10%).
78
+ */
79
+ jitter?: number;
80
+ onStateChange?: (state: CircuitState, operation: string) => void;
81
+ /**
82
+ * Supplying a Store switches the breaker into distributed mode: state is
83
+ * read/written through the store (e.g. Redis) instead of in-process
84
+ * memory, so all instances sharing that store observe the same breaker
85
+ * state. Omit it to keep state local to this process.
86
+ */
87
+ store?: Store;
88
+ /**
89
+ * What to do in distributed mode when the store itself throws (e.g. Redis
90
+ * is unreachable) while checking whether a call is allowed.
91
+ *
92
+ * `true` (default, "fail open"): treat the breaker as closed and let the
93
+ * call through. Favors availability — a store outage degrades you to "no
94
+ * protection," not "everything blocked."
95
+ *
96
+ * `false` ("fail closed"): throw `StoreUnavailableError` without calling
97
+ * the wrapped function. Favors safety.
98
+ *
99
+ * Writes back to the store are always best-effort and logged via
100
+ * `onStoreError` — they never override the real result of a call that
101
+ * already happened.
102
+ */
103
+ failOpenOnStoreError?: boolean;
104
+ onStoreError?: (error: unknown, operation: string) => void;
105
+ logger?: BreakerLogger;
106
+ }
107
+ /**
108
+ * A circuit breaker with an optional distributed mode.
109
+ *
110
+ * Local mode (default) tracks failures per-process in memory. Distributed
111
+ * mode (pass a `store`) persists state through the store so a failure burst
112
+ * trips the breaker for every instance sharing it.
113
+ *
114
+ * When the store implements the optional `recordFailureAtomic` and
115
+ * `claimTrial` methods (as `RedisStore` does), distributed mode gets exact
116
+ * failure counts and real single-trial half-open behavior instead of
117
+ * best-effort approximations. See the README for the full reliability
118
+ * model and the handful of tradeoffs that are fundamental to any
119
+ * distributed system (store TTL trust, clock skew on informational
120
+ * timestamps, etc.) rather than fixable engineering gaps.
121
+ */
122
+ export declare class CircuitBreaker {
123
+ private readonly options;
124
+ private readonly distributed;
125
+ private readonly warnedCapabilities;
126
+ private readonly metrics;
127
+ private readonly failures;
128
+ private readonly lastFailureTime;
129
+ private readonly openOperations;
130
+ private readonly openCounts;
131
+ private readonly trialInProgress;
132
+ private readonly trialClaimedAt;
133
+ private readonly errorRates;
134
+ private readonly sampleCounts;
135
+ private readonly monitorHandle;
136
+ constructor(options?: CircuitBreakerOptions);
137
+ /** Stops the local-mode monitor interval. Call this when the breaker is no longer needed. */
138
+ destroy(): void;
139
+ execute<T>(operation: string, fn: () => Promise<T>): Promise<T>;
140
+ /**
141
+ * Synchronous, best-effort, read-only check (never claims a trial slot).
142
+ * In distributed mode this cannot await the store, so it always returns
143
+ * `true` — use `canExecuteAsync` there.
144
+ */
145
+ canExecute(operation: string): boolean;
146
+ /**
147
+ * Async, store-aware, read-only check (never claims a trial slot — use
148
+ * `execute` for that). Honors `failOpenOnStoreError`. Because it's
149
+ * read-only, a `true` result is advisory: another caller (or `execute`
150
+ * itself, immediately after) can still claim the only trial slot first.
151
+ */
152
+ canExecuteAsync(operation: string): Promise<boolean>;
153
+ recordFailure(operation: string): void;
154
+ recordSuccess(operation: string): void;
155
+ getState(operation: string): Promise<CircuitBreakerState>;
156
+ reset(operation: string): Promise<void>;
157
+ /** Per-instance call counters for `operation`. In distributed mode this reflects only calls this instance handled — export it via your own metrics system and aggregate across instances, the same way you would any per-process Prometheus counter. */
158
+ getMetrics(operation: string): CircuitMetrics;
159
+ getAllMetrics(): Record<string, CircuitMetrics>;
160
+ private metricsFor;
161
+ private effectiveResetTimeout;
162
+ private key;
163
+ private trialKey;
164
+ private stateTtlSeconds;
165
+ private trialTtlSeconds;
166
+ private reportStoreError;
167
+ private warnMissingCapability;
168
+ private safeGet;
169
+ private safeSet;
170
+ /** Deletes the main state key for `operation`. Best-effort: logged, never thrown. */
171
+ private safeDelMain;
172
+ /** Deletes the trial-claim key for `operation`, releasing it early instead of waiting for its TTL. Best-effort. */
173
+ private safeDelTrial;
174
+ private closeDistributed;
175
+ /**
176
+ * Determines whether a call is allowed through in distributed mode.
177
+ * `claim: true` (used by `execute`) attempts to atomically claim the
178
+ * single half-open trial slot when eligible. `claim: false` (used by
179
+ * `canExecuteAsync`) is read-only and never claims.
180
+ */
181
+ private gateDistributed;
182
+ private executeDistributed;
183
+ private recordFailureDistributedCounting;
184
+ /**
185
+ * `strategy: 'errorRate'` counterpart to `recordFailureDistributedCounting`
186
+ * — called on every closed-phase call (success or failure), not just
187
+ * failures, since the rate needs both to be meaningful.
188
+ */
189
+ private recordOutcomeDistributed;
190
+ private reopenAfterFailedTrialDistributed;
191
+ private executeLocal;
192
+ /** Read-only gate check — never claims the trial slot. Used by `canExecute`. */
193
+ private peekLocalGate;
194
+ /** Gate check used by `execute` — claims the exclusive trial slot when eligible. */
195
+ private claimLocalGate;
196
+ private recordFailureLocalCounting;
197
+ /** `strategy: 'errorRate'` counterpart to `recordFailureLocalCounting` — called on every closed-phase call, not just failures. */
198
+ private recordRateOutcomeLocal;
199
+ private reopenAfterFailedTrialLocal;
200
+ private resetLocal;
201
+ /**
202
+ * Safety net only: force-releases a trial claim that's been held far
203
+ * longer than `trialTimeout` should ever allow, which can only happen if
204
+ * the wrapped function never settles (no timeout configured and it hangs
205
+ * forever). Without this, a single hung call would permanently wedge the
206
+ * breaker open with no way to ever attempt another trial.
207
+ */
208
+ private monitor;
209
+ private executeWithTimeout;
210
+ }
211
+ //# sourceMappingURL=circuit-breaker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"circuit-breaker.d.ts","sourceRoot":"","sources":["../src/circuit-breaker.ts"],"names":[],"mappings":"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"}