kinetex 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +246 -9
  2. package/dist/browser/kinetex.esm.js +38 -22
  3. package/dist/browser/kinetex.js +2545 -550
  4. package/dist/browser/kinetex.min.js +38 -22
  5. package/dist/cjs/aws-sigv4.js +133 -19
  6. package/dist/cjs/cache.js +49 -7
  7. package/dist/cjs/circuit-breaker.js +45 -3
  8. package/dist/cjs/client.js +387 -104
  9. package/dist/cjs/cookie-parser.js +103 -5
  10. package/dist/cjs/cookie-store.js +125 -28
  11. package/dist/cjs/core.js +465 -66
  12. package/dist/cjs/dedup.js +49 -11
  13. package/dist/cjs/digest.js +160 -24
  14. package/dist/cjs/graphql.js +164 -24
  15. package/dist/cjs/headers.js +303 -45
  16. package/dist/cjs/interceptors.js +221 -7
  17. package/dist/cjs/lifecycle.js +89 -40
  18. package/dist/cjs/logging.js +168 -15
  19. package/dist/cjs/mod.js +3 -2
  20. package/dist/cjs/pagination.js +247 -22
  21. package/dist/cjs/progress.js +177 -27
  22. package/dist/cjs/proxy.js +412 -0
  23. package/dist/cjs/response.js +316 -47
  24. package/dist/cjs/socks5.js +131 -15
  25. package/dist/cjs/sse.js +173 -43
  26. package/dist/cjs/url.js +191 -45
  27. package/dist/cjs/utils.js +222 -48
  28. package/dist/cjs/ws.js +19 -10
  29. package/dist/esm/aws-sigv4.js +133 -19
  30. package/dist/esm/aws-sigv4.js.map +1 -1
  31. package/dist/esm/cache.js +49 -7
  32. package/dist/esm/cache.js.map +1 -1
  33. package/dist/esm/circuit-breaker.js +45 -3
  34. package/dist/esm/circuit-breaker.js.map +1 -1
  35. package/dist/esm/client.js +387 -104
  36. package/dist/esm/client.js.map +1 -1
  37. package/dist/esm/cookie-parser.js +103 -5
  38. package/dist/esm/cookie-parser.js.map +1 -1
  39. package/dist/esm/cookie-store.js +125 -28
  40. package/dist/esm/cookie-store.js.map +1 -1
  41. package/dist/esm/core.js +465 -66
  42. package/dist/esm/core.js.map +1 -1
  43. package/dist/esm/dedup.js +49 -11
  44. package/dist/esm/dedup.js.map +1 -1
  45. package/dist/esm/digest.js +160 -24
  46. package/dist/esm/digest.js.map +1 -1
  47. package/dist/esm/graphql.js +164 -24
  48. package/dist/esm/graphql.js.map +1 -1
  49. package/dist/esm/headers.js +303 -45
  50. package/dist/esm/headers.js.map +1 -1
  51. package/dist/esm/interceptors.js +221 -7
  52. package/dist/esm/interceptors.js.map +1 -1
  53. package/dist/esm/lifecycle.js +89 -40
  54. package/dist/esm/lifecycle.js.map +1 -1
  55. package/dist/esm/logging.js +168 -15
  56. package/dist/esm/logging.js.map +1 -1
  57. package/dist/esm/mod.js +3 -2
  58. package/dist/esm/mod.js.map +1 -1
  59. package/dist/esm/pagination.js +247 -22
  60. package/dist/esm/pagination.js.map +1 -1
  61. package/dist/esm/progress.js +177 -27
  62. package/dist/esm/progress.js.map +1 -1
  63. package/dist/esm/proxy.js +413 -0
  64. package/dist/esm/proxy.js.map +1 -0
  65. package/dist/esm/response.js +316 -47
  66. package/dist/esm/response.js.map +1 -1
  67. package/dist/esm/socks5.js +131 -15
  68. package/dist/esm/socks5.js.map +1 -1
  69. package/dist/esm/sse.js +173 -43
  70. package/dist/esm/sse.js.map +1 -1
  71. package/dist/esm/types.js.map +1 -1
  72. package/dist/esm/url.js +191 -45
  73. package/dist/esm/url.js.map +1 -1
  74. package/dist/esm/utils.js +222 -48
  75. package/dist/esm/utils.js.map +1 -1
  76. package/dist/esm/ws.js +19 -10
  77. package/dist/esm/ws.js.map +1 -1
  78. package/dist/types/aws-sigv4.d.ts.map +1 -1
  79. package/dist/types/cache.d.ts +19 -1
  80. package/dist/types/cache.d.ts.map +1 -1
  81. package/dist/types/circuit-breaker.d.ts +14 -1
  82. package/dist/types/circuit-breaker.d.ts.map +1 -1
  83. package/dist/types/client.d.ts +69 -11
  84. package/dist/types/client.d.ts.map +1 -1
  85. package/dist/types/cookie-parser.d.ts +0 -17
  86. package/dist/types/cookie-parser.d.ts.map +1 -1
  87. package/dist/types/cookie-store.d.ts.map +1 -1
  88. package/dist/types/core.d.ts +103 -25
  89. package/dist/types/core.d.ts.map +1 -1
  90. package/dist/types/dedup.d.ts.map +1 -1
  91. package/dist/types/digest.d.ts +17 -37
  92. package/dist/types/digest.d.ts.map +1 -1
  93. package/dist/types/graphql.d.ts.map +1 -1
  94. package/dist/types/headers.d.ts +45 -27
  95. package/dist/types/headers.d.ts.map +1 -1
  96. package/dist/types/interceptors.d.ts +102 -0
  97. package/dist/types/interceptors.d.ts.map +1 -1
  98. package/dist/types/lifecycle.d.ts +19 -2
  99. package/dist/types/lifecycle.d.ts.map +1 -1
  100. package/dist/types/logging.d.ts +22 -3
  101. package/dist/types/logging.d.ts.map +1 -1
  102. package/dist/types/mod.d.ts +5 -3
  103. package/dist/types/mod.d.ts.map +1 -1
  104. package/dist/types/pagination.d.ts +0 -25
  105. package/dist/types/pagination.d.ts.map +1 -1
  106. package/dist/types/progress.d.ts +1 -1
  107. package/dist/types/progress.d.ts.map +1 -1
  108. package/dist/types/proxy.d.ts +50 -0
  109. package/dist/types/proxy.d.ts.map +1 -0
  110. package/dist/types/response.d.ts +7 -1
  111. package/dist/types/response.d.ts.map +1 -1
  112. package/dist/types/socks5.d.ts.map +1 -1
  113. package/dist/types/sse.d.ts.map +1 -1
  114. package/dist/types/types.d.ts +114 -3
  115. package/dist/types/types.d.ts.map +1 -1
  116. package/dist/types/url.d.ts +0 -14
  117. package/dist/types/url.d.ts.map +1 -1
  118. package/dist/types/utils.d.ts.map +1 -1
  119. package/dist/types/ws.d.ts.map +1 -1
  120. package/package.json +1 -1
