@gkoos/caracal 0.1.0 → 0.1.1

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 CHANGED
@@ -1,5 +1,16 @@
1
- # Changelog
2
-
3
- ## 0.1.0 - 2026-09-09
4
-
5
- Initial public release.
1
+ # Changelog
2
+
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - cf60c44: Fixed
8
+
9
+ - `failureThreshold` values that the distributed coordinator cannot resolve to thousandths are rejected at construction instead of being silently reinterpreted. Below `0.0005` the numerator rounded to 0, which made the comparison unconditionally true - the breaker opened on a success-only window and re-opened after every recovery; at `0.9995` and above it rounded to 1000, requiring every observation to fail, so the breaker effectively never opened
10
+ - `circuitBreaker.local` enforces the same `0.0005 <= failureThreshold < 0.9995` range, so one policy config behaves the same under either coordination
11
+ - an ignored result recorded nothing but also settled nothing, so a distributed half-open probe kept its slot until the probe lease elapsed: after an operation whose classifier returned `ignored`, the next call was rejected with `CircuitOpenError` for up to `probeLeaseTtlMs` (60s with the defaults), while the local breaker released its slot immediately. An ignored probe result now releases its slot without recording an outcome or advancing recovery, so one policy config behaves the same under either coordination
12
+ - packaging and event fixes: `engines.node` now requires `>=20.3.0` (the runtime, timeout and bulkhead policies, and the fetch adapter use `AbortSignal.any`, which landed in 20.3.0, so 20.0-20.2 installed cleanly and then failed at runtime); the published package now also ships `test/harness`, so the `@gkoos/caracal/testing` entry point has published source like every other entry point; and a local bulkhead's `bulkhead.released` event now reports its occupancy before the queued successor is admitted, instead of appearing to include it
13
+
14
+ ## 0.1.0 - 2026-09-09
15
+
16
+ Initial public release.
package/README.md CHANGED
@@ -2,6 +2,16 @@
2
2
  <img src="docs/caracal.svg" alt="Caracal" width="140">
3
3
  </p>
4
4
 
