@gkoos/caracal 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.
Files changed (49) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +21 -0
  3. package/README.md +311 -0
  4. package/dist/chunk-5CXDW7W6.js +202 -0
  5. package/dist/chunk-5CXDW7W6.js.map +1 -0
  6. package/dist/circuit-breaker-BSkcV0W_.d.ts +296 -0
  7. package/dist/fetch.d.ts +58 -0
  8. package/dist/fetch.js +117 -0
  9. package/dist/fetch.js.map +1 -0
  10. package/dist/index.d.ts +19 -0
  11. package/dist/index.js +1065 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/postgres.d.ts +28 -0
  14. package/dist/postgres.js +56 -0
  15. package/dist/postgres.js.map +1 -0
  16. package/dist/redis.d.ts +59 -0
  17. package/dist/redis.js +549 -0
  18. package/dist/redis.js.map +1 -0
  19. package/dist/retry-BFP_k3Hg.d.ts +26 -0
  20. package/dist/testing/index.d.ts +45 -0
  21. package/dist/testing/index.js +101 -0
  22. package/dist/testing/index.js.map +1 -0
  23. package/dist/types-Tf9T76C7.d.ts +187 -0
  24. package/package.json +127 -0
  25. package/src/adapters/fetch/adapter.ts +122 -0
  26. package/src/adapters/fetch/index.ts +15 -0
  27. package/src/adapters/fetch/retry-after.ts +111 -0
  28. package/src/adapters/postgres/adapter.ts +102 -0
  29. package/src/adapters/postgres/index.ts +7 -0
  30. package/src/coordination/redis/bulkhead.ts +61 -0
  31. package/src/coordination/redis/circuit-breaker.ts +270 -0
  32. package/src/coordination/redis/client.ts +78 -0
  33. package/src/coordination/redis/eval-script.ts +71 -0
  34. package/src/coordination/redis/keys.ts +32 -0
  35. package/src/coordination/redis/leases.ts +44 -0
  36. package/src/coordination/redis/scripts.ts +314 -0
  37. package/src/core/bulkhead.ts +336 -0
  38. package/src/core/circuit-breaker.ts +1066 -0
  39. package/src/core/index.ts +36 -0
  40. package/src/core/operation.ts +174 -0
  41. package/src/core/retry.ts +204 -0
  42. package/src/core/runtime.ts +123 -0
  43. package/src/core/scope-state-cache.ts +50 -0
  44. package/src/core/timeout.ts +73 -0
  45. package/src/core/types.ts +230 -0
  46. package/src/fetch.ts +17 -0
  47. package/src/index.ts +49 -0
  48. package/src/postgres.ts +9 -0
  49. package/src/redis.ts +8 -0