@@ -162,12 +162,61 @@ export function imdsCredentials(options = {}) {
162
162
  const credsRes = await fetchWithTimeout(`${endpoint}/latest/meta-data/iam/security-credentials/${encodeURIComponent(role)}`, { headers: { "x-aws-ec2-metadata-token": token } }, timeout);
163
163
  if (!credsRes.ok)
164
164
  throw new NetworkError(`IMDS credentials fetch failed: ${credsRes.status}`);
165
- const data = (await credsRes.json());
165
+ // Two failures used to escape this function unlabelled, and both of them
166
+ // turned into a silently broken signature rather than an error:
167
+ //
168
+ // - `Response.json()` throws a bare `SyntaxError` on a non-JSON body, so
169
+ // a proxy's HTML 502 page arrived with no `code` — a caller branching
170
+ // on `err.code === "ENETWORK"` never saw it, and the message said
171
+ // nothing about IMDS. Every other failure here is a `NetworkError`.
172
+ // - A well-formed but shapeless body parsed fine and produced a
173
+ // "successful" result: `{}` yielded `accessKeyId: undefined`, and
174
+ // `{"AccessKeyId": null, …}` yielded nulls. Both then went on to
175
+ // produce a SigV4 signature AWS rejects with an opaque
176
+ // `SignatureDoesNotMatch`, pointing at the caller rather than at the
177
+ // metadata endpoint that had actually answered with nonsense.
178
+ let data;
179
+ try {
180
+ data = (await credsRes.json());
181
+ }
182
+ catch (err) {
183
+ throw new NetworkError(`IMDS credentials response was not JSON: ${err instanceof Error ? err.message : String(err)}`);
184
+ }
185
+ if (data === null || typeof data !== "object") {
186
+ throw new NetworkError(`IMDS credentials response was not an object: ${typeof data}`);
187
+ }
188
+ const { AccessKeyId, SecretAccessKey, Token, Expiration } = data;
189
+ const missing = [];
190
+ if (typeof AccessKeyId !== "string" || AccessKeyId.length === 0)
191
+ missing.push("AccessKeyId");
192
+ if (typeof SecretAccessKey !== "string" || SecretAccessKey.length === 0)
193
+ missing.push("SecretAccessKey");
194
+ if (typeof Token !== "string" || Token.length === 0)
195
+ missing.push("Token");
196
+ if (missing.length > 0) {
197
+ throw new NetworkError(`IMDS credentials response is missing ${missing.join(", ")} — ` +
198
+ "the metadata endpoint returned a body this client cannot sign with");
199
+ }
200
+ // Re-read through a narrowing helper: the checks above report every missing
201
+ // field at once, which is the right message but does not let the compiler
202
+ // carry the narrowing into this scope.
203
+ const requireString = (value, name) => {
204
+ if (typeof value !== "string" || value.length === 0) {
205
+ throw new NetworkError(`IMDS credentials response is missing ${name}`);
206
+ }
207
+ return value;
208
+ };
209
+ const accessKeyId = requireString(AccessKeyId, "AccessKeyId");
210
+ const secretAccessKey = requireString(SecretAccessKey, "SecretAccessKey");
211
+ const sessionToken = requireString(Token, "Token");
166
212
  return {
167
- accessKeyId: data.AccessKeyId,
168
- secretAccessKey: data.SecretAccessKey,
169
- sessionToken: data.Token,
170
- expiration: data.Expiration,
213
+ accessKeyId,
214
+ secretAccessKey,
215
+ sessionToken,
216
+ // Optional: a role without an expiry is legal, and the signer treats a
217
+ // missing expiration as "do not cache". Omitted rather than set to
218
+ // `undefined` — the project uses exactOptionalPropertyTypes.
219
+ ...(typeof Expiration === "string" ? { expiration: Expiration } : {}),
171
220
  };
172
221
  });
