@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
package/dist/index.js
ADDED
|
@@ -0,0 +1,1065 @@
|
|
|
1
|
+
import { admissionSignal, emitRuntimeEvent, nextAttempt, withAdmissionSignal, withSignal } from './chunk-5CXDW7W6.js';
|
|
2
|
+
export { operation } from './chunk-5CXDW7W6.js';
|
|
3
|
+
import { randomUUID } from 'crypto';
|
|
4
|
+
|
|
5
|
+
var BulkheadRejectedError = class extends Error {
|
|
6
|
+
constructor(coordination, policyName, scope, reason) {
|
|
7
|
+
super(`Bulkhead ${policyName} rejected: ${reason}`);
|
|
8
|
+
this.coordination = coordination;
|
|
9
|
+
this.policyName = policyName;
|
|
10
|
+
this.scope = scope;
|
|
11
|
+
this.reason = reason;
|
|
12
|
+
this.name = "BulkheadRejectedError";
|
|
13
|
+
}
|
|
14
|
+
coordination;
|
|
15
|
+
policyName;
|
|
16
|
+
scope;
|
|
17
|
+
reason;
|
|
18
|
+
};
|
|
19
|
+
function validate(name, limit) {
|
|
20
|
+
if (!name.trim() || !Number.isSafeInteger(limit) || limit < 1)
|
|
21
|
+
throw new RangeError("Bulkhead needs a name and positive integer limit");
|
|
22
|
+
}
|
|
23
|
+
function event(context, coordination, policyName, scope, type, occupancy, reason) {
|
|
24
|
+
emitRuntimeEvent(context, {
|
|
25
|
+
type: `bulkhead.${type}`,
|
|
26
|
+
coordination,
|
|
27
|
+
policyName,
|
|
28
|
+
scope,
|
|
29
|
+
...occupancy === void 0 ? {} : { occupancy },
|
|
30
|
+
...reason === void 0 ? {} : { reason }
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function local(options) {
|
|
34
|
+
const { name, limit } = options;
|
|
35
|
+
const queue = options.queue && { ...options.queue };
|
|
36
|
+
validate(name, limit);
|
|
37
|
+
if (queue && (!Number.isSafeInteger(queue.limit) || queue.limit < 1 || !Number.isSafeInteger(queue.timeoutMs) || queue.timeoutMs < 1 || queue.timeoutMs > 2147483647))
|
|
38
|
+
throw new RangeError("Invalid bounded queue");
|
|
39
|
+
let occupancy = 0;
|
|
40
|
+
const waiting = [];
|
|
41
|
+
return Object.freeze({
|
|
42
|
+
name,
|
|
43
|
+
phase: "attempt",
|
|
44
|
+
coordination: "local",
|
|
45
|
+
snapshot: () => ({
|
|
46
|
+
coordination: "local",
|
|
47
|
+
occupancy,
|
|
48
|
+
waiting: waiting.length
|
|
49
|
+
}),
|
|
50
|
+
async execute(context, next) {
|
|
51
|
+
const signal = admissionSignal(context);
|
|
52
|
+
signal?.throwIfAborted();
|
|
53
|
+
const reject = (reason) => {
|
|
54
|
+
event(context, "local", name, "process", "rejected", occupancy, reason);
|
|
55
|
+
return new BulkheadRejectedError("local", name, "process", reason);
|
|
56
|
+
};
|
|
57
|
+
if (occupancy >= limit) {
|
|
58
|
+
if (!queue || waiting.length >= queue.limit) throw reject("capacity");
|
|
59
|
+
event(context, "local", name, "process", "waited", occupancy);
|
|
60
|
+
await new Promise((resolve, fail) => {
|
|
61
|
+
const cleanup = () => {
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
signal?.removeEventListener("abort", abort);
|
|
64
|
+
const i = waiting.indexOf(grant);
|
|
65
|
+
if (i >= 0) waiting.splice(i, 1);
|
|
66
|
+
};
|
|
67
|
+
const grant = () => {
|
|
68
|
+
cleanup();
|
|
69
|
+
occupancy++;
|
|
70
|
+
resolve();
|
|
71
|
+
};
|
|
72
|
+
const abort = () => {
|
|
73
|
+
cleanup();
|
|
74
|
+
event(
|
|
75
|
+
context,
|
|
76
|
+
"local",
|
|
77
|
+
name,
|
|
78
|
+
"process",
|
|
79
|
+
"rejected",
|
|
80
|
+
occupancy,
|
|
81
|
+
"cancelled"
|
|
82
|
+
);
|
|
83
|
+
fail(signal?.reason);
|
|
84
|
+
};
|
|
85
|
+
const timer = setTimeout(() => {
|
|
86
|
+
cleanup();
|
|
87
|
+
fail(reject("wait-timeout"));
|
|
88
|
+
}, queue.timeoutMs);
|
|
89
|
+
waiting.push(grant);
|
|
90
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
91
|
+
if (signal?.aborted) abort();
|
|
92
|
+
});
|
|
93
|
+
} else occupancy++;
|
|
94
|
+
event(context, "local", name, "process", "admitted", occupancy);
|
|
95
|
+
try {
|
|
96
|
+
signal?.throwIfAborted();
|
|
97
|
+
return await next(context);
|
|
98
|
+
} finally {
|
|
99
|
+
occupancy--;
|
|
100
|
+
waiting[0]?.();
|
|
101
|
+
event(context, "local", name, "process", "released", occupancy);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
function distributed(options) {
|
|
107
|
+
const {
|
|
108
|
+
name,
|
|
109
|
+
limit,
|
|
110
|
+
coordinator,
|
|
111
|
+
scope: resolveScope,
|
|
112
|
+
leaseMs = 3e4
|
|
113
|
+
} = options;
|
|
114
|
+
validate(name, limit);
|
|
115
|
+
if (!Number.isSafeInteger(leaseMs) || leaseMs < 100 || leaseMs > 864e5)
|
|
116
|
+
throw new RangeError("leaseMs must be 100..86400000");
|
|
117
|
+
return Object.freeze({
|
|
118
|
+
name,
|
|
119
|
+
phase: "attempt",
|
|
120
|
+
coordination: "distributed",
|
|
121
|
+
async execute(context, next) {
|
|
122
|
+
admissionSignal(context)?.throwIfAborted();
|
|
123
|
+
const scope = resolveScope(context);
|
|
124
|
+
if (typeof scope !== "string" || !scope.trim())
|
|
125
|
+
throw new TypeError("Invalid bulkhead scope");
|
|
126
|
+
const identity = { name, operation: context.operationName, scope };
|
|
127
|
+
const token = randomUUID();
|
|
128
|
+
const command = (action) => coordinator.command(identity, action, token, leaseMs, limit);
|
|
129
|
+
let admitted;
|
|
130
|
+
const started = performance.now();
|
|
131
|
+
try {
|
|
132
|
+
admitted = await command("acquire");
|
|
133
|
+
} catch (error) {
|
|
134
|
+
event(
|
|
135
|
+
context,
|
|
136
|
+
"distributed",
|
|
137
|
+
name,
|
|
138
|
+
scope,
|
|
139
|
+
"degraded",
|
|
140
|
+
void 0,
|
|
141
|
+
"admission-unknown"
|
|
142
|
+
);
|
|
143
|
+
event(
|
|
144
|
+
context,
|
|
145
|
+
"distributed",
|
|
146
|
+
name,
|
|
147
|
+
scope,
|
|
148
|
+
"rejected",
|
|
149
|
+
void 0,
|
|
150
|
+
"coordinator-unavailable"
|
|
151
|
+
);
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
if (!admitted.allowed) {
|
|
155
|
+
event(
|
|
156
|
+
context,
|
|
157
|
+
"distributed",
|
|
158
|
+
name,
|
|
159
|
+
scope,
|
|
160
|
+
"rejected",
|
|
161
|
+
admitted.occupancy,
|
|
162
|
+
"capacity"
|
|
163
|
+
);
|
|
164
|
+
throw new BulkheadRejectedError("distributed", name, scope, "capacity");
|
|
165
|
+
}
|
|
166
|
+
let stopped = false;
|
|
167
|
+
let lost = false;
|
|
168
|
+
let timer;
|
|
169
|
+
let deadlineTimer;
|
|
170
|
+
let deadline = started + leaseMs;
|
|
171
|
+
const controller = new AbortController();
|
|
172
|
+
const lose = () => {
|
|
173
|
+
if (lost || stopped) return;
|
|
174
|
+
lost = true;
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
event(context, "distributed", name, scope, "lease-lost");
|
|
177
|
+
event(
|
|
178
|
+
context,
|
|
179
|
+
"distributed",
|
|
180
|
+
name,
|
|
181
|
+
scope,
|
|
182
|
+
"degraded",
|
|
183
|
+
void 0,
|
|
184
|
+
"lease-uncertain"
|
|
185
|
+
);
|
|
186
|
+
controller.abort(
|
|
187
|
+
new BulkheadRejectedError("distributed", name, scope, "lease-lost")
|
|
188
|
+
);
|
|
189
|
+
};
|
|
190
|
+
const watch = () => {
|
|
191
|
+
clearTimeout(deadlineTimer);
|
|
192
|
+
deadlineTimer = setTimeout(
|
|
193
|
+
lose,
|
|
194
|
+
Math.max(0, deadline - performance.now())
|
|
195
|
+
);
|
|
196
|
+
};
|
|
197
|
+
const renew = async () => {
|
|
198
|
+
const sent = performance.now();
|
|
199
|
+
try {
|
|
200
|
+
if (!(await command("renew")).allowed || performance.now() >= deadline) {
|
|
201
|
+
lose();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
deadline = sent + leaseMs;
|
|
205
|
+
} catch {
|
|
206
|
+
lose();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (!stopped && !lost) {
|
|
210
|
+
watch();
|
|
211
|
+
timer = setTimeout(() => void renew(), leaseMs / 3);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
try {
|
|
215
|
+
if (performance.now() >= deadline)
|
|
216
|
+
throw new BulkheadRejectedError(
|
|
217
|
+
"distributed",
|
|
218
|
+
name,
|
|
219
|
+
scope,
|
|
220
|
+
"admission-expired"
|
|
221
|
+
);
|
|
222
|
+
admissionSignal(context)?.throwIfAborted();
|
|
223
|
+
event(
|
|
224
|
+
context,
|
|
225
|
+
"distributed",
|
|
226
|
+
name,
|
|
227
|
+
scope,
|
|
228
|
+
"admitted",
|
|
229
|
+
admitted.occupancy
|
|
230
|
+
);
|
|
231
|
+
watch();
|
|
232
|
+
timer = setTimeout(() => void renew(), leaseMs / 3);
|
|
233
|
+
return await next(
|
|
234
|
+
withAdmissionSignal(
|
|
235
|
+
context.capabilities.abort === "supported" ? withSignal(
|
|
236
|
+
context,
|
|
237
|
+
context.signal ? AbortSignal.any([context.signal, controller.signal]) : controller.signal
|
|
238
|
+
) : context,
|
|
239
|
+
controller.signal
|
|
240
|
+
)
|
|
241
|
+
);
|
|
242
|
+
} finally {
|
|
243
|
+
stopped = true;
|
|
244
|
+
clearTimeout(timer);
|
|
245
|
+
clearTimeout(deadlineTimer);
|
|
246
|
+
try {
|
|
247
|
+
const result = await command("release");
|
|
248
|
+
event(
|
|
249
|
+
context,
|
|
250
|
+
"distributed",
|
|
251
|
+
name,
|
|
252
|
+
scope,
|
|
253
|
+
"released",
|
|
254
|
+
result.occupancy,
|
|
255
|
+
result.allowed ? void 0 : "already-expired-or-released"
|
|
256
|
+
);
|
|
257
|
+
} catch {
|
|
258
|
+
event(
|
|
259
|
+
context,
|
|
260
|
+
"distributed",
|
|
261
|
+
name,
|
|
262
|
+
scope,
|
|
263
|
+
"degraded",
|
|
264
|
+
void 0,
|
|
265
|
+
"release-unknown"
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
var bulkhead = Object.freeze({ local, distributed });
|
|
273
|
+
|
|
274
|
+
// src/core/scope-state-cache.ts
|
|
275
|
+
function createScopeStateCache() {
|
|
276
|
+
const retained = /* @__PURE__ */ new Map();
|
|
277
|
+
const keyFor = (operation2, scope) => JSON.stringify([operation2, scope]);
|
|
278
|
+
return {
|
|
279
|
+
remember(operation2, scope, state) {
|
|
280
|
+
retained.set(keyFor(operation2, scope), state);
|
|
281
|
+
},
|
|
282
|
+
forget(operation2, scope) {
|
|
283
|
+
retained.delete(keyFor(operation2, scope));
|
|
284
|
+
},
|
|
285
|
+
read(operation2, scope) {
|
|
286
|
+
return retained.get(keyFor(operation2, scope));
|
|
287
|
+
},
|
|
288
|
+
size() {
|
|
289
|
+
return retained.size;
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// src/core/circuit-breaker.ts
|
|
295
|
+
function classifyOutcome(context, classifier, outcome) {
|
|
296
|
+
if (classifier !== void 0) {
|
|
297
|
+
return classifier(
|
|
298
|
+
outcome.status === "failure" ? outcome.error : void 0,
|
|
299
|
+
outcome.status === "success"
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
const classification2 = context.classify(outcome);
|
|
303
|
+
return classification2 === "retryable" ? "failure" : classification2;
|
|
304
|
+
}
|
|
305
|
+
var CircuitOpenError = class extends Error {
|
|
306
|
+
constructor(policyName, coordination, scope) {
|
|
307
|
+
super(`Circuit ${policyName} is open for scope "${scope}"`);
|
|
308
|
+
this.policyName = policyName;
|
|
309
|
+
this.coordination = coordination;
|
|
310
|
+
this.scope = scope;
|
|
311
|
+
this.name = "CircuitOpenError";
|
|
312
|
+
}
|
|
313
|
+
policyName;
|
|
314
|
+
coordination;
|
|
315
|
+
scope;
|
|
316
|
+
};
|
|
317
|
+
var DEFAULT_MINIMUM_THROUGHPUT = 5;
|
|
318
|
+
var DEFAULT_FAILURE_THRESHOLD = 0.5;
|
|
319
|
+
var DEFAULT_OPEN_MS = 1e4;
|
|
320
|
+
var DEFAULT_HALF_OPEN_SUCCESSES = 1;
|
|
321
|
+
var DEFAULT_HALF_OPEN_PROBES = 1;
|
|
322
|
+
var DEFAULT_WINDOW_SIZE = 100;
|
|
323
|
+
function validate2(opts) {
|
|
324
|
+
if (!opts.name.trim())
|
|
325
|
+
throw new RangeError("Circuit breaker name must not be empty");
|
|
326
|
+
const { minimumThroughput = DEFAULT_MINIMUM_THROUGHPUT } = opts;
|
|
327
|
+
if (!Number.isInteger(minimumThroughput) || minimumThroughput < 1)
|
|
328
|
+
throw new RangeError("minimumThroughput must be a positive integer");
|
|
329
|
+
const { failureThreshold = DEFAULT_FAILURE_THRESHOLD } = opts;
|
|
330
|
+
if (!Number.isFinite(failureThreshold) || failureThreshold <= 0 || failureThreshold >= 1)
|
|
331
|
+
throw new RangeError("failureThreshold must be a number in (0, 1)");
|
|
332
|
+
const { openMs = DEFAULT_OPEN_MS } = opts;
|
|
333
|
+
if (!Number.isInteger(openMs) || openMs < 1)
|
|
334
|
+
throw new RangeError("openMs must be a positive integer");
|
|
335
|
+
const { halfOpenSuccesses = DEFAULT_HALF_OPEN_SUCCESSES } = opts;
|
|
336
|
+
if (!Number.isInteger(halfOpenSuccesses) || halfOpenSuccesses < 1)
|
|
337
|
+
throw new RangeError("halfOpenSuccesses must be a positive integer");
|
|
338
|
+
const { halfOpenProbes = DEFAULT_HALF_OPEN_PROBES } = opts;
|
|
339
|
+
if (!Number.isInteger(halfOpenProbes) || halfOpenProbes < 1)
|
|
340
|
+
throw new RangeError("halfOpenProbes must be a positive integer");
|
|
341
|
+
const { windowSize = DEFAULT_WINDOW_SIZE } = opts;
|
|
342
|
+
if (!Number.isInteger(windowSize) || windowSize < 1)
|
|
343
|
+
throw new RangeError("windowSize must be a positive integer");
|
|
344
|
+
}
|
|
345
|
+
var SlidingWindow = class {
|
|
346
|
+
#size;
|
|
347
|
+
#buf;
|
|
348
|
+
#head = 0;
|
|
349
|
+
#count = 0;
|
|
350
|
+
#failures = 0;
|
|
351
|
+
constructor(size) {
|
|
352
|
+
this.#size = size;
|
|
353
|
+
this.#buf = new Array(size).fill(false);
|
|
354
|
+
}
|
|
355
|
+
record(failure) {
|
|
356
|
+
const evicted = this.#buf[this.#head] === true;
|
|
357
|
+
if (this.#count === this.#size) {
|
|
358
|
+
if (evicted) this.#failures--;
|
|
359
|
+
} else {
|
|
360
|
+
this.#count++;
|
|
361
|
+
}
|
|
362
|
+
this.#buf[this.#head] = failure;
|
|
363
|
+
if (failure) this.#failures++;
|
|
364
|
+
this.#head = (this.#head + 1) % this.#size;
|
|
365
|
+
}
|
|
366
|
+
get count() {
|
|
367
|
+
return this.#count;
|
|
368
|
+
}
|
|
369
|
+
get failures() {
|
|
370
|
+
return this.#failures;
|
|
371
|
+
}
|
|
372
|
+
get successes() {
|
|
373
|
+
return this.#count - this.#failures;
|
|
374
|
+
}
|
|
375
|
+
reset() {
|
|
376
|
+
this.#buf.fill(false);
|
|
377
|
+
this.#head = 0;
|
|
378
|
+
this.#count = 0;
|
|
379
|
+
this.#failures = 0;
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
function local2(options) {
|
|
383
|
+
validate2(options);
|
|
384
|
+
const name = options.name;
|
|
385
|
+
const minimumThroughput = options.minimumThroughput ?? DEFAULT_MINIMUM_THROUGHPUT;
|
|
386
|
+
const failureThreshold = options.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD;
|
|
387
|
+
const openMs = options.openMs ?? DEFAULT_OPEN_MS;
|
|
388
|
+
const halfOpenSuccessTarget = options.halfOpenSuccesses ?? DEFAULT_HALF_OPEN_SUCCESSES;
|
|
389
|
+
const halfOpenProbeLimit = options.halfOpenProbes ?? DEFAULT_HALF_OPEN_PROBES;
|
|
390
|
+
const windowSize = options.windowSize ?? DEFAULT_WINDOW_SIZE;
|
|
391
|
+
const classifier = options.classify;
|
|
392
|
+
let state = "closed";
|
|
393
|
+
let generation = 0;
|
|
394
|
+
let openedAt = 0;
|
|
395
|
+
let halfOpenSuccessCount = 0;
|
|
396
|
+
let halfOpenProbesInFlight = 0;
|
|
397
|
+
const window = new SlidingWindow(windowSize);
|
|
398
|
+
function transitionToOpen(context, previousState) {
|
|
399
|
+
state = "open";
|
|
400
|
+
generation++;
|
|
401
|
+
openedAt = Date.now();
|
|
402
|
+
halfOpenSuccessCount = 0;
|
|
403
|
+
halfOpenProbesInFlight = 0;
|
|
404
|
+
window.reset();
|
|
405
|
+
emitRuntimeEvent(context, {
|
|
406
|
+
type: "breaker.state-changed",
|
|
407
|
+
coordination: "local",
|
|
408
|
+
policyName: name,
|
|
409
|
+
scope: "process",
|
|
410
|
+
state: "open",
|
|
411
|
+
previousState
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
function transitionToHalfOpen(context) {
|
|
415
|
+
state = "half-open";
|
|
416
|
+
generation++;
|
|
417
|
+
halfOpenSuccessCount = 0;
|
|
418
|
+
halfOpenProbesInFlight = 0;
|
|
419
|
+
window.reset();
|
|
420
|
+
emitRuntimeEvent(context, {
|
|
421
|
+
type: "breaker.state-changed",
|
|
422
|
+
coordination: "local",
|
|
423
|
+
policyName: name,
|
|
424
|
+
scope: "process",
|
|
425
|
+
state: "half-open",
|
|
426
|
+
previousState: "open"
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
function transitionToClosed(context) {
|
|
430
|
+
state = "closed";
|
|
431
|
+
generation++;
|
|
432
|
+
halfOpenSuccessCount = 0;
|
|
433
|
+
halfOpenProbesInFlight = 0;
|
|
434
|
+
window.reset();
|
|
435
|
+
emitRuntimeEvent(context, {
|
|
436
|
+
type: "breaker.state-changed",
|
|
437
|
+
coordination: "local",
|
|
438
|
+
policyName: name,
|
|
439
|
+
scope: "process",
|
|
440
|
+
state: "closed",
|
|
441
|
+
previousState: "half-open"
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
function admit(context) {
|
|
445
|
+
if (state === "closed") return "closed";
|
|
446
|
+
if (state === "open") {
|
|
447
|
+
if (Date.now() - openedAt >= openMs) {
|
|
448
|
+
transitionToHalfOpen(context);
|
|
449
|
+
} else {
|
|
450
|
+
emitRuntimeEvent(context, {
|
|
451
|
+
type: "breaker.rejected",
|
|
452
|
+
coordination: "local",
|
|
453
|
+
policyName: name,
|
|
454
|
+
scope: "process",
|
|
455
|
+
state: "open"
|
|
456
|
+
});
|
|
457
|
+
return "rejected";
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (halfOpenProbesInFlight >= halfOpenProbeLimit) {
|
|
461
|
+
emitRuntimeEvent(context, {
|
|
462
|
+
type: "breaker.rejected",
|
|
463
|
+
coordination: "local",
|
|
464
|
+
policyName: name,
|
|
465
|
+
scope: "process",
|
|
466
|
+
state: "half-open"
|
|
467
|
+
});
|
|
468
|
+
return "rejected";
|
|
469
|
+
}
|
|
470
|
+
halfOpenProbesInFlight++;
|
|
471
|
+
emitRuntimeEvent(context, {
|
|
472
|
+
type: "breaker.probe-started",
|
|
473
|
+
coordination: "local",
|
|
474
|
+
policyName: name,
|
|
475
|
+
scope: "process"
|
|
476
|
+
});
|
|
477
|
+
return "half-open";
|
|
478
|
+
}
|
|
479
|
+
function observe(context, admitted, admittedGeneration, settled) {
|
|
480
|
+
if (admittedGeneration !== generation || admitted !== state) return;
|
|
481
|
+
const outcome = classifyOutcome(context, classifier, settled);
|
|
482
|
+
if (outcome === "ignored") {
|
|
483
|
+
if (admitted === "half-open") {
|
|
484
|
+
halfOpenProbesInFlight = Math.max(0, halfOpenProbesInFlight - 1);
|
|
485
|
+
}
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const failure = outcome === "failure";
|
|
489
|
+
emitRuntimeEvent(context, {
|
|
490
|
+
type: "breaker.observation",
|
|
491
|
+
coordination: "local",
|
|
492
|
+
policyName: name,
|
|
493
|
+
scope: "process",
|
|
494
|
+
outcome
|
|
495
|
+
});
|
|
496
|
+
if (admitted === "half-open") {
|
|
497
|
+
halfOpenProbesInFlight = Math.max(0, halfOpenProbesInFlight - 1);
|
|
498
|
+
if (failure) {
|
|
499
|
+
transitionToOpen(context, "half-open");
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
halfOpenSuccessCount++;
|
|
503
|
+
if (halfOpenSuccessCount >= halfOpenSuccessTarget) {
|
|
504
|
+
transitionToClosed(context);
|
|
505
|
+
}
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
window.record(failure);
|
|
509
|
+
if (window.count >= minimumThroughput && window.failures / window.count >= failureThreshold) {
|
|
510
|
+
transitionToOpen(context, "closed");
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return Object.freeze({
|
|
514
|
+
name,
|
|
515
|
+
coordination: "local",
|
|
516
|
+
snapshot() {
|
|
517
|
+
return {
|
|
518
|
+
coordination: "local",
|
|
519
|
+
state,
|
|
520
|
+
failures: window.failures,
|
|
521
|
+
successes: window.successes,
|
|
522
|
+
observations: window.count,
|
|
523
|
+
probesInFlight: halfOpenProbesInFlight,
|
|
524
|
+
halfOpenSuccesses: halfOpenSuccessCount
|
|
525
|
+
};
|
|
526
|
+
},
|
|
527
|
+
async execute(context, next) {
|
|
528
|
+
const admitted = admit(context);
|
|
529
|
+
const admittedGeneration = generation;
|
|
530
|
+
if (admitted === "rejected") {
|
|
531
|
+
throw new CircuitOpenError(name, "local", "process");
|
|
532
|
+
}
|
|
533
|
+
let isSuccess = false;
|
|
534
|
+
let value;
|
|
535
|
+
let error;
|
|
536
|
+
try {
|
|
537
|
+
value = await next(context);
|
|
538
|
+
isSuccess = true;
|
|
539
|
+
return value;
|
|
540
|
+
} catch (err) {
|
|
541
|
+
error = err;
|
|
542
|
+
throw err;
|
|
543
|
+
} finally {
|
|
544
|
+
observe(
|
|
545
|
+
context,
|
|
546
|
+
admitted,
|
|
547
|
+
admittedGeneration,
|
|
548
|
+
isSuccess ? { status: "success", value } : { status: "failure", error }
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
var DEFAULT_DIST_MINIMUM_THROUGHPUT = 20;
|
|
555
|
+
var DEFAULT_DIST_FAILURE_THRESHOLD = 0.5;
|
|
556
|
+
var DEFAULT_DIST_WINDOW_SIZE = 100;
|
|
557
|
+
var DEFAULT_DIST_OPEN_MS = 3e4;
|
|
558
|
+
var DEFAULT_DIST_HALF_OPEN_PROBES = 3;
|
|
559
|
+
var DEFAULT_DIST_HALF_OPEN_SUCCESSES = 2;
|
|
560
|
+
var DEFAULT_DIST_ON_COORDINATOR_ERROR = "fail-open";
|
|
561
|
+
function validateDistributed(opts) {
|
|
562
|
+
if (typeof opts.name !== "string" || !opts.name.trim())
|
|
563
|
+
throw new RangeError("Distributed circuit breaker name must not be empty");
|
|
564
|
+
if (!opts.coordinator || typeof opts.coordinator !== "object")
|
|
565
|
+
throw new TypeError("coordinator is required");
|
|
566
|
+
if (typeof opts.scope !== "function")
|
|
567
|
+
throw new TypeError("scope must be a function");
|
|
568
|
+
const { minimumThroughput = DEFAULT_DIST_MINIMUM_THROUGHPUT } = opts;
|
|
569
|
+
if (!Number.isInteger(minimumThroughput) || minimumThroughput < 1)
|
|
570
|
+
throw new RangeError("minimumThroughput must be a positive integer");
|
|
571
|
+
const { failureThreshold = DEFAULT_DIST_FAILURE_THRESHOLD } = opts;
|
|
572
|
+
if (!Number.isFinite(failureThreshold) || failureThreshold <= 0 || failureThreshold >= 1)
|
|
573
|
+
throw new RangeError("failureThreshold must be a number in (0, 1)");
|
|
574
|
+
const { openMs = DEFAULT_DIST_OPEN_MS } = opts;
|
|
575
|
+
if (!Number.isInteger(openMs) || openMs < 1)
|
|
576
|
+
throw new RangeError("openMs must be a positive integer");
|
|
577
|
+
const { halfOpenSuccesses = DEFAULT_DIST_HALF_OPEN_SUCCESSES } = opts;
|
|
578
|
+
if (!Number.isInteger(halfOpenSuccesses) || halfOpenSuccesses < 1)
|
|
579
|
+
throw new RangeError("halfOpenSuccesses must be a positive integer");
|
|
580
|
+
const { halfOpenProbes = DEFAULT_DIST_HALF_OPEN_PROBES } = opts;
|
|
581
|
+
if (!Number.isInteger(halfOpenProbes) || halfOpenProbes < 1)
|
|
582
|
+
throw new RangeError("halfOpenProbes must be a positive integer");
|
|
583
|
+
const { windowSize = DEFAULT_DIST_WINDOW_SIZE } = opts;
|
|
584
|
+
if (!Number.isInteger(windowSize) || windowSize < 1)
|
|
585
|
+
throw new RangeError("windowSize must be a positive integer");
|
|
586
|
+
if (opts.windowTtlMs !== void 0) {
|
|
587
|
+
if (!Number.isInteger(opts.windowTtlMs) || opts.windowTtlMs < 1)
|
|
588
|
+
throw new RangeError("windowTtlMs must be a positive integer");
|
|
589
|
+
}
|
|
590
|
+
if (opts.probeLeaseTtlMs !== void 0) {
|
|
591
|
+
if (!Number.isInteger(opts.probeLeaseTtlMs) || opts.probeLeaseTtlMs < 1)
|
|
592
|
+
throw new RangeError("probeLeaseTtlMs must be a positive integer");
|
|
593
|
+
}
|
|
594
|
+
if (opts.onCoordinatorError !== void 0 && opts.onCoordinatorError !== "fail-open" && opts.onCoordinatorError !== "fail-closed")
|
|
595
|
+
throw new TypeError(
|
|
596
|
+
'onCoordinatorError must be "fail-open" or "fail-closed"'
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
function distributed2(options) {
|
|
600
|
+
validateDistributed(options);
|
|
601
|
+
const name = options.name;
|
|
602
|
+
const coordinator = options.coordinator;
|
|
603
|
+
const resolveScope = options.scope;
|
|
604
|
+
const minimumThroughput = options.minimumThroughput ?? DEFAULT_DIST_MINIMUM_THROUGHPUT;
|
|
605
|
+
const failureThreshold = options.failureThreshold ?? DEFAULT_DIST_FAILURE_THRESHOLD;
|
|
606
|
+
const failureThresholdNumerator = Math.round(failureThreshold * 1e3);
|
|
607
|
+
const windowSize = options.windowSize ?? DEFAULT_DIST_WINDOW_SIZE;
|
|
608
|
+
const openMs = options.openMs ?? DEFAULT_DIST_OPEN_MS;
|
|
609
|
+
const windowTtlMs = options.windowTtlMs ?? Math.max(openMs * 3, 6e4);
|
|
610
|
+
const halfOpenProbes = options.halfOpenProbes ?? DEFAULT_DIST_HALF_OPEN_PROBES;
|
|
611
|
+
const halfOpenSuccesses = options.halfOpenSuccesses ?? DEFAULT_DIST_HALF_OPEN_SUCCESSES;
|
|
612
|
+
const probeLeaseTtlMs = options.probeLeaseTtlMs ?? openMs * 2;
|
|
613
|
+
const onCoordinatorError = options.onCoordinatorError ?? DEFAULT_DIST_ON_COORDINATOR_ERROR;
|
|
614
|
+
const classifier = options.classify;
|
|
615
|
+
const lastKnownState = createScopeStateCache();
|
|
616
|
+
return Object.freeze({
|
|
617
|
+
name,
|
|
618
|
+
coordination: "distributed",
|
|
619
|
+
async execute(context, next) {
|
|
620
|
+
const scope = resolveScope(context);
|
|
621
|
+
if (typeof scope !== "string" || !scope.trim())
|
|
622
|
+
throw new TypeError(
|
|
623
|
+
"circuitBreaker.distributed scope must be a non-empty string"
|
|
624
|
+
);
|
|
625
|
+
const identity = {
|
|
626
|
+
name,
|
|
627
|
+
operation: context.operationName,
|
|
628
|
+
scope
|
|
629
|
+
};
|
|
630
|
+
let stateData = null;
|
|
631
|
+
try {
|
|
632
|
+
stateData = await coordinator.readState(identity);
|
|
633
|
+
const observed = stateData?.state;
|
|
634
|
+
if (observed === "open" || observed === "half-open") {
|
|
635
|
+
lastKnownState.remember(identity.operation, scope, observed);
|
|
636
|
+
} else {
|
|
637
|
+
lastKnownState.forget(identity.operation, scope);
|
|
638
|
+
}
|
|
639
|
+
} catch (readError) {
|
|
640
|
+
emitRuntimeEvent(context, {
|
|
641
|
+
type: "breaker.coordinator-error",
|
|
642
|
+
coordination: "distributed",
|
|
643
|
+
policyName: name,
|
|
644
|
+
scope,
|
|
645
|
+
operation: "admit",
|
|
646
|
+
error: readError
|
|
647
|
+
});
|
|
648
|
+
const cached = lastKnownState.read(identity.operation, scope);
|
|
649
|
+
const effectiveBehavior = cached === "open" || cached === "half-open" ? "fail-closed" : onCoordinatorError;
|
|
650
|
+
emitRuntimeEvent(context, {
|
|
651
|
+
type: "breaker.degraded",
|
|
652
|
+
coordination: "distributed",
|
|
653
|
+
policyName: name,
|
|
654
|
+
scope,
|
|
655
|
+
reason: "coordinator-unavailable",
|
|
656
|
+
behavior: effectiveBehavior
|
|
657
|
+
});
|
|
658
|
+
if (effectiveBehavior === "fail-closed") {
|
|
659
|
+
emitRuntimeEvent(context, {
|
|
660
|
+
type: "breaker.rejected",
|
|
661
|
+
coordination: "distributed",
|
|
662
|
+
policyName: name,
|
|
663
|
+
scope,
|
|
664
|
+
state: "open"
|
|
665
|
+
});
|
|
666
|
+
throw new CircuitOpenError(name, "distributed", scope);
|
|
667
|
+
}
|
|
668
|
+
stateData = { state: "closed", generation: 0 };
|
|
669
|
+
}
|
|
670
|
+
const reportedState = stateData?.state ?? "closed";
|
|
671
|
+
const reportedGeneration = stateData?.generation ?? 0;
|
|
672
|
+
let admission;
|
|
673
|
+
if (reportedState === "closed") {
|
|
674
|
+
admission = { kind: "closed", generation: reportedGeneration };
|
|
675
|
+
} else {
|
|
676
|
+
const probeToken = randomUUID();
|
|
677
|
+
let probeResult;
|
|
678
|
+
try {
|
|
679
|
+
probeResult = await coordinator.admitProbe(identity, {
|
|
680
|
+
probeToken,
|
|
681
|
+
openMs,
|
|
682
|
+
halfOpenProbes,
|
|
683
|
+
probeLeaseTtlMs
|
|
684
|
+
});
|
|
685
|
+
} catch (admitError) {
|
|
686
|
+
emitRuntimeEvent(context, {
|
|
687
|
+
type: "breaker.coordinator-error",
|
|
688
|
+
coordination: "distributed",
|
|
689
|
+
policyName: name,
|
|
690
|
+
scope,
|
|
691
|
+
operation: "admit",
|
|
692
|
+
error: admitError
|
|
693
|
+
});
|
|
694
|
+
emitRuntimeEvent(context, {
|
|
695
|
+
type: "breaker.degraded",
|
|
696
|
+
coordination: "distributed",
|
|
697
|
+
policyName: name,
|
|
698
|
+
scope,
|
|
699
|
+
reason: "coordinator-unavailable",
|
|
700
|
+
behavior: "fail-closed"
|
|
701
|
+
});
|
|
702
|
+
emitRuntimeEvent(context, {
|
|
703
|
+
type: "breaker.rejected",
|
|
704
|
+
coordination: "distributed",
|
|
705
|
+
policyName: name,
|
|
706
|
+
scope,
|
|
707
|
+
state: reportedState === "open" ? "open" : "half-open"
|
|
708
|
+
});
|
|
709
|
+
throw new CircuitOpenError(name, "distributed", scope);
|
|
710
|
+
}
|
|
711
|
+
if (probeResult.type === "rejected") {
|
|
712
|
+
if (probeResult.reason === "closed") {
|
|
713
|
+
lastKnownState.forget(identity.operation, scope);
|
|
714
|
+
admission = { kind: "closed", generation: probeResult.generation };
|
|
715
|
+
} else {
|
|
716
|
+
const rejState = probeResult.reason === "open" ? "open" : "half-open";
|
|
717
|
+
emitRuntimeEvent(context, {
|
|
718
|
+
type: "breaker.rejected",
|
|
719
|
+
coordination: "distributed",
|
|
720
|
+
policyName: name,
|
|
721
|
+
scope,
|
|
722
|
+
state: rejState,
|
|
723
|
+
generation: probeResult.generation
|
|
724
|
+
});
|
|
725
|
+
throw new CircuitOpenError(name, "distributed", scope);
|
|
726
|
+
}
|
|
727
|
+
} else {
|
|
728
|
+
if (probeResult.stateChanged) {
|
|
729
|
+
lastKnownState.remember(identity.operation, scope, "half-open");
|
|
730
|
+
emitRuntimeEvent(context, {
|
|
731
|
+
type: "breaker.state-changed",
|
|
732
|
+
coordination: "distributed",
|
|
733
|
+
policyName: name,
|
|
734
|
+
scope,
|
|
735
|
+
state: "half-open",
|
|
736
|
+
previousState: "open",
|
|
737
|
+
generation: probeResult.generation
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
emitRuntimeEvent(context, {
|
|
741
|
+
type: "breaker.probe-started",
|
|
742
|
+
coordination: "distributed",
|
|
743
|
+
policyName: name,
|
|
744
|
+
scope,
|
|
745
|
+
generation: probeResult.generation
|
|
746
|
+
});
|
|
747
|
+
admission = {
|
|
748
|
+
kind: "probe",
|
|
749
|
+
probeToken,
|
|
750
|
+
generation: probeResult.generation
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
let isSuccess = false;
|
|
755
|
+
let value;
|
|
756
|
+
let thrownError;
|
|
757
|
+
try {
|
|
758
|
+
value = await next(context);
|
|
759
|
+
isSuccess = true;
|
|
760
|
+
return value;
|
|
761
|
+
} catch (err) {
|
|
762
|
+
thrownError = err;
|
|
763
|
+
throw err;
|
|
764
|
+
} finally {
|
|
765
|
+
const breakerOutcome = classifyOutcome(
|
|
766
|
+
context,
|
|
767
|
+
classifier,
|
|
768
|
+
isSuccess ? { status: "success", value } : { status: "failure", error: thrownError }
|
|
769
|
+
);
|
|
770
|
+
if (breakerOutcome !== "ignored") {
|
|
771
|
+
const outcomeStr = breakerOutcome === "success" ? "success" : "failure";
|
|
772
|
+
if (admission.kind === "closed") {
|
|
773
|
+
try {
|
|
774
|
+
const result = await coordinator.observe(identity, {
|
|
775
|
+
generation: admission.generation,
|
|
776
|
+
outcome: outcomeStr,
|
|
777
|
+
uuid: randomUUID(),
|
|
778
|
+
windowTtlMs,
|
|
779
|
+
minimumThroughput,
|
|
780
|
+
failureThresholdNumerator,
|
|
781
|
+
windowSize,
|
|
782
|
+
openMs
|
|
783
|
+
});
|
|
784
|
+
if (result.type === "stale") {
|
|
785
|
+
emitRuntimeEvent(context, {
|
|
786
|
+
type: "breaker.observation-stale",
|
|
787
|
+
coordination: "distributed",
|
|
788
|
+
policyName: name,
|
|
789
|
+
scope,
|
|
790
|
+
attemptGeneration: admission.generation,
|
|
791
|
+
currentGeneration: result.currentGeneration
|
|
792
|
+
});
|
|
793
|
+
} else {
|
|
794
|
+
emitRuntimeEvent(context, {
|
|
795
|
+
type: "breaker.observation",
|
|
796
|
+
coordination: "distributed",
|
|
797
|
+
policyName: name,
|
|
798
|
+
scope,
|
|
799
|
+
outcome: outcomeStr,
|
|
800
|
+
generation: admission.generation
|
|
801
|
+
});
|
|
802
|
+
if (result.type === "opened") {
|
|
803
|
+
lastKnownState.remember(identity.operation, scope, "open");
|
|
804
|
+
emitRuntimeEvent(context, {
|
|
805
|
+
type: "breaker.state-changed",
|
|
806
|
+
coordination: "distributed",
|
|
807
|
+
policyName: name,
|
|
808
|
+
scope,
|
|
809
|
+
state: "open",
|
|
810
|
+
previousState: "closed",
|
|
811
|
+
generation: result.newGeneration
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
} catch (observeError) {
|
|
816
|
+
emitRuntimeEvent(context, {
|
|
817
|
+
type: "breaker.coordinator-error",
|
|
818
|
+
coordination: "distributed",
|
|
819
|
+
policyName: name,
|
|
820
|
+
scope,
|
|
821
|
+
operation: "observe",
|
|
822
|
+
error: observeError
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
} else {
|
|
826
|
+
try {
|
|
827
|
+
const result = await coordinator.settleProbe(identity, {
|
|
828
|
+
probeToken: admission.probeToken,
|
|
829
|
+
outcome: outcomeStr,
|
|
830
|
+
generation: admission.generation,
|
|
831
|
+
halfOpenSuccesses,
|
|
832
|
+
openMs,
|
|
833
|
+
windowTtlMs
|
|
834
|
+
});
|
|
835
|
+
if (result.type === "stale") {
|
|
836
|
+
emitRuntimeEvent(context, {
|
|
837
|
+
type: "breaker.observation-stale",
|
|
838
|
+
coordination: "distributed",
|
|
839
|
+
policyName: name,
|
|
840
|
+
scope,
|
|
841
|
+
attemptGeneration: admission.generation,
|
|
842
|
+
currentGeneration: result.generation
|
|
843
|
+
});
|
|
844
|
+
} else {
|
|
845
|
+
emitRuntimeEvent(context, {
|
|
846
|
+
type: "breaker.observation",
|
|
847
|
+
coordination: "distributed",
|
|
848
|
+
policyName: name,
|
|
849
|
+
scope,
|
|
850
|
+
outcome: outcomeStr,
|
|
851
|
+
generation: admission.generation
|
|
852
|
+
});
|
|
853
|
+
if (result.type === "transitioned") {
|
|
854
|
+
if (result.newState === "closed") {
|
|
855
|
+
lastKnownState.forget(identity.operation, scope);
|
|
856
|
+
} else {
|
|
857
|
+
lastKnownState.remember(
|
|
858
|
+
identity.operation,
|
|
859
|
+
scope,
|
|
860
|
+
result.newState
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
emitRuntimeEvent(context, {
|
|
864
|
+
type: "breaker.state-changed",
|
|
865
|
+
coordination: "distributed",
|
|
866
|
+
policyName: name,
|
|
867
|
+
scope,
|
|
868
|
+
state: result.newState,
|
|
869
|
+
previousState: "half-open",
|
|
870
|
+
generation: result.newGeneration
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
} catch (settleError) {
|
|
875
|
+
emitRuntimeEvent(context, {
|
|
876
|
+
type: "breaker.coordinator-error",
|
|
877
|
+
coordination: "distributed",
|
|
878
|
+
policyName: name,
|
|
879
|
+
scope,
|
|
880
|
+
operation: "settle-probe",
|
|
881
|
+
error: settleError
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
var circuitBreaker = Object.freeze({ local: local2, distributed: distributed2 });
|
|
891
|
+
|
|
892
|
+
// src/core/retry.ts
|
|
893
|
+
function throwIfAborted(signal) {
|
|
894
|
+
if (!signal?.aborted) {
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
throw signal.reason ?? new DOMException("The operation was aborted", "AbortError");
|
|
898
|
+
}
|
|
899
|
+
function retryContext(context, outcome) {
|
|
900
|
+
return {
|
|
901
|
+
outcome,
|
|
902
|
+
result: outcome.status === "success" ? outcome.value : void 0,
|
|
903
|
+
error: outcome.status === "failure" ? outcome.error : void 0,
|
|
904
|
+
capabilities: context.capabilities,
|
|
905
|
+
metadata: context.metadata
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
function delayFor(options, context, attempt, outcome) {
|
|
909
|
+
const configured = options.delay;
|
|
910
|
+
const delay = typeof configured === "function" ? configured(attempt, retryContext(context, outcome)) : configured ?? 0;
|
|
911
|
+
if (!Number.isFinite(delay) || delay < 0) {
|
|
912
|
+
throw new RangeError("retry delay must be a finite non-negative number");
|
|
913
|
+
}
|
|
914
|
+
return delay;
|
|
915
|
+
}
|
|
916
|
+
function wait(delayMs, signal) {
|
|
917
|
+
if (delayMs === 0) {
|
|
918
|
+
throwIfAborted(signal);
|
|
919
|
+
return Promise.resolve();
|
|
920
|
+
}
|
|
921
|
+
return new Promise((resolve, reject) => {
|
|
922
|
+
const timer = setTimeout(done, delayMs);
|
|
923
|
+
function done() {
|
|
924
|
+
signal?.removeEventListener("abort", aborted);
|
|
925
|
+
resolve();
|
|
926
|
+
}
|
|
927
|
+
function aborted() {
|
|
928
|
+
clearTimeout(timer);
|
|
929
|
+
reject(
|
|
930
|
+
signal?.reason ?? new DOMException("The operation was aborted", "AbortError")
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
if (signal?.aborted) {
|
|
934
|
+
aborted();
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
signal?.addEventListener("abort", aborted, { once: true });
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
function summarized(outcome) {
|
|
941
|
+
return outcome.status === "success" ? { status: "success", value: void 0 } : { status: "failure", error: outcome.error };
|
|
942
|
+
}
|
|
943
|
+
function classification(context, outcome) {
|
|
944
|
+
return context.classify(outcome);
|
|
945
|
+
}
|
|
946
|
+
function retry(options) {
|
|
947
|
+
if (!Number.isInteger(options.maxAttempts) || options.maxAttempts < 1) {
|
|
948
|
+
throw new RangeError("maxAttempts must be a positive integer");
|
|
949
|
+
}
|
|
950
|
+
return Object.freeze({
|
|
951
|
+
name: "retry",
|
|
952
|
+
async execute(initialContext, next) {
|
|
953
|
+
let context = initialContext;
|
|
954
|
+
for (; ; ) {
|
|
955
|
+
throwIfAborted(admissionSignal(context));
|
|
956
|
+
try {
|
|
957
|
+
const value = await next(context);
|
|
958
|
+
const outcome = { status: "success", value };
|
|
959
|
+
const outcomeClassification = classification(context, outcome);
|
|
960
|
+
if (outcomeClassification !== "retryable" || context.capabilities.replay !== "safe") {
|
|
961
|
+
return value;
|
|
962
|
+
}
|
|
963
|
+
if (context.attempt >= options.maxAttempts) {
|
|
964
|
+
emitRuntimeEvent(context, {
|
|
965
|
+
type: "retry.exhausted",
|
|
966
|
+
outcome: summarized(outcome),
|
|
967
|
+
classification: outcomeClassification
|
|
968
|
+
});
|
|
969
|
+
return value;
|
|
970
|
+
}
|
|
971
|
+
const delayMs = delayFor(options, context, context.attempt, outcome);
|
|
972
|
+
emitRuntimeEvent(context, {
|
|
973
|
+
type: "retry.scheduled",
|
|
974
|
+
nextAttempt: context.attempt + 1,
|
|
975
|
+
delayMs,
|
|
976
|
+
outcome: summarized(outcome),
|
|
977
|
+
classification: outcomeClassification
|
|
978
|
+
});
|
|
979
|
+
await wait(delayMs, admissionSignal(context));
|
|
980
|
+
context = nextAttempt(context);
|
|
981
|
+
} catch (error) {
|
|
982
|
+
const outcome = { status: "failure", error };
|
|
983
|
+
const outcomeClassification = classification(context, outcome);
|
|
984
|
+
if (admissionSignal(context)?.aborted || context.capabilities.replay !== "safe" || outcomeClassification !== "retryable") {
|
|
985
|
+
throw error;
|
|
986
|
+
}
|
|
987
|
+
if (context.attempt >= options.maxAttempts) {
|
|
988
|
+
emitRuntimeEvent(context, {
|
|
989
|
+
type: "retry.exhausted",
|
|
990
|
+
outcome: summarized(outcome),
|
|
991
|
+
classification: outcomeClassification
|
|
992
|
+
});
|
|
993
|
+
throw error;
|
|
994
|
+
}
|
|
995
|
+
const delayMs = delayFor(options, context, context.attempt, outcome);
|
|
996
|
+
emitRuntimeEvent(context, {
|
|
997
|
+
type: "retry.scheduled",
|
|
998
|
+
nextAttempt: context.attempt + 1,
|
|
999
|
+
delayMs,
|
|
1000
|
+
outcome: summarized(outcome),
|
|
1001
|
+
classification: outcomeClassification
|
|
1002
|
+
});
|
|
1003
|
+
await wait(delayMs, admissionSignal(context));
|
|
1004
|
+
context = nextAttempt(context);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// src/core/timeout.ts
|
|
1012
|
+
var TimeoutError = class extends Error {
|
|
1013
|
+
timeoutMs;
|
|
1014
|
+
constructor(timeoutMs) {
|
|
1015
|
+
super(`Operation timed out after ${timeoutMs}ms`);
|
|
1016
|
+
this.name = "TimeoutError";
|
|
1017
|
+
this.timeoutMs = timeoutMs;
|
|
1018
|
+
}
|
|
1019
|
+
};
|
|
1020
|
+
function combineSignals(external, timeoutController) {
|
|
1021
|
+
if (external === void 0) {
|
|
1022
|
+
return timeoutController.signal;
|
|
1023
|
+
}
|
|
1024
|
+
return AbortSignal.any([external, timeoutController.signal]);
|
|
1025
|
+
}
|
|
1026
|
+
function timeout(options) {
|
|
1027
|
+
if (!Number.isFinite(options.ms) || options.ms <= 0) {
|
|
1028
|
+
throw new RangeError("timeout ms must be a finite positive number");
|
|
1029
|
+
}
|
|
1030
|
+
return Object.freeze({
|
|
1031
|
+
name: "timeout",
|
|
1032
|
+
async execute(context, next) {
|
|
1033
|
+
const timeoutError = new TimeoutError(options.ms);
|
|
1034
|
+
const supportsAbort = context.capabilities.abort === "supported";
|
|
1035
|
+
const controller = new AbortController();
|
|
1036
|
+
const attemptContext = withAdmissionSignal(
|
|
1037
|
+
supportsAbort ? withSignal(context, combineSignals(context.signal, controller)) : context,
|
|
1038
|
+
controller.signal
|
|
1039
|
+
);
|
|
1040
|
+
let timer;
|
|
1041
|
+
const timeoutPromise = new Promise((_resolve, reject) => {
|
|
1042
|
+
timer = setTimeout(() => {
|
|
1043
|
+
controller.abort(timeoutError);
|
|
1044
|
+
emitRuntimeEvent(context, {
|
|
1045
|
+
type: "timeout.triggered",
|
|
1046
|
+
timeoutMs: options.ms,
|
|
1047
|
+
abortRequested: supportsAbort
|
|
1048
|
+
});
|
|
1049
|
+
reject(timeoutError);
|
|
1050
|
+
}, options.ms);
|
|
1051
|
+
});
|
|
1052
|
+
try {
|
|
1053
|
+
return await Promise.race([next(attemptContext), timeoutPromise]);
|
|
1054
|
+
} finally {
|
|
1055
|
+
if (timer !== void 0) {
|
|
1056
|
+
clearTimeout(timer);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
export { BulkheadRejectedError, CircuitOpenError, TimeoutError, bulkhead, circuitBreaker, retry, timeout };
|
|
1064
|
+
//# sourceMappingURL=index.js.map
|
|
1065
|
+
//# sourceMappingURL=index.js.map
|