kinetex 1.2.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 (126) hide show
  1. package/README.md +1164 -453
  2. package/dist/browser/kinetex.esm.js +38 -22
  3. package/dist/browser/kinetex.js +3127 -715
  4. package/dist/browser/kinetex.min.js +38 -22
  5. package/dist/cjs/aws-sigv4.js +137 -20
  6. package/dist/cjs/cache.js +101 -21
  7. package/dist/cjs/circuit-breaker.js +69 -7
  8. package/dist/cjs/client.js +838 -191
  9. package/dist/cjs/cookie-parser.js +110 -9
  10. package/dist/cjs/cookie-store.js +141 -36
  11. package/dist/cjs/core.js +501 -63
  12. package/dist/cjs/dedup.js +58 -18
  13. package/dist/cjs/digest.js +185 -23
  14. package/dist/cjs/graphql.js +164 -24
  15. package/dist/cjs/headers.js +362 -48
  16. package/dist/cjs/interceptors.js +285 -29
  17. package/dist/cjs/lifecycle.js +89 -40
  18. package/dist/cjs/logging.js +169 -16
  19. package/dist/cjs/mod.js +3 -2
  20. package/dist/cjs/pagination.js +261 -28
  21. package/dist/cjs/progress.js +282 -52
  22. package/dist/cjs/proxy.js +412 -0
  23. package/dist/cjs/response.js +316 -47
  24. package/dist/cjs/socks5.js +167 -36
  25. package/dist/cjs/sse.js +201 -34
  26. package/dist/cjs/url.js +191 -45
  27. package/dist/cjs/utils.js +222 -48
  28. package/dist/cjs/worker.js +6 -6
  29. package/dist/cjs/ws.js +32 -16
  30. package/dist/esm/aws-sigv4.js +137 -20
  31. package/dist/esm/aws-sigv4.js.map +1 -1
  32. package/dist/esm/cache.js +101 -21
  33. package/dist/esm/cache.js.map +1 -1
  34. package/dist/esm/circuit-breaker.js +69 -7
  35. package/dist/esm/circuit-breaker.js.map +1 -1
  36. package/dist/esm/client.js +838 -191
  37. package/dist/esm/client.js.map +1 -1
  38. package/dist/esm/cookie-parser.js +110 -9
  39. package/dist/esm/cookie-parser.js.map +1 -1
  40. package/dist/esm/cookie-store.js +141 -36
  41. package/dist/esm/cookie-store.js.map +1 -1
  42. package/dist/esm/core.js +501 -63
  43. package/dist/esm/core.js.map +1 -1
  44. package/dist/esm/dedup.js +58 -18
  45. package/dist/esm/dedup.js.map +1 -1
  46. package/dist/esm/digest.js +185 -23
  47. package/dist/esm/digest.js.map +1 -1
  48. package/dist/esm/graphql.js +164 -24
  49. package/dist/esm/graphql.js.map +1 -1
  50. package/dist/esm/headers.js +362 -48
  51. package/dist/esm/headers.js.map +1 -1
  52. package/dist/esm/interceptors.js +285 -29
  53. package/dist/esm/interceptors.js.map +1 -1
  54. package/dist/esm/lifecycle.js +89 -40
  55. package/dist/esm/lifecycle.js.map +1 -1
  56. package/dist/esm/logging.js +169 -16
  57. package/dist/esm/logging.js.map +1 -1
  58. package/dist/esm/mod.js +3 -2
  59. package/dist/esm/mod.js.map +1 -1
  60. package/dist/esm/pagination.js +261 -28
  61. package/dist/esm/pagination.js.map +1 -1
  62. package/dist/esm/progress.js +282 -52
  63. package/dist/esm/progress.js.map +1 -1
  64. package/dist/esm/proxy.js +413 -0
  65. package/dist/esm/proxy.js.map +1 -0
  66. package/dist/esm/response.js +316 -47
  67. package/dist/esm/response.js.map +1 -1
  68. package/dist/esm/socks5.js +167 -36
  69. package/dist/esm/socks5.js.map +1 -1
  70. package/dist/esm/sse.js +201 -34
  71. package/dist/esm/sse.js.map +1 -1
  72. package/dist/esm/types.js.map +1 -1
  73. package/dist/esm/url.js +191 -45
  74. package/dist/esm/url.js.map +1 -1
  75. package/dist/esm/utils.js +222 -48
  76. package/dist/esm/utils.js.map +1 -1
  77. package/dist/esm/worker.js +6 -6
  78. package/dist/esm/worker.js.map +1 -1
  79. package/dist/esm/ws.js +32 -16
  80. package/dist/esm/ws.js.map +1 -1
  81. package/dist/types/aws-sigv4.d.ts.map +1 -1
  82. package/dist/types/cache.d.ts +27 -2
  83. package/dist/types/cache.d.ts.map +1 -1
  84. package/dist/types/circuit-breaker.d.ts +14 -1
  85. package/dist/types/circuit-breaker.d.ts.map +1 -1
  86. package/dist/types/client.d.ts +98 -23
  87. package/dist/types/client.d.ts.map +1 -1
  88. package/dist/types/cookie-parser.d.ts +0 -17
  89. package/dist/types/cookie-parser.d.ts.map +1 -1
  90. package/dist/types/cookie-store.d.ts.map +1 -1
  91. package/dist/types/core.d.ts +109 -25
  92. package/dist/types/core.d.ts.map +1 -1
  93. package/dist/types/dedup.d.ts +0 -7
  94. package/dist/types/dedup.d.ts.map +1 -1
  95. package/dist/types/digest.d.ts +31 -37
  96. package/dist/types/digest.d.ts.map +1 -1
  97. package/dist/types/graphql.d.ts.map +1 -1
  98. package/dist/types/headers.d.ts +62 -29
  99. package/dist/types/headers.d.ts.map +1 -1
  100. package/dist/types/interceptors.d.ts +102 -0
  101. package/dist/types/interceptors.d.ts.map +1 -1
  102. package/dist/types/lifecycle.d.ts +19 -2
  103. package/dist/types/lifecycle.d.ts.map +1 -1
  104. package/dist/types/logging.d.ts +23 -4
  105. package/dist/types/logging.d.ts.map +1 -1
  106. package/dist/types/mod.d.ts +5 -3
  107. package/dist/types/mod.d.ts.map +1 -1
  108. package/dist/types/pagination.d.ts +0 -25
  109. package/dist/types/pagination.d.ts.map +1 -1
  110. package/dist/types/progress.d.ts +1 -1
  111. package/dist/types/progress.d.ts.map +1 -1
  112. package/dist/types/proxy.d.ts +50 -0
  113. package/dist/types/proxy.d.ts.map +1 -0
  114. package/dist/types/response.d.ts +7 -1
  115. package/dist/types/response.d.ts.map +1 -1
  116. package/dist/types/socks5.d.ts.map +1 -1
  117. package/dist/types/sse.d.ts.map +1 -1
  118. package/dist/types/types.d.ts +139 -5
  119. package/dist/types/types.d.ts.map +1 -1
  120. package/dist/types/url.d.ts +0 -14
  121. package/dist/types/url.d.ts.map +1 -1
  122. package/dist/types/utils.d.ts.map +1 -1
  123. package/dist/types/worker.d.ts +6 -6
  124. package/dist/types/worker.d.ts.map +1 -1
  125. package/dist/types/ws.d.ts.map +1 -1
  126. package/package.json +2 -2
@@ -7,13 +7,20 @@ import { ProgressTracker, withUploadProgress } from "./progress.js";
7
7
  import { DedupMap } from "./dedup.js";
8
8
  import { CircuitBreakerRegistry, } from "./circuit-breaker.js";
9
9
  import { WSClient } from "./ws.js";
10
- import { KinetexError, HTTPStatusError, toRequestId } from "./types.js";
10
+ import { KinetexError, HTTPStatusError, AbortError, RedirectError, toRequestId } from "./types.js";
11
11
  import { isValidHeaderName, isValidHeaderValue, isSafeURL, uint8ArrayToBase64, randomBytes, } from "./utils.js";
12
- import { getAuthFingerprint } from "./cache.js";
13
- import { createRateLimitInterceptor } from "./interceptors.js";
12
+ import { getAuthFingerprint, CREDENTIAL_HEADERS } from "./cache.js";
13
+ import { createRateLimitInterceptor, ConcurrencyLimiter } from "./interceptors.js";
14
14
  import { SigV4Signer } from "./aws-sigv4.js";
15
- import { createDigestAuthorization } from "./digest.js";
15
+ import { createDigestAuthorizer } from "./digest.js";
16
+ import { generateIdempotencyKey, isValidIdempotencyKey, parseRetryAfter } from "./headers.js";
17
+ import { DEFAULT_ACCEPT_ENCODING, encodeMultipart } from "./core.js";
16
18
  import { createTransport, sendWithTimeout, decompressBodyStream, readRawBody, parseBody, RUNTIME, IS_NODE, } from "./core.js";
19
+ /**
20
+ * Hard ceiling on redirect hops followed by the manual redirect follower.
21
+ * Overridable per client / per request with `maxRedirects`.
22
+ */
23
+ const DEFAULT_MAX_REDIRECTS = 20;
17
24
  /** Default retry configuration used when no retry config is provided. */