@@ -0,0 +1,1066 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import { emitRuntimeEvent } from "./runtime.js"
3
+ import { createScopeStateCache } from "./scope-state-cache.js"
4
+ import type { ExecutionContext, Next, Outcome, Policy } from "./types.js"
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Public types
8
+ // ---------------------------------------------------------------------------
9
+
10
+ export type BreakerState = "closed" | "open" | "half-open"
11
+
12
+ /** Classification of an attempt outcome for circuit-breaker purposes. */
13
+ export type BreakerOutcome = "success" | "failure" | "ignored"
14
+
15
+ /**
16
+ * Called after every attempt that the breaker observes. Returning "ignored"
17
+ * means the outcome neither counts toward nor clears failures.
18
+ */
19
+ export type BreakerClassifier = (
20
+ error: unknown,
21
+ isSuccess: boolean,
22
+ ) => BreakerOutcome
23
+
24
+ function classifyOutcome(
25
+ context: ExecutionContext,
26
+ classifier: BreakerClassifier | undefined,
27
+ outcome: Outcome<unknown>,
28
+ ): BreakerOutcome {
29
+ if (classifier !== undefined) {
30
+ return classifier(
31
+ outcome.status === "failure" ? outcome.error : undefined,
32
+ outcome.status === "success",
33
+ )
34
+ }
35
+
36
+ const classification = context.classify(outcome)
37
+ return classification === "retryable" ? "failure" : classification
38
+ }
39
+
40
+ export interface LocalBreakerOptions {
41
+ /** Identifies this policy in events and introspection. */
42
+ readonly name: string
43
+ /**
44
+ * Minimum number of observations in the window before the breaker may open.
45
+ * Default: 5.
46
+ */
47
+ readonly minimumThroughput?: number
48
+ /**
49
+ * Fraction of failures (0–1 exclusive) that triggers opening.
50
+ * Default: 0.5.
51
+ */
52
+ readonly failureThreshold?: number
53
+ /**
54
+ * How long (ms) the breaker stays open before entering half-open.
55
+ * Default: 10 000.
56
+ */
57
+ readonly openMs?: number
58
+ /**
59
+ * Number of consecutive successes needed to close from half-open.
60
+ * Default: 1.
61
+ */
62
+ readonly halfOpenSuccesses?: number
63
+ /**
64
+ * Maximum concurrent probes allowed in half-open state.
65
+ * Default: 1.
66
+ */
67
+ readonly halfOpenProbes?: number
68
+ /**
69
+ * Sliding-window size (number of observations retained).
70
+ * Default: 100.
71
+ */
72
+ readonly windowSize?: number
73
+ /** Custom outcome classifier. Defaults to the adapter classification; retryable counts as failure. */
74
+ readonly classify?: BreakerClassifier
75
+ }
76
+
77
+ export interface BreakerSnapshot {
78
+ readonly coordination: "local"
79
+ readonly state: BreakerState
80
+ readonly failures: number
81
+ readonly successes: number
82
+ readonly observations: number
83
+ /** Only meaningful in half-open; number of probes currently in-flight. */
84
+ readonly probesInFlight: number
85
+ /** Only meaningful in half-open; consecutive successes so far this epoch. */
86
+ readonly halfOpenSuccesses: number
87
+ }
88
+
89
+ export class CircuitOpenError extends Error {
90
+ constructor(
91
+ readonly policyName: string,
92
+ readonly coordination: "local" | "distributed",
93
+ readonly scope: string,
94
+ ) {
95
+ super(`Circuit ${policyName} is open for scope "${scope}"`)
96
+ this.name = "CircuitOpenError"
97
+ }
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Defaults
102
+ // ---------------------------------------------------------------------------
103
+
104
+ const DEFAULT_MINIMUM_THROUGHPUT = 5
105
+ const DEFAULT_FAILURE_THRESHOLD = 0.5
106
+ const DEFAULT_OPEN_MS = 10_000
107
+ const DEFAULT_HALF_OPEN_SUCCESSES = 1
108
+ const DEFAULT_HALF_OPEN_PROBES = 1
109
+ const DEFAULT_WINDOW_SIZE = 100
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Validation
113
+ // ---------------------------------------------------------------------------
114
+
115
+ function validate(opts: LocalBreakerOptions): void {
116
+ if (!opts.name.trim())
117
+ throw new RangeError("Circuit breaker name must not be empty")
118
+
119
+ const { minimumThroughput = DEFAULT_MINIMUM_THROUGHPUT } = opts
120
+ if (!Number.isInteger(minimumThroughput) || minimumThroughput < 1)
121
+ throw new RangeError("minimumThroughput must be a positive integer")
122
+
123
+ const { failureThreshold = DEFAULT_FAILURE_THRESHOLD } = opts
124
+ if (
125
+ !Number.isFinite(failureThreshold) ||
126
+ failureThreshold <= 0 ||
127
+ failureThreshold >= 1
128
+ )
129
+ throw new RangeError("failureThreshold must be a number in (0, 1)")
130
+
131
+ const { openMs = DEFAULT_OPEN_MS } = opts
132
+ if (!Number.isInteger(openMs) || openMs < 1)
133
+ throw new RangeError("openMs must be a positive integer")
134
+
135
+ const { halfOpenSuccesses = DEFAULT_HALF_OPEN_SUCCESSES } = opts
136
+ if (!Number.isInteger(halfOpenSuccesses) || halfOpenSuccesses < 1)
137
+ throw new RangeError("halfOpenSuccesses must be a positive integer")
138
+
139
+ const { halfOpenProbes = DEFAULT_HALF_OPEN_PROBES } = opts
140
+ if (!Number.isInteger(halfOpenProbes) || halfOpenProbes < 1)
141
+ throw new RangeError("halfOpenProbes must be a positive integer")
142
+
143
+ const { windowSize = DEFAULT_WINDOW_SIZE } = opts
144
+ if (!Number.isInteger(windowSize) || windowSize < 1)
145
+ throw new RangeError("windowSize must be a positive integer")
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Sliding window (circular buffer of booleans: true = failure)
150
+ // ---------------------------------------------------------------------------
151
+
152
+ class SlidingWindow {
153
+ readonly #size: number
154
+ readonly #buf: boolean[]
155
+ #head = 0
156
+ #count = 0
157
+ #failures = 0
158
+
159
+ constructor(size: number) {
160
+ this.#size = size
161
+ this.#buf = new Array<boolean>(size).fill(false)
162
+ }
163
+
164
+ record(failure: boolean): void {
165
+ const evicted = this.#buf[this.#head] === true
166
+ if (this.#count === this.#size) {
167
+ if (evicted) this.#failures--
168
+ } else {
169
+ this.#count++
170
+ }
171
+ this.#buf[this.#head] = failure
172
+ if (failure) this.#failures++
173
+ this.#head = (this.#head + 1) % this.#size
174
+ }
175
+
176
+ get count(): number {
177
+ return this.#count
178
+ }
179
+
180
+ get failures(): number {
181
+ return this.#failures
182
+ }
183
+
184
+ get successes(): number {
185
+ return this.#count - this.#failures
186
+ }
187
+
188
+ reset(): void {
189
+ this.#buf.fill(false)
190
+ this.#head = 0
191
+ this.#count = 0
192
+ this.#failures = 0
193
+ }
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Local circuit breaker factory
198
+ // ---------------------------------------------------------------------------
199
+
200
+ type LocalBreakerPolicy = Policy & {
201
+ readonly coordination: "local"
202
+ snapshot(): BreakerSnapshot
203
+ }
204
+
205
+ function local(options: LocalBreakerOptions): LocalBreakerPolicy {
206
+ validate(options)
207
+
208
+ const name = options.name
209
+ const minimumThroughput =
210
+ options.minimumThroughput ?? DEFAULT_MINIMUM_THROUGHPUT
211
+ const failureThreshold = options.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD
212
+ const openMs = options.openMs ?? DEFAULT_OPEN_MS
213
+ const halfOpenSuccessTarget =
214
+ options.halfOpenSuccesses ?? DEFAULT_HALF_OPEN_SUCCESSES
215
+ const halfOpenProbeLimit = options.halfOpenProbes ?? DEFAULT_HALF_OPEN_PROBES
216
+ const windowSize = options.windowSize ?? DEFAULT_WINDOW_SIZE
217
+ const classifier = options.classify
218
+
219
+ let state: BreakerState = "closed"
220
+ let generation = 0
221
+ let openedAt = 0
222
+ let halfOpenSuccessCount = 0
223
+ let halfOpenProbesInFlight = 0
224
+ const window = new SlidingWindow(windowSize)
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // Transition helpers
228
+ // ---------------------------------------------------------------------------
229
+
230
+ function transitionToOpen(
231
+ context: ExecutionContext,
232
+ previousState: "closed" | "half-open",
233
+ ): void {
234
+ state = "open"
235
+ generation++
236
+ openedAt = Date.now()
237
+ halfOpenSuccessCount = 0
238
+ halfOpenProbesInFlight = 0
239
+ window.reset()
240
+ emitRuntimeEvent(context, {
241
+ type: "breaker.state-changed",
242
+ coordination: "local",
243
+ policyName: name,
244
+ scope: "process",
245
+ state: "open",
246
+ previousState,
247
+ })
248
+ }
249
+
250
+ function transitionToHalfOpen(context: ExecutionContext): void {
251
+ state = "half-open"
252
+ generation++
253
+ halfOpenSuccessCount = 0
254
+ halfOpenProbesInFlight = 0
255
+ window.reset()
256
+ emitRuntimeEvent(context, {
257
+ type: "breaker.state-changed",
258
+ coordination: "local",
259
+ policyName: name,
260
+ scope: "process",
261
+ state: "half-open",
262
+ previousState: "open",
263
+ })
264
+ }
265
+
266
+ function transitionToClosed(context: ExecutionContext): void {
267
+ state = "closed"
268
+ generation++
269
+ halfOpenSuccessCount = 0
270
+ halfOpenProbesInFlight = 0
271
+ window.reset()
272
+ emitRuntimeEvent(context, {
273
+ type: "breaker.state-changed",
274
+ coordination: "local",
275
+ policyName: name,
276
+ scope: "process",
277
+ state: "closed",
278
+ previousState: "half-open",
279
+ })
280
+ }
281
+
282
+ function admit(
283
+ context: ExecutionContext,
284
+ ): "closed" | "half-open" | "rejected" {
285
+ if (state === "closed") return "closed"
286
+
287
+ if (state === "open") {
288
+ if (Date.now() - openedAt >= openMs) {
289
+ transitionToHalfOpen(context)
290
+ // fall through to half-open admission below
291
+ } else {
292
+ emitRuntimeEvent(context, {
293
+ type: "breaker.rejected",
294
+ coordination: "local",
295
+ policyName: name,
296
+ scope: "process",
297
+ state: "open",
298
+ })
299
+ return "rejected"
300
+ }
301
+ }
302
+
303
+ // half-open
304
+ if (halfOpenProbesInFlight >= halfOpenProbeLimit) {
305
+ emitRuntimeEvent(context, {
306
+ type: "breaker.rejected",
307
+ coordination: "local",
308
+ policyName: name,
309
+ scope: "process",
310
+ state: "half-open",
311
+ })
312
+ return "rejected"
313
+ }
314
+
315
+ halfOpenProbesInFlight++
316
+ emitRuntimeEvent(context, {
317
+ type: "breaker.probe-started",
318
+ coordination: "local",
319
+ policyName: name,
320
+ scope: "process",
321
+ })
322
+ return "half-open"
323
+ }
324
+
325
+ function observe(
326
+ context: ExecutionContext,
327
+ admitted: "closed" | "half-open",
328
+ admittedGeneration: number,
329
+ settled: Outcome<unknown>,
330
+ ): void {
331
+ // Transitions reset the window and probe accounting. Older work must not
332
+ // contribute observations or release a probe slot in the new generation.
333
+ if (admittedGeneration !== generation || admitted !== state) return
334
+
335
+ const outcome = classifyOutcome(context, classifier, settled)
336
+
337
+ if (outcome === "ignored") {
338
+ if (admitted === "half-open") {
339
+ halfOpenProbesInFlight = Math.max(0, halfOpenProbesInFlight - 1)
340
+ }
341
+ return
342
+ }
343
+
344
+ const failure = outcome === "failure"
345
+
346
+ emitRuntimeEvent(context, {
347
+ type: "breaker.observation",
348
+ coordination: "local",
349
+ policyName: name,
350
+ scope: "process",
351
+ outcome,
352
+ })
353
+
354
+ if (admitted === "half-open") {
355
+ halfOpenProbesInFlight = Math.max(0, halfOpenProbesInFlight - 1)
356
+
357
+ if (failure) {
358
+ transitionToOpen(context, "half-open")
359
+ return
360
+ }
361
+
362
+ halfOpenSuccessCount++
363
+ if (halfOpenSuccessCount >= halfOpenSuccessTarget) {
364
+ transitionToClosed(context)
365
+ }
366
+ return
367
+ }
368
+
369
+ // admitted === "closed"
370
+ window.record(failure)
371
+
372
+ if (
373
+ window.count >= minimumThroughput &&
374
+ window.failures / window.count >= failureThreshold
375
+ ) {
376
+ transitionToOpen(context, "closed")
377
+ }
378
+ }
379
+
380
+ // ---------------------------------------------------------------------------
381
+ // Policy implementation
382
+ // ---------------------------------------------------------------------------
383
+
384
+ return Object.freeze({
385
+ name,
386
+ coordination: "local" as const,
387
+
388
+ snapshot(): BreakerSnapshot {
389
+ return {
390
+ coordination: "local",
391
+ state,
392
+ failures: window.failures,
393
+ successes: window.successes,
394
+ observations: window.count,
395
+ probesInFlight: halfOpenProbesInFlight,
396
+ halfOpenSuccesses: halfOpenSuccessCount,
397
+ }
398
+ },
399
+
400
+ async execute<Result>(
401
+ context: ExecutionContext,
402
+ next: Next<Result>,
403
+ ): Promise<Result> {
404
+ const admitted = admit(context)
405
+ const admittedGeneration = generation
406
+
407
+ if (admitted === "rejected") {
408
+ throw new CircuitOpenError(name, "local", "process")
409
+ }
410
+
411
+ let isSuccess = false
412
+ let value: Result | undefined
413
+ let error: unknown
414
+
415
+ try {
416
+ value = await next(context)
417
+ isSuccess = true
418
+ return value
419
+ } catch (err) {
420
+ error = err
421
+ throw err
422
+ } finally {
423
+ observe(
424
+ context,
425
+ admitted,
426
+ admittedGeneration,
427
+ isSuccess
428
+ ? { status: "success", value }
429
+ : { status: "failure", error },
430
+ )
431
+ }
432
+ },
433
+ })
434
+ }
435
+
436
+ export interface BreakerIdentity {
437
+ readonly name: string
438
+ readonly operation: string
439
+ readonly scope: string
440
+ }
441
+
442
+ export type ObserveResult =
443
+ | { readonly type: "stale"; readonly currentGeneration: number }
444
+ | {
445
+ readonly type: "observed"
446
+ readonly generation: number
447
+ readonly windowTotal: number
448
+ readonly windowFailures: number
449
+ }
450
+ | {
451
+ readonly type: "opened"
452
+ readonly newGeneration: number
453
+ readonly windowTotal: number
454
+ readonly windowFailures: number
455
+ }
456
+
457
+ export type AdmitProbeResult =
458
+ | {
459
+ readonly type: "rejected"
460
+ readonly reason: "closed" | "open" | "probe-limit"
461
+ readonly generation: number
462
+ }
463
+ | {
464
+ readonly type: "admitted"
465
+ readonly generation: number
466
+ readonly probeCount: number
467
+ readonly stateChanged: boolean
468
+ }
469
+
470
+ export type SettleProbeResult =
471
+ | { readonly type: "stale"; readonly generation: number }
472
+ | {
473
+ readonly type: "settled"
474
+ readonly state: BreakerState
475
+ readonly generation: number
476
+ }
477
+ | {
478
+ readonly type: "transitioned"
479
+ readonly newState: BreakerState
480
+ readonly newGeneration: number
481
+ readonly previousState: "half-open"
482
+ }
483
+
484
+ /**
485
+ * Policy-specific coordinator capability for `circuitBreaker.distributed()`.
486
+ * The Redis implementation lives in `@gkoos/caracal/redis`; the memory implementation
487
+ * lives in `test/support/memory-coordinator` and must not be a production export.
488
+ */
489
+ export interface BreakerCoordinator {
490
+ readState(
491
+ identity: BreakerIdentity,
492
+ ): Promise<{ state: BreakerState; generation: number } | null>
493
+
494
+ observe(
495
+ identity: BreakerIdentity,
496
+ params: {
497
+ readonly generation: number
498
+ readonly outcome: "success" | "failure"
499
+ readonly uuid: string
500
+ readonly windowTtlMs: number
501
+ readonly minimumThroughput: number
502
+ readonly failureThresholdNumerator: number
503
+ readonly windowSize: number
504
+ readonly openMs: number
505
+ },
506
+ ): Promise<ObserveResult>
507
+
508
+ admitProbe(
509
+ identity: BreakerIdentity,
510
+ params: {
511
+ readonly probeToken: string
512
+ readonly openMs: number
513
+ readonly halfOpenProbes: number
514
+ readonly probeLeaseTtlMs: number
515
+ },
516
+ ): Promise<AdmitProbeResult>
517
+
518
+ settleProbe(
519
+ identity: BreakerIdentity,
520
+ params: {
521
+ readonly probeToken: string
522
+ readonly outcome: "success" | "failure"
523
+ readonly generation: number
524
+ readonly halfOpenSuccesses: number
525
+ readonly openMs: number
526
+ /**
527
+ * Observation retention period, used as a floor for the CLOSED cleanup
528
+ * TTL: the state hash must not expire before the window it governs, or
529
+ * retained members would outlive their epoch. Optional so that custom
530
+ * coordinators keep compiling; the policy always supplies it and the
531
+ * Redis coordinator falls back to `openMs × 2` when it is missing.
532
+ */
533
+ readonly windowTtlMs?: number
534
+ },
535
+ ): Promise<SettleProbeResult>
536
+ }
537
+
538
+ /**
539
+ * Distributed (Redis-backed) circuit breaker options.
540
+ *
541
+ * The defaults are mutually consistent. Overriding one timer or count usually
542
+ * means revisiting the related ones; see the Redis coordination guide
543
+ * (`docs/redis.md`, "Keeping the breaker knobs consistent") for the constraints
544
+ * and the symptoms of getting them wrong.
545
+ */
546
+ export interface DistributedBreakerOptions {
547
+ /** Identifies this policy in events and introspection. */
548
+ readonly name: string
549
+ /** Redis-backed coordinator. See `redisCircuitBreakerCoordinator` in `@gkoos/caracal/redis`. */
550
+ readonly coordinator: BreakerCoordinator
551
+ /**
552
+ * Maps an execution context to the coordination scope key.
553
+ * `scope: ctx => 'process'` is NOT local mode, it's still Redis-backed
554
+ * with full coordinator-failure semantics.
555
+ */
556
+ readonly scope: (context: ExecutionContext) => string
557
+ /**
558
+ * Minimum observations in the window before the breaker may open. Default: 20.
559
+ *
560
+ * Requires `windowSize >= minimumThroughput`; a smaller window can never
561
+ * reach this count, so the breaker would never open.
562
+ */
563
+ readonly minimumThroughput?: number
564
+ /** Failure fraction (0–1 exclusive) that triggers opening. Default: 0.5. */
565
+ readonly failureThreshold?: number
566
+ /**
567
+ * Sliding-window size (observation count). Default: 100.
568
+ *
569
+ * Must be at least `minimumThroughput`: the count cap trims the window, so a
570
+ * smaller window keeps the observed total below the opening threshold and the
571
+ * breaker never opens.
572
+ */
573
+ readonly windowSize?: number
574
+ /**
575
+ * Observation retention period in ms. Observations older than this are
576
+ * pruned regardless of `windowSize`. Default: max(openMs × 3, 60_000).
577
+ *
578
+ * Must be long enough to accumulate `minimumThroughput` observations at your
579
+ * traffic rate. If pruning fires first the window never fills and the
580
+ * breaker never opens; the default is a proxy for that, not a measurement.
581
+ */
582
+ readonly windowTtlMs?: number
583
+ /** How long (ms) the breaker stays open before half-open. Default: 30_000. */
584
+ readonly openMs?: number
585
+ /** Maximum concurrent half-open probes per scope. Default: 3. */
586
+ readonly halfOpenProbes?: number
587
+ /**
588
+ * Probe successes needed to close from half-open. Default: 2.
589
+ *
590
+ * Any probe failure resets progress back to OPEN, so a value that is large
591
+ * relative to the probe rate keeps traffic throttled long after the
592
+ * downstream recovered.
593
+ */
594
+ readonly halfOpenSuccesses?: number
595
+ /**
596
+ * Probe token TTL in ms. A dead worker's probe expires without blocking
597
+ * recovery. Default: openMs × 2.
598
+ *
599
+ * Must exceed the slowest probe settle time (at least `timeoutMs`): if a live
600
+ * token expires mid-probe the slot is re-issued, more than `halfOpenProbes`
601
+ * probes run concurrently, and the late settle is dropped as stale. It is
602
+ * also the worst case a HALF_OPEN window stalls while crashed workers hold
603
+ * every slot, so do not make it arbitrarily large.
604
+ */
605
+ readonly probeLeaseTtlMs?: number
606
+ /**
607
+ * What to do when the coordinator is unreachable and the last known state
608
+ * for the scope was CLOSED (or no prior successful read has occurred).
609
+ * `"fail-open"` allows the attempt through (default).
610
+ * `"fail-closed"` rejects it with CircuitOpenError.
611
+ *
612
+ * If the last successfully-read state was OPEN or HALF_OPEN the attempt is
613
+ * always rejected, regardless of this setting. Admitting work into a
614
+ * known-open breaker removes the protection it exists to provide.
615
+ *
616
+ * Coordinator unavailability during probe admission (after a successful
617
+ * readState that returned OPEN/HALF_OPEN) also always fails closed.
618
+ */
619
+ readonly onCoordinatorError?: "fail-open" | "fail-closed"
620
+ /** Custom outcome classifier. Defaults to the adapter classification; retryable counts as failure. */
621
+ readonly classify?: BreakerClassifier
622
+ }
623
+
624
+ const DEFAULT_DIST_MINIMUM_THROUGHPUT = 20
625
+ const DEFAULT_DIST_FAILURE_THRESHOLD = 0.5
626
+ const DEFAULT_DIST_WINDOW_SIZE = 100
627
+ const DEFAULT_DIST_OPEN_MS = 30_000
628
+ const DEFAULT_DIST_HALF_OPEN_PROBES = 3
629
+ const DEFAULT_DIST_HALF_OPEN_SUCCESSES = 2
630
+ const DEFAULT_DIST_ON_COORDINATOR_ERROR = "fail-open" as const
631
+
632
+ function validateDistributed(opts: DistributedBreakerOptions): void {
633
+ if (typeof opts.name !== "string" || !opts.name.trim())
634
+ throw new RangeError("Distributed circuit breaker name must not be empty")
635
+ if (!opts.coordinator || typeof opts.coordinator !== "object")
636
+ throw new TypeError("coordinator is required")
637
+ if (typeof opts.scope !== "function")
638
+ throw new TypeError("scope must be a function")
639
+
640
+ const { minimumThroughput = DEFAULT_DIST_MINIMUM_THROUGHPUT } = opts
641
+ if (!Number.isInteger(minimumThroughput) || minimumThroughput < 1)
642
+ throw new RangeError("minimumThroughput must be a positive integer")
643
+
644
+ const { failureThreshold = DEFAULT_DIST_FAILURE_THRESHOLD } = opts
645
+ if (
646
+ !Number.isFinite(failureThreshold) ||
647
+ failureThreshold <= 0 ||
648
+ failureThreshold >= 1
649
+ )
650
+ throw new RangeError("failureThreshold must be a number in (0, 1)")
651
+
652
+ const { openMs = DEFAULT_DIST_OPEN_MS } = opts
653
+ if (!Number.isInteger(openMs) || openMs < 1)
654
+ throw new RangeError("openMs must be a positive integer")
655
+
656
+ const { halfOpenSuccesses = DEFAULT_DIST_HALF_OPEN_SUCCESSES } = opts
657
+ if (!Number.isInteger(halfOpenSuccesses) || halfOpenSuccesses < 1)
658
+ throw new RangeError("halfOpenSuccesses must be a positive integer")
659
+
660
+ const { halfOpenProbes = DEFAULT_DIST_HALF_OPEN_PROBES } = opts
661
+ if (!Number.isInteger(halfOpenProbes) || halfOpenProbes < 1)
662
+ throw new RangeError("halfOpenProbes must be a positive integer")
663
+
664
+ const { windowSize = DEFAULT_DIST_WINDOW_SIZE } = opts
665
+ if (!Number.isInteger(windowSize) || windowSize < 1)
666
+ throw new RangeError("windowSize must be a positive integer")
667
+
668
+ if (opts.windowTtlMs !== undefined) {
669
+ if (!Number.isInteger(opts.windowTtlMs) || opts.windowTtlMs < 1)
670
+ throw new RangeError("windowTtlMs must be a positive integer")
671
+ }
672
+
673
+ if (opts.probeLeaseTtlMs !== undefined) {
674
+ if (!Number.isInteger(opts.probeLeaseTtlMs) || opts.probeLeaseTtlMs < 1)
675
+ throw new RangeError("probeLeaseTtlMs must be a positive integer")
676
+ }
677
+
678
+ if (
679
+ opts.onCoordinatorError !== undefined &&
680
+ opts.onCoordinatorError !== "fail-open" &&
681
+ opts.onCoordinatorError !== "fail-closed"
682
+ )
683
+ throw new TypeError(
684
+ 'onCoordinatorError must be "fail-open" or "fail-closed"',
685
+ )
686
+ }
687
+
688
+ type DistributedBreakerPolicy = Policy & {
689
+ readonly coordination: "distributed"
690
+ }
691
+
692
+ function distributed(
693
+ options: DistributedBreakerOptions,
694
+ ): DistributedBreakerPolicy {
695
+ validateDistributed(options)
696
+
697
+ const name = options.name
698
+ const coordinator = options.coordinator
699
+ const resolveScope = options.scope
700
+ const minimumThroughput =
701
+ options.minimumThroughput ?? DEFAULT_DIST_MINIMUM_THROUGHPUT
702
+ const failureThreshold =
703
+ options.failureThreshold ?? DEFAULT_DIST_FAILURE_THRESHOLD
704
+ const failureThresholdNumerator = Math.round(failureThreshold * 1000)
705
+ const windowSize = options.windowSize ?? DEFAULT_DIST_WINDOW_SIZE
706
+ const openMs = options.openMs ?? DEFAULT_DIST_OPEN_MS
707
+ const windowTtlMs = options.windowTtlMs ?? Math.max(openMs * 3, 60_000)
708
+ const halfOpenProbes = options.halfOpenProbes ?? DEFAULT_DIST_HALF_OPEN_PROBES
709
+ const halfOpenSuccesses =
710
+ options.halfOpenSuccesses ?? DEFAULT_DIST_HALF_OPEN_SUCCESSES
711
+ const probeLeaseTtlMs = options.probeLeaseTtlMs ?? openMs * 2
712
+ const onCoordinatorError =
713
+ options.onCoordinatorError ?? DEFAULT_DIST_ON_COORDINATOR_ERROR
714
+ const classifier = options.classify
715
+
716
+ // Per-(operation, scope) record of the last known NON-CLOSED state.
717
+ // Used as a fallback when readState() fails: if the last confirmed state was
718
+ // OPEN or HALF_OPEN the breaker must fail-closed regardless of
719
+ // onCoordinatorError, because admitting traffic to a known-open breaker
720
+ // removes the protection the breaker exists to provide.
721
+ // CLOSED is not retained: it is indistinguishable from "never seen" for this
722
+ // decision, and storing it would grow without bound with scope cardinality.
723
+ const lastKnownState = createScopeStateCache()
724
+
725
+ // Admission state tracked per-attempt within a single execute() call.
726
+ type AdmissionState =
727
+ | { readonly kind: "closed"; readonly generation: number }
728
+ | {
729
+ readonly kind: "probe"
730
+ readonly probeToken: string
731
+ readonly generation: number
732
+ }
733
+
734
+ return Object.freeze({
735
+ name,
736
+ coordination: "distributed" as const,
737
+
738
+ async execute<Result>(
739
+ context: ExecutionContext,
740
+ next: Next<Result>,
741
+ ): Promise<Result> {
742
+ const scope = resolveScope(context)
743
+ if (typeof scope !== "string" || !scope.trim())
744
+ throw new TypeError(
745
+ "circuitBreaker.distributed scope must be a non-empty string",
746
+ )
747
+
748
+ const identity: BreakerIdentity = {
749
+ name,
750
+ operation: context.operationName,
751
+ scope,
752
+ }
753
+
754
+ // ---- ADMISSION ----
755
+
756
+ let stateData: { state: BreakerState; generation: number } | null = null
757
+
758
+ try {
759
+ stateData = await coordinator.readState(identity)
760
+ // Remember only non-closed states: CLOSED is indistinguishable from
761
+ // "never seen" for the failure decision below, so storing it would grow
762
+ // this process-local record with every scope ever observed.
763
+ const observed = stateData?.state
764
+ if (observed === "open" || observed === "half-open") {
765
+ lastKnownState.remember(identity.operation, scope, observed)
766
+ } else {
767
+ lastKnownState.forget(identity.operation, scope)
768
+ }
769
+ } catch (readError) {
770
+ emitRuntimeEvent(context, {
771
+ type: "breaker.coordinator-error",
772
+ coordination: "distributed",
773
+ policyName: name,
774
+ scope,
775
+ operation: "admit",
776
+ error: readError,
777
+ })
778
+
779
+ // If the last successfully-read state for this scope was OPEN or
780
+ // HALF_OPEN, we know the breaker was non-closed before the outage.
781
+ // Admitting work in that case removes the protection the breaker
782
+ // provides, so we always fail-closed regardless of onCoordinatorError.
783
+ const cached = lastKnownState.read(identity.operation, scope)
784
+ const effectiveBehavior =
785
+ cached === "open" || cached === "half-open"
786
+ ? "fail-closed"
787
+ : onCoordinatorError
788
+
789
+ emitRuntimeEvent(context, {
790
+ type: "breaker.degraded",
791
+ coordination: "distributed",
792
+ policyName: name,
793
+ scope,
794
+ reason: "coordinator-unavailable",
795
+ behavior: effectiveBehavior,
796
+ })
797
+
798
+ if (effectiveBehavior === "fail-closed") {
799
+ emitRuntimeEvent(context, {
800
+ type: "breaker.rejected",
801
+ coordination: "distributed",
802
+ policyName: name,
803
+ scope,
804
+ state: "open",
805
+ })
806
+ throw new CircuitOpenError(name, "distributed", scope)
807
+ }
808
+ // fail-open: last known state was CLOSED (or no prior read succeeded).
809
+ // Treat as CLOSED with generation 0.
810
+ stateData = { state: "closed", generation: 0 }
811
+ }
812
+
813
+ // null from readState means missing hash key → implicit CLOSED / generation 0
814
+ const reportedState: BreakerState = stateData?.state ?? "closed"
815
+ const reportedGeneration: number = stateData?.generation ?? 0
816
+ let admission: AdmissionState
817
+
818
+ if (reportedState === "closed") {
819
+ admission = { kind: "closed", generation: reportedGeneration }
820
+ } else {
821
+ // OPEN or HALF_OPEN: attempt probe admission
822
+ const probeToken = randomUUID()
823
+ let probeResult: AdmitProbeResult
824
+
825
+ try {
826
+ probeResult = await coordinator.admitProbe(identity, {
827
+ probeToken,
828
+ openMs,
829
+ halfOpenProbes,
830
+ probeLeaseTtlMs,
831
+ })
832
+ } catch (admitError) {
833
+ // Coordinator unavailable during probe admission → always fail-closed
834
+ emitRuntimeEvent(context, {
835
+ type: "breaker.coordinator-error",
836
+ coordination: "distributed",
837
+ policyName: name,
838
+ scope,
839
+ operation: "admit",
840
+ error: admitError,
841
+ })
842
+ emitRuntimeEvent(context, {
843
+ type: "breaker.degraded",
844
+ coordination: "distributed",
845
+ policyName: name,
846
+ scope,
847
+ reason: "coordinator-unavailable",
848
+ behavior: "fail-closed",
849
+ })
850
+ emitRuntimeEvent(context, {
851
+ type: "breaker.rejected",
852
+ coordination: "distributed",
853
+ policyName: name,
854
+ scope,
855
+ state: reportedState === "open" ? "open" : "half-open",
856
+ })
857
+ throw new CircuitOpenError(name, "distributed", scope)
858
+ }
859
+
860
+ if (probeResult.type === "rejected") {
861
+ if (probeResult.reason === "closed") {
862
+ // Breaker closed between readState and admitProbe; treat as CLOSED
863
+ lastKnownState.forget(identity.operation, scope)
864
+ admission = { kind: "closed", generation: probeResult.generation }
865
+ } else {
866
+ // "open" → openMs not yet elapsed; still OPEN
867
+ // "probe-limit" → admitProbe atomically transitioned (or was) HALF_OPEN
868
+ const rejState =
869
+ probeResult.reason === "open"
870
+ ? ("open" as const)
871
+ : ("half-open" as const)
872
+ emitRuntimeEvent(context, {
873
+ type: "breaker.rejected",
874
+ coordination: "distributed",
875
+ policyName: name,
876
+ scope,
877
+ state: rejState,
878
+ generation: probeResult.generation,
879
+ })
880
+ throw new CircuitOpenError(name, "distributed", scope)
881
+ }
882
+ } else {
883
+ // Admitted as probe
884
+ if (probeResult.stateChanged) {
885
+ lastKnownState.remember(identity.operation, scope, "half-open")
886
+ emitRuntimeEvent(context, {
887
+ type: "breaker.state-changed",
888
+ coordination: "distributed",
889
+ policyName: name,
890
+ scope,
891
+ state: "half-open",
892
+ previousState: "open",
893
+ generation: probeResult.generation,
894
+ })
895
+ }
896
+ emitRuntimeEvent(context, {
897
+ type: "breaker.probe-started",
898
+ coordination: "distributed",
899
+ policyName: name,
900
+ scope,
901
+ generation: probeResult.generation,
902
+ })
903
+ admission = {
904
+ kind: "probe",
905
+ probeToken,
906
+ generation: probeResult.generation,
907
+ }
908
+ }
909
+ }
910
+
911
+ // ---- EXECUTION ----
912
+
913
+ let isSuccess = false
914
+ let value: Result | undefined
915
+ let thrownError: unknown
916
+
917
+ try {
918
+ value = await next(context)
919
+ isSuccess = true
920
+ return value
921
+ } catch (err) {
922
+ thrownError = err
923
+ throw err
924
+ } finally {
925
+ // ---- SETTLEMENT ----
926
+ const breakerOutcome = classifyOutcome(
927
+ context,
928
+ classifier,
929
+ isSuccess
930
+ ? { status: "success", value }
931
+ : { status: "failure", error: thrownError },
932
+ )
933
+ if (breakerOutcome !== "ignored") {
934
+ const outcomeStr =
935
+ breakerOutcome === "success"
936
+ ? ("success" as const)
937
+ : ("failure" as const)
938
+
939
+ if (admission.kind === "closed") {
940
+ try {
941
+ const result = await coordinator.observe(identity, {
942
+ generation: admission.generation,
943
+ outcome: outcomeStr,
944
+ uuid: randomUUID(),
945
+ windowTtlMs,
946
+ minimumThroughput,
947
+ failureThresholdNumerator,
948
+ windowSize,
949
+ openMs,
950
+ })
951
+ if (result.type === "stale") {
952
+ emitRuntimeEvent(context, {
953
+ type: "breaker.observation-stale",
954
+ coordination: "distributed",
955
+ policyName: name,
956
+ scope,
957
+ attemptGeneration: admission.generation,
958
+ currentGeneration: result.currentGeneration,
959
+ })
960
+ } else {
961
+ emitRuntimeEvent(context, {
962
+ type: "breaker.observation",
963
+ coordination: "distributed",
964
+ policyName: name,
965
+ scope,
966
+ outcome: outcomeStr,
967
+ generation: admission.generation,
968
+ })
969
+ if (result.type === "opened") {
970
+ lastKnownState.remember(identity.operation, scope, "open")
971
+ emitRuntimeEvent(context, {
972
+ type: "breaker.state-changed",
973
+ coordination: "distributed",
974
+ policyName: name,
975
+ scope,
976
+ state: "open",
977
+ previousState: "closed",
978
+ generation: result.newGeneration,
979
+ })
980
+ }
981
+ }
982
+ } catch (observeError) {
983
+ // Coordinator error during observation: drop the datapoint — do not
984
+ // fail the caller, one lost observation has low impact. Emit the
985
+ // diagnostic event so operators can detect that the breaker has
986
+ // stopped learning (e.g. sustained coordinator unavailability).
987
+ emitRuntimeEvent(context, {
988
+ type: "breaker.coordinator-error",
989
+ coordination: "distributed",
990
+ policyName: name,
991
+ scope,
992
+ operation: "observe",
993
+ error: observeError,
994
+ })
995
+ }
996
+ } else {
997
+ // Probe settlement
998
+ try {
999
+ const result = await coordinator.settleProbe(identity, {
1000
+ probeToken: admission.probeToken,
1001
+ outcome: outcomeStr,
1002
+ generation: admission.generation,
1003
+ halfOpenSuccesses,
1004
+ openMs,
1005
+ windowTtlMs,
1006
+ })
1007
+ if (result.type === "stale") {
1008
+ emitRuntimeEvent(context, {
1009
+ type: "breaker.observation-stale",
1010
+ coordination: "distributed",
1011
+ policyName: name,
1012
+ scope,
1013
+ attemptGeneration: admission.generation,
1014
+ currentGeneration: result.generation,
1015
+ })
1016
+ } else {
1017
+ emitRuntimeEvent(context, {
1018
+ type: "breaker.observation",
1019
+ coordination: "distributed",
1020
+ policyName: name,
1021
+ scope,
1022
+ outcome: outcomeStr,
1023
+ generation: admission.generation,
1024
+ })
1025
+ if (result.type === "transitioned") {
1026
+ if (result.newState === "closed") {
1027
+ lastKnownState.forget(identity.operation, scope)
1028
+ } else {
1029
+ lastKnownState.remember(
1030
+ identity.operation,
1031
+ scope,
1032
+ result.newState,
1033
+ )
1034
+ }
1035
+ emitRuntimeEvent(context, {
1036
+ type: "breaker.state-changed",
1037
+ coordination: "distributed",
1038
+ policyName: name,
1039
+ scope,
1040
+ state: result.newState,
1041
+ previousState: "half-open",
1042
+ generation: result.newGeneration,
1043
+ })
1044
+ }
1045
+ }
1046
+ } catch (settleError) {
1047
+ // Coordinator error during probe settlement: drop the result — the
1048
+ // probe token expires via TTL without blocking recovery. Emit the
1049
+ // diagnostic event so operators can detect the failure.
1050
+ emitRuntimeEvent(context, {
1051
+ type: "breaker.coordinator-error",
1052
+ coordination: "distributed",
1053
+ policyName: name,
1054
+ scope,
1055
+ operation: "settle-probe",
1056
+ error: settleError,
1057
+ })
1058
+ }
1059
+ }
1060
+ }
1061
+ }
1062
+ },
1063
+ })
1064
+ }
1065
+
1066
+ export const circuitBreaker = Object.freeze({ local, distributed })