steady-circuit-breaker 1.0.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) 2024
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,98 @@
1
+ # Steady Circuit Breaker
2
+
3
+ A tiny, deterministic circuit breaker for Node.js ≥ 18. It wraps any async/promise-based operation and prevents cascading failures by opening after sustained errors, probing for recovery, and closing again when healthy.
4
+
5
+ ## What is a circuit breaker?
6
+ A circuit breaker watches the success/failure ratio of a dependency. When errors exceed a threshold, the breaker **opens** and short-circuits further calls, letting the dependency recover. After a cooldown it allows a limited number of **half-open** probes. If they succeed, the breaker **closes** and traffic resumes; if they fail, it re-opens.
7
+
8
+ ```
9
+ CLOSED --(failure rate > threshold)--> OPEN
10
+ ^ |
11
+ | |
12
+ | v
13
+ +----(all probes succeed)<--HALF_OPEN
14
+ (any probe fails)---->
15
+ ```
16
+
17
+ ## Quick start
18
+ ```ts
19
+ import { CircuitBreaker, CircuitBreakerOpenError } from "steady-circuit-breaker";
20
+
21
+ const breaker = new CircuitBreaker({
22
+ failureThreshold: 0.6,
23
+ minRequests: 10,
24
+ openTimeoutMs: 5_000,
25
+ halfOpenMaxRequests: 3
26
+ });
27
+
28
+ async function safeCall() {
29
+ return breaker.execute(async () => {
30
+ const res = await riskyOperation();
31
+ return res.data;
32
+ });
33
+ }
34
+
35
+ try {
36
+ const data = await safeCall();
37
+ console.log(data);
38
+ } catch (error) {
39
+ if (error instanceof CircuitBreakerOpenError) {
40
+ // fast-fail: dependency is unhealthy; consider fallback
41
+ return useCache();
42
+ }
43
+ throw error;
44
+ }
45
+ ```
46
+
47
+ ## Configuration
48
+ | option | type | description |
49
+ | --- | --- | --- |
50
+ | `failureThreshold` | `number (0-1)` | Failure rate that trips the breaker once `minRequests` have run. `0.5` means >50% failures trigger OPEN. |
51
+ | `minRequests` | `integer ≥ 1` | Minimum number of recorded calls before the failure rate is evaluated. Prevents noise. |
52
+ | `openTimeoutMs` | `number > 0` | Time to stay OPEN before allowing HALF_OPEN probe calls. |
53
+ | `halfOpenMaxRequests` | `integer ≥ 1` | Maximum concurrent probe executions allowed while HALF_OPEN. All must succeed to close; one failure re-opens immediately. |
54
+
55
+ ## Event hooks
56
+ Subscribe to lifecycle hooks without decorators or global state:
57
+
58
+ ```ts
59
+ const unsubscribe = breaker.on("stateChange", ({ previous, current }) => {
60
+ telemetry.trackState(previous, current);
61
+ });
62
+
63
+ breaker.on("success", ({ durationMs }) => metrics.recordLatency(durationMs));
64
+ breaker.on("failure", ({ error }) => metrics.increment("cb.fail", error));
65
+ breaker.on("reject", () => alerts.warn("breaker open"));
66
+
67
+ // stop listening when finished
68
+ unsubscribe();
69
+ ```
70
+
71
+ Hooks are synchronous fire-and-forget to avoid backpressure.
72
+
73
+ ## Events
74
+ - `stateChange`: Fired on every state transition with `{ previous, current }`.
75
+ - `success`: Fired after an execution succeeds with `{ state, durationMs }`.
76
+ - `failure`: Fired when an execution throws/rejects with `{ state, error }`.
77
+ - `reject`: Fired when a call is short-circuited with `{ state, error }`.
78
+
79
+ ## Testing & build
80
+ ```
81
+ npm test # runs Vitest
82
+ npm run build # emits dist/index.js, dist/index.cjs, dist/index.d.ts
83
+ ```
84
+
85
+ ## Non-goals
86
+ This library intentionally avoids the following (by design for V1):
87
+ - HTTP/middleware integrations
88
+ - Retries, rate limiting, bulkheads, backoff
89
+ - Distributed/shared state or Redis
90
+ - Metrics exporters or dashboards
91
+ - Decorators, reflection, or framework bindings
92
+ - Sliding time windows or adaptive configuration
93
+
94
+ ## Project status
95
+ - Zero runtime dependencies
96
+ - Dual ESM/CJS with type declarations
97
+ - Tree-shakable (`"sideEffects": false`)
98
+ - Targets strict TypeScript + Node.js ≥ 18
package/dist/index.cjs ADDED
@@ -0,0 +1,241 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ CircuitBreaker: () => CircuitBreaker,
24
+ CircuitBreakerOpenError: () => CircuitBreakerOpenError,
25
+ CircuitBreakerState: () => CircuitBreakerState
26
+ });
27
+ module.exports = __toCommonJS(index_exports);
28
+
29
+ // src/errors.ts
30
+ var CircuitBreakerOpenError = class extends Error {
31
+ constructor(message = "Circuit breaker is open") {
32
+ super(message);
33
+ this.name = "CircuitBreakerOpenError";
34
+ }
35
+ };
36
+
37
+ // src/events.ts
38
+ var EventDispatcher = class {
39
+ constructor() {
40
+ this.listeners = {};
41
+ }
42
+ on(event, listener) {
43
+ if (!this.listeners[event]) {
44
+ this.listeners[event] = /* @__PURE__ */ new Set();
45
+ }
46
+ this.listeners[event].add(listener);
47
+ return () => this.off(event, listener);
48
+ }
49
+ off(event, listener) {
50
+ this.listeners[event]?.delete(listener);
51
+ }
52
+ emit(event, payload) {
53
+ const handlers = this.listeners[event];
54
+ if (!handlers || handlers.size === 0) {
55
+ return;
56
+ }
57
+ for (const handler of handlers) {
58
+ handler(payload);
59
+ }
60
+ }
61
+ };
62
+
63
+ // src/metrics.ts
64
+ var Metrics = class {
65
+ constructor() {
66
+ this.successCount = 0;
67
+ this.failureCount = 0;
68
+ }
69
+ recordSuccess() {
70
+ this.successCount += 1;
71
+ }
72
+ recordFailure() {
73
+ this.failureCount += 1;
74
+ }
75
+ get totalCount() {
76
+ return this.successCount + this.failureCount;
77
+ }
78
+ get failureRate() {
79
+ const total = this.totalCount;
80
+ if (total === 0) {
81
+ return 0;
82
+ }
83
+ return this.failureCount / total;
84
+ }
85
+ reset() {
86
+ this.successCount = 0;
87
+ this.failureCount = 0;
88
+ }
89
+ };
90
+
91
+ // src/state.ts
92
+ var CircuitBreakerState = /* @__PURE__ */ ((CircuitBreakerState2) => {
93
+ CircuitBreakerState2["CLOSED"] = "CLOSED";
94
+ CircuitBreakerState2["OPEN"] = "OPEN";
95
+ CircuitBreakerState2["HALF_OPEN"] = "HALF_OPEN";
96
+ return CircuitBreakerState2;
97
+ })(CircuitBreakerState || {});
98
+
99
+ // src/CircuitBreaker.ts
100
+ var CircuitBreaker = class {
101
+ constructor(options) {
102
+ this.metrics = new Metrics();
103
+ this.emitter = new EventDispatcher();
104
+ this.state = "CLOSED" /* CLOSED */;
105
+ this.lastOpenedAt = null;
106
+ this.halfOpenInFlight = 0;
107
+ this.halfOpenSuccessCount = 0;
108
+ this.options = this.validateOptions(options);
109
+ }
110
+ getState() {
111
+ return this.state;
112
+ }
113
+ on(event, listener) {
114
+ return this.emitter.on(event, listener);
115
+ }
116
+ off(event, listener) {
117
+ this.emitter.off(event, listener);
118
+ }
119
+ async execute(action) {
120
+ this.maybeTransitionFromOpen();
121
+ if (this.state === "OPEN" /* OPEN */) {
122
+ this.rejectExecution();
123
+ }
124
+ let countedProbe = false;
125
+ if (this.state === "HALF_OPEN" /* HALF_OPEN */) {
126
+ if (this.halfOpenInFlight >= this.options.halfOpenMaxRequests) {
127
+ this.rejectExecution();
128
+ }
129
+ this.halfOpenInFlight += 1;
130
+ countedProbe = true;
131
+ }
132
+ const start = Date.now();
133
+ try {
134
+ const result = await action();
135
+ this.handleSuccess();
136
+ this.emitter.emit("success", {
137
+ state: this.state,
138
+ durationMs: Date.now() - start
139
+ });
140
+ return result;
141
+ } catch (error) {
142
+ this.handleFailure();
143
+ this.emitter.emit("failure", {
144
+ state: this.state,
145
+ error
146
+ });
147
+ throw error;
148
+ } finally {
149
+ if (countedProbe) {
150
+ this.halfOpenInFlight = Math.max(0, this.halfOpenInFlight - 1);
151
+ }
152
+ }
153
+ }
154
+ handleSuccess() {
155
+ if (this.state === "HALF_OPEN" /* HALF_OPEN */) {
156
+ this.halfOpenSuccessCount += 1;
157
+ if (this.halfOpenSuccessCount >= this.options.halfOpenMaxRequests) {
158
+ this.transitionTo("CLOSED" /* CLOSED */);
159
+ }
160
+ return;
161
+ }
162
+ if (this.state === "CLOSED" /* CLOSED */) {
163
+ this.metrics.recordSuccess();
164
+ }
165
+ }
166
+ handleFailure() {
167
+ if (this.state === "HALF_OPEN" /* HALF_OPEN */) {
168
+ this.transitionTo("OPEN" /* OPEN */);
169
+ return;
170
+ }
171
+ if (this.state !== "CLOSED" /* CLOSED */) {
172
+ return;
173
+ }
174
+ this.metrics.recordFailure();
175
+ if (this.metrics.totalCount < this.options.minRequests) {
176
+ return;
177
+ }
178
+ if (this.metrics.failureRate > this.options.failureThreshold) {
179
+ this.transitionTo("OPEN" /* OPEN */);
180
+ }
181
+ }
182
+ maybeTransitionFromOpen() {
183
+ if (this.state !== "OPEN" /* OPEN */ || this.lastOpenedAt === null) {
184
+ return;
185
+ }
186
+ const elapsed = Date.now() - this.lastOpenedAt;
187
+ if (elapsed >= this.options.openTimeoutMs) {
188
+ this.transitionTo("HALF_OPEN" /* HALF_OPEN */);
189
+ }
190
+ }
191
+ transitionTo(next) {
192
+ if (this.state === next) {
193
+ return;
194
+ }
195
+ const previous = this.state;
196
+ this.state = next;
197
+ if (next === "OPEN" /* OPEN */) {
198
+ this.lastOpenedAt = Date.now();
199
+ this.halfOpenInFlight = 0;
200
+ this.halfOpenSuccessCount = 0;
201
+ } else {
202
+ this.lastOpenedAt = null;
203
+ this.halfOpenInFlight = 0;
204
+ }
205
+ if (next === "HALF_OPEN" /* HALF_OPEN */ || next === "CLOSED" /* CLOSED */) {
206
+ this.metrics.reset();
207
+ this.halfOpenSuccessCount = 0;
208
+ }
209
+ this.emitter.emit("stateChange", { previous, current: next });
210
+ }
211
+ rejectExecution() {
212
+ const error = new CircuitBreakerOpenError();
213
+ this.emitter.emit("reject", {
214
+ state: this.state,
215
+ error
216
+ });
217
+ throw error;
218
+ }
219
+ validateOptions(options) {
220
+ const { failureThreshold, minRequests, openTimeoutMs, halfOpenMaxRequests } = options;
221
+ if (!Number.isFinite(failureThreshold) || failureThreshold <= 0 || failureThreshold >= 1) {
222
+ throw new Error("failureThreshold must be a number between 0 and 1");
223
+ }
224
+ if (!Number.isInteger(minRequests) || minRequests <= 0) {
225
+ throw new Error("minRequests must be a positive integer");
226
+ }
227
+ if (!Number.isFinite(openTimeoutMs) || openTimeoutMs <= 0) {
228
+ throw new Error("openTimeoutMs must be a positive number");
229
+ }
230
+ if (!Number.isInteger(halfOpenMaxRequests) || halfOpenMaxRequests <= 0) {
231
+ throw new Error("halfOpenMaxRequests must be a positive integer");
232
+ }
233
+ return { failureThreshold, minRequests, openTimeoutMs, halfOpenMaxRequests };
234
+ }
235
+ };
236
+ // Annotate the CommonJS export names for ESM import in node:
237
+ 0 && (module.exports = {
238
+ CircuitBreaker,
239
+ CircuitBreakerOpenError,
240
+ CircuitBreakerState
241
+ });
@@ -0,0 +1,63 @@
1
+ declare enum CircuitBreakerState {
2
+ CLOSED = "CLOSED",
3
+ OPEN = "OPEN",
4
+ HALF_OPEN = "HALF_OPEN"
5
+ }
6
+
7
+ interface StateChangeEvent {
8
+ previous: CircuitBreakerState;
9
+ current: CircuitBreakerState;
10
+ }
11
+ interface SuccessEvent {
12
+ state: CircuitBreakerState;
13
+ durationMs: number;
14
+ }
15
+ interface FailureEvent {
16
+ state: CircuitBreakerState;
17
+ error: unknown;
18
+ }
19
+ interface RejectEvent {
20
+ state: CircuitBreakerState;
21
+ error: Error;
22
+ }
23
+ interface CircuitBreakerEvents extends Record<string, unknown> {
24
+ stateChange: StateChangeEvent;
25
+ success: SuccessEvent;
26
+ failure: FailureEvent;
27
+ reject: RejectEvent;
28
+ }
29
+ type Listener<T> = (payload: T) => void;
30
+
31
+ interface CircuitBreakerOptions {
32
+ failureThreshold: number;
33
+ minRequests: number;
34
+ openTimeoutMs: number;
35
+ halfOpenMaxRequests: number;
36
+ }
37
+ type ExecutionAction<T> = () => PromiseLike<T> | T;
38
+ declare class CircuitBreaker {
39
+ private readonly options;
40
+ private readonly metrics;
41
+ private readonly emitter;
42
+ private state;
43
+ private lastOpenedAt;
44
+ private halfOpenInFlight;
45
+ private halfOpenSuccessCount;
46
+ constructor(options: CircuitBreakerOptions);
47
+ getState(): CircuitBreakerState;
48
+ on<K extends keyof CircuitBreakerEvents>(event: K, listener: Listener<CircuitBreakerEvents[K]>): () => void;
49
+ off<K extends keyof CircuitBreakerEvents>(event: K, listener: Listener<CircuitBreakerEvents[K]>): void;
50
+ execute<T>(action: ExecutionAction<T>): Promise<T>;
51
+ private handleSuccess;
52
+ private handleFailure;
53
+ private maybeTransitionFromOpen;
54
+ private transitionTo;
55
+ private rejectExecution;
56
+ private validateOptions;
57
+ }
58
+
59
+ declare class CircuitBreakerOpenError extends Error {
60
+ constructor(message?: string);
61
+ }
62
+
63
+ export { CircuitBreaker, type CircuitBreakerEvents, CircuitBreakerOpenError, type CircuitBreakerOptions, CircuitBreakerState, type ExecutionAction, type FailureEvent, type Listener, type RejectEvent, type StateChangeEvent, type SuccessEvent };
@@ -0,0 +1,63 @@
1
+ declare enum CircuitBreakerState {
2
+ CLOSED = "CLOSED",
3
+ OPEN = "OPEN",
4
+ HALF_OPEN = "HALF_OPEN"
5
+ }
6
+
7
+ interface StateChangeEvent {
8
+ previous: CircuitBreakerState;
9
+ current: CircuitBreakerState;
10
+ }
11
+ interface SuccessEvent {
12
+ state: CircuitBreakerState;
13
+ durationMs: number;
14
+ }
15
+ interface FailureEvent {
16
+ state: CircuitBreakerState;
17
+ error: unknown;
18
+ }
19
+ interface RejectEvent {
20
+ state: CircuitBreakerState;
21
+ error: Error;
22
+ }
23
+ interface CircuitBreakerEvents extends Record<string, unknown> {
24
+ stateChange: StateChangeEvent;
25
+ success: SuccessEvent;
26
+ failure: FailureEvent;
27
+ reject: RejectEvent;
28
+ }
29
+ type Listener<T> = (payload: T) => void;
30
+
31
+ interface CircuitBreakerOptions {
32
+ failureThreshold: number;
33
+ minRequests: number;
34
+ openTimeoutMs: number;
35
+ halfOpenMaxRequests: number;
36
+ }
37
+ type ExecutionAction<T> = () => PromiseLike<T> | T;
38
+ declare class CircuitBreaker {
39
+ private readonly options;
40
+ private readonly metrics;
41
+ private readonly emitter;
42
+ private state;
43
+ private lastOpenedAt;
44
+ private halfOpenInFlight;
45
+ private halfOpenSuccessCount;
46
+ constructor(options: CircuitBreakerOptions);
47
+ getState(): CircuitBreakerState;
48
+ on<K extends keyof CircuitBreakerEvents>(event: K, listener: Listener<CircuitBreakerEvents[K]>): () => void;
49
+ off<K extends keyof CircuitBreakerEvents>(event: K, listener: Listener<CircuitBreakerEvents[K]>): void;
50
+ execute<T>(action: ExecutionAction<T>): Promise<T>;
51
+ private handleSuccess;
52
+ private handleFailure;
53
+ private maybeTransitionFromOpen;
54
+ private transitionTo;
55
+ private rejectExecution;
56
+ private validateOptions;
57
+ }
58
+
59
+ declare class CircuitBreakerOpenError extends Error {
60
+ constructor(message?: string);
61
+ }
62
+
63
+ export { CircuitBreaker, type CircuitBreakerEvents, CircuitBreakerOpenError, type CircuitBreakerOptions, CircuitBreakerState, type ExecutionAction, type FailureEvent, type Listener, type RejectEvent, type StateChangeEvent, type SuccessEvent };
package/dist/index.js ADDED
@@ -0,0 +1,212 @@
1
+ // src/errors.ts
2
+ var CircuitBreakerOpenError = class extends Error {
3
+ constructor(message = "Circuit breaker is open") {
4
+ super(message);
5
+ this.name = "CircuitBreakerOpenError";
6
+ }
7
+ };
8
+
9
+ // src/events.ts
10
+ var EventDispatcher = class {
11
+ constructor() {
12
+ this.listeners = {};
13
+ }
14
+ on(event, listener) {
15
+ if (!this.listeners[event]) {
16
+ this.listeners[event] = /* @__PURE__ */ new Set();
17
+ }
18
+ this.listeners[event].add(listener);
19
+ return () => this.off(event, listener);
20
+ }
21
+ off(event, listener) {
22
+ this.listeners[event]?.delete(listener);
23
+ }
24
+ emit(event, payload) {
25
+ const handlers = this.listeners[event];
26
+ if (!handlers || handlers.size === 0) {
27
+ return;
28
+ }
29
+ for (const handler of handlers) {
30
+ handler(payload);
31
+ }
32
+ }
33
+ };
34
+
35
+ // src/metrics.ts
36
+ var Metrics = class {
37
+ constructor() {
38
+ this.successCount = 0;
39
+ this.failureCount = 0;
40
+ }
41
+ recordSuccess() {
42
+ this.successCount += 1;
43
+ }
44
+ recordFailure() {
45
+ this.failureCount += 1;
46
+ }
47
+ get totalCount() {
48
+ return this.successCount + this.failureCount;
49
+ }
50
+ get failureRate() {
51
+ const total = this.totalCount;
52
+ if (total === 0) {
53
+ return 0;
54
+ }
55
+ return this.failureCount / total;
56
+ }
57
+ reset() {
58
+ this.successCount = 0;
59
+ this.failureCount = 0;
60
+ }
61
+ };
62
+
63
+ // src/state.ts
64
+ var CircuitBreakerState = /* @__PURE__ */ ((CircuitBreakerState2) => {
65
+ CircuitBreakerState2["CLOSED"] = "CLOSED";
66
+ CircuitBreakerState2["OPEN"] = "OPEN";
67
+ CircuitBreakerState2["HALF_OPEN"] = "HALF_OPEN";
68
+ return CircuitBreakerState2;
69
+ })(CircuitBreakerState || {});
70
+
71
+ // src/CircuitBreaker.ts
72
+ var CircuitBreaker = class {
73
+ constructor(options) {
74
+ this.metrics = new Metrics();
75
+ this.emitter = new EventDispatcher();
76
+ this.state = "CLOSED" /* CLOSED */;
77
+ this.lastOpenedAt = null;
78
+ this.halfOpenInFlight = 0;
79
+ this.halfOpenSuccessCount = 0;
80
+ this.options = this.validateOptions(options);
81
+ }
82
+ getState() {
83
+ return this.state;
84
+ }
85
+ on(event, listener) {
86
+ return this.emitter.on(event, listener);
87
+ }
88
+ off(event, listener) {
89
+ this.emitter.off(event, listener);
90
+ }
91
+ async execute(action) {
92
+ this.maybeTransitionFromOpen();
93
+ if (this.state === "OPEN" /* OPEN */) {
94
+ this.rejectExecution();
95
+ }
96
+ let countedProbe = false;
97
+ if (this.state === "HALF_OPEN" /* HALF_OPEN */) {
98
+ if (this.halfOpenInFlight >= this.options.halfOpenMaxRequests) {
99
+ this.rejectExecution();
100
+ }
101
+ this.halfOpenInFlight += 1;
102
+ countedProbe = true;
103
+ }
104
+ const start = Date.now();
105
+ try {
106
+ const result = await action();
107
+ this.handleSuccess();
108
+ this.emitter.emit("success", {
109
+ state: this.state,
110
+ durationMs: Date.now() - start
111
+ });
112
+ return result;
113
+ } catch (error) {
114
+ this.handleFailure();
115
+ this.emitter.emit("failure", {
116
+ state: this.state,
117
+ error
118
+ });
119
+ throw error;
120
+ } finally {
121
+ if (countedProbe) {
122
+ this.halfOpenInFlight = Math.max(0, this.halfOpenInFlight - 1);
123
+ }
124
+ }
125
+ }
126
+ handleSuccess() {
127
+ if (this.state === "HALF_OPEN" /* HALF_OPEN */) {
128
+ this.halfOpenSuccessCount += 1;
129
+ if (this.halfOpenSuccessCount >= this.options.halfOpenMaxRequests) {
130
+ this.transitionTo("CLOSED" /* CLOSED */);
131
+ }
132
+ return;
133
+ }
134
+ if (this.state === "CLOSED" /* CLOSED */) {
135
+ this.metrics.recordSuccess();
136
+ }
137
+ }
138
+ handleFailure() {
139
+ if (this.state === "HALF_OPEN" /* HALF_OPEN */) {
140
+ this.transitionTo("OPEN" /* OPEN */);
141
+ return;
142
+ }
143
+ if (this.state !== "CLOSED" /* CLOSED */) {
144
+ return;
145
+ }
146
+ this.metrics.recordFailure();
147
+ if (this.metrics.totalCount < this.options.minRequests) {
148
+ return;
149
+ }
150
+ if (this.metrics.failureRate > this.options.failureThreshold) {
151
+ this.transitionTo("OPEN" /* OPEN */);
152
+ }
153
+ }
154
+ maybeTransitionFromOpen() {
155
+ if (this.state !== "OPEN" /* OPEN */ || this.lastOpenedAt === null) {
156
+ return;
157
+ }
158
+ const elapsed = Date.now() - this.lastOpenedAt;
159
+ if (elapsed >= this.options.openTimeoutMs) {
160
+ this.transitionTo("HALF_OPEN" /* HALF_OPEN */);
161
+ }
162
+ }
163
+ transitionTo(next) {
164
+ if (this.state === next) {
165
+ return;
166
+ }
167
+ const previous = this.state;
168
+ this.state = next;
169
+ if (next === "OPEN" /* OPEN */) {
170
+ this.lastOpenedAt = Date.now();
171
+ this.halfOpenInFlight = 0;
172
+ this.halfOpenSuccessCount = 0;
173
+ } else {
174
+ this.lastOpenedAt = null;
175
+ this.halfOpenInFlight = 0;
176
+ }
177
+ if (next === "HALF_OPEN" /* HALF_OPEN */ || next === "CLOSED" /* CLOSED */) {
178
+ this.metrics.reset();
179
+ this.halfOpenSuccessCount = 0;
180
+ }
181
+ this.emitter.emit("stateChange", { previous, current: next });
182
+ }
183
+ rejectExecution() {
184
+ const error = new CircuitBreakerOpenError();
185
+ this.emitter.emit("reject", {
186
+ state: this.state,
187
+ error
188
+ });
189
+ throw error;
190
+ }
191
+ validateOptions(options) {
192
+ const { failureThreshold, minRequests, openTimeoutMs, halfOpenMaxRequests } = options;
193
+ if (!Number.isFinite(failureThreshold) || failureThreshold <= 0 || failureThreshold >= 1) {
194
+ throw new Error("failureThreshold must be a number between 0 and 1");
195
+ }
196
+ if (!Number.isInteger(minRequests) || minRequests <= 0) {
197
+ throw new Error("minRequests must be a positive integer");
198
+ }
199
+ if (!Number.isFinite(openTimeoutMs) || openTimeoutMs <= 0) {
200
+ throw new Error("openTimeoutMs must be a positive number");
201
+ }
202
+ if (!Number.isInteger(halfOpenMaxRequests) || halfOpenMaxRequests <= 0) {
203
+ throw new Error("halfOpenMaxRequests must be a positive integer");
204
+ }
205
+ return { failureThreshold, minRequests, openTimeoutMs, halfOpenMaxRequests };
206
+ }
207
+ };
208
+ export {
209
+ CircuitBreaker,
210
+ CircuitBreakerOpenError,
211
+ CircuitBreakerState
212
+ };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "steady-circuit-breaker",
3
+ "version": "1.0.0",
4
+ "description": "Deterministic, promise-based circuit breaker for Node.js.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.cjs",
8
+ "module": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js",
14
+ "require": "./dist/index.cjs"
15
+ },
16
+ "./package.json": "./package.json"
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsup src/index.ts --format esm,cjs --dts --clean --out-dir dist",
23
+ "test": "vitest run"
24
+ },
25
+ "keywords": [
26
+ "circuit-breaker",
27
+ "resilience",
28
+ "typescript"
29
+ ],
30
+ "sideEffects": false,
31
+ "author": "",
32
+ "engines": {
33
+ "node": ">=18"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^25.1.0",
37
+ "tsup": "^8.5.1",
38
+ "typescript": "^5.9.3",
39
+ "vitest": "^4.0.18"
40
+ }
41
+ }