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