@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.d.ts
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/** Circuit breaker states. */
|
|
2
|
+
declare enum BreakerState {
|
|
3
|
+
/** Normal operation. Calls pass through. Failures raise the count. */
|
|
4
|
+
CLOSED = "CLOSED",
|
|
5
|
+
/** Tripped. Calls are rejected immediately without invoking the wrapped fn. */
|
|
6
|
+
OPEN = "OPEN",
|
|
7
|
+
/** Trial window. Calls are allowed to probe recovery. */
|
|
8
|
+
HALF_OPEN = "HALF_OPEN"
|
|
9
|
+
}
|
|
10
|
+
/** Which level of the two-level breaker rejected a call. */
|
|
11
|
+
type BreakerLevel = "service" | "endpoint";
|
|
12
|
+
/** Per-breaker tuning. Any field omitted falls back to registry defaults. */
|
|
13
|
+
interface RuleTuning {
|
|
14
|
+
/** Count value that trips a CLOSED breaker to OPEN. */
|
|
15
|
+
failureThreshold?: number;
|
|
16
|
+
/** Time (ms) a breaker stays OPEN before allowing a trial call (HALF_OPEN). */
|
|
17
|
+
cooldownMs?: number;
|
|
18
|
+
/** Consecutive trial successes required in HALF_OPEN to close. */
|
|
19
|
+
successThreshold?: number;
|
|
20
|
+
}
|
|
21
|
+
/** Status-code classification shared by both rule types. */
|
|
22
|
+
interface StatusClassification {
|
|
23
|
+
/** Response statuses counted as success (count -1). */
|
|
24
|
+
successServerStatus?: number[];
|
|
25
|
+
/** Response statuses counted as failure (count +1). */
|
|
26
|
+
failedServerResStatus?: number[];
|
|
27
|
+
}
|
|
28
|
+
/** Hooks a rule can carry. */
|
|
29
|
+
interface RuleHooks {
|
|
30
|
+
/**
|
|
31
|
+
* Called when THIS rule's breaker changes state. Runs in addition to the
|
|
32
|
+
* registry-wide `onStateChange`. Use it to react per endpoint/service:
|
|
33
|
+
* alert, flip a feature flag, warm a fallback, etc.
|
|
34
|
+
*/
|
|
35
|
+
onStateChange?: (event: StateChangeEvent) => void;
|
|
36
|
+
/**
|
|
37
|
+
* Recipients to alert for THIS rule's transitions. Surfaced on the
|
|
38
|
+
* StateChangeEvent so an alerter (e.g. {@link emailAlerter}) can mail the
|
|
39
|
+
* right people per route. Does not send anything on its own.
|
|
40
|
+
*/
|
|
41
|
+
alertEmails?: string[];
|
|
42
|
+
}
|
|
43
|
+
/** A rule scoped to a single endpoint (one outgoing route). */
|
|
44
|
+
interface EndpointRule extends RuleTuning, StatusClassification, RuleHooks {
|
|
45
|
+
type: "endpoint";
|
|
46
|
+
/** Substring searched in the outgoing request path; rule applies if found. */
|
|
47
|
+
match: string;
|
|
48
|
+
}
|
|
49
|
+
/** A rule scoped to a whole service (aggregates its non-skipped endpoints). */
|
|
50
|
+
interface ServiceRule extends RuleTuning, StatusClassification, RuleHooks {
|
|
51
|
+
type: "service";
|
|
52
|
+
/** Substring identifying the service (e.g. host) in the outgoing path. */
|
|
53
|
+
match: string;
|
|
54
|
+
/** Endpoint substrings under this service that must NOT count toward it. */
|
|
55
|
+
skipList?: string[];
|
|
56
|
+
}
|
|
57
|
+
type Rule = EndpointRule | ServiceRule;
|
|
58
|
+
/** Resolved, fully-populated tuning used by a live breaker. */
|
|
59
|
+
interface ResolvedTuning {
|
|
60
|
+
failureThreshold: number;
|
|
61
|
+
cooldownMs: number;
|
|
62
|
+
successThreshold: number;
|
|
63
|
+
}
|
|
64
|
+
interface RegistryConfig {
|
|
65
|
+
/** Route checklist evaluated against every outgoing request, in order. */
|
|
66
|
+
rules: Rule[];
|
|
67
|
+
/** Tuning applied to any rule that omits a field. */
|
|
68
|
+
defaults?: RuleTuning;
|
|
69
|
+
/**
|
|
70
|
+
* Decides success/failure when a matched rule gives no status lists.
|
|
71
|
+
* Default: 2xx is success, everything else failure.
|
|
72
|
+
*/
|
|
73
|
+
defaultIsSuccess?: (status: number) => boolean;
|
|
74
|
+
/**
|
|
75
|
+
* What part of the URL the `match`/`skipList` substrings test against.
|
|
76
|
+
* "path" (default) strips the query string — avoids matching/logging tokens
|
|
77
|
+
* or PII carried in query params.
|
|
78
|
+
*/
|
|
79
|
+
matchOn?: "path" | "url";
|
|
80
|
+
/** Called whenever any breaker changes state. For logging/metrics. */
|
|
81
|
+
onStateChange?: (event: StateChangeEvent) => void;
|
|
82
|
+
}
|
|
83
|
+
interface StateChangeEvent {
|
|
84
|
+
level: BreakerLevel;
|
|
85
|
+
/** The rule `match` string that keys this breaker. Never the raw URL. */
|
|
86
|
+
key: string;
|
|
87
|
+
from: BreakerState;
|
|
88
|
+
to: BreakerState;
|
|
89
|
+
at: number;
|
|
90
|
+
/** Recipients configured on the rule (`alertEmails`), if any. */
|
|
91
|
+
alertEmails?: string[];
|
|
92
|
+
}
|
|
93
|
+
/** Identifies an outgoing request to guard. */
|
|
94
|
+
interface GuardRequest {
|
|
95
|
+
url: string;
|
|
96
|
+
method?: string;
|
|
97
|
+
}
|
|
98
|
+
/** Snapshot of a single breaker for inspection. */
|
|
99
|
+
interface BreakerSnapshot {
|
|
100
|
+
state: BreakerState;
|
|
101
|
+
/** Current leaky-bucket count. */
|
|
102
|
+
count: number;
|
|
103
|
+
openedAt: number | null;
|
|
104
|
+
}
|
|
105
|
+
/** Outcome of classifying a response/error against a rule. */
|
|
106
|
+
type Outcome = "success" | "failure";
|
|
107
|
+
|
|
108
|
+
/** Injectable clock so tests need not sleep. */
|
|
109
|
+
type Clock = () => number;
|
|
110
|
+
/**
|
|
111
|
+
* A single in-memory leaky-bucket circuit breaker.
|
|
112
|
+
*
|
|
113
|
+
* - failure raises `count` by 1; success lowers it by 1 (floored at 0)
|
|
114
|
+
* - `count >= failureThreshold` trips CLOSED -> OPEN
|
|
115
|
+
* - after `cooldownMs` an OPEN breaker allows trial calls (HALF_OPEN)
|
|
116
|
+
* - `successThreshold` consecutive trial successes close it; any trial failure
|
|
117
|
+
* re-opens it
|
|
118
|
+
*
|
|
119
|
+
* Knows nothing about service/endpoint keys; the registry composes two per call.
|
|
120
|
+
* HALF_OPEN admits concurrent probes (no in-flight reservation) by design.
|
|
121
|
+
*/
|
|
122
|
+
declare class Breaker {
|
|
123
|
+
private readonly tuning;
|
|
124
|
+
private readonly now;
|
|
125
|
+
private readonly onTransition?;
|
|
126
|
+
private state;
|
|
127
|
+
private count;
|
|
128
|
+
private halfOpenSuccesses;
|
|
129
|
+
private openedAt;
|
|
130
|
+
constructor(tuning: ResolvedTuning, now?: Clock, onTransition?: ((from: BreakerState, to: BreakerState) => void) | undefined);
|
|
131
|
+
/**
|
|
132
|
+
* Whether a call may proceed. Performs the OPEN -> HALF_OPEN transition when
|
|
133
|
+
* the cooldown has elapsed. Does not mutate counts. Call once per guarded
|
|
134
|
+
* call, before invoking the wrapped fn.
|
|
135
|
+
*/
|
|
136
|
+
canRequest(): boolean;
|
|
137
|
+
/** Record a successful guarded call. */
|
|
138
|
+
onSuccess(): void;
|
|
139
|
+
/** Record a failed guarded call. */
|
|
140
|
+
onFailure(): void;
|
|
141
|
+
/** Earliest epoch ms a rejected caller may retry (cooldown end). */
|
|
142
|
+
retryAt(): number;
|
|
143
|
+
snapshot(): BreakerSnapshot;
|
|
144
|
+
/** Force back to CLOSED (manual operator reset). */
|
|
145
|
+
reset(): void;
|
|
146
|
+
private maybeHalfOpen;
|
|
147
|
+
private open;
|
|
148
|
+
private close;
|
|
149
|
+
private transition;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Thrown when a request is rejected because its service- or endpoint-level
|
|
154
|
+
* breaker is OPEN. The wrapped request was never sent.
|
|
155
|
+
*/
|
|
156
|
+
declare class CircuitOpenError extends Error {
|
|
157
|
+
readonly level: BreakerLevel;
|
|
158
|
+
/** The rule `match` string keying the open breaker. Not the raw URL. */
|
|
159
|
+
readonly key: string;
|
|
160
|
+
/** Epoch ms when callers may retry (breaker eligible for HALF_OPEN trial). */
|
|
161
|
+
readonly retryAt: number;
|
|
162
|
+
constructor(args: {
|
|
163
|
+
level: BreakerLevel;
|
|
164
|
+
key: string;
|
|
165
|
+
retryAt: number;
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
/** Type guard for catch blocks. */
|
|
169
|
+
declare function isCircuitOpenError(error: unknown): error is CircuitOpenError;
|
|
170
|
+
|
|
171
|
+
/** A request's resolved breakers, returned by {@link CircuitBreaker.resolve}. */
|
|
172
|
+
interface Matched {
|
|
173
|
+
service?: {
|
|
174
|
+
rule: Rule;
|
|
175
|
+
breaker: Breaker;
|
|
176
|
+
};
|
|
177
|
+
endpoint?: {
|
|
178
|
+
rule: Rule;
|
|
179
|
+
breaker: Breaker;
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Config-driven, two-level circuit breaker. Built once per process from a rule
|
|
184
|
+
* checklist, then placed in front of every outgoing request (directly via
|
|
185
|
+
* {@link guard} or through the axios/fetch adapters).
|
|
186
|
+
*
|
|
187
|
+
* Each request is matched against the rules to find its service rule and its
|
|
188
|
+
* endpoint rule. The request passes only if both matched breakers are non-open;
|
|
189
|
+
* the response status then classifies the call as success (count -1) or failure
|
|
190
|
+
* (count +1) on each level. Requests matching no rule pass through untouched.
|
|
191
|
+
*
|
|
192
|
+
* State is in process memory only; each replica trips independently.
|
|
193
|
+
*/
|
|
194
|
+
declare class CircuitBreaker {
|
|
195
|
+
private readonly config;
|
|
196
|
+
private readonly now;
|
|
197
|
+
private readonly matchOn;
|
|
198
|
+
private readonly isSuccess;
|
|
199
|
+
private readonly serviceBreakers;
|
|
200
|
+
private readonly endpointBreakers;
|
|
201
|
+
constructor(config: RegistryConfig, now?: Clock);
|
|
202
|
+
/**
|
|
203
|
+
* Guard an outgoing request. Resolves the rules for `req.url`, rejects with
|
|
204
|
+
* {@link CircuitOpenError} (without sending) when either matched breaker is
|
|
205
|
+
* open, otherwise runs `fn`, classifies the result by status, and records the
|
|
206
|
+
* outcome on both levels. The response is returned as-is even when its status
|
|
207
|
+
* counts as a failure; network errors are recorded then re-thrown.
|
|
208
|
+
*/
|
|
209
|
+
guard<T>(req: GuardRequest, fn: () => Promise<T>): Promise<T>;
|
|
210
|
+
/**
|
|
211
|
+
* Resolve which service/endpoint breakers apply to a request. Low-level seam
|
|
212
|
+
* for adapters; most callers should use {@link guard}.
|
|
213
|
+
*/
|
|
214
|
+
resolve(req: GuardRequest): Matched;
|
|
215
|
+
/**
|
|
216
|
+
* Return a {@link CircuitOpenError} if either matched breaker is open (and
|
|
217
|
+
* advance OPEN -> HALF_OPEN when due), else null. Does not send anything.
|
|
218
|
+
*/
|
|
219
|
+
checkOpen(matched: Matched): CircuitOpenError | null;
|
|
220
|
+
/** Record the outcome of a completed request on its matched breakers. */
|
|
221
|
+
settle(matched: Matched, status: number | undefined): void;
|
|
222
|
+
/**
|
|
223
|
+
* Wrap a request function once and get back a guarded function with the same
|
|
224
|
+
* signature. `req` may be static or derived per call from the arguments.
|
|
225
|
+
*/
|
|
226
|
+
wrap<Args extends unknown[], T>(req: GuardRequest | ((...args: Args) => GuardRequest), fn: (...args: Args) => Promise<T>): (...args: Args) => Promise<T>;
|
|
227
|
+
/** Inspect a breaker by level and rule-match key. Null if not yet created. */
|
|
228
|
+
stateOf(level: BreakerLevel, key: string): BreakerSnapshot | null;
|
|
229
|
+
/** Force a single breaker back to CLOSED. */
|
|
230
|
+
reset(level: BreakerLevel, key: string): void;
|
|
231
|
+
/** Force every breaker back to CLOSED. */
|
|
232
|
+
resetAll(): void;
|
|
233
|
+
/**
|
|
234
|
+
* Snapshot every live breaker, grouped by level and keyed by rule `match`.
|
|
235
|
+
* Only breakers that have seen at least one request appear.
|
|
236
|
+
*/
|
|
237
|
+
snapshots(): {
|
|
238
|
+
service: Record<string, BreakerSnapshot>;
|
|
239
|
+
endpoint: Record<string, BreakerSnapshot>;
|
|
240
|
+
};
|
|
241
|
+
/**
|
|
242
|
+
* Print the state of every live breaker, one line each. Pass a custom sink
|
|
243
|
+
* (e.g. a logger) instead of the default `console.log`.
|
|
244
|
+
*/
|
|
245
|
+
printStatus(log?: (line: string) => void): void;
|
|
246
|
+
private record;
|
|
247
|
+
private breakerFor;
|
|
248
|
+
private resolveTuning;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Default success predicate: any 2xx. */
|
|
252
|
+
declare function defaultIsSuccess(status: number): boolean;
|
|
253
|
+
/**
|
|
254
|
+
* Classify a response status against a rule.
|
|
255
|
+
*
|
|
256
|
+
* Precedence:
|
|
257
|
+
* 1. status in `failedServerResStatus` -> failure
|
|
258
|
+
* 2. status in `successServerStatus` -> success
|
|
259
|
+
* 3. otherwise -> `isSuccess(status)` (registry default, 2xx)
|
|
260
|
+
*
|
|
261
|
+
* `status === undefined` (network error, no response) is always a failure.
|
|
262
|
+
*/
|
|
263
|
+
declare function classifyStatus(status: number | undefined, rule: StatusClassification, isSuccess: (status: number) => boolean): Outcome;
|
|
264
|
+
/**
|
|
265
|
+
* Best-effort status extraction for both fetch (`Response.status`) and axios
|
|
266
|
+
* (`response.status`, error `err.response.status`) shapes.
|
|
267
|
+
*/
|
|
268
|
+
declare function statusOf(value: unknown, isError: boolean): number | undefined;
|
|
269
|
+
|
|
270
|
+
interface MatchResult {
|
|
271
|
+
/** First service rule whose match is in the path and not excluded by skipList. */
|
|
272
|
+
service?: ServiceRule;
|
|
273
|
+
/** First endpoint rule whose match is in the path. */
|
|
274
|
+
endpoint?: EndpointRule;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Reduce a URL to the string the rules test against. With matchOn "path" the
|
|
278
|
+
* query string is dropped so tokens/PII in query params are never matched or
|
|
279
|
+
* surfaced. Falls back to the raw input when it is not a parseable URL (e.g. a
|
|
280
|
+
* relative path passed to a client with a baseURL).
|
|
281
|
+
*/
|
|
282
|
+
declare function matchTarget(url: string, matchOn: "path" | "url"): string;
|
|
283
|
+
/**
|
|
284
|
+
* Resolve which service and endpoint rules apply to an outgoing target.
|
|
285
|
+
* First match wins for each level (rules are evaluated in declared order).
|
|
286
|
+
*/
|
|
287
|
+
declare function resolveRules(fullUrl: string, target: string, rules: Rule[]): MatchResult;
|
|
288
|
+
|
|
289
|
+
/** Minimal structural type for a fetch implementation (no DOM lib needed). */
|
|
290
|
+
type FetchLike = (input: unknown, init?: unknown) => Promise<unknown>;
|
|
291
|
+
/**
|
|
292
|
+
* Wrap a fetch implementation so every call is guarded by the breaker. The
|
|
293
|
+
* returned function has the same signature as `fetch`. A non-2xx Response is
|
|
294
|
+
* still returned to the caller (fetch does not throw on HTTP errors); the
|
|
295
|
+
* breaker classifies it by status. Network rejections are recorded then
|
|
296
|
+
* re-thrown. A request whose breaker is open rejects with CircuitOpenError and
|
|
297
|
+
* is never sent.
|
|
298
|
+
*
|
|
299
|
+
* Defaults to the global `fetch` (Node 18+). Pass `fetchImpl` to use a custom
|
|
300
|
+
* client or a per-instance fetch.
|
|
301
|
+
*/
|
|
302
|
+
declare function wrapFetch(breaker: CircuitBreaker, fetchImpl?: FetchLike): FetchLike;
|
|
303
|
+
|
|
304
|
+
interface AxiosConfigLike {
|
|
305
|
+
url?: string;
|
|
306
|
+
baseURL?: string;
|
|
307
|
+
method?: string;
|
|
308
|
+
[key: string]: unknown;
|
|
309
|
+
}
|
|
310
|
+
interface InterceptorManagerLike<V> {
|
|
311
|
+
use(onFulfilled?: (value: V) => V | Promise<V>, onRejected?: (error: unknown) => unknown): number;
|
|
312
|
+
}
|
|
313
|
+
/** Structural subset of an axios instance: just the interceptor managers. */
|
|
314
|
+
interface AxiosInstanceLike {
|
|
315
|
+
interceptors: {
|
|
316
|
+
request: InterceptorManagerLike<AxiosConfigLike>;
|
|
317
|
+
response: InterceptorManagerLike<{
|
|
318
|
+
status?: number;
|
|
319
|
+
config?: AxiosConfigLike;
|
|
320
|
+
}>;
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Attach the breaker to an axios instance via interceptors so every request
|
|
325
|
+
* through it is guarded. Open breakers reject the request before it is sent;
|
|
326
|
+
* responses and errors are classified by status and recorded.
|
|
327
|
+
*
|
|
328
|
+
* Works against any object shaped like an axios instance (axios, an
|
|
329
|
+
* axios.create() instance, or a compatible client). Returns the same instance.
|
|
330
|
+
*
|
|
331
|
+
* Known limitation: a request that is cancelled after passing the open check
|
|
332
|
+
* records no outcome (neither success nor failure).
|
|
333
|
+
*/
|
|
334
|
+
declare function attachAxios<T extends AxiosInstanceLike>(client: T, breaker: CircuitBreaker): T;
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Structural subset of a nodemailer transporter. Any object with a compatible
|
|
338
|
+
* `sendMail` works (nodemailer's `createTransport(...)` result, a mock, etc.).
|
|
339
|
+
* The transporter is injected so SMTP credentials live in your env / secrets
|
|
340
|
+
* manager, never in this package or its config.
|
|
341
|
+
*/
|
|
342
|
+
interface MailTransporter {
|
|
343
|
+
sendMail(message: {
|
|
344
|
+
from?: string;
|
|
345
|
+
to: string | string[];
|
|
346
|
+
subject: string;
|
|
347
|
+
text?: string;
|
|
348
|
+
html?: string;
|
|
349
|
+
}): Promise<unknown>;
|
|
350
|
+
}
|
|
351
|
+
interface EmailAlertOptions {
|
|
352
|
+
/** Your nodemailer transporter (or anything matching MailTransporter). */
|
|
353
|
+
transporter: MailTransporter;
|
|
354
|
+
/** From address. Required by most SMTP servers. */
|
|
355
|
+
from?: string;
|
|
356
|
+
/**
|
|
357
|
+
* Which transitions send mail. Default: only OPEN (a breaker tripped).
|
|
358
|
+
*/
|
|
359
|
+
when?: BreakerState[];
|
|
360
|
+
/** Recipients used when a rule has no `alertEmails` of its own. */
|
|
361
|
+
to?: string[];
|
|
362
|
+
/** Override the subject line. */
|
|
363
|
+
subject?: (event: StateChangeEvent) => string;
|
|
364
|
+
/** Override the body. Return `text` and/or `html`. */
|
|
365
|
+
body?: (event: StateChangeEvent) => {
|
|
366
|
+
text?: string;
|
|
367
|
+
html?: string;
|
|
368
|
+
};
|
|
369
|
+
/** Sink for send failures. Defaults to console.error. */
|
|
370
|
+
log?: (message: string, error: unknown) => void;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Build an `onStateChange` handler that emails an alert when a breaker
|
|
374
|
+
* transitions into one of `when` (default: OPEN only). Recipients come from the
|
|
375
|
+
* rule's `alertEmails`, falling back to `options.to`. If no recipients resolve,
|
|
376
|
+
* nothing is sent.
|
|
377
|
+
*
|
|
378
|
+
* Sending is non-blocking and never throws into the breaker: a sendMail failure
|
|
379
|
+
* is caught and logged so alerting can never affect guarded traffic.
|
|
380
|
+
*
|
|
381
|
+
* Attach it registry-wide (fires for every rule) or per rule.
|
|
382
|
+
*/
|
|
383
|
+
declare function emailAlerter(options: EmailAlertOptions): (event: StateChangeEvent) => void;
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Build a circuit breaker from a rule checklist. Create one per process at
|
|
387
|
+
* startup, then place it in front of outgoing requests via the axios/fetch
|
|
388
|
+
* adapters or {@link CircuitBreaker.guard} / {@link CircuitBreaker.wrap}.
|
|
389
|
+
*/
|
|
390
|
+
declare function createBreaker(config: RegistryConfig, now?: Clock): CircuitBreaker;
|
|
391
|
+
|
|
392
|
+
export { type AxiosInstanceLike, Breaker, type BreakerLevel, type BreakerSnapshot, BreakerState, CircuitBreaker, CircuitOpenError, type Clock, type EmailAlertOptions, type EndpointRule, type FetchLike, type GuardRequest, type MailTransporter, type MatchResult, type Matched, type Outcome, type RegistryConfig, type ResolvedTuning, type Rule, type RuleHooks, type RuleTuning, type ServiceRule, type StateChangeEvent, type StatusClassification, attachAxios, classifyStatus, createBreaker, defaultIsSuccess, emailAlerter, isCircuitOpenError, matchTarget, resolveRules, statusOf, wrapFetch };
|