173
222
  }
@@ -339,6 +388,12 @@ function buildCanonicalHeaders(headers, unsignedExtra) {
339
388
  const entries = [];
340
389
  for (const [name, value] of Object.entries(headers)) {
341
390
  const lower = name.toLowerCase();
391
+ // An empty header name is not a valid HTTP field-name (RFC 9110 §5.1) and
392
+ // used to reach the canonical request verbatim, producing a `:value` line
393
+ // and a leading `;` in SignedHeaders. AWS answers that with an opaque
394
+ // SignatureDoesNotMatch rather than saying the header name was empty.
395
+ if (lower === "")
396
+ continue;
342
397
  if (unsigned.has(lower) && !ALWAYS_SIGNED_HEADERS.has(lower))
343
398
  continue;
344
399
  // Trim + collapse internal whitespace
@@ -446,10 +501,19 @@ export async function signRequest(request, config) {
446
501
  const amzDate = formatAmzDate(signingDate);
447
502
  const dateStamp = formatDateStamp(signingDate);
448
503
  const parsedUrl = new URL(request.url);
449
- // Build the headers to sign — start from request headers
504
+ // Build the headers to sign — start from request headers.
505
+ //
506
+ // An explicitly-supplied `host` (in any casing) must WIN. Setting it is how
507
+ // you sign for a virtual-hosted-style bucket, a custom endpoint or a proxy.
508
+ // The URL host used to be injected unconditionally, which left both keys in
509
+ // the map whenever the caller used a different casing; `buildCanonicalHeaders`
510
+ // lowercased them and its dedupe step joined them, so the request was signed
511
+ // as `host:override.example,s3.amazonaws.com` — a host that can never
512
+ // validate, and one the library reported no error about.
513
+ const hasExplicitHost = Object.keys(request.headers).some((k) => k.toLowerCase() === "host");
450
514
  const headers = {
451
515
  ...request.headers,
452
- host: parsedUrl.host,
516
+ ...(hasExplicitHost ? {} : { host: parsedUrl.host }),
453
517
  "x-amz-date": amzDate,
454
518
  };
455
519
  if (config.unsignedPayload) {
@@ -498,11 +562,22 @@ export async function presignRequest(request, config, options = {}) {
498
562
  const signingDate = resolveSigningDate(config);
499
563
  const amzDate = formatAmzDate(signingDate);
500
564
  const dateStamp = formatDateStamp(signingDate);
501
- const expiresIn = options.expiresIn ?? 3600;
502
- // Validate expiresIn range - different services have different limits
565
+ // Validate and CLAMP expiresIn. AWS enforces a hard maximum per service and
566
+ // rejects an out-of-range or fractional value with an opaque
567
+ // AuthorizationQueryParametersError at use time, long after the URL was
568
+ // handed out. The old code logged a warning and then wrote the caller's
569
+ // number straight into the query string, so `expiresIn: 0` produced
570
+ // `X-Amz-Expires=0`, `expiresIn: -1` produced `=-1`, and `expiresIn: 1e9`
571
+ // produced a link valid for ~31 years — every one of them permanently
572
+ // unusable, with a console warning as the only signal. Clamping can only
573
+ // shorten a link, never lengthen one, so it is the safe direction.
503
574
  const maxExpires = config.service === "s3" ? 604800 : 3600;
504
- if (expiresIn < 1 || expiresIn > maxExpires) {
505
- console.warn(`[aws-sigv4] presignRequest: expiresIn should be 1-${maxExpires} seconds for ${config.service}, got ${expiresIn}`);
575
+ const requested = options.expiresIn ?? 3600;
576
+ const expiresIn = Number.isFinite(requested)
577
+ ? Math.min(Math.max(Math.floor(requested), 1), maxExpires)
578
+ : maxExpires;
579
+ if (expiresIn !== requested) {
580
+ console.warn(`[aws-sigv4] presignRequest: expiresIn must be an integer in 1-${maxExpires} for ${config.service}, got ${requested} — clamped to ${expiresIn}`);
506
581
  }
507
582
  const parsedUrl = new URL(request.url);
508
583
  const credentialScope = `${dateStamp}/${config.region}/${config.service}/aws4_request`;
@@ -520,10 +595,14 @@ export async function presignRequest(request, config, options = {}) {
520
595
  parsedUrl.searchParams.set(k, v);
521
596
  }
522
597
  }
523
- // Determine signed headers (only "host" for presigned URLs typically)
598
+ // Determine signed headers (only "host" for presigned URLs typically).
599
+ // An explicit `host` wins here for the same reason it does in sign() — a
600
+ // differently-cased caller header used to be merged with the URL host into
601
+ // `host:override.example,s3.amazonaws.com`, which can never validate.
602
+ const presignHasExplicitHost = Object.keys(request.headers).some((k) => k.toLowerCase() === "host");
524
603
  const headers = {
525
604
  ...request.headers,
526
- host: parsedUrl.host,
605
+ ...(presignHasExplicitHost ? {} : { host: parsedUrl.host }),
527
606
  };
528
607
  const unsignedHdrs = config.unsignedHeaders ?? [];
529
608
  // For presigned URLs, payload hash is always UNSIGNED-PAYLOAD
@@ -666,14 +745,49 @@ export async function signS3PostPolicy(policy, config) {
666
745
  * Returns 0 if the header is absent or unparseable.
667
746
  */
668
747
  export function detectClockSkew(responseHeaders) {
669
- const dateHeader = responseHeaders["date"] ?? responseHeaders["Date"];
670
- if (!dateHeader)
671
- return 0;
672
- const serverTime = new Date(dateHeader).getTime();
673
- if (isNaN(serverTime))
674
- return 0;
748
+ // `x-amz-date` is AWS's own signed timestamp and is the one present on the
749
+ // clock-skew error itself. `Date` is generated by whatever proxy fronts the
750
+ // endpoint and is frequently absent. Reading only `Date` meant a skew was
751
+ // silently undetectable on exactly the responses that carry it.
752
+ const amzDate = responseHeaders["x-amz-date"] ?? responseHeaders["X-Amz-Date"];
753
+ let serverTime;
754
+ if (amzDate !== undefined) {
755
+ serverTime = parseAmzDate(amzDate);
756
+ if (isNaN(serverTime))
757
+ return 0;
758
+ }
759
+ else {
760
+ const dateHeader = responseHeaders["date"] ?? responseHeaders["Date"];
761
+ if (!dateHeader)
762
+ return 0;
763
+ serverTime = new Date(dateHeader).getTime();
764
+ if (isNaN(serverTime))
765
+ return 0;
766
+ }
675
767
  return Math.round((serverTime - Date.now()) / 1000);
676
768
  }
769
+ /**
770
+ * Parse AWS's basic ISO 8601 timestamp (`20300101T120000Z`), which is not a
771
+ * form `Date` can parse.
772
+ *
773
+ * @param value - Candidate `x-amz-date` value
774
+ * @returns Epoch milliseconds, or `NaN` when the value is not in that form
775
+ */
776
+ function parseAmzDate(value) {
777
+ const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(value.trim());
778
+ if (!m)
779
+ return NaN;
780
+ const [, y, mo, d, h, mi, sec] = m;
781
+ // Reject values that roll over silently (e.g. month 13) instead of
782
+ // producing a date in the following month.
783
+ const t = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(sec));
784
+ const dt = new Date(t);
785
+ if (dt.getUTCFullYear() !== Number(y) || dt.getUTCMonth() + 1 !== Number(mo))
786
+ return NaN;
787
+ if (dt.getUTCDate() !== Number(d))
788
+ return NaN;
789
+ return t;
790
+ }
677
791
  /**
678
792
  * Determine if an error response is a clock skew error.
679
793
  */
package/dist/cjs/cache.js CHANGED
@@ -425,6 +425,12 @@ async function defaultCacheKey(req) {
425
425
  * Compute cache TTL, SWR window, and stale-on-error window
426
426
  * from Cache-Control headers, Expires header, or Last-Modified heuristic.
427
427
  */
428
+ /** True when the response may be stored but must be revalidated before reuse. */
429
+ function computeMustRevalidate(response) {
430
+ const cc = response.headers["cache-control"] ?? response.headers["Cache-Control"] ?? "";
431
+ const d = parseCacheControl(cc);
432
+ return d.noCache || d.mustRevalidate;
433
+ }
428
434
  function computeTTL(response, defaultTtl, honor) {
429
435
  const cc = response.headers["cache-control"] ?? response.headers["Cache-Control"] ?? "";
430
436
  const d = parseCacheControl(cc);
@@ -453,7 +459,15 @@ function computeTTL(response, defaultTtl, honor) {
453
459
  if (lm) {
454
460
  const lmTime = Date.parse(lm);
455
461
  if (!isNaN(lmTime)) {
456
- ttlMs = Math.min((Date.now() - lmTime) * 0.1, defaultTtl);
462
+ // RFC 9111 §4.2.2: the heuristic lifetime is 10% of the interval
463
+ // between Last-Modified and the response's Date. That interval is
464
+ // negative whenever Last-Modified is in the future — routine, since
465
+ // the origin and the client rarely share a clock — and there was no
466
+ // lower clamp, so the entry was given a negative freshness lifetime
467
+ // and a staleUntil *before* its creation time. The clamp to 0 is the
468
+ // RFC's "already stale"; the stale windows then apply normally,
469
+ // instead of landing in the past and being unreachable.
470
+ ttlMs = Math.min(Math.max((Date.now() - lmTime) * 0.1, 0), defaultTtl);
457
471
  }
458
472
  else {
459
473
  ttlMs = defaultTtl;
@@ -474,7 +488,11 @@ function computeTTL(response, defaultTtl, honor) {
474
488
  ttlMs,
475
489
  swrMs,
476
490
  staleOnError,
477
- shouldCache: ttlMs > 0 || swrMs > 0,
491
+ // Storable if it is fresh now, or already stale but covered by a
492
+ // stale-while-revalidate / stale-if-error window. The two windows were
493
+ // missing from this decision, so `max-age=0, stale-if-error=60` — the
494
+ // point of the directive — was reported as uncacheable.
495
+ shouldCache: ttlMs > 0 || swrMs > 0 || staleOnError > 0,
478
496
  };
479
497
  }
480
498
  // ============================================================================
@@ -674,7 +692,12 @@ export class HTTPCache {
674
692
  this.stats.hits++;
675
693
  this.lru.touch(key);
676
694
  this._updateHitRate();
677
- return { entry: cloneEntry(entry), stale: false };
695
+ // RFC 9111 §5.2.2.4: a `no-cache` response may be stored but must not be
696
+ // reused without revalidating. It used to be reported as a plain fresh
697
+ // hit, so an origin that sent `Cache-Control: no-cache` had its response
698
+ // served indefinitely from cache. Reporting it as stale routes it into the
699
+ // existing serve-stale-and-revalidate path instead.
700
+ return { entry: cloneEntry(entry), stale: entry.mustRevalidate === true };
678
701
  }
679
702
  // Stale-While-Revalidate window
680
703
  if (entry.staleUntil > now) {
@@ -722,7 +745,14 @@ export class HTTPCache {
722
745
  // 9.10 — cap TTL to maxAbsoluteAgeMs so stored entries can never outlive the limit
723
746
  const absoluteCap = this.cfg.maxAbsoluteAgeMs;
724
747
  const cappedTtlMs = Math.min(ttlMs === Infinity ? absoluteCap : ttlMs, absoluteCap);
725
- if (cappedTtlMs <= 0 && !options.force)
748
+ // A zero (or negative) freshness lifetime does not by itself mean
749
+ // "unstoreable": RFC 9111 §4.2 still permits storing a response that is
750
+ // already stale, provided a stale-while-revalidate or stale-if-error
751
+ // window covers it — and `computeTTL` has just told us exactly that via
752
+ // `shouldCache`. This guard used to discard that decision, so the canonical
753
+ // CDN recipe `Cache-Control: max-age=0, stale-while-revalidate=60` was
754
+ // never cached at all, defeating the one directive it exists to enable.
755
+ if (cappedTtlMs <= 0 && swrMs <= 0 && staleOnError <= 0 && !options.force)
726
756
  return false;
727
757
  const varyHeader = res.headers["vary"] ?? res.headers["Vary"] ?? null;
728
758
  const varyKey = varyHeader ? buildVaryKey(varyHeader, req.headers) : "";
@@ -739,6 +769,7 @@ export class HTTPCache {
739
769
  etag: res.headers["etag"] ?? res.headers["ETag"] ?? null,
740
770
  lastModified: res.headers["last-modified"] ?? res.headers["Last-Modified"] ?? null,
741
771
  varyKey,
772
+ mustRevalidate: computeMustRevalidate(res),
742
773
  tags,
743
774
  size: bodySize + 256,
744
775
  };
@@ -909,16 +940,27 @@ export class HTTPCache {
909
940
  getStats() {
910
941
  return { ...this.stats };
911
942
  }
912
- /** Reset all cache statistics to zero. */
943
+ /**
944
+ * Reset the cache's counters to zero.
945
+ *
946
+ * `totalEntries` and `totalSizeBytes` are deliberately NOT reset: they are
947
+ * gauges of what is actually stored, not counters of events, and
948
+ * `_ensureCapacity()` reads them as the authoritative figures for the
949
+ * `maxEntries` / `maxSizeBytes` caps. Zeroing them while the entries are
950
+ * still in storage made eviction compare against a fiction — a cache with
951
+ * `maxSizeBytes: 600` was measured holding 1028 bytes across 4 entries
952
+ * after a reset — and made `getStats().totalEntries` report 0 for entries
953
+ * that were still there and still being served. Call `clear()` to empty the
954
+ * cache; use this to zero the hit/miss/eviction counters.
955
+ */
913
956
  resetStats() {
914
957
  this.stats = {
958
+ ...this.stats,
915
959
  hits: 0,
916
960
  misses: 0,
917
961
  staleHits: 0,
918
962
  errors: 0,
919
963
  evictions: 0,
920
- totalEntries: 0,
921
- totalSizeBytes: 0,
922
964
  hitRate: 0,
923
965
  };
924
966
  }
@@ -29,7 +29,11 @@ export class CircuitOpenError extends Error {
29
29
  * @param state - Snapshot of breaker state at rejection time
30
30
  */
31
31
  constructor(key, state) {
32
- super(`Circuit breaker OPEN for "${key}" — request rejected`);
32
+ // The half-open probe limit rejects callers too, and in that case
33
+ // `state.state` is HALF_OPEN: the circuit is recovering, not open. Saying
34
+ // "OPEN" there pointed operators at the wrong problem during exactly the
35
+ // window they were watching to see recovery.
36
+ super(`Circuit breaker ${state.state} for "${key}" — request rejected`);
33
37
  this.name = "CircuitOpenError";
34
38
  this.state = state;
35
39
  }
@@ -97,6 +101,22 @@ export class CircuitBreaker {
97
101
  this.key = key;
98
102
  this.failureThreshold = config.failureThreshold ?? 5;
99
103
  this.windowSize = config.windowSize ?? 10;
104
+ // The window is a sliding buffer of the most recent `windowSize` results,
105
+ // so it can hold at most `windowSize` failures. If the window is smaller
106
+ // than the threshold, `failures >= failureThreshold` is unreachable and
107
+ // the circuit can never open — a breaker that silently does nothing, with
108
+ // no error and no other symptom. `{ windowSize: 2, failureThreshold: 5 }`
109
+ // stayed CLOSED through 50 consecutive failures. The window is widened to
110
+ // the threshold instead, which is the smallest coherent reading of the
111
+ // configuration. windowSize: 0 is the documented consecutive-count mode
112
+ // and is left alone.
113
+ if (this.windowSize > 0 && this.windowSize < this.failureThreshold) {
114
+ console.warn(`[CircuitBreaker] "${key}": windowSize (${this.windowSize}) is smaller than ` +
115
+ `failureThreshold (${this.failureThreshold}), so the threshold could never be ` +
116
+ `reached and the circuit would never open. Raising windowSize to ` +
117
+ `${this.failureThreshold}.`);
118
+ this.windowSize = this.failureThreshold;
119
+ }
100
120
  this.resetTimeoutMs = config.resetTimeoutMs ?? 30_000;
101
121
  this.successThreshold = config.successThreshold ?? 2;
102
122
  this.halfOpenConcurrency = config.halfOpenConcurrency ?? 1;
@@ -109,8 +129,27 @@ export class CircuitBreaker {
109
129
  this.cfg = config;
110
130
  }
111
131
  // ── Public API ──────────────────────────────────────────────────────────
112
- /** The current circuit state. */
132
+ /**
133
+ * The current circuit state, as of right now.
134
+ *
135
+ * The OPEN → HALF_OPEN transition is driven by elapsed time, but it was only
136
+ * ever evaluated inside `execute()`. A caller reading `state` — a health
137
+ * endpoint, a dashboard, the `onRejected` snapshot — therefore saw a stale
138
+ * `OPEN` for as long as no traffic arrived, and could not tell a circuit
139
+ * that was about to probe from one that had been down for an hour. The
140
+ * elapsed check is applied on read as well. It is a pure read: the
141
+ * `onHalfOpen` callback still fires from `execute()`, so observing the state
142
+ * never mutates the breaker.
143
+ */
113
144
  get state() {
145
+ return this._effectiveState();
146
+ }
147
+ /** The state implied by `_state` and the elapsed time, without mutating anything. */
148
+ _effectiveState() {
149
+ if (this._state === "OPEN" && this._openedAt !== null) {
150
+ if (Date.now() - this._openedAt >= this.resetTimeoutMs)
151
+ return "HALF_OPEN";
152
+ }
114
153
  return this._state;
115
154
  }
116
155
  /** Snapshot of all counters — suitable for logging or dashboards. */
@@ -125,7 +164,10 @@ export class CircuitBreaker {
125
164
  failureCount++;
126
165
  }
127
166
  return {
128
- state: this._state,
167
+ // Time-derived, for the same reason as the `state` getter: a snapshot
168
+ // taken between the reset window elapsing and the next request would
169
+ // otherwise report a circuit as OPEN when it is ready to probe.
170
+ state: this._effectiveState(),
129
171
  failureCount,
130
172
  successCount: this._consecutiveSucc,
131
173
  lastFailureAt: this._lastFailureAt,