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 +21 -0
- package/README.md +98 -0
- package/dist/index.cjs +241 -0
- package/dist/index.d.cts +63 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +212 -0
- package/package.json +41 -0
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
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
|
+
}
|