5
+ ![npm](https://img.shields.io/npm/v/@gkoos/caracal)
6
+ ![Downloads](https://img.shields.io/npm/dm/@gkoos/caracal)
7
+ ![GitHub stars](https://img.shields.io/github/stars/gkoos/caracal?style=social)
8
+
9
+ ![Build](https://github.com/gkoos/caracal/actions/workflows/ci.yml/badge.svg)
10
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/gkoos/caracal/badge)](https://scorecard.dev/viewer/?uri=github.com/gkoos/caracal)
11
+
12
+ ![MIT](https://img.shields.io/npm/l/@gkoos/caracal)
13
+ ![Types](https://img.shields.io/npm/types/@gkoos/caracal)
14
+
5
15
  # Caracal
6
16
 
7
17
  **Scoped distributed resilience for asynchronous operations.**
@@ -185,7 +185,12 @@ interface BreakerCoordinator {
185
185
  }): Promise<AdmitProbeResult>;
186
186
  settleProbe(identity: BreakerIdentity, params: {
187
187
  readonly probeToken: string;
188
- readonly outcome: "success" | "failure";
188
+ /**
189
+ * `"success"` and `"failure"` record an outcome; `"ignored"` releases the
190
+ * probe's slot without recording anything, which is what the policy sends
191
+ * when its classifier ignores the result.
192
+ */
193
+ readonly outcome: "success" | "failure" | "ignored";
189
194
  readonly generation: number;
190
195
  readonly halfOpenSuccesses: number;
191
196
  readonly openMs: number;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { A as AdmitProbeResult, B as BreakerClassifier, a as BreakerCoordinator, b as BreakerIdentity, c as BreakerOutcome, d as BreakerSnapshot, e as BreakerState, f as BulkheadCoordinator, g as BulkheadRejectedError, C as CircuitOpenError, D as DistributedBreakerOptions, h as DistributedBulkheadOptions, L as LocalBreakerOptions, i as LocalBulkheadOptions, O as ObserveResult, S as SettleProbeResult, j as bulkhead, k as circuitBreaker } from './circuit-breaker-BSkcV0W_.js';
1
+ export { A as AdmitProbeResult, B as BreakerClassifier, a as BreakerCoordinator, b as BreakerIdentity, c as BreakerOutcome, d as BreakerSnapshot, e as BreakerState, f as BulkheadCoordinator, g as BulkheadRejectedError, C as CircuitOpenError, D as DistributedBreakerOptions, h as DistributedBulkheadOptions, L as LocalBreakerOptions, i as LocalBulkheadOptions, O as ObserveResult, S as SettleProbeResult, j as bulkhead, k as circuitBreaker } from './circuit-breaker-DHEz0YLV.js';
2
2
  import { a as OperationOptions, b as Operation, P as Policy } from './types-Tf9T76C7.js';
3
3
  export { A as Adapter, C as Classification, E as EventSink, c as EventSinks, d as ExecutionContext, e as ExecutionMetadata, N as Next, O as OperationCapabilities, f as OperationEvent, g as OperationExecuteOptions, h as Outcome, i as OutcomeClassifier } from './types-Tf9T76C7.js';
4
4
  export { R as RetryContext, a as RetryDelay, b as RetryOptions, r as retry } from './retry-BFP_k3Hg.js';
package/dist/index.js CHANGED
@@ -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
  });
@@ -320,6 +320,14 @@ var DEFAULT_OPEN_MS = 1e4;
320
320
  var DEFAULT_HALF_OPEN_SUCCESSES = 1;
321
321
  var DEFAULT_HALF_OPEN_PROBES = 1;
322
322
  var DEFAULT_WINDOW_SIZE = 100;
323
+ var FAILURE_THRESHOLD_SCALE = 1e3;
324
+ function assertResolvableThreshold(failureThreshold) {
325
+ const numerator = Math.round(failureThreshold * FAILURE_THRESHOLD_SCALE);
326
+ if (numerator < 1 || numerator >= FAILURE_THRESHOLD_SCALE)
327
+ throw new RangeError(
328
+ `failureThreshold must be at least 0.0005 and below 0.9995 (thresholds are resolved to thousandths); got ${failureThreshold}`
329
+ );
330
+ }
323
331
  function validate2(opts) {
324
332
  if (!opts.name.trim())
325
333
  throw new RangeError("Circuit breaker name must not be empty");
@@ -329,6 +337,7 @@ function validate2(opts) {
329
337
  const { failureThreshold = DEFAULT_FAILURE_THRESHOLD } = opts;
330
338
  if (!Number.isFinite(failureThreshold) || failureThreshold <= 0 || failureThreshold >= 1)
331
339
  throw new RangeError("failureThreshold must be a number in (0, 1)");
340
+ assertResolvableThreshold(failureThreshold);
332
341
  const { openMs = DEFAULT_OPEN_MS } = opts;
333
342
  if (!Number.isInteger(openMs) || openMs < 1)
334
343
  throw new RangeError("openMs must be a positive integer");
@@ -571,6 +580,7 @@ function validateDistributed(opts) {
571
580
  const { failureThreshold = DEFAULT_DIST_FAILURE_THRESHOLD } = opts;
572
581
  if (!Number.isFinite(failureThreshold) || failureThreshold <= 0 || failureThreshold >= 1)
573
582
  throw new RangeError("failureThreshold must be a number in (0, 1)");
583
+ assertResolvableThreshold(failureThreshold);
574
584
  const { openMs = DEFAULT_DIST_OPEN_MS } = opts;
575
585
  if (!Number.isInteger(openMs) || openMs < 1)
576
586
  throw new RangeError("openMs must be a positive integer");
@@ -603,7 +613,9 @@ function distributed2(options) {
603
613
  const resolveScope = options.scope;
604
614
  const minimumThroughput = options.minimumThroughput ?? DEFAULT_DIST_MINIMUM_THROUGHPUT;
605
615
  const failureThreshold = options.failureThreshold ?? DEFAULT_DIST_FAILURE_THRESHOLD;
606
- const failureThresholdNumerator = Math.round(failureThreshold * 1e3);
616
+ const failureThresholdNumerator = Math.round(
617
+ failureThreshold * FAILURE_THRESHOLD_SCALE
618
+ );
607
619
  const windowSize = options.windowSize ?? DEFAULT_DIST_WINDOW_SIZE;
608
620
  const openMs = options.openMs ?? DEFAULT_DIST_OPEN_MS;
609
621
  const windowTtlMs = options.windowTtlMs ?? Math.max(openMs * 3, 6e4);
@@ -767,8 +779,9 @@ function distributed2(options) {
767
779
  classifier,
768
780
  isSuccess ? { status: "success", value } : { status: "failure", error: thrownError }
769
781
  );
770
- if (breakerOutcome !== "ignored") {
782
+ if (breakerOutcome !== "ignored" || admission.kind === "probe") {
771
783
  const outcomeStr = breakerOutcome === "success" ? "success" : "failure";
784
+ const settleOutcome = breakerOutcome === "ignored" ? "ignored" : outcomeStr;
772
785
  if (admission.kind === "closed") {
773
786
  try {
774
787
  const result = await coordinator.observe(identity, {
@@ -826,49 +839,51 @@ function distributed2(options) {
826
839
  try {
827
840
  const result = await coordinator.settleProbe(identity, {
828
841
  probeToken: admission.probeToken,
829
- outcome: outcomeStr,
842
+ outcome: settleOutcome,
830
843
  generation: admission.generation,
831
844
  halfOpenSuccesses,
832
845
  openMs,
833
846
  windowTtlMs
834
847
  });
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
- }
848
+ if (breakerOutcome !== "ignored") {
849
+ if (result.type === "stale") {
863
850
  emitRuntimeEvent(context, {
864
- type: "breaker.state-changed",
851
+ type: "breaker.observation-stale",
865
852
  coordination: "distributed",
866
853
  policyName: name,
867
854
  scope,
868
- state: result.newState,
869
- previousState: "half-open",
870
- generation: result.newGeneration
855
+ attemptGeneration: admission.generation,
856
+ currentGeneration: result.generation
857
+ });
858
+ } else {
859
+ emitRuntimeEvent(context, {
860
+ type: "breaker.observation",
861
+ coordination: "distributed",
862
+ policyName: name,
863
+ scope,
864
+ outcome: outcomeStr,
865
+ generation: admission.generation
871
866
  });
867
+ if (result.type === "transitioned") {
868
+ if (result.newState === "closed") {
869
+ lastKnownState.forget(identity.operation, scope);
870
+ } else {
871
+ lastKnownState.remember(
872
+ identity.operation,
873
+ scope,
874
+ result.newState
875
+ );
876
+ }
877
+ emitRuntimeEvent(context, {
878
+ type: "breaker.state-changed",
879
+ coordination: "distributed",
880
+ policyName: name,
881
+ scope,
882
+ state: result.newState,
883
+ previousState: "half-open",
884
+ generation: result.newGeneration
885
+ });
886
+ }
872
887
  }
873
888
  }
874
889
  } catch (settleError) {