@prash0029/circuit-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 +21 -0
- package/README.md +196 -0
- package/dist/index.cjs +584 -0
- package/dist/index.d.cts +392 -0
- package/dist/index.d.ts +392 -0
- package/dist/index.js +544 -0
- package/package.json +53 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
// src/types.ts
|
|
2
|
+
var BreakerState = /* @__PURE__ */ ((BreakerState2) => {
|
|
3
|
+
BreakerState2["CLOSED"] = "CLOSED";
|
|
4
|
+
BreakerState2["OPEN"] = "OPEN";
|
|
5
|
+
BreakerState2["HALF_OPEN"] = "HALF_OPEN";
|
|
6
|
+
return BreakerState2;
|
|
7
|
+
})(BreakerState || {});
|
|
8
|
+
|
|
9
|
+
// src/breaker.ts
|
|
10
|
+
var Breaker = class {
|
|
11
|
+
constructor(tuning, now = Date.now, onTransition) {
|
|
12
|
+
this.tuning = tuning;
|
|
13
|
+
this.now = now;
|
|
14
|
+
this.onTransition = onTransition;
|
|
15
|
+
}
|
|
16
|
+
tuning;
|
|
17
|
+
now;
|
|
18
|
+
onTransition;
|
|
19
|
+
state = "CLOSED" /* CLOSED */;
|
|
20
|
+
count = 0;
|
|
21
|
+
halfOpenSuccesses = 0;
|
|
22
|
+
openedAt = null;
|
|
23
|
+
/**
|
|
24
|
+
* Whether a call may proceed. Performs the OPEN -> HALF_OPEN transition when
|
|
25
|
+
* the cooldown has elapsed. Does not mutate counts. Call once per guarded
|
|
26
|
+
* call, before invoking the wrapped fn.
|
|
27
|
+
*/
|
|
28
|
+
canRequest() {
|
|
29
|
+
this.maybeHalfOpen();
|
|
30
|
+
return this.state !== "OPEN" /* OPEN */;
|
|
31
|
+
}
|
|
32
|
+
/** Record a successful guarded call. */
|
|
33
|
+
onSuccess() {
|
|
34
|
+
if (this.state === "HALF_OPEN" /* HALF_OPEN */) {
|
|
35
|
+
this.halfOpenSuccesses += 1;
|
|
36
|
+
if (this.halfOpenSuccesses >= this.tuning.successThreshold) {
|
|
37
|
+
this.close();
|
|
38
|
+
}
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (this.state === "CLOSED" /* CLOSED */ && this.count > 0) {
|
|
42
|
+
this.count -= 1;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Record a failed guarded call. */
|
|
46
|
+
onFailure() {
|
|
47
|
+
if (this.state === "HALF_OPEN" /* HALF_OPEN */) {
|
|
48
|
+
this.open();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (this.state === "CLOSED" /* CLOSED */) {
|
|
52
|
+
this.count += 1;
|
|
53
|
+
if (this.count >= this.tuning.failureThreshold) {
|
|
54
|
+
this.open();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Earliest epoch ms a rejected caller may retry (cooldown end). */
|
|
59
|
+
retryAt() {
|
|
60
|
+
if (this.openedAt === null) {
|
|
61
|
+
return this.now();
|
|
62
|
+
}
|
|
63
|
+
return this.openedAt + this.tuning.cooldownMs;
|
|
64
|
+
}
|
|
65
|
+
snapshot() {
|
|
66
|
+
return { state: this.state, count: this.count, openedAt: this.openedAt };
|
|
67
|
+
}
|
|
68
|
+
/** Force back to CLOSED (manual operator reset). */
|
|
69
|
+
reset() {
|
|
70
|
+
this.close();
|
|
71
|
+
}
|
|
72
|
+
maybeHalfOpen() {
|
|
73
|
+
if (this.state === "OPEN" /* OPEN */ && this.openedAt !== null && this.now() - this.openedAt >= this.tuning.cooldownMs) {
|
|
74
|
+
this.halfOpenSuccesses = 0;
|
|
75
|
+
this.transition("HALF_OPEN" /* HALF_OPEN */);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
open() {
|
|
79
|
+
this.openedAt = this.now();
|
|
80
|
+
this.halfOpenSuccesses = 0;
|
|
81
|
+
this.transition("OPEN" /* OPEN */);
|
|
82
|
+
}
|
|
83
|
+
close() {
|
|
84
|
+
this.count = 0;
|
|
85
|
+
this.halfOpenSuccesses = 0;
|
|
86
|
+
this.openedAt = null;
|
|
87
|
+
this.transition("CLOSED" /* CLOSED */);
|
|
88
|
+
}
|
|
89
|
+
transition(to) {
|
|
90
|
+
const from = this.state;
|
|
91
|
+
if (from === to) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
this.state = to;
|
|
95
|
+
this.onTransition?.(from, to);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// src/classify.ts
|
|
100
|
+
function defaultIsSuccess(status) {
|
|
101
|
+
return status >= 200 && status < 300;
|
|
102
|
+
}
|
|
103
|
+
function classifyStatus(status, rule, isSuccess) {
|
|
104
|
+
if (status === void 0) {
|
|
105
|
+
return "failure";
|
|
106
|
+
}
|
|
107
|
+
if (rule.failedServerResStatus?.includes(status)) {
|
|
108
|
+
return "failure";
|
|
109
|
+
}
|
|
110
|
+
if (rule.successServerStatus?.includes(status)) {
|
|
111
|
+
return "success";
|
|
112
|
+
}
|
|
113
|
+
return isSuccess(status) ? "success" : "failure";
|
|
114
|
+
}
|
|
115
|
+
function statusOf(value, isError) {
|
|
116
|
+
if (value == null || typeof value !== "object") {
|
|
117
|
+
return void 0;
|
|
118
|
+
}
|
|
119
|
+
const record = value;
|
|
120
|
+
if (isError) {
|
|
121
|
+
const response = record["response"];
|
|
122
|
+
const fromResponse = readStatus(response);
|
|
123
|
+
if (fromResponse !== void 0) {
|
|
124
|
+
return fromResponse;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return readStatus(record);
|
|
128
|
+
}
|
|
129
|
+
function readStatus(value) {
|
|
130
|
+
if (value == null || typeof value !== "object") {
|
|
131
|
+
return void 0;
|
|
132
|
+
}
|
|
133
|
+
const status = value["status"];
|
|
134
|
+
return typeof status === "number" ? status : void 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// src/errors.ts
|
|
138
|
+
var CircuitOpenError = class extends Error {
|
|
139
|
+
level;
|
|
140
|
+
/** The rule `match` string keying the open breaker. Not the raw URL. */
|
|
141
|
+
key;
|
|
142
|
+
/** Epoch ms when callers may retry (breaker eligible for HALF_OPEN trial). */
|
|
143
|
+
retryAt;
|
|
144
|
+
constructor(args) {
|
|
145
|
+
super(`Circuit open for ${args.level} "${args.key}"; request not sent`);
|
|
146
|
+
this.name = "CircuitOpenError";
|
|
147
|
+
this.level = args.level;
|
|
148
|
+
this.key = args.key;
|
|
149
|
+
this.retryAt = args.retryAt;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
function isCircuitOpenError(error) {
|
|
153
|
+
return error instanceof CircuitOpenError;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// src/matcher.ts
|
|
157
|
+
function matchTarget(url, matchOn) {
|
|
158
|
+
if (matchOn === "url") {
|
|
159
|
+
return url;
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
const parsed = new URL(url, "http://placeholder.invalid");
|
|
163
|
+
return parsed.pathname;
|
|
164
|
+
} catch {
|
|
165
|
+
const queryStart = url.indexOf("?");
|
|
166
|
+
return queryStart === -1 ? url : url.slice(0, queryStart);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function resolveRules(fullUrl2, target, rules) {
|
|
170
|
+
const result = {};
|
|
171
|
+
for (const rule of rules) {
|
|
172
|
+
if (rule.type === "service") {
|
|
173
|
+
if (!result.service && target.includes(rule.match) && !isSkipped(target, fullUrl2, rule)) {
|
|
174
|
+
result.service = rule;
|
|
175
|
+
}
|
|
176
|
+
} else if (!result.endpoint && target.includes(rule.match)) {
|
|
177
|
+
result.endpoint = rule;
|
|
178
|
+
}
|
|
179
|
+
if (result.service && result.endpoint) {
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
function isSkipped(target, fullUrl2, rule) {
|
|
186
|
+
if (!rule.skipList || rule.skipList.length === 0) {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
return rule.skipList.some(
|
|
190
|
+
(skip) => target.includes(skip) || fullUrl2.includes(skip)
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// src/registry.ts
|
|
195
|
+
var BUILT_IN_TUNING = {
|
|
196
|
+
failureThreshold: 5,
|
|
197
|
+
cooldownMs: 3e4,
|
|
198
|
+
successThreshold: 2
|
|
199
|
+
};
|
|
200
|
+
var CircuitBreaker = class {
|
|
201
|
+
constructor(config, now = Date.now) {
|
|
202
|
+
this.config = config;
|
|
203
|
+
this.now = now;
|
|
204
|
+
this.matchOn = config.matchOn ?? "path";
|
|
205
|
+
this.isSuccess = config.defaultIsSuccess ?? defaultIsSuccess;
|
|
206
|
+
}
|
|
207
|
+
config;
|
|
208
|
+
now;
|
|
209
|
+
matchOn;
|
|
210
|
+
isSuccess;
|
|
211
|
+
serviceBreakers = /* @__PURE__ */ new Map();
|
|
212
|
+
endpointBreakers = /* @__PURE__ */ new Map();
|
|
213
|
+
/**
|
|
214
|
+
* Guard an outgoing request. Resolves the rules for `req.url`, rejects with
|
|
215
|
+
* {@link CircuitOpenError} (without sending) when either matched breaker is
|
|
216
|
+
* open, otherwise runs `fn`, classifies the result by status, and records the
|
|
217
|
+
* outcome on both levels. The response is returned as-is even when its status
|
|
218
|
+
* counts as a failure; network errors are recorded then re-thrown.
|
|
219
|
+
*/
|
|
220
|
+
async guard(req, fn) {
|
|
221
|
+
const matched = this.resolve(req);
|
|
222
|
+
const open = this.checkOpen(matched);
|
|
223
|
+
if (open) {
|
|
224
|
+
throw open;
|
|
225
|
+
}
|
|
226
|
+
let response;
|
|
227
|
+
let thrown;
|
|
228
|
+
let isError = false;
|
|
229
|
+
try {
|
|
230
|
+
response = await fn();
|
|
231
|
+
} catch (error) {
|
|
232
|
+
thrown = error;
|
|
233
|
+
isError = true;
|
|
234
|
+
}
|
|
235
|
+
this.settle(matched, statusOf(isError ? thrown : response, isError));
|
|
236
|
+
if (isError) {
|
|
237
|
+
throw thrown;
|
|
238
|
+
}
|
|
239
|
+
return response;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Resolve which service/endpoint breakers apply to a request. Low-level seam
|
|
243
|
+
* for adapters; most callers should use {@link guard}.
|
|
244
|
+
*/
|
|
245
|
+
resolve(req) {
|
|
246
|
+
const target = matchTarget(req.url, this.matchOn);
|
|
247
|
+
const { service, endpoint } = resolveRules(
|
|
248
|
+
req.url,
|
|
249
|
+
target,
|
|
250
|
+
this.config.rules
|
|
251
|
+
);
|
|
252
|
+
return {
|
|
253
|
+
service: service ? { rule: service, breaker: this.breakerFor("service", service) } : void 0,
|
|
254
|
+
endpoint: endpoint ? { rule: endpoint, breaker: this.breakerFor("endpoint", endpoint) } : void 0
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Return a {@link CircuitOpenError} if either matched breaker is open (and
|
|
259
|
+
* advance OPEN -> HALF_OPEN when due), else null. Does not send anything.
|
|
260
|
+
*/
|
|
261
|
+
checkOpen(matched) {
|
|
262
|
+
if (matched.service && !matched.service.breaker.canRequest()) {
|
|
263
|
+
return new CircuitOpenError({
|
|
264
|
+
level: "service",
|
|
265
|
+
key: matched.service.rule.match,
|
|
266
|
+
retryAt: matched.service.breaker.retryAt()
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
if (matched.endpoint && !matched.endpoint.breaker.canRequest()) {
|
|
270
|
+
return new CircuitOpenError({
|
|
271
|
+
level: "endpoint",
|
|
272
|
+
key: matched.endpoint.rule.match,
|
|
273
|
+
retryAt: matched.endpoint.breaker.retryAt()
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
/** Record the outcome of a completed request on its matched breakers. */
|
|
279
|
+
settle(matched, status) {
|
|
280
|
+
if (matched.service) {
|
|
281
|
+
this.record(
|
|
282
|
+
matched.service.breaker,
|
|
283
|
+
classifyStatus(status, matched.service.rule, this.isSuccess)
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
if (matched.endpoint) {
|
|
287
|
+
this.record(
|
|
288
|
+
matched.endpoint.breaker,
|
|
289
|
+
classifyStatus(status, matched.endpoint.rule, this.isSuccess)
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Wrap a request function once and get back a guarded function with the same
|
|
295
|
+
* signature. `req` may be static or derived per call from the arguments.
|
|
296
|
+
*/
|
|
297
|
+
wrap(req, fn) {
|
|
298
|
+
return (...args) => {
|
|
299
|
+
const resolved = typeof req === "function" ? req(...args) : req;
|
|
300
|
+
return this.guard(resolved, () => fn(...args));
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
/** Inspect a breaker by level and rule-match key. Null if not yet created. */
|
|
304
|
+
stateOf(level, key) {
|
|
305
|
+
const map = level === "service" ? this.serviceBreakers : this.endpointBreakers;
|
|
306
|
+
return map.get(key)?.snapshot() ?? null;
|
|
307
|
+
}
|
|
308
|
+
/** Force a single breaker back to CLOSED. */
|
|
309
|
+
reset(level, key) {
|
|
310
|
+
const map = level === "service" ? this.serviceBreakers : this.endpointBreakers;
|
|
311
|
+
map.get(key)?.reset();
|
|
312
|
+
}
|
|
313
|
+
/** Force every breaker back to CLOSED. */
|
|
314
|
+
resetAll() {
|
|
315
|
+
for (const breaker of this.serviceBreakers.values()) breaker.reset();
|
|
316
|
+
for (const breaker of this.endpointBreakers.values()) breaker.reset();
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Snapshot every live breaker, grouped by level and keyed by rule `match`.
|
|
320
|
+
* Only breakers that have seen at least one request appear.
|
|
321
|
+
*/
|
|
322
|
+
snapshots() {
|
|
323
|
+
const dump = (map) => {
|
|
324
|
+
const out = {};
|
|
325
|
+
for (const [key, breaker] of map) {
|
|
326
|
+
out[key] = breaker.snapshot();
|
|
327
|
+
}
|
|
328
|
+
return out;
|
|
329
|
+
};
|
|
330
|
+
return {
|
|
331
|
+
service: dump(this.serviceBreakers),
|
|
332
|
+
endpoint: dump(this.endpointBreakers)
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Print the state of every live breaker, one line each. Pass a custom sink
|
|
337
|
+
* (e.g. a logger) instead of the default `console.log`.
|
|
338
|
+
*/
|
|
339
|
+
printStatus(log = console.log) {
|
|
340
|
+
const lines = [];
|
|
341
|
+
const collect = (level, map) => {
|
|
342
|
+
for (const [key, breaker] of map) {
|
|
343
|
+
const snap = breaker.snapshot();
|
|
344
|
+
lines.push(
|
|
345
|
+
`[${level}] ${key} :: ${snap.state} count=${snap.count}` + (snap.openedAt === null ? "" : ` openedAt=${snap.openedAt}`)
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
collect("service", this.serviceBreakers);
|
|
350
|
+
collect("endpoint", this.endpointBreakers);
|
|
351
|
+
if (lines.length === 0) {
|
|
352
|
+
log("circuit-breaker: no breakers active yet");
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
log(`circuit-breaker status (${lines.length}):`);
|
|
356
|
+
for (const line of lines) log(` ${line}`);
|
|
357
|
+
}
|
|
358
|
+
record(breaker, outcome) {
|
|
359
|
+
if (outcome === "success") {
|
|
360
|
+
breaker.onSuccess();
|
|
361
|
+
} else {
|
|
362
|
+
breaker.onFailure();
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
breakerFor(level, rule) {
|
|
366
|
+
const map = level === "service" ? this.serviceBreakers : this.endpointBreakers;
|
|
367
|
+
let breaker = map.get(rule.match);
|
|
368
|
+
if (!breaker) {
|
|
369
|
+
breaker = new Breaker(this.resolveTuning(rule), this.now, (from, to) => {
|
|
370
|
+
const event = {
|
|
371
|
+
level,
|
|
372
|
+
key: rule.match,
|
|
373
|
+
from,
|
|
374
|
+
to,
|
|
375
|
+
at: this.now(),
|
|
376
|
+
alertEmails: rule.alertEmails
|
|
377
|
+
};
|
|
378
|
+
rule.onStateChange?.(event);
|
|
379
|
+
this.config.onStateChange?.(event);
|
|
380
|
+
});
|
|
381
|
+
map.set(rule.match, breaker);
|
|
382
|
+
}
|
|
383
|
+
return breaker;
|
|
384
|
+
}
|
|
385
|
+
resolveTuning(rule) {
|
|
386
|
+
const defaults = this.config.defaults ?? {};
|
|
387
|
+
return {
|
|
388
|
+
failureThreshold: rule.failureThreshold ?? defaults.failureThreshold ?? BUILT_IN_TUNING.failureThreshold,
|
|
389
|
+
cooldownMs: rule.cooldownMs ?? defaults.cooldownMs ?? BUILT_IN_TUNING.cooldownMs,
|
|
390
|
+
successThreshold: rule.successThreshold ?? defaults.successThreshold ?? BUILT_IN_TUNING.successThreshold
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
// src/adapters/fetch.ts
|
|
396
|
+
function urlOf(input) {
|
|
397
|
+
if (typeof input === "string") {
|
|
398
|
+
return input;
|
|
399
|
+
}
|
|
400
|
+
if (input && typeof input === "object") {
|
|
401
|
+
const url = input["url"];
|
|
402
|
+
if (typeof url === "string") {
|
|
403
|
+
return url;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return String(input);
|
|
407
|
+
}
|
|
408
|
+
function methodOf(input, init) {
|
|
409
|
+
const fromInit = init && typeof init === "object" ? init["method"] : void 0;
|
|
410
|
+
if (typeof fromInit === "string") {
|
|
411
|
+
return fromInit;
|
|
412
|
+
}
|
|
413
|
+
const fromReq = input && typeof input === "object" ? input["method"] : void 0;
|
|
414
|
+
return typeof fromReq === "string" ? fromReq : void 0;
|
|
415
|
+
}
|
|
416
|
+
function wrapFetch(breaker, fetchImpl) {
|
|
417
|
+
const impl = fetchImpl ?? globalThis.fetch;
|
|
418
|
+
if (typeof impl !== "function") {
|
|
419
|
+
throw new Error(
|
|
420
|
+
"wrapFetch: no fetch implementation available; pass one explicitly"
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
return (input, init) => breaker.guard(
|
|
424
|
+
{ url: urlOf(input), method: methodOf(input, init) },
|
|
425
|
+
() => impl(input, init)
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// src/adapters/axios.ts
|
|
430
|
+
var MATCHED = /* @__PURE__ */ Symbol("circuit-breaker:matched");
|
|
431
|
+
function fullUrl(config) {
|
|
432
|
+
const base = config.baseURL ?? "";
|
|
433
|
+
const url = config.url ?? "";
|
|
434
|
+
if (!base) {
|
|
435
|
+
return url;
|
|
436
|
+
}
|
|
437
|
+
if (!url) {
|
|
438
|
+
return base;
|
|
439
|
+
}
|
|
440
|
+
return `${base.replace(/\/+$/, "")}/${url.replace(/^\/+/, "")}`;
|
|
441
|
+
}
|
|
442
|
+
function attachAxios(client, breaker) {
|
|
443
|
+
client.interceptors.request.use((config) => {
|
|
444
|
+
const matched = breaker.resolve({
|
|
445
|
+
url: fullUrl(config),
|
|
446
|
+
method: config.method
|
|
447
|
+
});
|
|
448
|
+
const openError = breaker.checkOpen(matched);
|
|
449
|
+
if (openError) {
|
|
450
|
+
return Promise.reject(openError);
|
|
451
|
+
}
|
|
452
|
+
config[MATCHED] = matched;
|
|
453
|
+
return config;
|
|
454
|
+
});
|
|
455
|
+
client.interceptors.response.use(
|
|
456
|
+
(response) => {
|
|
457
|
+
const matched = readMatched(response.config);
|
|
458
|
+
if (matched) {
|
|
459
|
+
breaker.settle(matched, statusOf(response, false));
|
|
460
|
+
}
|
|
461
|
+
return response;
|
|
462
|
+
},
|
|
463
|
+
(error) => {
|
|
464
|
+
if (isCircuitOpenError(error)) {
|
|
465
|
+
return Promise.reject(error);
|
|
466
|
+
}
|
|
467
|
+
const axiosError = error;
|
|
468
|
+
const matched = readMatched(axiosError.config);
|
|
469
|
+
if (matched) {
|
|
470
|
+
breaker.settle(matched, statusOf(axiosError, true));
|
|
471
|
+
}
|
|
472
|
+
return Promise.reject(error);
|
|
473
|
+
}
|
|
474
|
+
);
|
|
475
|
+
return client;
|
|
476
|
+
}
|
|
477
|
+
function readMatched(config) {
|
|
478
|
+
if (!config) {
|
|
479
|
+
return void 0;
|
|
480
|
+
}
|
|
481
|
+
const value = config[MATCHED];
|
|
482
|
+
return value;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// src/adapters/email.ts
|
|
486
|
+
function defaultSubject(event) {
|
|
487
|
+
return `[circuit-breaker] ${event.level} "${event.key}" -> ${event.to}`;
|
|
488
|
+
}
|
|
489
|
+
function defaultText(event) {
|
|
490
|
+
return [
|
|
491
|
+
`Circuit breaker state change.`,
|
|
492
|
+
`level: ${event.level}`,
|
|
493
|
+
`key: ${event.key}`,
|
|
494
|
+
`from: ${event.from}`,
|
|
495
|
+
`to: ${event.to}`,
|
|
496
|
+
`at: ${new Date(event.at).toISOString()}`
|
|
497
|
+
].join("\n");
|
|
498
|
+
}
|
|
499
|
+
function emailAlerter(options) {
|
|
500
|
+
const when = options.when ?? ["OPEN" /* OPEN */];
|
|
501
|
+
const log = options.log ?? ((message, error) => console.error(message, error));
|
|
502
|
+
return (event) => {
|
|
503
|
+
if (!when.includes(event.to)) {
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
const recipients = event.alertEmails && event.alertEmails.length > 0 ? event.alertEmails : options.to ?? [];
|
|
507
|
+
if (recipients.length === 0) {
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
const subject = options.subject ? options.subject(event) : defaultSubject(event);
|
|
511
|
+
const parts = options.body ? options.body(event) : { text: defaultText(event) };
|
|
512
|
+
Promise.resolve().then(
|
|
513
|
+
() => options.transporter.sendMail({
|
|
514
|
+
from: options.from,
|
|
515
|
+
to: recipients,
|
|
516
|
+
subject,
|
|
517
|
+
...parts
|
|
518
|
+
})
|
|
519
|
+
).catch(
|
|
520
|
+
(error) => log(`circuit-breaker: alert email failed for "${event.key}"`, error)
|
|
521
|
+
);
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// src/index.ts
|
|
526
|
+
function createBreaker(config, now) {
|
|
527
|
+
return new CircuitBreaker(config, now);
|
|
528
|
+
}
|
|
529
|
+
export {
|
|
530
|
+
Breaker,
|
|
531
|
+
BreakerState,
|
|
532
|
+
CircuitBreaker,
|
|
533
|
+
CircuitOpenError,
|
|
534
|
+
attachAxios,
|
|
535
|
+
classifyStatus,
|
|
536
|
+
createBreaker,
|
|
537
|
+
defaultIsSuccess,
|
|
538
|
+
emailAlerter,
|
|
539
|
+
isCircuitOpenError,
|
|
540
|
+
matchTarget,
|
|
541
|
+
resolveRules,
|
|
542
|
+
statusOf,
|
|
543
|
+
wrapFetch
|
|
544
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@prash0029/circuit-breaker",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Two-level, config-driven in-memory circuit breaker for guarding outbound server API calls (per-service + per-endpoint).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"require": "./dist/index.cjs"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
|
24
|
+
"test": "vitest run",
|
|
25
|
+
"test:watch": "vitest",
|
|
26
|
+
"typecheck": "tsc --noEmit",
|
|
27
|
+
"lint": "eslint src test",
|
|
28
|
+
"prepublishOnly": "npm run typecheck && npm run test && npm run build"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"circuit-breaker",
|
|
32
|
+
"resilience",
|
|
33
|
+
"fault-tolerance",
|
|
34
|
+
"typescript"
|
|
35
|
+
],
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"author": "Sai Prashanth",
|
|
38
|
+
"private": false,
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/Prash-29/circuit-breaker.git"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public",
|
|
45
|
+
"registry": "https://registry.npmjs.org/"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "^22.0.0",
|
|
49
|
+
"tsup": "^8.0.0",
|
|
50
|
+
"typescript": "^5.4.0",
|
|
51
|
+
"vitest": "^2.0.0"
|
|
52
|
+
}
|
|
53
|
+
}
|