@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.
- package/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/README.md +311 -0
- package/dist/chunk-5CXDW7W6.js +202 -0
- package/dist/chunk-5CXDW7W6.js.map +1 -0
- package/dist/circuit-breaker-BSkcV0W_.d.ts +296 -0
- package/dist/fetch.d.ts +58 -0
- package/dist/fetch.js +117 -0
- package/dist/fetch.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +1065 -0
- package/dist/index.js.map +1 -0
- package/dist/postgres.d.ts +28 -0
- package/dist/postgres.js +56 -0
- package/dist/postgres.js.map +1 -0
- package/dist/redis.d.ts +59 -0
- package/dist/redis.js +549 -0
- package/dist/redis.js.map +1 -0
- package/dist/retry-BFP_k3Hg.d.ts +26 -0
- package/dist/testing/index.d.ts +45 -0
- package/dist/testing/index.js +101 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/types-Tf9T76C7.d.ts +187 -0
- package/package.json +127 -0
- package/src/adapters/fetch/adapter.ts +122 -0
- package/src/adapters/fetch/index.ts +15 -0
- package/src/adapters/fetch/retry-after.ts +111 -0
- package/src/adapters/postgres/adapter.ts +102 -0
- package/src/adapters/postgres/index.ts +7 -0
- package/src/coordination/redis/bulkhead.ts +61 -0
- package/src/coordination/redis/circuit-breaker.ts +270 -0
- package/src/coordination/redis/client.ts +78 -0
- package/src/coordination/redis/eval-script.ts +71 -0
- package/src/coordination/redis/keys.ts +32 -0
- package/src/coordination/redis/leases.ts +44 -0
- package/src/coordination/redis/scripts.ts +314 -0
- package/src/core/bulkhead.ts +336 -0
- package/src/core/circuit-breaker.ts +1066 -0
- package/src/core/index.ts +36 -0
- package/src/core/operation.ts +174 -0
- package/src/core/retry.ts +204 -0
- package/src/core/runtime.ts +123 -0
- package/src/core/scope-state-cache.ts +50 -0
- package/src/core/timeout.ts +73 -0
- package/src/core/types.ts +230 -0
- package/src/fetch.ts +17 -0
- package/src/index.ts +49 -0
- package/src/postgres.ts +9 -0
- 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 })
|