18
25
  const DEFAULT_RETRY = {
19
26
  maxRetries: 3,
@@ -160,14 +167,118 @@ const HAR_REDACT_HEADERS = new Set([
160
167
  "passwd",
161
168
  "secret",
162
169
  ]);
170
+ /**
171
+ * `meta` key carrying the number of response-interceptor re-sends so a
172
+ * self-retriggering interceptor cannot loop forever.
173
+ */
174
+ /** Request-meta key: a clock-skew correction has already been spent. */
175
+ const AWS_SKEW_CORRECTED = "__awsSkewCorrected";
176
+ const INTERCEPTOR_RESEND_DEPTH = "__interceptorResendDepth";
177
+ /** Hard cap on consecutive response-interceptor re-sends (digest refresh, etc.). */
178
+ const MAX_INTERCEPTOR_RESENDS = 5;
179
+ /** Query-parameter names whose values are redacted in HAR output. */
180
+ const HAR_REDACT_PARAMS = new Set([
181
+ "api_key",
182
+ "apikey",
183
+ "access_token",
184
+ "refresh_token",
185
+ "id_token",
186
+ "token",
187
+ "auth",
188
+ "authorization",
189
+ "key",
190
+ "secret",
191
+ "password",
192
+ "passwd",
193
+ "signature",
194
+ "sig",
195
+ "x-amz-signature",
196
+ "x-amz-credential",
197
+ "x-amz-security-token",
198
+ "x-goog-signature",
199
+ "sas",
200
+ "session",
201
+ "sessionid",
202
+ "jwt",
203
+ "code",
204
+ ]);
205
+ /** Maximum number of body characters recorded in a HAR entry. */
206
+ const HAR_MAX_BODY_CHARS = 8192;
207
+ /** HTML/other content types whose bodies are never recorded in HAR output. */
208
+ function isHARBodySafeToRecord(contentType) {
209
+ if (!contentType)
210
+ return false;
211
+ const ct = contentType.toLowerCase();
212
+ return (ct.includes("json") ||
213
+ ct.includes("xml") ||
214
+ ct.includes("text/plain") ||
215
+ ct.includes("application/javascript"));
216
+ }
217
+ /**
218
+ * Redact sensitive query-parameter values in a URL, preserving everything else
219
+ * (scheme, host, path, parameter names, ordering) so the HAR stays useful.
220
+ */
221
+ function redactHARUrl(url) {
222
+ try {
223
+ const u = new URL(url);
224
+ let changed = false;
225
+ for (const key of [...u.searchParams.keys()]) {
226
+ if (HAR_REDACT_PARAMS.has(key.toLowerCase())) {
227
+ u.searchParams.set(key, "***REDACTED***");
228
+ changed = true;
229
+ }
230
+ }
231
+ // Hash can carry an implicit-access-token (S3, Firebase, share links).
232
+ if (u.hash && (u.hash.includes("token") || u.hash.includes("sig") || u.hash.length > 1)) {
233
+ u.hash = "#***REDACTED***";
234
+ changed = true;
235
+ }
236
+ return changed ? u.toString() : url;
237
+ }
238
+ catch {
239
+ // Unparseable URL — fall back to a regex that masks known param names.
240
+ return url.replace(/([?&])(api_key|apikey|access_token|refresh_token|token|secret|password|signature|sig)=([^&#]*)/gi, "$1$2=***REDACTED***");
241
+ }
242
+ }
163
243
  /** Redact a single header value for HAR output. */
164
244
  function redactHARHeader(name, value) {
165
- return HAR_REDACT_HEADERS.has(name.toLowerCase()) ? { name, value: "***REDACTED***" } : { name, value };
245
+ return HAR_REDACT_HEADERS.has(name.toLowerCase())
246
+ ? { name, value: "***REDACTED***" }
247
+ : { name, value };
166
248
  }
167
249
  /**
168
250
  * O(1) ring-buffer HAR entry recorder.
169
251
  * Stores up to `maxEntries` entries, evicting oldest first.
170
252
  */
253
+ /**
254
+ * The `postData` block for a recorded request, or `{}` when there is nothing
255
+ * safe or possible to record.
256
+ *
257
+ * Kept out of `record()` so the recorder's entry literal reads as the HAR it
258
+ * claims to conform to, and so the "may I record this?" decision has one home.
259
+ */
260
+ function harPostData(req) {
261
+ if (!req.body)
262
+ return {};
263
+ const mimeType = req.headers["content-type"] ?? "";
264
+ // Same policy as the response body: skip anything that is not plain text.
265
+ if (mimeType && !isHARBodySafeToRecord(mimeType))
266
+ return {};
267
+ let text;
268
+ if (typeof req.body === "string") {
269
+ text = req.body;
270
+ }
271
+ else if (req.body instanceof Uint8Array) {
272
+ text = new TextDecoder().decode(req.body);
273
+ }
274
+ else if (req.body instanceof ArrayBuffer) {
275
+ text = new TextDecoder().decode(new Uint8Array(req.body));
276
+ }
277
+ else {
278
+ return {}; // stream / FormData / Blob: not readable without consuming it
279
+ }
280
+ return { postData: { mimeType, text: text.slice(0, HAR_MAX_BODY_CHARS) } };
281
+ }
171
282
  class HARRecorder {
172
283
  /** Ring buffer of entries keyed by monotonic counter. */
173
284
  _buf = new Map();
@@ -204,10 +315,16 @@ class HARRecorder {
204
315
  // Browser Resource Timing API — accurate per-request breakdown
205
316
  if (typeof performance !== "undefined" &&
206
317
  typeof performance.getEntriesByType === "function") {
207
- const entries = performance.getEntriesByType("resource");
208
- // Find the most recent entry matching this URL
209
- const entry = entries.filter((e) => e.name === res.url).pop();
210
- if (entry && entry.requestStart > 0) {
318
+ // getEntriesByName narrows the buffer instead of scanning every resource
319
+ // entry for each recorded request (was O(entries) per request).
320
+ const entries = (typeof performance.getEntriesByName === "function"
321
+ ? performance.getEntriesByName(res.url)
322
+ : performance.getEntriesByType("resource").filter((e) => e.name === res.url));
323
+ // Most recent entry for this URL. Entries are startTime-ordered, so the
324
+ // last one is the most recent — and, unlike a name-only match, we also
325
+ // require it to be recent enough to actually belong to this request.
326
+ const entry = entries[entries.length - 1];
327
+ if (entry && entry.requestStart > 0 && Date.now() - entry.startTime < 60_000) {
211
328
  sendMs = Math.max(0, entry.responseStart - entry.requestStart);
212
329
  receiveMs = Math.max(0, entry.responseEnd - entry.responseStart);
213
330
  waitMs = Math.max(0, total - sendMs - receiveMs);
@@ -224,12 +341,16 @@ class HARRecorder {
224
341
  time: total,
225
342
  request: {
226
343
  method: req.method,
227
- url: req.url,
344
+ // Redacted: HAR logs are routinely exported and shared, and a query
345
+ // string is just as leaky as a header (?api_key=, ?access_token=,
346
+ // ?signature=). Previously only headers were redacted, so the full URL
347
+ // and every query value landed in the log verbatim.
348
+ url: redactHARUrl(req.url),
228
349
  httpVersion: res.httpVersion,
229
350
  headers: Object.entries(req.headers).map(([name, value]) => redactHARHeader(name, value)),
230
351
  queryString: (() => {
231
352
  try {
232
- return Array.from(new URL(req.url).searchParams.entries()).map(([name, value]) => ({
353
+ return Array.from(new URL(redactHARUrl(req.url)).searchParams.entries()).map(([name, value]) => ({
233
354
  name,
234
355
  value,
235
356
  }));
@@ -249,6 +370,17 @@ class HARRecorder {
249
370
  return req.body.byteLength;
250
371
  return -1; // Unknown (stream, FormData, etc.)
251
372
  })(),
373
+ // `postData` is declared on `HAREntry` as "Posted data, if applicable"
374
+ // and was never written, so a HAR exported from a client that POSTs
375
+ // anything shows an empty request body in every viewer — the response
376
+ // body, the query string, the headers and the URL are all there, and
377
+ // the one part that explains what was actually sent is not. The gates
378
+ // are the ones the response side already uses: a body is recorded only
379
+ // when its content type is safe to record, it is truncated to the same
380
+ // limit, and a body that cannot be read without consuming it (a stream,
381
+ // a FormData) is omitted rather than guessed at — which is what the
382
+ // `bodySize: -1` above already admits.
383
+ ...harPostData(req),
252
384
  },
253
385
  response: {
254
386
  status: res.status,
@@ -258,9 +390,14 @@ class HARRecorder {
258
390
  content: {
259
391
  size: res.rawBody?.byteLength ?? 0,
260
392
  mimeType: res.headers["content-type"] ?? "application/octet-stream",
261
- ...(typeof res.data === "string" ? { text: res.data } : {}),
393
+ // Body text is only kept for non-HTML payloads and is truncated:
394
+ // response bodies routinely carry tokens and PII.
395
+ ...(typeof res.data === "string" && isHARBodySafeToRecord(res.headers["content-type"])
396
+ ? { text: res.data.slice(0, HAR_MAX_BODY_CHARS) }
397
+ : {}),
262
398
  },
263
- redirectURL: res.headers["location"] ?? "",
399
+ // The Location header can itself carry a signed URL — redact it too.
400
+ redirectURL: res.headers["location"] ? redactHARUrl(res.headers["location"]) : "",
264
401
  bodySize: res.rawBody?.byteLength ?? 0,
265
402
  },
266
403
  timings: {
@@ -383,22 +520,15 @@ async function applyAuth(req, auth) {
383
520
  * Headers stripped when a redirect crosses origins (FIX H2).
384
521
  * These carry credentials and must never be forwarded to a different origin.
385
522
  */
523
+ // Derived from the single CREDENTIAL_HEADERS list in cache.ts so the strip
524
+ // list, the dedup key and the cache key can never drift apart. (The previous
525
+ // list also carried `www-authenticate`, a RESPONSE header that can never appear
526
+ // on an outgoing request.)
386
527
  const CROSS_ORIGIN_STRIP_HEADERS = new Set([
387
- "authorization",
388
- "cookie",
389
- "proxy-authorization",
390
- "x-api-key",
391
- "x-auth-token",
392
- "x-access-token",
393
- "x-refresh-token",
394
- "x-csrf-token",
395
- "x-session-id",
396
- "x-session-token",
397
- "x-secret",
398
- "x-secret-key",
399
- "x-private-key",
400
- "api-key",
401
- "apikey",
528
+ ...CREDENTIAL_HEADERS,
529
+ // Response-only per RFC 9110, so it can never legitimately appear on an
530
+ // outgoing request — kept in the strip list as defence in depth for callers
531
+ // that copy a full header bag (including response headers) onto a request.
402
532
  "www-authenticate",
403
533
  ]);
404
534
  /**
@@ -429,7 +559,7 @@ function redactUserInfo(url) {
429
559
  * @returns Fully-qualified URL string.
430
560
  * @throws {KinetexError} EVALIDATION — if URL is unsafe, params exceed limits, or URL too long.
431
561
  */
432
- function buildURL(base, url, params) {
562
+ function buildURL(base, url, params, allowedSchemes = ["http", "https"]) {
433
563
  const MAX_QUERY_PARAM_COUNT = 100;
434
564
  const MAX_URL_LENGTH = 8192;
435
565
  let full;
@@ -458,13 +588,18 @@ function buildURL(base, url, params) {
458
588
  }
459
589
  }
460
590
  if (!params || Object.keys(params).length === 0) {
461
- if (!isSafeURL(full)) {
591
+ if (!isSafeURL(full, allowedSchemes)) {
462
592
  throw new KinetexError(`URL "${redactUserInfo(full)}" failed safety check — blocked private/loopback address or forbidden scheme`, "EVALIDATION");
463
593
  }
464
594
  return full;
465
595
  }
466
596
  try {
467
597
  const u = new URL(full);
598
+ // The params branch screens the URL after appending them, so it needs the
599
+ // same scheme list as the no-params branch above.
600
+ if (!isSafeURL(u, allowedSchemes)) {
601
+ throw new KinetexError(`URL "${redactUserInfo(full)}" failed safety check — blocked private/loopback address or forbidden scheme`, "EVALIDATION");
602
+ }
468
603
  let paramCount = 0;
469
604
  for (const [key, value] of Object.entries(params)) {
470
605
  if (value === null || value === undefined)
@@ -659,7 +794,13 @@ function computeRetryDelay(cfg, attempt, retryAfterMs) {
659
794
  return cfg.maxDelayMs;
660
795
  }
661
796
  const capped = Math.min(exp, cfg.maxDelayMs);
662
- return Math.floor(capped + capped * cfg.jitter * Math.random());
797
+ const jittered = capped + capped * cfg.jitter * Math.random();
798
+ // `maxDelayMs` is documented as a maximum, so the cap must be re-applied
799
+ // *after* jitter. It was not: base 1000, cap 1200, jitter 1 returned 2000ms,
800
+ // so the option never bounded worst-case latency at all — it bounded only the
801
+ // pre-jitter base. With the default jitter of 0.3 a 30 s cap still allowed
802
+ // 39 s.
803
+ return Math.min(Math.floor(jittered), cfg.maxDelayMs);
663
804
  }
664
805
  /**
665
806
  * Extract and parse the Retry-After header value.
@@ -671,15 +812,21 @@ function getRetryAfterMs(headers) {
671
812
  const ra = headers["retry-after"];
672
813
  if (!ra)
673
814
  return null;
674
- if (/^\d+$/.test(ra.trim())) {
675
- const seconds = parseInt(ra, 10);
676
- if (!isFinite(seconds) || seconds < 0)
677
- return null;
815
+ // Delegate to the single RFC 7231 §7.1.1 parser instead of a second,
816
+ // looser copy: Date.parse() accepts "-5", "1.5" and "+5" as dates, which
817
+ // used to yield a 0 ms (i.e. "ignore Retry-After") back-off on a 429.
818
+ const parsed = parseRetryAfter(ra);
819
+ if (parsed.delay !== null) {
678
820
  const MAX_RETRY_AFTER_SEC = 86_400; // 24 hours
679
- return Math.min(seconds, MAX_RETRY_AFTER_SEC) * 1000;
821
+ return Math.min(parsed.delay, MAX_RETRY_AFTER_SEC) * 1000;
822
+ }
823
+ if (parsed.date) {
824
+ // A date in the past genuinely means "retry now" — 0 is the answer, not a
825
+ // parse failure. Cap at 24 h so a bogus far-future date cannot stall.
826
+ const MAX_RETRY_AFTER_MS = 86_400_000;
827
+ return Math.max(0, Math.min(parsed.date.getTime() - Date.now(), MAX_RETRY_AFTER_MS));
680
828
  }
681
- const ms = Date.parse(ra);
682
- return isNaN(ms) ? null : Math.max(0, Math.min(ms - Date.now(), 86_400_000));
829
+ return null;
683
830
  }
684
831
  // ============================================================================
685
832
  // §8 MAIN KINETEX CLASS
@@ -693,11 +840,12 @@ function getRetryAfterMs(headers) {
693
840
  *
694
841
  * const client = kinetex({ baseURL: "https://api.example.com" });
695
842
  *
696
- * // Fluent chain
697
- * const user = await client.get("/users/1").json<User>();
843
+ * // Fluent chain — `get()` returns a Promise, so use the uppercase
844
+ * // `GET()` builder if you want to chain `.json()` onto it.
845
+ * const user = await client.GET("/users/1").json<User>();
698
846
  *
699
- * // Standard send
700
- * const res = await client.send<User>({ url: "/users/1", method: "GET" });
847
+ * // Standard send — `send(url, method, options)`, not an object argument
848
+ * const res = await client.send<User>("/users/1", "GET");
701
849
  * ```
702
850
  */
703
851
  export class Kinetex {
@@ -733,6 +881,14 @@ export class Kinetex {
733
881
  _circuitBreakerKeyFn = null;
734
882
  /** Active WebSocket connections tracked for cleanup on destroy(). */
735
883
  _wsClients = new Set();
884
+ /**
885
+ * Optional bulkhead bounding in-flight requests. `null` when
886
+ * `concurrencyLimit` is not configured, which keeps the hot path free of a
887
+ * limiter check.
888
+ */
889
+ _concurrencyLimiter;
890
+ /** SigV4 signer kept so a clock-skew correction survives across retries. */
891
+ _awsSigner = null;
736
892
  /**
737
893
  * @param config - Global client configuration.
738
894
  */
@@ -751,10 +907,17 @@ export class Kinetex {
751
907
  const rlInterceptor = createRateLimitInterceptor(config.rateLimit);
752
908
  this.interceptors.addRequest(rlInterceptor);
753
909
  }
910
+ // Concurrency limiter (bulkhead). Held as an object rather than a request
911
+ // interceptor because a permit must survive until the request settles,
912
+ // and interceptors cannot wrap the downstream call.
913
+ this._concurrencyLimiter = config.concurrencyLimit
914
+ ? new ConcurrencyLimiter(config.concurrencyLimit)
915
+ : null;
754
916
  // AWS SigV4 request signing — registered synchronously (static import).
755
917
  // Active immediately; no race between first request and interceptor registration.
756
918
  if (config.awsSigning) {
757
919
  const signer = new SigV4Signer(config.awsSigning);
920
+ this._awsSigner = signer;
758
921
  this.interceptors.addRequest(async (ctx) => {
759
922
  const req = ctx.request;
760
923
  let signableBody = null;
@@ -772,7 +935,15 @@ export class Kinetex {
772
935
  });
773
936
  }
774
937
  // Transport — pass strictHeaders option through to FetchTransport
775
- this.transport = createTransport(config.fetch, config.httpVersion !== "HTTP/1.1", undefined, config.strictHeaders ? { strict: true } : undefined);
938
+ this.transport = createTransport(config.fetch, config.httpVersion !== "HTTP/1.1",
939
+ // The HTTP/2 session pool was configurable on the transport but the
940
+ // client always passed `undefined` here, so `sessionPool` on the client
941
+ // config could not tune it.
942
+ config.sessionPool, {
943
+ ...(config.strictHeaders ? { strict: true } : {}),
944
+ ...(config.dispatcher !== undefined ? { dispatcher: config.dispatcher } : {}),
945
+ ...(config.proxy !== undefined ? { proxy: config.proxy } : {}),
946
+ });
776
947
  // Register config-level interceptors
777
948
  if (config.interceptors) {
778
949
  config.interceptors.request?.forEach((fn) => this.interceptors.addRequest(fn));
@@ -782,6 +953,11 @@ export class Kinetex {
782
953
  // Digest auth interceptor — handles 401 → parse challenge → retry
783
954
  if (config.auth?.type === "digest") {
784
955
  const digestConfig = config.auth;
956
+ // Per-client nonce counter. RFC 7616 requires `nc` to strictly increase
957
+ // for every request reusing a nonce; the stateless helper always used
958
+ // 00000001, so any server enforcing replay protection rejected the second
959
+ // authenticated request with 401.
960
+ const digestAuthorizer = createDigestAuthorizer();
785
961
  this.interceptors.addResponse(async (ctx) => {
786
962
  if (!ctx.response)
787
963
  return;
@@ -793,8 +969,9 @@ export class Kinetex {
793
969
  if (ctx.request.meta.__digestRetried)
794
970
  return;
795
971
  const method = ctx.request.method;
796
- const uri = new URL(ctx.request.url).pathname + new URL(ctx.request.url).search;
797
- const authHeader = await createDigestAuthorization(wwwAuth, digestConfig.username, digestConfig.password, method, uri);
972
+ const parsedUrl = new URL(ctx.request.url);
973
+ const uri = parsedUrl.pathname + parsedUrl.search;
974
+ const authHeader = await digestAuthorizer(wwwAuth, digestConfig.username, digestConfig.password, method, uri);
798
975
  return {
799
976
  ...ctx.request,
800
977
  headers: { ...ctx.request.headers, authorization: authHeader },
@@ -893,22 +1070,30 @@ export class Kinetex {
893
1070
  const errEject = this.useError(async (ctx) => {
894
1071
  if (!ctx.error)
895
1072
  return;
1073
+ const hookReq = {
1074
+ url: ctx.request.url,
1075
+ method: ctx.request.method,
1076
+ headers: ctx.request.headers,
1077
+ body: ctx.request.body,
1078
+ signal: ctx.request.signal,
1079
+ meta: ctx.request.meta,
1080
+ };
1081
+ // A failed request can still have produced a response: HTTPStatusError
1082
+ // carries the KinetexResponse, and createLoggingHooks' onError reads
1083
+ // `err.response?.status`. Hardcoding null here made every bridged
1084
+ // onError hook see no response, so log entries silently recorded a null
1085
+ // status for 4xx/5xx. Only genuinely response-less errors (network,
1086
+ // timeout, abort) keep null.
1087
+ const errResponse = toHookResponse(ctx.error.response, hookReq);
896
1088
  const hookErr = {
897
1089
  error: ctx.error,
898
- request: {
899
- url: ctx.request.url,
900
- method: ctx.request.method,
901
- headers: ctx.request.headers,
902
- body: ctx.request.body,
903
- signal: ctx.request.signal,
904
- meta: ctx.request.meta,
905
- },
906
- response: null,
1090
+ request: hookReq,
1091
+ response: errResponse,
907
1092
  attempt: ctx.attempt,
908
1093
  };
909
1094
  const hookCtx = {
910
1095
  request: hookErr.request,
911
- response: null,
1096
+ response: errResponse,
912
1097
  error: ctx.error,
913
1098
  startedAt: ctx.startedAt,
914
1099
  attempt: ctx.attempt,
@@ -1162,20 +1347,19 @@ export class Kinetex {
1162
1347
  /**
1163
1348
  * Returns deduplication metrics.
1164
1349
  *
1165
- * @returns An object with `hits` (coalesced request count), `misses` (actual network request count),
1166
- * and `inFlightCount` (currently in-flight requests), or `null` if dedup is not enabled.
1350
+ * The full `DedupMap.getStats()` snapshot, so a caller has to reach for one
1351
+ * shape rather than two. The wrapper used to expose only `hits`, `misses`
1352
+ * and `inFlightCount` — which meant the hit rate, the only figure anyone
1353
+ * actually wants from a dedup map, was unavailable on the client even
1354
+ * though the map computed it.
1355
+ *
1356
+ * @returns `{ hits, misses, totalRequests, hitRate, inFlightCount, trackedKeys }`,
1357
+ * or `null` if dedup is not enabled.
1167
1358
  */
1168
1359
  get dedupMetrics() {
1169
1360
  if (!this._dedup)
1170
1361
  return null;
1171
- return {
1172
- /** Number of requests that shared an in-flight or windowed response. */
1173
- hits: this._dedup.hits,
1174
- /** Number of requests that triggered a real network call. */
1175
- misses: this._dedup.misses,
1176
- /** Number of currently in-flight requests. */
1177
- inFlightCount: this._dedup.inFlightCount,
1178
- };
1362
+ return this._dedup.getStats();
1179
1363
  }
1180
1364
  // ── §8.5d Circuit Breaker ────────────────────────────────────────────────
1181
1365
  /**
@@ -1251,7 +1435,20 @@ export class Kinetex {
1251
1435
  * ```
1252
1436
  */
1253
1437
  async ws(url, options = {}) {
1254
- const fullURL = buildURL(this.cfg.baseURL, url, this.cfg.params);
1438
+ // `ws://` / `wss://` have to be allowed here, and only here. The default
1439
+ // `["http", "https"]` is what stops an ordinary HTTP request from being
1440
+ // pointed at a WebSocket scheme, and every other buildURL caller keeps it.
1441
+ // Without this opt-in the documented `client.ws("wss://…")` form — the one
1442
+ // in the README and in this method's own JSDoc — failed the SSRF scheme
1443
+ // check and threw EVALIDATION on every call, so `client.ws()` could not
1444
+ // connect to anything. Only the scheme list is widened: the loopback and
1445
+ // private-range checks still apply to WebSocket URLs.
1446
+ const fullURL = buildURL(this.cfg.baseURL, url, this.cfg.params, [
1447
+ "http",
1448
+ "https",
1449
+ "ws",
1450
+ "wss",
1451
+ ]);
1255
1452
  const headers = mergeHeaders(this.cfg.headers, options.headers);
1256
1453
  // Apply auth headers manually since WS handshake goes through the browser
1257
1454
  // WS API which doesn't use the kinetex transport pipeline.
@@ -1294,7 +1491,12 @@ export class Kinetex {
1294
1491
  if (this.cfg.baseURL) {
1295
1492
  const baseUrl = new URL(this.cfg.baseURL);
1296
1493
  const wsIsSecure = wsUrl.protocol === "wss:";
1297
- const httpIsSecure = baseUrl.protocol === "https:";
1494
+ // A baseURL may itself be a WebSocket URL — `kinetex({ baseURL:
1495
+ // "wss://…" })` then `client.ws("/path")` is the natural spelling, and
1496
+ // it is what the README's origin-validation section shows. Comparing
1497
+ // `wss:` only against `https:` rejected that pairing outright, so a
1498
+ // client configured with a WebSocket baseURL could never open a socket.
1499
+ const httpIsSecure = baseUrl.protocol === "https:" || baseUrl.protocol === "wss:";
1298
1500
  if (wsIsSecure !== httpIsSecure || wsUrl.host !== baseUrl.host) {
1299
1501
  throw new KinetexError(`WebSocket origin ${wsUrl.origin} does not match baseURL origin ${baseUrl.origin}`, "EVALIDATION");
1300
1502
  }
@@ -1389,15 +1591,17 @@ export class Kinetex {
1389
1591
  }
1390
1592
  // ── Build initial request ─────────────────────────────────────────────
1391
1593
  const fullUrl = buildURL(options.baseURL ?? this.cfg.baseURL, url, mergeParams(this.cfg.params, options.params));
1392
- // FIX (M7): proxy configuration was stored but never consumed — a silent
1393
- // no-op that sent traffic directly to the target, bypassing the user's
1394
- // proxy entirely. Fail fast with actionable guidance instead.
1395
- const proxy = options.proxy ?? this.cfg.proxy;
1396
- if (proxy) {
1397
- throw new KinetexError("proxy is configured but kinetex's built-in transports cannot route through it: " +
1398
- "HTTP(S) proxies require a custom fetch with a proxy agent (e.g. undici ProxyAgent " +
1399
- "passed via the `fetch` option), and SOCKS5 requires createSocks5Tunnel() from " +
1400
- "kinetex/socks5. Set up one of those instead of relying on `proxy` silently doing nothing.", "EVALIDATION");
1594
+ // A client-level `proxy` is honoured by the Node transport, which tunnels
1595
+ // every connection through it with CONNECT. A per-request `proxy` cannot be:
1596
+ // the transport pools one connection per origin, so honouring a per-request
1597
+ // proxy would mean tearing the pool down mid-flight. Reject it with an
1598
+ // accurate reason rather than silently ignoring it.
1599
+ const perRequestProxy = options.proxy;
1600
+ if (perRequestProxy) {
1601
+ throw new KinetexError("A per-request `proxy` is not supported because the transport pools one connection " +
1602
+ "per origin — set `proxy` on the client instead, or use a custom `fetch` with a " +
1603
+ "proxy agent for per-request routing. SOCKS5 requires createSocks5Tunnel() from " +
1604
+ "kinetex/socks5.", "EVALIDATION");
1401
1605
  }
1402
1606
  // Enforce HTTPS-only if configured
1403
1607
  if (this.cfg.httpsOnly) {
@@ -1413,6 +1617,14 @@ export class Kinetex {
1413
1617
  throw new KinetexError(`Invalid URL: ${err}`, "EVALIDATION");
1414
1618
  }
1415
1619
  }
1620
+ // A multipart body has no size until it is encoded, and the encoding is
1621
+ // the same call the dispatch path makes below, so it is done once here and
1622
+ // the result reused. The guard used to estimate it with a flat 76 bytes per
1623
+ // part, which is less than the framing `encodeMultipart` actually writes
1624
+ // for its 70-character generated boundary: a form sized to exactly the
1625
+ // estimate passed this check and then went out roughly 45 bytes per part
1626
+ // over the limit the caller had set.
1627
+ let preEncodedForm;
1416
1628
  // Enforce request size limit if configured
1417
1629
  const maxRequestSize = options.maxRequestSize ?? this.cfg.maxRequestSize ?? 0;
1418
1630
  if (maxRequestSize > 0 && options.body) {
@@ -1440,19 +1652,13 @@ export class Kinetex {
1440
1652
  bodySize = new TextEncoder().encode(options.body.toString()).byteLength;
1441
1653
  }
1442
1654
  else if (options.body instanceof FormData) {
1443
- // FIX (H4): estimate multipart size instead of skipping entirely —
1444
- // the old skip allowed unbounded uploads past the configured limit.
1445
- const boundaryOverhead = 76; // per part: --boundary, headers, CRLF (conservative)
1446
- for (const [name, value] of options.body) {
1447
- bodySize += new TextEncoder().encode(name).byteLength + boundaryOverhead;
1448
- if (typeof value === "string") {
1449
- bodySize += new TextEncoder().encode(value).byteLength;
1450
- }
1451
- else {
1452
- bodySize += value.size;
1453
- }
1454
- }
1455
- bodySize += boundaryOverhead; // final boundary
1655
+ // FIX (H4): the old branch skipped FormData entirely, which allowed
1656
+ // unbounded uploads past the configured limit; it then replaced the
1657
+ // skip with an estimate whose per-part constant was smaller than the
1658
+ // framing it stood in for. The exact bytes are the only honest
1659
+ // answer, and they are needed a few lines below anyway.
1660
+ preEncodedForm = await encodeMultipart(options.body);
1661
+ bodySize = preEncodedForm.bytes.byteLength;
1456
1662
  }
1457
1663
  else if (options.body instanceof ReadableStream) {
1458
1664
  // FIX (H4): a stream's size cannot be known without consuming it —
@@ -1461,7 +1667,14 @@ export class Kinetex {
1461
1667
  throw new KinetexError(`maxRequestSize cannot be enforced for ReadableStream bodies — pass maxRequestSize: 0 to opt out, or buffer the body first`, "EVALIDATION");
1462
1668
  }
1463
1669
  else if (options.body && typeof options.body === "object") {
1464
- bodySize = new TextEncoder().encode(JSON.stringify(options.body)).byteLength;
1670
+ try {
1671
+ bodySize = new TextEncoder().encode(JSON.stringify(options.body)).byteLength;
1672
+ }
1673
+ catch (err) {
1674
+ // A circular (or BigInt-containing) body threw a raw TypeError from
1675
+ // inside the size guard, masking the real serialization error.
1676
+ throw new KinetexError(`Cannot measure request body size: ${err instanceof Error ? err.message : String(err)}`, "EVALIDATION");
1677
+ }
1465
1678
  }
1466
1679
  if (bodySize > maxRequestSize) {
1467
1680
  throw new KinetexError(`Request body size ${bodySize} bytes exceeds limit of ${maxRequestSize} bytes`, "EVALIDATION");
@@ -1469,13 +1682,48 @@ export class Kinetex {
1469
1682
  }
1470
1683
  let req = {
1471
1684
  url: fullUrl,
1472
- method,
1685
+ // Use the normalized method, not the caller's casing: `send(url, "patch")`
1686
+ // passed validation above but used to put the literal string "patch" on
1687
+ // the wire. fetch() only normalizes delete/get/head/options/post/put, so a
1688
+ // lowercase PATCH/CONNECT went out verbatim and servers answered 405.
1689
+ method: normalizedMethod,
1473
1690
  headers: mergeHeaders(this.cfg.headers, options.headers),
1474
- body: options.body ?? null,
1691
+ // A plain object/array is accepted by the public API (RequestBody) and is
1692
+ // JSON-encoded in the block immediately below, so the request object only
1693
+ // ever holds a real BodyInit by the time this function returns.
1694
+ body: (options.body ?? null),
1475
1695
  signal: options.signal ?? null,
1696
+ // Resolved once here so the manual redirect follower sees the same
1697
+ // effective values the caller asked for. Spread (not `??`) because the
1698
+ // project uses exactOptionalPropertyTypes.
1699
+ ...(options.followRedirects !== undefined || this.cfg.followRedirects !== undefined
1700
+ ? { followRedirects: options.followRedirects ?? this.cfg.followRedirects }
1701
+ : {}),
1702
+ ...(options.maxRedirects !== undefined || this.cfg.maxRedirects !== undefined
1703
+ ? { maxRedirects: options.maxRedirects ?? this.cfg.maxRedirects }
1704
+ : {}),
1476
1705
  meta: { ...options.meta },
1477
1706
  httpVersion: options.httpVersion ?? this.cfg.httpVersion ?? "HTTP/2",
1478
1707
  };
1708
+ // A `FormData` body is encoded here rather than handed to the transport,
1709
+ // because the encoding and the `Content-Type` that describes it have to be
1710
+ // produced together. The raw Node transports bypass fetch, and
1711
+ // `serializeRawBody` — the function written so they would not send an empty
1712
+ // body — covered `URLSearchParams` and `Blob` but not `FormData`, so on the
1713
+ // default transport on Node a form upload went out with no body and no
1714
+ // `Content-Type` and the server recorded an empty form. The response was an
1715
+ // ordinary 200, so nothing downstream could tell.
1716
+ if (req.body !== null && req.body instanceof FormData && !req.headers["content-type"]) {
1717
+ const encoded = preEncodedForm ?? (await encodeMultipart(req.body));
1718
+ req = {
1719
+ ...req,
1720
+ headers: {
1721
+ ...req.headers,
1722
+ "content-type": `multipart/form-data; boundary=${encoded.boundary}`,
1723
+ },
1724
+ body: encoded.bytes,
1725
+ };
1726
+ }
1479
1727
  // Default Content-Type for JSON bodies
1480
1728
  if (req.body !== null &&
1481
1729
  typeof req.body === "object" &&
@@ -1492,6 +1740,18 @@ export class Kinetex {
1492
1740
  body: JSON.stringify(req.body),
1493
1741
  };
1494
1742
  }
1743
+ // A URLSearchParams body is urlencoded by the raw Node transports, which
1744
+ // do not set a content-type the way fetch does. Without this the server
1745
+ // receives the bytes but cannot parse them as a form.
1746
+ if (req.body !== null &&
1747
+ typeof URLSearchParams !== "undefined" &&
1748
+ req.body instanceof URLSearchParams &&
1749
+ !req.headers["content-type"]) {
1750
+ req = {
1751
+ ...req,
1752
+ headers: { ...req.headers, "content-type": "application/x-www-form-urlencoded" },
1753
+ };
1754
+ }
1495
1755
  // ── Apply auth ─────────────────────────────────────────────────────────
1496
1756
  const auth = options.auth !== false ? (options.auth ?? this.cfg.auth) : undefined;
1497
1757
  if (auth)
@@ -1512,33 +1772,52 @@ export class Kinetex {
1512
1772
  // Works with any OpenTelemetry SDK — just call client.setTracer(tracer).
1513
1773
  // If no tracer is set we still propagate a randomly-generated trace ID
1514
1774
  // when the caller passes options.meta.traceId (useful for manual tracing).
1775
+ // Everything between startSpan() and the dispatch try/catch below can
1776
+ // throw (traceparent building, the circuit-breaker key fn, auth
1777
+ // fingerprinting). Wrap it so a failure still ends the span instead of
1778
+ // abandoning it — an unended span is never exported and never reports the
1779
+ // error, and holds its attributes in the tracer's memory.
1515
1780
  let _otelSpan = null;
1516
- if (this._otelTracer) {
1517
- _otelSpan = this._otelTracer.startSpan(`HTTP ${req.method}`, { kind: 3 /* CLIENT */ });
1518
- const { traceparent, traceId, spanId } = buildTraceparent(_otelSpan);
1519
- _otelSpan.setAttribute("http.request.method", req.method);
1520
- _otelSpan.setAttribute("url.full", req.url);
1521
- try {
1522
- _otelSpan.setAttribute("server.address", new URL(req.url).hostname);
1781
+ try {
1782
+ if (this._otelTracer) {
1783
+ _otelSpan = this._otelTracer.startSpan(`HTTP ${req.method}`, { kind: 3 /* CLIENT */ });
1784
+ const { traceparent, traceId, spanId } = buildTraceparent(_otelSpan);
1785
+ _otelSpan.setAttribute("http.request.method", req.method);
1786
+ _otelSpan.setAttribute("url.full", req.url);
1787
+ try {
1788
+ _otelSpan.setAttribute("server.address", new URL(req.url).hostname);
1789
+ }
1790
+ catch {
1791
+ // Skip hostname attribute if URL is invalid
1792
+ }
1793
+ req = {
1794
+ ...req,
1795
+ headers: { ...req.headers, traceparent },
1796
+ meta: { ...req.meta, traceId, spanId },
1797
+ };
1523
1798
  }
1524
- catch {
1525
- // Skip hostname attribute if URL is invalid
1799
+ else if (req.meta["traceId"] && !req.headers["traceparent"]) {
1800
+ // Manual trace propagation — caller set traceId in meta
1801
+ const traceId = String(req.meta["traceId"]);
1802
+ const spanId = randomHex(16);
1803
+ req = {
1804
+ ...req,
1805
+ headers: { ...req.headers, traceparent: `00-${traceId}-${spanId}-01` },
1806
+ meta: { ...req.meta, spanId },
1807
+ };
1526
1808
  }
1527
- req = {
1528
- ...req,
1529
- headers: { ...req.headers, traceparent },
1530
- meta: { ...req.meta, traceId, spanId },
1531
- };
1532
1809
  }
1533
- else if (req.meta["traceId"] && !req.headers["traceparent"]) {
1534
- // Manual trace propagation — caller set traceId in meta
1535
- const traceId = String(req.meta["traceId"]);
1536
- const spanId = randomHex(16);
1537
- req = {
1538
- ...req,
1539
- headers: { ...req.headers, traceparent: `00-${traceId}-${spanId}-01` },
1540
- meta: { ...req.meta, spanId },
1541
- };
1810
+ catch (tracingErr) {
1811
+ if (_otelSpan) {
1812
+ _otelSpan.setStatus({
1813
+ code: 2 /* ERROR */,
1814
+ message: tracingErr instanceof Error ? tracingErr.message : String(tracingErr),
1815
+ });
1816
+ if (tracingErr instanceof Error)
1817
+ _otelSpan.recordException(tracingErr);
1818
+ _otelSpan.end();
1819
+ }
1820
+ throw tracingErr;
1542
1821
  }
1543
1822
  // Determine key for dedup + circuit breaker.
1544
1823
  // Uses circuitBreakerKeyFn if configured (e.g. per-method isolation),
@@ -1570,8 +1849,13 @@ export class Kinetex {
1570
1849
  // SECURITY: The dedup key includes a fingerprint of auth-sensitive headers
1571
1850
  // so requests from different users (different Authorization / Cookie) are
1572
1851
  // NEVER coalesced — each user gets their own isolated in-flight slot.
1573
- const authFp = await getAuthFingerprint(req.headers ?? {});
1574
- const _dedupKey = `${req.method}:${req.url}${authFp ? ":" + authFp : ""}`;
1852
+ // Fingerprinting hashes the credential headers with SHA-256, so it is only
1853
+ // paid when dedup is actually enabled.
1854
+ let _dedupKey = "";
1855
+ if (this._dedup) {
1856
+ const authFp = await getAuthFingerprint(req.headers ?? {});
1857
+ _dedupKey = `${req.method}:${req.url}${authFp ? ":" + authFp : ""}`;
1858
+ }
1575
1859
  const _dedupedFactory = this._dedup
1576
1860
  ? () => this._dedup
1577
1861
  .execute(req.method, _dedupKey, _execFactory)
@@ -1611,12 +1895,95 @@ export class Kinetex {
1611
1895
  * and retries on failure per the retry config.
1612
1896
  */
1613
1897
  async _executeWithRetry(req, retryCfg, timeout, options, startMs, wallClockMs) {
1898
+ // A permit is held for the whole logical request — including every retry
1899
+ // attempt — and released in `finally`, so a throw or an exhausted retry
1900
+ // budget can never leak one and permanently shrink the pool.
1901
+ const run = async () => {
1902
+ if (!this._concurrencyLimiter) {
1903
+ return await this._executeWithRetryInner(req, retryCfg, timeout, options, startMs, wallClockMs);
1904
+ }
1905
+ await this._concurrencyLimiter.acquire(req.signal);
1906
+ try {
1907
+ return await this._executeWithRetryInner(req, retryCfg, timeout, options, startMs, wallClockMs);
1908
+ }
1909
+ finally {
1910
+ this._concurrencyLimiter.release();
1911
+ }
1912
+ };
1913
+ // Metrics measure the whole logical request, retries included, and are
1914
+ // emitted from `finally` so failures and aborts are counted too. The
1915
+ // no-tracer case is not short-circuited here: `_recordMetrics` returns
1916
+ // immediately when telemetry is off, and this client then pays nothing
1917
+ // beyond the call it already makes on every request.
1918
+ const metricsStart = Date.now();
1919
+ let status;
1920
+ let errorCode;
1921
+ try {
1922
+ const res = await run();
1923
+ status = res.status;
1924
+ return res;
1925
+ }
1926
+ catch (err) {
1927
+ errorCode = err.code;
1928
+ throw err;
1929
+ }
1930
+ finally {
1931
+ this._recordMetrics(req, status, errorCode, Date.now() - metricsStart);
1932
+ }
1933
+ }
1934
+ /**
1935
+ * Emit request metrics to the configured tracer, if it supports them.
1936
+ *
1937
+ * Failures here are swallowed on purpose: telemetry must never be able to
1938
+ * fail a request that otherwise succeeded.
1939
+ *
1940
+ * @param req - The originating request.
1941
+ * @param status - Final HTTP status, or undefined if the request threw.
1942
+ * @param errorCode - `KinetexError` code, or undefined on success.
1943
+ * @param durationMs - Wall-clock duration of the logical request.
1944
+ */
1945
+ _recordMetrics(req, status, errorCode, durationMs) {
1946
+ const tracer = this._otelTracer;
1947
+ if (!tracer)
1948
+ return;
1949
+ // `req.url` is always an absolute, already-parsed URL by the time a
1950
+ // request reaches here: `buildURL()` constructs it and the SSRF safety
1951
+ // check parses it again before dispatch, so there is nothing to guard.
1952
+ const attributes = {
1953
+ "http.request.method": req.method,
1954
+ "server.address": new URL(req.url).hostname,
1955
+ };
1956
+ if (status !== undefined)
1957
+ attributes["http.response.status_code"] = status;
1958
+ if (errorCode !== undefined)
1959
+ attributes["error.type"] = errorCode;
1960
+ try {
1961
+ // Seconds, per OTel semantic conventions for http.client.request.duration.
1962
+ tracer.recordHistogram?.("http.client.request.duration", durationMs / 1000, attributes);
1963
+ tracer.incrementCounter?.("http.client.request.count", 1, attributes);
1964
+ if (errorCode !== undefined) {
1965
+ tracer.incrementCounter?.("http.client.error.count", 1, attributes);
1966
+ }
1967
+ }
1968
+ catch {
1969
+ // Swallowed — see the doc comment.
1970
+ }
1971
+ }
1972
+ /** Retry loop body. See {@link _executeWithRetry} for the concurrency gate. */
1973
+ async _executeWithRetryInner(req, retryCfg, timeout, options, startMs, wallClockMs) {
1614
1974
  let attempt = 0;
1615
1975
  while (true) {
1616
1976
  attempt++;
1617
1977
  // If caller aborted between retries, stop immediately
1618
1978
  if (req.signal?.aborted) {
1619
- throw createAbortError();
1979
+ throw createAbortError(req);
1980
+ }
1981
+ // A ReadableStream / Blob body is not replayable: the first attempt
1982
+ // consumes (and locks) it, so a retry re-wraps an already-locked stream
1983
+ // for upload progress and sends an empty body. Fail loudly on the retry
1984
+ // instead of silently transmitting nothing.
1985
+ if (attempt > 1 && isNonReplayableBody(req.body)) {
1986
+ throw new KinetexError("Cannot retry a request whose body is a stream or Blob — the body was consumed by the first attempt. Buffer it first, or disable retry for this request.", "EVALIDATION", { request: req });
1620
1987
  }
1621
1988
  try {
1622
1989
  const res = await this._executeOnce(req, timeout, options, startMs, attempt, wallClockMs, retryCfg);
@@ -1646,20 +2013,68 @@ export class Kinetex {
1646
2013
  return res;
1647
2014
  }
1648
2015
  catch (err) {
1649
- if (retryCfg && attempt <= retryCfg.maxRetries) {
2016
+ if (globalThis.__KINETEX_DEBUG_RETRY) {
2017
+ console.log("DBG catch", {
2018
+ attempt,
2019
+ maxRetries: retryCfg === false ? "FALSE" : retryCfg?.maxRetries,
2020
+ methods: retryCfg === false ? "FALSE" : retryCfg?.methods,
2021
+ code: err?.code,
2022
+ method: req.method,
2023
+ });
2024
+ }
2025
+ // Clock-skew correction. `SigV4Signer.handleClockSkewError` existed and
2026
+ // `detectClockSkew` was a public export, but nothing in the library
2027
+ // ever called them: a client with a wrong clock got a 403
2028
+ // `RequestTimeTooSkewed`, retried if 403 happened to be in `statuses`,
2029
+ // and re-signed the identical wrong timestamp every attempt. Correcting
2030
+ // here is bounded to one attempt per logical request, so a server that
2031
+ // keeps reporting a different time cannot spin.
2032
+ let clockSkewCorrected = false;
2033
+ const skewResponse = err.response;
2034
+ if (this._awsSigner && skewResponse && err.code === "EHTTPSTATUS") {
2035
+ // `data` is the parsed body: a string for text, a Uint8Array for any
2036
+ // other content type (which is what an XML error body arrives as),
2037
+ // and an object only for JSON.
2038
+ const raw = skewResponse.data;
2039
+ const body = typeof raw === "string"
2040
+ ? raw
2041
+ : raw instanceof Uint8Array
2042
+ ? new TextDecoder().decode(raw)
2043
+ : raw instanceof ArrayBuffer
2044
+ ? new TextDecoder().decode(new Uint8Array(raw))
2045
+ : JSON.stringify(raw ?? "");
2046
+ if (this._awsSigner.handleClockSkewError(skewResponse.status, body, skewResponse.headers)) {
2047
+ clockSkewCorrected = !req.meta[AWS_SKEW_CORRECTED];
2048
+ if (clockSkewCorrected)
2049
+ req.meta[AWS_SKEW_CORRECTED] = true;
2050
+ }
2051
+ }
2052
+ if (retryCfg && (attempt <= retryCfg.maxRetries || clockSkewCorrected)) {
2053
+ // A thrown HTTPStatusError already carries the full response. This
2054
+ // path used to hand hooks a context with `response: null` and a hard
2055
+ // `null` Retry-After, so the *default* case — throwOnError: true,
2056
+ // which is what every 429/503 goes through — ignored the server's
2057
+ // Retry-After entirely and never fired lifecycle onRetry hooks.
2058
+ const errResponse = skewResponse ?? null;
1650
2059
  const retryCtx = {
1651
2060
  request: req,
1652
- response: null,
2061
+ response: errResponse,
1653
2062
  error: err,
1654
2063
  attempt,
1655
2064
  maxRetries: retryCfg.maxRetries,
1656
2065
  };
1657
- const doRetry = retryCfg.shouldRetry
1658
- ? await retryCfg.shouldRetry(retryCtx)
1659
- : shouldRetry(retryCfg, retryCtx);
2066
+ const doRetry = clockSkewCorrected
2067
+ ? true
2068
+ : retryCfg.shouldRetry
2069
+ ? await retryCfg.shouldRetry(retryCtx)
2070
+ : shouldRetry(retryCfg, retryCtx);
1660
2071
  if (doRetry) {
1661
- const delay = computeRetryDelay(retryCfg, attempt, null);
2072
+ const delay = computeRetryDelay(retryCfg, attempt, errResponse ? getRetryAfterMs(errResponse.headers) : null);
1662
2073
  await retryCfg.onRetry?.(retryCtx, delay);
2074
+ await this.cfg.hooks?.onRetry?.reduce(async (p, fn) => {
2075
+ await p;
2076
+ await fn(retryCtx);
2077
+ }, Promise.resolve());
1663
2078
  await sleep(delay, req.signal);
1664
2079
  continue;
1665
2080
  }
@@ -1686,11 +2101,25 @@ export class Kinetex {
1686
2101
  // intermediate redirect responses. When a cookie jar is active we must follow
1687
2102
  // redirects ourselves one hop at a time so we can capture cookies at each step.
1688
2103
  /**
1689
- * Follow redirects manually, one hop at a time, to capture Set-Cookie headers.
1690
- * fetch() auto-follows redirects but silently drops Set-Cookie from intermediary hops.
2104
+ * Follow redirects manually, one hop at a time.
2105
+ *
2106
+ * Two independent reasons this path exists:
2107
+ * 1. fetch() auto-follows redirects but silently drops Set-Cookie from
2108
+ * intermediary hops, so an active cookie jar must see every hop itself.
2109
+ * 2. Per the Fetch spec, a cross-origin redirect only drops
2110
+ * `authorization` / `cookie` / `proxy-authorization`. Custom credential
2111
+ * headers (apikey, X-Company-Key, ...) are forwarded verbatim, so any
2112
+ * request carrying one must also be followed manually.
2113
+ *
2114
+ * @param jar - Optional cookie jar. When omitted, no cookie header is
2115
+ * rebuilt and intermediate Set-Cookie headers are ignored.
1691
2116
  */
1692
2117
  async _sendFollowingRedirects(req, timeout, jar, appliedAuth) {
1693
- const MAX_REDIRECTS = 20;
2118
+ // `maxRedirects` / `followRedirects` were documented on KinetexConfig and
2119
+ // SendOptions but never read, so the documented default and the enforced one
2120
+ // had drifted apart. Both are honoured here now.
2121
+ const maxRedirects = Math.max(0, req.maxRedirects ?? DEFAULT_MAX_REDIRECTS);
2122
+ const followRedirects = req.followRedirects !== false && maxRedirects > 0;
1694
2123
  let currentReq = { ...req, redirect: "manual" };
1695
2124
  const origin0 = (() => {
1696
2125
  try {
@@ -1702,7 +2131,7 @@ export class Kinetex {
1702
2131
  })();
1703
2132
  // Track visited URLs to detect redirect loops
1704
2133
  const visited = new Set();
1705
- for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
2134
+ for (let hop = 0; hop <= maxRedirects; hop++) {
1706
2135
  // FIX H2 (part 2): re-apply auth on every hop ONLY while we remain on the
1707
2136
  // original origin. Once a redirect has crossed origins, credential-bearing
1708
2137
  // headers must not be re-injected — otherwise the cross-origin strip in
@@ -1751,19 +2180,35 @@ export class Kinetex {
1751
2180
  if (isRedirect) {
1752
2181
  // Check for redirect loops
1753
2182
  if (visited.has(raw.url)) {
1754
- throw new KinetexError(`Redirect loop detected: ${raw.url}`, "ENETWORK", {
1755
- request: req,
1756
- });
2183
+ throw new RedirectError(`Redirect loop detected: ${raw.url}`, req);
1757
2184
  }
1758
2185
  visited.add(raw.url);
1759
2186
  // Capture cookies from this redirect hop
1760
- jar.processResponseHeaders(raw.headers, {
2187
+ jar?.processResponseHeaders(raw.headers, {
1761
2188
  url: raw.url,
1762
2189
  });
1763
- if (hop === MAX_REDIRECTS) {
1764
- throw new KinetexError(`Too many redirects (exceeded ${MAX_REDIRECTS})`, "ENETWORK", {
1765
- request: req,
1766
- });
2190
+ // `followRedirects: false` (or `maxRedirects: 0`) hands the 3xx back to
2191
+ // the caller instead of chasing it — the same shape fetch() returns for
2192
+ // `redirect: "manual"`. It reported `redirected: true`, which is the one
2193
+ // value `redirected` must never take: no hop was taken, `res.url` is
2194
+ // still the request's own URL, and the whole point of the option is that
2195
+ // the caller now has to read `Location` and decide for itself. A caller
2196
+ // branching on `if (res.redirected)` to detect a cross-origin bounce was
2197
+ // told it had already been redirected to a URL it never requested.
2198
+ if (!followRedirects)
2199
+ return { ...raw, redirected: false };
2200
+ if (hop === maxRedirects) {
2201
+ // `EREDIRECT`, not `ENETWORK`. A redirect chain that has run out of
2202
+ // hops is a deterministic answer from the origin: the same request
2203
+ // produces the same chain. As an `ENETWORK` it fell into
2204
+ // `shouldRetry`'s network-error case and the whole chain was replayed
2205
+ // once per attempt — 16 requests against a server already looping,
2206
+ // under a `maxRedirects: 3` the caller had set, and after the whole
2207
+ // backoff schedule before the error they asked for finally arrived.
2208
+ // `shouldRetry` already had a non-retryable `EREDIRECT` case, and
2209
+ // `RedirectError` was already exported and documented; nothing
2210
+ // constructed it.
2211
+ throw new RedirectError(`Too many redirects (exceeded ${maxRedirects})`, req);
1767
2212
  }
1768
2213
  // Drain the redirect body (usually empty, but must be cancelled)
1769
2214
  if (raw.body) {
@@ -1786,6 +2231,19 @@ export class Kinetex {
1786
2231
  if (protocol !== "http:" && protocol !== "https:") {
1787
2232
  throw new KinetexError(`Unsafe redirect to ${protocol} detected — only HTTP(S) allowed`, "ENETWORK", { request: req });
1788
2233
  }
2234
+ // SSRF GATE (P0): the initial URL is screened by buildURL → isSafeURL,
2235
+ // but a redirect target never went through that check. Without this a
2236
+ // public host could 302 the client straight at link-local/loopback
2237
+ // addresses (169.254.169.254, 127.0.0.1, 10/8, ::1, …) and the whole
2238
+ // private-network block list would be bypassable. Re-validate every hop.
2239
+ if (!isSafeURL(nextUrl)) {
2240
+ throw new KinetexError(`Unsafe redirect target blocked: ${redactUserInfo(location)}`, "EVALIDATION", { request: req });
2241
+ }
2242
+ // httpsOnly must hold for redirect legs too, otherwise a redirect is a
2243
+ // trivial downgrade from https:// to http:// past the pre-flight guard.
2244
+ if (this.cfg.httpsOnly && protocol !== "https:") {
2245
+ throw new KinetexError(`HTTPS-only mode enabled but redirect target uses ${protocol}`, "EVALIDATION", { request: req });
2246
+ }
1789
2247
  }
1790
2248
  catch (err) {
1791
2249
  if (err instanceof KinetexError)
@@ -1801,30 +2259,50 @@ export class Kinetex {
1801
2259
  ? "GET"
1802
2260
  : currentReq.method;
1803
2261
  const nextBody = nextMethod === "GET" || nextMethod === "HEAD" ? null : currentReq.body;
1804
- // Rebuild Cookie header for the next hop using the updated jar
1805
- const cookieHeader = jar.getCookieHeader({ url: nextUrl, http: true });
1806
2262
  const nextHeaders = { ...currentReq.headers };
1807
- if (cookieHeader) {
1808
- nextHeaders["cookie"] = cookieHeader;
1809
- }
1810
- else {
1811
- delete nextHeaders["cookie"];
1812
- }
1813
2263
  // FIX H2: When the redirect crosses origins, strip credential-bearing
1814
2264
  // headers (Authorization, Cookie, proxy auth, API keys) so secrets are
1815
2265
  // never forwarded to a different origin (RFC 9110 7.1 semantics).
1816
- // Cookies for the new origin are re-established by the jar lookup above;
1817
- // jar scoping guarantees only same-site cookies apply.
2266
+ //
2267
+ // ORDERING (this must happen BEFORE the cookie header is rebuilt): the
2268
+ // previous order computed the new origin's cookie header first and then
2269
+ // deleted it again in the strip loop, so every cross-origin hop was
2270
+ // sent without the cookies the jar had just scoped for it.
2271
+ let crossOrigin = false;
1818
2272
  try {
1819
- if (new URL(nextUrl).origin !== new URL(currentReq.url).origin) {
1820
- for (const h of CROSS_ORIGIN_STRIP_HEADERS) {
1821
- delete nextHeaders[h];
1822
- }
1823
- }
2273
+ crossOrigin = new URL(nextUrl).origin !== new URL(currentReq.url).origin;
1824
2274
  }
1825
2275
  catch {
1826
2276
  /* nextUrl was already validated above */
1827
2277
  }
2278
+ if (crossOrigin) {
2279
+ for (const h of CROSS_ORIGIN_STRIP_HEADERS) {
2280
+ delete nextHeaders[h];
2281
+ }
2282
+ // An `apikey` auth header name is chosen by the application, so it is
2283
+ // not in the well-known list. It is a credential all the same, and it
2284
+ // was being forwarded verbatim to the new origin.
2285
+ if (appliedAuth && appliedAuth.type === "apikey") {
2286
+ delete nextHeaders[appliedAuth.header.toLowerCase()];
2287
+ }
2288
+ }
2289
+ // Rebuild the Cookie header for the next hop from the updated jar.
2290
+ // Jar scoping guarantees only cookies that match the NEW origin are
2291
+ // attached, which is exactly the post-strip state we want.
2292
+ if (jar) {
2293
+ const cookieHeader = jar.getCookieHeader({ url: nextUrl, http: true });
2294
+ if (cookieHeader) {
2295
+ nextHeaders["cookie"] = cookieHeader;
2296
+ }
2297
+ else {
2298
+ delete nextHeaders["cookie"];
2299
+ }
2300
+ }
2301
+ else if (crossOrigin) {
2302
+ // No jar: drop the caller's cookie header with the other credentials
2303
+ // (mirrors what fetch() does for a cross-origin redirect).
2304
+ delete nextHeaders["cookie"];
2305
+ }
1828
2306
  currentReq = {
1829
2307
  ...currentReq,
1830
2308
  url: nextUrl,
@@ -1837,10 +2315,12 @@ export class Kinetex {
1837
2315
  }
1838
2316
  // Not a redirect — return the final raw response as-is.
1839
2317
  // _executeOnce will capture its Set-Cookie headers via the normal path.
1840
- return raw;
2318
+ // The chain was followed by hand, so report `redirected: true` for hop > 0
2319
+ // to match what fetch() reports under redirect: "follow".
2320
+ return hop > 0 ? { ...raw, redirected: true } : raw;
1841
2321
  }
1842
2322
  // Unreachable
1843
- throw new KinetexError("Redirect loop", "ENETWORK", { request: req });
2323
+ throw new RedirectError("Redirect loop", req);
1844
2324
  }
1845
2325
  // ── §8.8 Single attempt ──────────────────────────────────────────────────
1846
2326
  /**
@@ -1877,9 +2357,16 @@ export class Kinetex {
1877
2357
  }
1878
2358
  this._trace(_traceId, "lifecycle_before", "end", startMs, attempt);
1879
2359
  // ── Cache lookup ───────────────────────────────────────────────────────
2360
+ // `noCache()` sets `cache: { forceRefresh: true }`, and `forceRefresh` was
2361
+ // declared on `CacheRequestConfig` and read *nowhere*: the lookup below ran
2362
+ // exactly as if no option had been passed, so a warm entry was served and
2363
+ // the fluent method documented as "Force a fresh fetch, bypassing any cached
2364
+ // response" did not fetch. The fix is to skip the read for this request —
2365
+ // the write still happens, which is what "refresh" means.
2366
+ const forceRefresh = options.cache !== false && options.cache?.forceRefresh === true;
1880
2367
  if (options.cache !== false && this.cfg.cache) {
1881
2368
  const cache = await this.getCache();
1882
- if (cache) {
2369
+ if (cache && !forceRefresh) {
1883
2370
  const cacheReq = { url: req.url, method: req.method, headers: req.headers };
1884
2371
  const hit = await cache.get(cacheReq);
1885
2372
  if (hit && !hit.stale) {
@@ -1943,7 +2430,15 @@ export class Kinetex {
1943
2430
  }
1944
2431
  }
1945
2432
  finally {
1946
- (await this.getCache())?.clearSWRInFlight(cacheReq);
2433
+ // Must not be able to skip: if getCache() rejects, the in-flight
2434
+ // marker survives and this key can never revalidate again, so
2435
+ // every future stale hit would be served stale forever.
2436
+ try {
2437
+ (await this.getCache())?.clearSWRInFlight(cacheReq);
2438
+ }
2439
+ catch {
2440
+ /* isolate — never leave the SWR marker stuck */
2441
+ }
1947
2442
  }
1948
2443
  })();
1949
2444
  }
@@ -2037,21 +2532,39 @@ export class Kinetex {
2037
2532
  ...req,
2038
2533
  headers: {
2039
2534
  ...req.headers,
2040
- "accept-encoding": "gzip, deflate, br",
2535
+ "accept-encoding": DEFAULT_ACCEPT_ENCODING,
2041
2536
  },
2042
2537
  };
2043
2538
  }
2044
2539
  // ── Dispatch ───────────────────────────────────────────────────────────
2045
- // When a cookie jar is active we must follow redirects manually so we can
2046
- // capture Set-Cookie headers from every intermediate hop — fetch() drops
2047
- // them silently when auto-following.
2540
+ // Redirects are ALWAYS followed by hand, and that is not a preference.
2541
+ //
2542
+ // `_sendFollowingRedirects` is the only place kinetex checks a redirect
2543
+ // *target*: the SSRF gate (`isSafeURL` on every hop), the `httpsOnly`
2544
+ // policy, the redirect-loop detector, the `maxRedirects` cap, and the
2545
+ // RFC 7231 method downgrade for 301/302/303. It also exists to capture
2546
+ // intermediate Set-Cookie and to strip credentials across origins, which is
2547
+ // why it used to be entered only when a cookie jar or a forwarded credential
2548
+ // header happened to be configured.
2549
+ //
2550
+ // An ordinary GET is neither. So an ordinary GET's redirects were chased by
2551
+ // the transport instead, and every check above was skipped: the HTTP/2
2552
+ // transport's own loop resolved any `Location` and dialled it, and
2553
+ // `FetchTransport` handed fetch `redirect: "follow"`, which does the same.
2554
+ // A 302 to `http://127.0.0.1:9/` opened the socket (`ECONNREFUSED` came
2555
+ // back from the loopback port, which is the point — nothing refused the
2556
+ // connection first), and a 302 to `http://169.254.169.254/` was answered by
2557
+ // the cloud metadata service. `httpsOnly: true` changed nothing on either
2558
+ // path; both reported the same opaque `Protocol error`.
2559
+ //
2560
+ // The two reasons the follower was originally introduced are reasons it is
2561
+ // *necessary*, not an exhaustive list of when it applies.
2048
2562
  const dispatchJar = await this.getCookieJar();
2563
+ const effectiveAuth = options.auth !== false ? (options.auth ?? this.cfg.auth) : undefined;
2049
2564
  this._trace(_traceId, "transport_send", "start", startMs, attempt);
2050
2565
  let raw;
2051
2566
  try {
2052
- raw = dispatchJar
2053
- ? await this._sendFollowingRedirects(req, timeout, dispatchJar, options.auth !== false ? (options.auth ?? this.cfg.auth) : undefined)
2054
- : await sendWithTimeout(this.transport, req, timeout);
2567
+ raw = await this._sendFollowingRedirects(req, timeout, dispatchJar ?? undefined, effectiveAuth);
2055
2568
  }
2056
2569
  catch (err) {
2057
2570
  // Cancel the progress-tracking ReadableStream to release the underlying
@@ -2069,6 +2582,20 @@ export class Kinetex {
2069
2582
  throw err;
2070
2583
  }
2071
2584
  this._trace(_traceId, "transport_send", "end", startMs, attempt);
2585
+ // ── Lifecycle: after request ───────────────────────────────────────────
2586
+ // "After the request is sent (before response is processed)" — the window
2587
+ // between the transport answering and the response being built, and the
2588
+ // only place a caller can observe that the wire round trip is over without
2589
+ // also having to see the parsed response. It was declared on `LifecycleHooks`
2590
+ // and documented twice in the README, and never invoked: a caller who
2591
+ // registered it got silence, on every code path, for every status.
2592
+ //
2593
+ // Deliberately *not* fired on the throw above: the request was not sent.
2594
+ if (this.cfg.hooks?.onAfterRequest) {
2595
+ for (const fn of this.cfg.hooks.onAfterRequest) {
2596
+ await fn(req, this._hookCtx(ctx));
2597
+ }
2598
+ }
2072
2599
  // ── Handle 304 Not Modified ────────────────────────────────────────────
2073
2600
  if (raw.status === 304) {
2074
2601
  const cache = await this.getCache();
@@ -2111,9 +2638,18 @@ export class Kinetex {
2111
2638
  },
2112
2639
  ...(req.signal !== null ? { signal: req.signal } : {}),
2113
2640
  });
2641
+ // Capture the SOURCE in its own binding. `bodyStream` is reassigned to
2642
+ // this wrapper immediately after construction, so closing over it made
2643
+ // `cancel()` cancel *itself*: readRawBody's reader.cancel() (size limit,
2644
+ // abort, read error) re-entered this function, which threw
2645
+ // "Invalid state: ReadableStream is locked" from inside the cancel
2646
+ // algorithm and left that inner promise unhandled (process-level crash on
2647
+ // Node). start() only worked by accident, relying on the async-fn body
2648
+ // running synchronously up to the first await.
2649
+ const source = bodyStream;
2114
2650
  bodyStream = new ReadableStream({
2115
2651
  async start(controller) {
2116
- const reader = bodyStream.getReader();
2652
+ const reader = source.getReader();
2117
2653
  try {
2118
2654
  while (true) {
2119
2655
  const { done, value } = await reader.read();
@@ -2133,8 +2669,10 @@ export class Kinetex {
2133
2669
  reader.releaseLock();
2134
2670
  }
2135
2671
  },
2136
- cancel() {
2137
- bodyStream?.cancel();
2672
+ cancel(reason) {
2673
+ // Forward cancellation to the real source and swallow its failure:
2674
+ // a rejection here is never observed by the cancelling reader.
2675
+ void source.cancel(reason).catch(() => { });
2138
2676
  },
2139
2677
  });
2140
2678
  }
@@ -2159,9 +2697,9 @@ export class Kinetex {
2159
2697
  status: raw.status,
2160
2698
  statusText: raw.statusText,
2161
2699
  headers: raw.headers,
2162
- data: this.cfg.transformResponse
2163
- ? this.cfg.transformResponse(data, {})
2164
- : data,
2700
+ // transformResponse is applied below, once `res` exists, so it receives
2701
+ // the real response object instead of the `{}` placeholder it used to get.
2702
+ data,
2165
2703
  rawBody,
2166
2704
  url: raw.url,
2167
2705
  cached: false,
@@ -2171,6 +2709,14 @@ export class Kinetex {
2171
2709
  request: req,
2172
2710
  attempt,
2173
2711
  };
2712
+ // Apply transformResponse now that the response object exists, so the hook
2713
+ // receives the real response (status/headers/url) rather than the empty
2714
+ // placeholder it used to be handed.
2715
+ if (this.cfg.transformResponse) {
2716
+ // `data` is readonly on the public type; the cast is confined to this one
2717
+ // write, immediately after construction.
2718
+ res.data = this.cfg.transformResponse(res.data, res);
2719
+ }
2174
2720
  // ── Store in cache ─────────────────────────────────────────────────────
2175
2721
  if (options.cache !== false && this.cfg.cache) {
2176
2722
  const cache = await this.getCache();
@@ -2249,6 +2795,16 @@ export class Kinetex {
2249
2795
  current = result;
2250
2796
  }
2251
2797
  else if ("url" in result && "method" in result && !("status" in result)) {
2798
+ // Re-sending from a response interceptor used to be unbounded: an
2799
+ // interceptor that always returns a modified request (the classic
2800
+ // token-refresh shape, but also a mis-written one) recursed forever,
2801
+ // each level a fresh _executeOnce with a fresh interceptor context.
2802
+ // The depth therefore travels on request meta, which _executeOnce
2803
+ // carries into the nested call (ctx.store would not survive it).
2804
+ const resendDepth = Number(_req.meta[INTERCEPTOR_RESEND_DEPTH] ?? 0);
2805
+ if (resendDepth >= MAX_INTERCEPTOR_RESENDS) {
2806
+ throw new KinetexError(`Response interceptor re-send limit reached (${MAX_INTERCEPTOR_RESENDS}) — refusing to loop`, "EVALIDATION", { request: _req });
2807
+ }
2252
2808
  if (retryCfg && attempt <= retryCfg.maxRetries) {
2253
2809
  const retryCtx = {
2254
2810
  request: _req,
@@ -2265,7 +2821,13 @@ export class Kinetex {
2265
2821
  }, Promise.resolve());
2266
2822
  await sleep(delay, _req.signal);
2267
2823
  }
2268
- return this._executeOnce(result, timeout, options, startMs, attempt + 1, undefined, retryCfg);
2824
+ return this._executeOnce({
2825
+ ...result,
2826
+ meta: {
2827
+ ...result.meta,
2828
+ [INTERCEPTOR_RESEND_DEPTH]: resendDepth + 1,
2829
+ },
2830
+ }, timeout, options, startMs, attempt + 1, undefined, retryCfg);
2269
2831
  }
2270
2832
  }
2271
2833
  return current;
@@ -2556,7 +3118,10 @@ export class Kinetex {
2556
3118
  * Call this when the client is no longer needed to prevent memory leaks.
2557
3119
  * @returns A promise that resolves when cleanup is complete.
2558
3120
  */
2559
- async destroy() {
3121
+ // Not `async`: nothing here awaits, so the returned promise is resolved
3122
+ // explicitly instead. The signature stays Promise<void> — callers and the
3123
+ // docs `await client.destroy()`.
3124
+ destroy() {
2560
3125
  // Close all tracked WebSocket connections
2561
3126
  for (const ws of this._wsClients) {
2562
3127
  try {
@@ -2567,23 +3132,22 @@ export class Kinetex {
2567
3132
  }
2568
3133
  }
2569
3134
  this._wsClients.clear();
2570
- if (this._cache) {
2571
- try {
2572
- await this._cache.clear();
2573
- }
2574
- catch {
2575
- /* best-effort */
2576
- }
2577
- }
3135
+ // NOTE: the cache is deliberately NOT cleared here. destroy() releases
3136
+ // resources; it must not purge data, and a user-supplied adapter
3137
+ // (localStorage / Cloudflare KV / Redis) would lose every persisted entry.
3138
+ // Call `client.getCache().then(c => c.clear())` explicitly to empty it.
2578
3139
  if (IS_NODE && this.transport && "destroy" in this.transport) {
2579
3140
  this.transport.destroy();
2580
3141
  }
3142
+ // Reject anyone still parked on the queue — it can never drain now.
3143
+ this._concurrencyLimiter?.drain();
2581
3144
  this._cookieJar = null;
2582
3145
  this._logger = null;
2583
3146
  this._dedup?.clear();
2584
3147
  this._circuitBreakers?.clear?.();
2585
3148
  this._otelTracer = null;
2586
3149
  this.interceptors.clear();
3150
+ return Promise.resolve();
2587
3151
  }
2588
3152
  }
2589
3153
  // ============================================================================
@@ -2631,6 +3195,34 @@ export class FluentRequest {
2631
3195
  };
2632
3196
  return this;
2633
3197
  }
3198
+ /**
3199
+ * Attach an `Idempotency-Key`, generating one when none is given.
3200
+ *
3201
+ * Lets a retried `POST` be recognised as the same logical operation by the
3202
+ * server instead of creating a duplicate. Because the header is set on the
3203
+ * request options — not regenerated per attempt — every retry of this
3204
+ * request carries the same key, which is the entire point.
3205
+ *
3206
+ * @param value - An explicit key, or `undefined` to generate a v4 UUID.
3207
+ * @throws {TypeError} If `value` is not a valid key (see `isValidIdempotencyKey`).
3208
+ *
3209
+ * @example
3210
+ * ```ts
3211
+ * await client.POST("/charges").withJSON(body).idempotencyKey().json();
3212
+ * ```
3213
+ */
3214
+ idempotencyKey(value) {
3215
+ const key = value === undefined ? generateIdempotencyKey() : value;
3216
+ if (!isValidIdempotencyKey(key)) {
3217
+ throw new TypeError(`idempotencyKey: ${typeof key === "string" ? JSON.stringify(key) : typeof key} is not a ` +
3218
+ "valid Idempotency-Key — expected 1-255 visible ASCII characters");
3219
+ }
3220
+ this._options.headers = {
3221
+ ...this._options.headers,
3222
+ "idempotency-key": key,
3223
+ };
3224
+ return this;
3225
+ }
2634
3226
  /** Merge a headers map. */
2635
3227
  headers(headers) {
2636
3228
  this._options.headers = {
@@ -2837,16 +3429,40 @@ export class FluentRequest {
2837
3429
  // §15 UTILITIES
2838
3430
  // ============================================================================
2839
3431
  /**
2840
- * Create an AbortError that is compatible across runtimes.
2841
- * Uses DOMException where available (browser/Deno), falls back to plain Error.
3432
+ * Convert a KinetexResponse into the lifecycle HookResponse shape, or null
3433
+ * when the request never produced one (network error, timeout, abort).
3434
+ *
3435
+ * Used by the error-hook bridge so onError hooks can read the HTTP status of
3436
+ * a failed request instead of always seeing null.
3437
+ */
3438
+ function toHookResponse(res, request) {
3439
+ if (!res)
3440
+ return null;
3441
+ return {
3442
+ status: res.status,
3443
+ statusText: res.statusText,
3444
+ headers: res.headers,
3445
+ body: res.rawBody ?? null,
3446
+ request,
3447
+ };
3448
+ }
3449
+ /**
3450
+ * The abort error raised by the retry loop itself.
3451
+ *
3452
+ * This used to build a bare `DOMException`, which is a real `Error` but not a
3453
+ * `KinetexError`: it carries no `code`, so `err.code === "EABORT"` and
3454
+ * `err.isAbort` were both false here while every other abort path in the
3455
+ * library (see core.ts) raised `EABORT`. Callers documented to see `AbortError`
3456
+ * therefore got a structurally different error depending on whether the signal
3457
+ * fired mid-request or mid-retry-delay. The library's own `AbortError` keeps
3458
+ * `name === "AbortError"`, so name-based checks like `isAbortError` are
3459
+ * unaffected.
3460
+ *
3461
+ * @param request - The request being retried, attached when available.
3462
+ * @returns A KinetexError with code `EABORT`.
2842
3463
  */
2843
- function createAbortError() {
2844
- if (typeof DOMException !== "undefined") {
2845
- return new DOMException("Aborted", "AbortError");
2846
- }
2847
- const err = new Error("Aborted");
2848
- err.name = "AbortError";
2849
- return err;
3464
+ function createAbortError(request) {
3465
+ return new AbortError(request);
2850
3466
  }
2851
3467
  /**
2852
3468
  * Merge two query parameter maps into one.
@@ -2882,6 +3498,16 @@ function sleep(ms, signal) {
2882
3498
  signal?.addEventListener("abort", onAbort, { once: true });
2883
3499
  });
2884
3500
  }
3501
+ /**
3502
+ * True for request bodies that cannot be sent twice: a ReadableStream is
3503
+ * consumed (and locked) by the first attempt, and a Blob-backed stream is
3504
+ * derived from an already-read handle. Both are fine once, never on retry.
3505
+ */
3506
+ function isNonReplayableBody(body) {
3507
+ if (body instanceof ReadableStream)
3508
+ return true;
3509
+ return typeof Blob !== "undefined" && body instanceof Blob;
3510
+ }
2885
3511
  /** Cross-runtime performance.now() — falls back to Date.now(). */
2886
3512
  function perfNow() {
2887
3513
  return typeof performance !== "undefined" ? performance.now() : Date.now();
@@ -2942,7 +3568,13 @@ export function createMethodCircuitBreakerKey(req) {
2942
3568
  export class BatchQueue {
2943
3569
  /** The parent Kinetex instance used to send requests. */
2944
3570
  _client;
2945
- /** Maximum number of requests to flush at once. */
3571
+ /**
3572
+ * Maximum number of requests taken out of the queue per flush.
3573
+ * NOTE: this is a batching size, NOT a concurrency limit — every request in a
3574
+ * batch is dispatched immediately and in parallel, and `flush()` drains the
3575
+ * whole queue the same way. Use `maxBatch` to bound how much is dispatched per
3576
+ * tick, and a semaphore or rate limiter to bound actual parallelism.
3577
+ */
2946
3578
  _maxBatch;
2947
3579
  /** Milliseconds to wait before flushing an incomplete batch. */
2948
3580
  _flushMs;
@@ -2956,8 +3588,19 @@ export class BatchQueue {
2956
3588
  */
2957
3589
  constructor(client, options = {}) {
2958
3590
  this._client = client;
2959
- this._maxBatch = options.maxBatch ?? 100;
2960
- this._flushMs = options.flushMs ?? 0;
3591
+ // maxBatch must be a positive integer: _flushNow() splices exactly
3592
+ // `_maxBatch` items, so 0 (or a negative value) spliced nothing and made
3593
+ // flush() spin forever on a queue it could never drain.
3594
+ const maxBatch = options.maxBatch ?? 100;
3595
+ if (!Number.isInteger(maxBatch) || maxBatch < 1) {
3596
+ throw new RangeError(`BatchQueue maxBatch must be a positive integer (got ${maxBatch})`);
3597
+ }
3598
+ this._maxBatch = maxBatch;
3599
+ const flushMs = options.flushMs ?? 0;
3600
+ if (!Number.isFinite(flushMs) || flushMs < 0) {
3601
+ throw new RangeError(`BatchQueue flushMs must be a non-negative finite number (got ${flushMs})`);
3602
+ }
3603
+ this._flushMs = flushMs;
2961
3604
  }
2962
3605
  /**
2963
3606
  * Enqueue a request. Returns a promise that resolves when the batch
@@ -3018,7 +3661,11 @@ export class BatchQueue {
3018
3661
  * Loops until the queue is empty so items beyond maxBatch are not orphaned.
3019
3662
  */
3020
3663
  flush() {
3021
- while (this._queue.length > 0)
3664
+ // The constructor guarantees _maxBatch >= 1, so every _flushNow() removes at
3665
+ // least one item. The counter is defence in depth against a future change
3666
+ // reintroducing a zero-progress flush (which would spin forever).
3667
+ let guard = this._queue.length + 1;
3668
+ while (this._queue.length > 0 && guard-- > 0)
3022
3669
  this._flushNow();
3023
3670
  }
3024
3671
  /** How many requests are currently queued (not yet sent). */