@gkoos/caracal 0.1.0 → 0.2.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 +40 -5
- package/README.md +13 -3
- package/dist/chunk-FD7IZJKY.js +84 -0
- package/dist/chunk-FD7IZJKY.js.map +1 -0
- package/dist/{chunk-5CXDW7W6.js → chunk-JV2OLYOF.js} +5 -82
- package/dist/chunk-JV2OLYOF.js.map +1 -0
- package/dist/{circuit-breaker-BSkcV0W_.d.ts → circuit-breaker-xZ8uenT8.d.ts} +7 -2
- package/dist/fetch.d.ts +6 -3
- package/dist/fetch.js +4 -2
- package/dist/fetch.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +96 -41
- package/dist/index.js.map +1 -1
- package/dist/postgres.d.ts +1 -1
- package/dist/redis.d.ts +10 -4
- package/dist/redis.js +15 -1
- package/dist/redis.js.map +1 -1
- package/dist/{retry-BFP_k3Hg.d.ts → retry-DD85oXL9.d.ts} +1 -1
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +2 -1
- package/dist/testing/index.js.map +1 -1
- package/dist/{types-Tf9T76C7.d.ts → types-C-Ml-MKp.d.ts} +7 -0
- package/package.json +9 -5
- package/src/adapters/fetch/retry-after.ts +11 -3
- package/src/coordination/redis/client.ts +15 -1
- package/src/coordination/redis/scripts.ts +20 -3
- package/src/core/bulkhead.ts +20 -3
- package/src/core/circuit-breaker.ts +81 -36
- package/src/core/operation.ts +2 -5
- package/src/core/retry.ts +46 -2
- package/src/core/runtime.ts +26 -5
- package/src/core/timeout.ts +11 -1
- package/src/core/types.ts +8 -0
- package/test/harness/adapter-contract.ts +170 -0
- package/test/harness/index.ts +15 -0
- package/dist/chunk-5CXDW7W6.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
export { operation } from './chunk-JV2OLYOF.js';
|
|
2
|
+
import { MAX_TIMER_MS, admissionSignal, emitRuntimeEvent, nextAttempt, withAdmissionSignal, withSignal } from './chunk-FD7IZJKY.js';
|
|
3
3
|
import { randomUUID } from 'crypto';
|
|
4
4
|
|
|
5
5
|
var BulkheadRejectedError = class extends Error {
|
|
@@ -34,7 +34,7 @@ function local(options) {
|
|
|
34
34
|
const { name, limit } = options;
|
|
35
35
|
const queue = options.queue && { ...options.queue };
|
|
36
36
|
validate(name, limit);
|
|
37
|
-
if (queue && (!Number.isSafeInteger(queue.limit) || queue.limit < 1 || !Number.isSafeInteger(queue.timeoutMs) || queue.timeoutMs < 1 || queue.timeoutMs >
|
|
37
|
+
if (queue && (!Number.isSafeInteger(queue.limit) || queue.limit < 1 || !Number.isSafeInteger(queue.timeoutMs) || queue.timeoutMs < 1 || queue.timeoutMs > MAX_TIMER_MS))
|
|
38
38
|
throw new RangeError("Invalid bounded queue");
|
|
39
39
|
let occupancy = 0;
|
|
40
40
|
const waiting = [];
|
|
@@ -97,8 +97,8 @@ function local(options) {
|
|
|
97
97
|
return await next(context);
|
|
98
98
|
} finally {
|
|
99
99
|
occupancy--;
|
|
100
|
-
waiting[0]?.();
|
|
101
100
|
event(context, "local", name, "process", "released", occupancy);
|
|
101
|
+
waiting[0]?.();
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
});
|
|
@@ -212,13 +212,23 @@ function distributed(options) {
|
|
|
212
212
|
}
|
|
213
213
|
};
|
|
214
214
|
try {
|
|
215
|
-
if (performance.now() >= deadline)
|
|
215
|
+
if (performance.now() >= deadline) {
|
|
216
|
+
event(
|
|
217
|
+
context,
|
|
218
|
+
"distributed",
|
|
219
|
+
name,
|
|
220
|
+
scope,
|
|
221
|
+
"rejected",
|
|
222
|
+
admitted.occupancy,
|
|
223
|
+
"admission-expired"
|
|
224
|
+
);
|
|
216
225
|
throw new BulkheadRejectedError(
|
|
217
226
|
"distributed",
|
|
218
227
|
name,
|
|
219
228
|
scope,
|
|
220
229
|
"admission-expired"
|
|
221
230
|
);
|
|
231
|
+
}
|
|
222
232
|
admissionSignal(context)?.throwIfAborted();
|
|
223
233
|
event(
|
|
224
234
|
context,
|
|
@@ -320,6 +330,14 @@ var DEFAULT_OPEN_MS = 1e4;
|
|
|
320
330
|
var DEFAULT_HALF_OPEN_SUCCESSES = 1;
|
|
321
331
|
var DEFAULT_HALF_OPEN_PROBES = 1;
|
|
322
332
|
var DEFAULT_WINDOW_SIZE = 100;
|
|
333
|
+
var FAILURE_THRESHOLD_SCALE = 1e3;
|
|
334
|
+
function assertResolvableThreshold(failureThreshold) {
|
|
335
|
+
const numerator = Math.round(failureThreshold * FAILURE_THRESHOLD_SCALE);
|
|
336
|
+
if (numerator < 1 || numerator >= FAILURE_THRESHOLD_SCALE)
|
|
337
|
+
throw new RangeError(
|
|
338
|
+
`failureThreshold must be at least 0.0005 and below 0.9995 (thresholds are resolved to thousandths); got ${failureThreshold}`
|
|
339
|
+
);
|
|
340
|
+
}
|
|
323
341
|
function validate2(opts) {
|
|
324
342
|
if (!opts.name.trim())
|
|
325
343
|
throw new RangeError("Circuit breaker name must not be empty");
|
|
@@ -329,6 +347,7 @@ function validate2(opts) {
|
|
|
329
347
|
const { failureThreshold = DEFAULT_FAILURE_THRESHOLD } = opts;
|
|
330
348
|
if (!Number.isFinite(failureThreshold) || failureThreshold <= 0 || failureThreshold >= 1)
|
|
331
349
|
throw new RangeError("failureThreshold must be a number in (0, 1)");
|
|
350
|
+
assertResolvableThreshold(failureThreshold);
|
|
332
351
|
const { openMs = DEFAULT_OPEN_MS } = opts;
|
|
333
352
|
if (!Number.isInteger(openMs) || openMs < 1)
|
|
334
353
|
throw new RangeError("openMs must be a positive integer");
|
|
@@ -571,6 +590,7 @@ function validateDistributed(opts) {
|
|
|
571
590
|
const { failureThreshold = DEFAULT_DIST_FAILURE_THRESHOLD } = opts;
|
|
572
591
|
if (!Number.isFinite(failureThreshold) || failureThreshold <= 0 || failureThreshold >= 1)
|
|
573
592
|
throw new RangeError("failureThreshold must be a number in (0, 1)");
|
|
593
|
+
assertResolvableThreshold(failureThreshold);
|
|
574
594
|
const { openMs = DEFAULT_DIST_OPEN_MS } = opts;
|
|
575
595
|
if (!Number.isInteger(openMs) || openMs < 1)
|
|
576
596
|
throw new RangeError("openMs must be a positive integer");
|
|
@@ -603,7 +623,9 @@ function distributed2(options) {
|
|
|
603
623
|
const resolveScope = options.scope;
|
|
604
624
|
const minimumThroughput = options.minimumThroughput ?? DEFAULT_DIST_MINIMUM_THROUGHPUT;
|
|
605
625
|
const failureThreshold = options.failureThreshold ?? DEFAULT_DIST_FAILURE_THRESHOLD;
|
|
606
|
-
const failureThresholdNumerator = Math.round(
|
|
626
|
+
const failureThresholdNumerator = Math.round(
|
|
627
|
+
failureThreshold * FAILURE_THRESHOLD_SCALE
|
|
628
|
+
);
|
|
607
629
|
const windowSize = options.windowSize ?? DEFAULT_DIST_WINDOW_SIZE;
|
|
608
630
|
const openMs = options.openMs ?? DEFAULT_DIST_OPEN_MS;
|
|
609
631
|
const windowTtlMs = options.windowTtlMs ?? Math.max(openMs * 3, 6e4);
|
|
@@ -767,8 +789,9 @@ function distributed2(options) {
|
|
|
767
789
|
classifier,
|
|
768
790
|
isSuccess ? { status: "success", value } : { status: "failure", error: thrownError }
|
|
769
791
|
);
|
|
770
|
-
if (breakerOutcome !== "ignored") {
|
|
792
|
+
if (breakerOutcome !== "ignored" || admission.kind === "probe") {
|
|
771
793
|
const outcomeStr = breakerOutcome === "success" ? "success" : "failure";
|
|
794
|
+
const settleOutcome = breakerOutcome === "ignored" ? "ignored" : outcomeStr;
|
|
772
795
|
if (admission.kind === "closed") {
|
|
773
796
|
try {
|
|
774
797
|
const result = await coordinator.observe(identity, {
|
|
@@ -826,49 +849,51 @@ function distributed2(options) {
|
|
|
826
849
|
try {
|
|
827
850
|
const result = await coordinator.settleProbe(identity, {
|
|
828
851
|
probeToken: admission.probeToken,
|
|
829
|
-
outcome:
|
|
852
|
+
outcome: settleOutcome,
|
|
830
853
|
generation: admission.generation,
|
|
831
854
|
halfOpenSuccesses,
|
|
832
855
|
openMs,
|
|
833
856
|
windowTtlMs
|
|
834
857
|
});
|
|
835
|
-
if (
|
|
836
|
-
|
|
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
|
-
}
|
|
858
|
+
if (breakerOutcome !== "ignored") {
|
|
859
|
+
if (result.type === "stale") {
|
|
863
860
|
emitRuntimeEvent(context, {
|
|
864
|
-
type: "breaker.
|
|
861
|
+
type: "breaker.observation-stale",
|
|
865
862
|
coordination: "distributed",
|
|
866
863
|
policyName: name,
|
|
867
864
|
scope,
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
865
|
+
attemptGeneration: admission.generation,
|
|
866
|
+
currentGeneration: result.generation
|
|
867
|
+
});
|
|
868
|
+
} else {
|
|
869
|
+
emitRuntimeEvent(context, {
|
|
870
|
+
type: "breaker.observation",
|
|
871
|
+
coordination: "distributed",
|
|
872
|
+
policyName: name,
|
|
873
|
+
scope,
|
|
874
|
+
outcome: outcomeStr,
|
|
875
|
+
generation: admission.generation
|
|
871
876
|
});
|
|
877
|
+
if (result.type === "transitioned") {
|
|
878
|
+
if (result.newState === "closed") {
|
|
879
|
+
lastKnownState.forget(identity.operation, scope);
|
|
880
|
+
} else {
|
|
881
|
+
lastKnownState.remember(
|
|
882
|
+
identity.operation,
|
|
883
|
+
scope,
|
|
884
|
+
result.newState
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
emitRuntimeEvent(context, {
|
|
888
|
+
type: "breaker.state-changed",
|
|
889
|
+
coordination: "distributed",
|
|
890
|
+
policyName: name,
|
|
891
|
+
scope,
|
|
892
|
+
state: result.newState,
|
|
893
|
+
previousState: "half-open",
|
|
894
|
+
generation: result.newGeneration
|
|
895
|
+
});
|
|
896
|
+
}
|
|
872
897
|
}
|
|
873
898
|
}
|
|
874
899
|
} catch (settleError) {
|
|
@@ -911,6 +936,11 @@ function delayFor(options, context, attempt, outcome) {
|
|
|
911
936
|
if (!Number.isFinite(delay) || delay < 0) {
|
|
912
937
|
throw new RangeError("retry delay must be a finite non-negative number");
|
|
913
938
|
}
|
|
939
|
+
if (delay > MAX_TIMER_MS) {
|
|
940
|
+
throw new RangeError(
|
|
941
|
+
`retry delay must not exceed ${MAX_TIMER_MS} ms, the largest delay setTimeout honours`
|
|
942
|
+
);
|
|
943
|
+
}
|
|
914
944
|
return delay;
|
|
915
945
|
}
|
|
916
946
|
function wait(delayMs, signal) {
|
|
@@ -947,6 +977,11 @@ function retry(options) {
|
|
|
947
977
|
if (!Number.isInteger(options.maxAttempts) || options.maxAttempts < 1) {
|
|
948
978
|
throw new RangeError("maxAttempts must be a positive integer");
|
|
949
979
|
}
|
|
980
|
+
if (typeof options.delay === "number" && (!Number.isFinite(options.delay) || options.delay < 0 || options.delay > MAX_TIMER_MS)) {
|
|
981
|
+
throw new RangeError(
|
|
982
|
+
`retry delay must be a finite number within 0..${MAX_TIMER_MS} ms`
|
|
983
|
+
);
|
|
984
|
+
}
|
|
950
985
|
return Object.freeze({
|
|
951
986
|
name: "retry",
|
|
952
987
|
async execute(initialContext, next) {
|
|
@@ -958,6 +993,12 @@ function retry(options) {
|
|
|
958
993
|
const outcome = { status: "success", value };
|
|
959
994
|
const outcomeClassification = classification(context, outcome);
|
|
960
995
|
if (outcomeClassification !== "retryable" || context.capabilities.replay !== "safe") {
|
|
996
|
+
emitRuntimeEvent(context, {
|
|
997
|
+
type: "retry.declined",
|
|
998
|
+
outcome: summarized(outcome),
|
|
999
|
+
classification: outcomeClassification,
|
|
1000
|
+
reason: context.capabilities.replay !== "safe" ? "replay-unsafe" : "not-retryable"
|
|
1001
|
+
});
|
|
961
1002
|
return value;
|
|
962
1003
|
}
|
|
963
1004
|
if (context.attempt >= options.maxAttempts) {
|
|
@@ -981,7 +1022,16 @@ function retry(options) {
|
|
|
981
1022
|
} catch (error) {
|
|
982
1023
|
const outcome = { status: "failure", error };
|
|
983
1024
|
const outcomeClassification = classification(context, outcome);
|
|
984
|
-
if (admissionSignal(context)?.aborted
|
|
1025
|
+
if (admissionSignal(context)?.aborted) {
|
|
1026
|
+
throw error;
|
|
1027
|
+
}
|
|
1028
|
+
if (context.capabilities.replay !== "safe" || outcomeClassification !== "retryable") {
|
|
1029
|
+
emitRuntimeEvent(context, {
|
|
1030
|
+
type: "retry.declined",
|
|
1031
|
+
outcome: summarized(outcome),
|
|
1032
|
+
classification: outcomeClassification,
|
|
1033
|
+
reason: context.capabilities.replay !== "safe" ? "replay-unsafe" : "not-retryable"
|
|
1034
|
+
});
|
|
985
1035
|
throw error;
|
|
986
1036
|
}
|
|
987
1037
|
if (context.attempt >= options.maxAttempts) {
|
|
@@ -1027,6 +1077,11 @@ function timeout(options) {
|
|
|
1027
1077
|
if (!Number.isFinite(options.ms) || options.ms <= 0) {
|
|
1028
1078
|
throw new RangeError("timeout ms must be a finite positive number");
|
|
1029
1079
|
}
|
|
1080
|
+
if (options.ms > MAX_TIMER_MS) {
|
|
1081
|
+
throw new RangeError(
|
|
1082
|
+
`timeout ms must not exceed ${MAX_TIMER_MS} ms, the largest delay setTimeout honours`
|
|
1083
|
+
);
|
|
1084
|
+
}
|
|
1030
1085
|
return Object.freeze({
|
|
1031
1086
|
name: "timeout",
|
|
1032
1087
|
async execute(context, next) {
|