kinetex 1.3.0 → 1.5.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 (123) hide show
  1. package/README.md +413 -52
  2. package/dist/browser/kinetex.esm.js +38 -22
  3. package/dist/browser/kinetex.js +3134 -787
  4. package/dist/browser/kinetex.min.js +38 -22
  5. package/dist/cjs/aws-sigv4.js +140 -33
  6. package/dist/cjs/cache.js +58 -7
  7. package/dist/cjs/circuit-breaker.js +53 -4
  8. package/dist/cjs/client.js +726 -188
  9. package/dist/cjs/cookie-parser.js +131 -6
  10. package/dist/cjs/cookie-store.js +125 -28
  11. package/dist/cjs/core.js +691 -70
  12. package/dist/cjs/dedup.js +142 -21
  13. package/dist/cjs/digest.js +195 -38
  14. package/dist/cjs/graphql.js +166 -35
  15. package/dist/cjs/headers.js +327 -46
  16. package/dist/cjs/interceptors.js +227 -24
  17. package/dist/cjs/lifecycle.js +92 -59
  18. package/dist/cjs/logging.js +160 -24
  19. package/dist/cjs/mod.js +3 -2
  20. package/dist/cjs/pagination.js +247 -22
  21. package/dist/cjs/progress.js +178 -30
  22. package/dist/cjs/proxy.js +429 -0
  23. package/dist/cjs/response.js +506 -110
  24. package/dist/cjs/socks5.js +136 -15
  25. package/dist/cjs/sse.js +173 -43
  26. package/dist/cjs/url.js +191 -45
  27. package/dist/cjs/utils.js +335 -73
  28. package/dist/cjs/ws.js +62 -12
  29. package/dist/esm/aws-sigv4.js +140 -33
  30. package/dist/esm/aws-sigv4.js.map +1 -1
  31. package/dist/esm/cache.js +58 -7
  32. package/dist/esm/cache.js.map +1 -1
  33. package/dist/esm/circuit-breaker.js +53 -4
  34. package/dist/esm/circuit-breaker.js.map +1 -1
  35. package/dist/esm/client.js +726 -188
  36. package/dist/esm/client.js.map +1 -1
  37. package/dist/esm/cookie-parser.js +131 -6
  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 +691 -70
  42. package/dist/esm/core.js.map +1 -1
  43. package/dist/esm/dedup.js +142 -21
  44. package/dist/esm/dedup.js.map +1 -1
  45. package/dist/esm/digest.js +195 -38
  46. package/dist/esm/digest.js.map +1 -1
  47. package/dist/esm/graphql.js +166 -35
  48. package/dist/esm/graphql.js.map +1 -1
  49. package/dist/esm/headers.js +327 -46
  50. package/dist/esm/headers.js.map +1 -1
  51. package/dist/esm/interceptors.js +227 -24
  52. package/dist/esm/interceptors.js.map +1 -1
  53. package/dist/esm/lifecycle.js +92 -59
  54. package/dist/esm/lifecycle.js.map +1 -1
  55. package/dist/esm/logging.js +160 -24
  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 +178 -30
  62. package/dist/esm/progress.js.map +1 -1
  63. package/dist/esm/proxy.js +430 -0
  64. package/dist/esm/proxy.js.map +1 -0
  65. package/dist/esm/response.js +506 -110
  66. package/dist/esm/response.js.map +1 -1
  67. package/dist/esm/socks5.js +136 -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 +335 -73
  75. package/dist/esm/utils.js.map +1 -1
  76. package/dist/esm/ws.js +62 -12
  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 +15 -2
  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 +216 -25
  89. package/dist/types/core.d.ts.map +1 -1
  90. package/dist/types/dedup.d.ts +38 -3
  91. package/dist/types/dedup.d.ts.map +1 -1
  92. package/dist/types/digest.d.ts +17 -37
  93. package/dist/types/digest.d.ts.map +1 -1
  94. package/dist/types/graphql.d.ts.map +1 -1
  95. package/dist/types/headers.d.ts +68 -31
  96. package/dist/types/headers.d.ts.map +1 -1
  97. package/dist/types/interceptors.d.ts +126 -38
  98. package/dist/types/interceptors.d.ts.map +1 -1
  99. package/dist/types/lifecycle.d.ts +19 -2
  100. package/dist/types/lifecycle.d.ts.map +1 -1
  101. package/dist/types/logging.d.ts +22 -3
  102. package/dist/types/logging.d.ts.map +1 -1
  103. package/dist/types/mod.d.ts +5 -3
  104. package/dist/types/mod.d.ts.map +1 -1
  105. package/dist/types/pagination.d.ts +0 -25
  106. package/dist/types/pagination.d.ts.map +1 -1
  107. package/dist/types/progress.d.ts +1 -1
  108. package/dist/types/progress.d.ts.map +1 -1
  109. package/dist/types/proxy.d.ts +50 -0
  110. package/dist/types/proxy.d.ts.map +1 -0
  111. package/dist/types/response.d.ts +49 -2
  112. package/dist/types/response.d.ts.map +1 -1
  113. package/dist/types/socks5.d.ts.map +1 -1
  114. package/dist/types/sse.d.ts.map +1 -1
  115. package/dist/types/types.d.ts +219 -26
  116. package/dist/types/types.d.ts.map +1 -1
  117. package/dist/types/url.d.ts +0 -25
  118. package/dist/types/url.d.ts.map +1 -1
  119. package/dist/types/utils.d.ts +69 -1
  120. package/dist/types/utils.d.ts.map +1 -1
  121. package/dist/types/ws.d.ts +0 -1
  122. package/dist/types/ws.d.ts.map +1 -1
  123. package/package.json +10 -4
@@ -4,16 +4,17 @@
4
4
  * retry, auth, cache, cookie jar, HAR, logging, progress, and SSE/GraphQL/Pagination.
5
5
  */
6
6
  import { ProgressTracker, withUploadProgress } from "./progress.js";
7
- import { DedupMap } from "./dedup.js";
7
+ import { DedupMap, dedupKeyFrom, identityFingerprint } from "./dedup.js";
8
8
  import { CircuitBreakerRegistry, } from "./circuit-breaker.js";
9
9
  import { WSClient } from "./ws.js";
10
- import { KinetexError, HTTPStatusError, AbortError, toRequestId } from "./types.js";
11
- import { isValidHeaderName, isValidHeaderValue, isSafeURL, uint8ArrayToBase64, randomBytes, } from "./utils.js";
12
- import { getAuthFingerprint, CREDENTIAL_HEADERS } from "./cache.js";
13
- import { createRateLimitInterceptor } from "./interceptors.js";
10
+ import { KinetexError, HTTPStatusError, AbortError, RedirectError, toRequestId } from "./types.js";
11
+ import { isValidHeaderName, isValidHeaderValue, isSafeURL, uint8ArrayToBase64, randomBytes, perfNow, isSensitiveHeader, } from "./utils.js";
12
+ import { CREDENTIAL_HEADERS } from "./cache.js";
13
+ import { createRateLimitInterceptor, ConcurrencyLimiter } from "./interceptors.js";
14
14
  import { SigV4Signer } from "./aws-sigv4.js";
15
15
  import { createDigestAuthorizer } from "./digest.js";
16
- import { DEFAULT_ACCEPT_ENCODING } from "./core.js";
16
+ import { generateIdempotencyKey, isValidIdempotencyKey, parseRetryAfter } from "./headers.js";
17
+ import { DEFAULT_ACCEPT_ENCODING, encodeMultipart } from "./core.js";
17
18
  import { createTransport, sendWithTimeout, decompressBodyStream, readRawBody, parseBody, RUNTIME, IS_NODE, } from "./core.js";
18
19
  /**
19
20
  * Hard ceiling on redirect hops followed by the manual redirect follower.
@@ -141,6 +142,15 @@ function randomHex(len) {
141
142
  * Mirrors logging.ts DEFAULT_REDACT_HEADERS — HAR logs are routinely exported
142
143
  * and shared, so credentials must never appear verbatim.
143
144
  */
145
+ /**
146
+ * Ceiling on a buffered response body when the caller sets no limit.
147
+ *
148
+ * Roughly the largest JSON document that is still sane to hand to
149
+ * `JSON.parse`, and far below the point where a single response can take a
150
+ * process down. Not a security boundary on its own — it is the difference
151
+ * between one bad response being a failed request and being an OOM kill.
152
+ */
153
+ const DEFAULT_MAX_RESPONSE_SIZE = 100 * 1024 * 1024;
144
154
  const HAR_REDACT_HEADERS = new Set([
145
155
  "authorization",
146
156
  "proxy-authorization",
@@ -170,6 +180,8 @@ const HAR_REDACT_HEADERS = new Set([
170
180
  * `meta` key carrying the number of response-interceptor re-sends so a
171
181
  * self-retriggering interceptor cannot loop forever.
172
182
  */
183
+ /** Request-meta key: a clock-skew correction has already been spent. */
184
+ const AWS_SKEW_CORRECTED = "__awsSkewCorrected";
173
185
  const INTERCEPTOR_RESEND_DEPTH = "__interceptorResendDepth";
174
186
  /** Hard cap on consecutive response-interceptor re-sends (digest refresh, etc.). */
175
187
  const MAX_INTERCEPTOR_RESENDS = 5;
@@ -199,6 +211,92 @@ const HAR_REDACT_PARAMS = new Set([
199
211
  "jwt",
200
212
  "code",
201
213
  ]);
214
+ /**
215
+ * Body field names whose values are redacted from a recorded HAR body.
216
+ *
217
+ * The same names already redacted from HAR headers and query parameters.
218
+ * Headers were covered and query parameters were covered, but a JSON request
219
+ * body was recorded verbatim: `{"password":"..."}` went into the export in
220
+ * clear text while `{"password":"..."}` in a header was masked. HAR files get
221
+ * attached to bug reports and handed to support teams, so a body is the most
222
+ * likely place for a credential to travel. Nested objects and arrays are
223
+ * walked to any depth.
224
+ */
225
+ /**
226
+ * Replace the literal values this request's own credentials take, anywhere in
227
+ * a recorded body.
228
+ *
229
+ * Field-name redaction cannot help a `text/plain` body, where there are no
230
+ * field names — and a server echoing a token back is the common case (an
231
+ * error body quoting the offending header, a "session invalid" message).
232
+ * Matching the exact values kinetex already knows are sensitive is precise: no
233
+ * pattern matching, no false positives on ordinary text.
234
+ */
235
+ function scrubKnownSecrets(text, req) {
236
+ const secrets = [];
237
+ // Credential headers are usually scheme-prefixed — `Bearer <token>` — and a
238
+ // body that echoes the token back echoes only the token, not the scheme.
239
+ // Scrubbing the whole header value would therefore match nothing.
240
+ const add = (v) => {
241
+ if (typeof v === "string" && v.length >= 8)
242
+ secrets.push(v);
243
+ };
244
+ for (const [name, value] of Object.entries(req.headers ?? {})) {
245
+ if (isSensitiveHeader(name, HAR_REDACT_HEADERS) && typeof value === "string") {
246
+ add(value);
247
+ const sp = value.indexOf(" ");
248
+ if (sp !== -1)
249
+ add(value.slice(sp + 1));
250
+ }
251
+ }
252
+ let query;
253
+ try {
254
+ query = new URL(req.url).searchParams;
255
+ }
256
+ catch {
257
+ query = new URLSearchParams();
258
+ }
259
+ for (const [key, value] of query.entries()) {
260
+ if (HAR_REDACT_PARAMS.has(key.toLowerCase()) &&
261
+ typeof value === "string" &&
262
+ value.length >= 8) {
263
+ secrets.push(value);
264
+ }
265
+ }
266
+ let out = text;
267
+ for (const secret of secrets)
268
+ out = out.split(secret).join(HAR_REDACT_MASK);
269
+ return out;
270
+ }
271
+ function redactHARBody(text, mimeType) {
272
+ if (!/json/i.test(mimeType))
273
+ return text;
274
+ let parsed;
275
+ try {
276
+ parsed = JSON.parse(text);
277
+ }
278
+ catch {
279
+ return text; // Not valid JSON despite the header; nothing safe to rewrite.
280
+ }
281
+ const walk = (node) => {
282
+ if (Array.isArray(node))
283
+ return node.map(walk);
284
+ if (node !== null && typeof node === "object") {
285
+ const out = {};
286
+ for (const [key, value] of Object.entries(node)) {
287
+ out[key] = HAR_REDACT_PARAMS.has(key.toLowerCase()) ? HAR_REDACT_MASK : walk(value);
288
+ }
289
+ return out;
290
+ }
291
+ return node;
292
+ };
293
+ try {
294
+ return JSON.stringify(walk(parsed));
295
+ }
296
+ catch {
297
+ return text;
298
+ }
299
+ }
202
300
  /** Maximum number of body characters recorded in a HAR entry. */
203
301
  const HAR_MAX_BODY_CHARS = 8192;
204
302
  /** HTML/other content types whose bodies are never recorded in HAR output. */
@@ -238,15 +336,57 @@ function redactHARUrl(url) {
238
336
  }
239
337
  }
240
338
  /** Redact a single header value for HAR output. */
339
+ /**
340
+ * HAR entries carry their own mask ("***REDACTED***"), distinct from the
341
+ * logger's "***" and the logging interceptor's "**REDACTED**". A recorded HAR
342
+ * is a file a user reads and shares, so the string is part of its output; the
343
+ * shared helper takes it as an argument rather than the three being merged.
344
+ */
345
+ const HAR_REDACT_MASK = "***REDACTED***";
241
346
  function redactHARHeader(name, value) {
242
- return HAR_REDACT_HEADERS.has(name.toLowerCase())
243
- ? { name, value: "***REDACTED***" }
244
- : { name, value };
347
+ // A HAR stores headers as `{name, value}` pairs rather than a record, so it
348
+ // uses the shared case-insensitive predicate rather than the record helper.
349
+ return {
350
+ name,
351
+ value: isSensitiveHeader(name, HAR_REDACT_HEADERS) ? HAR_REDACT_MASK : value,
352
+ };
245
353
  }
246
354
  /**
247
355
  * O(1) ring-buffer HAR entry recorder.
248
356
  * Stores up to `maxEntries` entries, evicting oldest first.
249
357
  */
358
+ /**
359
+ * The `postData` block for a recorded request, or `{}` when there is nothing
360
+ * safe or possible to record.
361
+ *
362
+ * Kept out of `record()` so the recorder's entry literal reads as the HAR it
363
+ * claims to conform to, and so the "may I record this?" decision has one home.
364
+ */
365
+ function harPostData(req) {
366
+ if (!req.body)
367
+ return {};
368
+ const mimeType = req.headers["content-type"] ?? "";
369
+ // Same policy as the response body: skip anything that is not plain text.
370
+ if (mimeType && !isHARBodySafeToRecord(mimeType))
371
+ return {};
372
+ let text;
373
+ if (typeof req.body === "string") {
374
+ text = req.body;
375
+ }
376
+ else if (req.body instanceof Uint8Array) {
377
+ text = new TextDecoder().decode(req.body);
378
+ }
379
+ else if (req.body instanceof ArrayBuffer) {
380
+ text = new TextDecoder().decode(new Uint8Array(req.body));
381
+ }
382
+ else {
383
+ return {}; // stream / FormData / Blob: not readable without consuming it
384
+ }
385
+ // Redact before truncating: truncating first could cut a redaction in half
386
+ // and leave the head of a secret in the export.
387
+ const redacted = scrubKnownSecrets(redactHARBody(text, mimeType), req);
388
+ return { postData: { mimeType, text: redacted.slice(0, HAR_MAX_BODY_CHARS) } };
389
+ }
250
390
  class HARRecorder {
251
391
  /** Ring buffer of entries keyed by monotonic counter. */
252
392
  _buf = new Map();
@@ -338,6 +478,17 @@ class HARRecorder {
338
478
  return req.body.byteLength;
339
479
  return -1; // Unknown (stream, FormData, etc.)
340
480
  })(),
481
+ // `postData` is declared on `HAREntry` as "Posted data, if applicable"
482
+ // and was never written, so a HAR exported from a client that POSTs
483
+ // anything shows an empty request body in every viewer — the response
484
+ // body, the query string, the headers and the URL are all there, and
485
+ // the one part that explains what was actually sent is not. The gates
486
+ // are the ones the response side already uses: a body is recorded only
487
+ // when its content type is safe to record, it is truncated to the same
488
+ // limit, and a body that cannot be read without consuming it (a stream,
489
+ // a FormData) is omitted rather than guessed at — which is what the
490
+ // `bodySize: -1` above already admits.
491
+ ...harPostData(req),
341
492
  },
342
493
  response: {
343
494
  status: res.status,
@@ -350,7 +501,12 @@ class HARRecorder {
350
501
  // Body text is only kept for non-HTML payloads and is truncated:
351
502
  // response bodies routinely carry tokens and PII.
352
503
  ...(typeof res.data === "string" && isHARBodySafeToRecord(res.headers["content-type"])
353
- ? { text: res.data.slice(0, HAR_MAX_BODY_CHARS) }
504
+ ? {
505
+ // Same reason as the request side: a `text/plain` body has no
506
+ // field names to redact by, and a server that echoes the token
507
+ // back would otherwise put it in the export verbatim.
508
+ text: scrubKnownSecrets(res.data, req).slice(0, HAR_MAX_BODY_CHARS),
509
+ }
354
510
  : {}),
355
511
  },
356
512
  // The Location header can itself carry a signed URL — redact it too.
@@ -473,35 +629,6 @@ async function applyAuth(req, auth) {
473
629
  // ============================================================================
474
630
  // §5 URL BUILDING
475
631
  // ============================================================================
476
- /**
477
- * Headers the Fetch spec already drops when a redirect crosses origins.
478
- * Anything credential-bearing outside this set is forwarded by fetch() itself,
479
- * which is why those requests must be redirected manually.
480
- * See {@link CROSS_ORIGIN_STRIP_HEADERS}.
481
- */
482
- const FETCH_SPEC_STRIPPED_ON_REDIRECT = new Set(["authorization", "cookie", "proxy-authorization"]);
483
- /**
484
- * True when the request carries a credential-bearing header that fetch()
485
- * would forward across a cross-origin redirect. Drives the decision to follow
486
- * redirects manually even when no cookie jar is configured.
487
- *
488
- * Two sources are consulted: the well-known CREDENTIAL_HEADERS names, and a
489
- * declared `apikey` auth header, whose name is chosen by the application and so
490
- * cannot be known to the library.
491
- */
492
- function hasForwardedCredentials(headers, auth) {
493
- if (auth && auth.type === "apikey")
494
- return true;
495
- if (!headers)
496
- return false;
497
- for (const name of Object.keys(headers)) {
498
- const lower = name.toLowerCase();
499
- if (CROSS_ORIGIN_STRIP_HEADERS.has(lower) && !FETCH_SPEC_STRIPPED_ON_REDIRECT.has(lower)) {
500
- return true;
501
- }
502
- }
503
- return false;
504
- }
505
632
  /**
506
633
  * Headers stripped when a redirect crosses origins (FIX H2).
507
634
  * These carry credentials and must never be forwarded to a different origin.
@@ -545,8 +672,49 @@ function redactUserInfo(url) {
545
672
  * @returns Fully-qualified URL string.
546
673
  * @throws {KinetexError} EVALIDATION — if URL is unsafe, params exceed limits, or URL too long.
547
674
  */
548
- function buildURL(base, url, params) {
675
+ function buildURL(base, url, params, allowedSchemes = ["http", "https"], allowPrivateNetwork = false) {
549
676
  const MAX_QUERY_PARAM_COUNT = 100;
677
+ /**
678
+ * Walk query params once, enforcing the count limit, handing each surviving
679
+ * key/value pair to `emit`.
680
+ *
681
+ * `null` and `undefined` are dropped, and an array expands to one entry per
682
+ * non-null element. Both URL-assembly paths below did exactly this and each
683
+ * carried its own copy of the limit check — four copies of the same `throw`,
684
+ * inside two loops — so the two paths could drift and the cap could end up
685
+ * enforced in one spelling of the query but not the other.
686
+ *
687
+ * Encoding stays with the caller, and so does the array/scalar distinction:
688
+ * the primary path must `append` array elements (so `?a=1&a=2` survives) but
689
+ * `set` a scalar (so it replaces a value the URL already carried). The
690
+ * fallback appends to a list it is building and does not care. That
691
+ * difference is observable, so `emit` is told which case it is in.
692
+ */
693
+ const forEachQueryParam = (params, emit) => {
694
+ let paramCount = 0;
695
+ const count = () => {
696
+ paramCount++;
697
+ if (paramCount > MAX_QUERY_PARAM_COUNT) {
698
+ throw new KinetexError(`Query parameter count ${paramCount} exceeds limit of ${MAX_QUERY_PARAM_COUNT}`, "EVALIDATION");
699
+ }
700
+ };
701
+ for (const [key, value] of Object.entries(params)) {
702
+ if (value === null || value === undefined)
703
+ continue;
704
+ if (Array.isArray(value)) {
705
+ for (const v of value) {
706
+ if (v === null || v === undefined)
707
+ continue;
708
+ count();
709
+ emit(key, String(v), true);
710
+ }
711
+ }
712
+ else {
713
+ count();
714
+ emit(key, String(value), false);
715
+ }
716
+ }
717
+ };
550
718
  const MAX_URL_LENGTH = 8192;
551
719
  let full;
552
720
  if (/^https?:\/\//i.test(url)) {
@@ -574,44 +742,33 @@ function buildURL(base, url, params) {
574
742
  }
575
743
  }
576
744
  if (!params || Object.keys(params).length === 0) {
577
- if (!isSafeURL(full)) {
745
+ if (!isSafeURL(full, allowedSchemes, { allowPrivateNetwork })) {
578
746
  throw new KinetexError(`URL "${redactUserInfo(full)}" failed safety check — blocked private/loopback address or forbidden scheme`, "EVALIDATION");
579
747
  }
580
748
  return full;
581
749
  }
582
750
  try {
583
751
  const u = new URL(full);
584
- let paramCount = 0;
585
- for (const [key, value] of Object.entries(params)) {
586
- if (value === null || value === undefined)
587
- continue;
588
- const strValue = String(value);
589
- if (Array.isArray(value)) {
590
- for (const v of value) {
591
- if (v !== null && v !== undefined) {
592
- paramCount++;
593
- if (paramCount > MAX_QUERY_PARAM_COUNT) {
594
- throw new KinetexError(`Query parameter count ${paramCount} exceeds limit of ${MAX_QUERY_PARAM_COUNT}`, "EVALIDATION");
595
- }
596
- u.searchParams.append(key, String(v));
597
- }
598
- }
599
- }
600
- else {
601
- paramCount++;
602
- if (paramCount > MAX_QUERY_PARAM_COUNT) {
603
- throw new KinetexError(`Query parameter count ${paramCount} exceeds limit of ${MAX_QUERY_PARAM_COUNT}`, "EVALIDATION");
604
- }
605
- u.searchParams.set(key, strValue);
606
- }
752
+ // The params branch screens the URL after appending them, so it needs the
753
+ // same scheme list as the no-params branch above.
754
+ if (!isSafeURL(u, allowedSchemes, { allowPrivateNetwork })) {
755
+ throw new KinetexError(`URL "${redactUserInfo(full)}" failed safety check — blocked private/loopback address or forbidden scheme`, "EVALIDATION");
607
756
  }
757
+ // An array element appends, so `?a=1&a=2` survives; a scalar sets, so it
758
+ // replaces a value the URL already carried under that name.
759
+ forEachQueryParam(params, (key, value, fromArray) => {
760
+ if (fromArray)
761
+ u.searchParams.append(key, value);
762
+ else
763
+ u.searchParams.set(key, value);
764
+ });
608
765
  const result = u.toString();
609
766
  // Check URL length limit
610
767
  if (result.length > MAX_URL_LENGTH) {
611
768
  throw new KinetexError(`URL length ${result.length} bytes exceeds limit of ${MAX_URL_LENGTH} bytes`, "EVALIDATION");
612
769
  }
613
770
  // Validate the final URL with params
614
- if (!isSafeURL(result)) {
771
+ if (!isSafeURL(result, allowedSchemes, { allowPrivateNetwork })) {
615
772
  throw new KinetexError(`URL "${redactUserInfo(result)}" failed safety check — blocked private/loopback address or forbidden scheme`, "EVALIDATION");
616
773
  }
617
774
  return result;
@@ -627,29 +784,9 @@ function buildURL(base, url, params) {
627
784
  full += "?";
628
785
  }
629
786
  const paramParts = [];
630
- let paramCount = 0;
631
- for (const [key, value] of Object.entries(params ?? {})) {
632
- if (value === null || value === undefined)
633
- continue;
634
- if (Array.isArray(value)) {
635
- for (const v of value) {
636
- if (v !== null && v !== undefined) {
637
- paramCount++;
638
- if (paramCount > MAX_QUERY_PARAM_COUNT) {
639
- throw new KinetexError(`Query parameter count ${paramCount} exceeds limit of ${MAX_QUERY_PARAM_COUNT}`, "EVALIDATION");
640
- }
641
- paramParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(v))}`);
642
- }
643
- }
644
- }
645
- else {
646
- paramCount++;
647
- if (paramCount > MAX_QUERY_PARAM_COUNT) {
648
- throw new KinetexError(`Query parameter count ${paramCount} exceeds limit of ${MAX_QUERY_PARAM_COUNT}`, "EVALIDATION");
649
- }
650
- paramParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
651
- }
652
- }
787
+ forEachQueryParam(params ?? {}, (key, value) => {
788
+ paramParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
789
+ });
653
790
  const result = full + paramParts.join("&");
654
791
  // Check URL length limit
655
792
  if (result.length > MAX_URL_LENGTH) {
@@ -657,7 +794,7 @@ function buildURL(base, url, params) {
657
794
  }
658
795
  // FIX M4: the manual param-concat fallback previously returned WITHOUT a
659
796
  // safety check — validate the assembled URL like every other path.
660
- if (!isSafeURL(result)) {
797
+ if (!isSafeURL(result, allowedSchemes, { allowPrivateNetwork })) {
661
798
  throw new KinetexError(`URL "${redactUserInfo(result)}" failed safety check — blocked private/loopback address or forbidden scheme`, "EVALIDATION");
662
799
  }
663
800
  return result;
@@ -713,11 +850,42 @@ function mergeHeaders(...sources) {
713
850
  * @param ctx - Current retry context (error, response, attempt count).
714
851
  * @returns Whether a retry should be attempted.
715
852
  */
853
+ /**
854
+ * A request body rendered for a log entry, or `null` when it cannot be.
855
+ *
856
+ * A `ReadableStream` or a `FormData` is deliberately not consumed to log it:
857
+ * reading either would consume the payload the request is about to send, so
858
+ * logging would break the request it is describing.
859
+ */
860
+ function loggableBody(body) {
861
+ if (body === null || body === undefined)
862
+ return null;
863
+ if (typeof body === "string")
864
+ return body;
865
+ if (body instanceof Uint8Array)
866
+ return body;
867
+ try {
868
+ return JSON.stringify(body);
869
+ }
870
+ catch {
871
+ // Cyclic, or otherwise not serialisable. The request still has to go out.
872
+ return null;
873
+ }
874
+ }
716
875
  function shouldRetry(cfg, ctx) {
717
876
  if (ctx.attempt > cfg.maxRetries)
718
877
  return false;
719
- if (!cfg.methods.includes(ctx.request.method))
878
+ // `Idempotency-Key` is a declaration that this exact request may be
879
+ // repeated: the whole point of the header is that the server collapses
880
+ // duplicates. Without this, POST is never retried, so a caller who attached
881
+ // a key — which kinetex generates for exactly this purpose — still lost the
882
+ // request to the first transient 503, and the changelog's claim that "every
883
+ // retry reuses it" described retries that could never happen. A caller opts
884
+ // in by sending the header, which is the same contract the header carries.
885
+ const hasIdempotencyKey = Object.keys(ctx.request.headers ?? {}).some((name) => name.toLowerCase() === "idempotency-key" && ctx.request.headers[name] !== "");
886
+ if (!hasIdempotencyKey && !cfg.methods.includes(ctx.request.method)) {
720
887
  return false;
888
+ }
721
889
  if (cfg.shouldRetry)
722
890
  return cfg.shouldRetry(ctx); // caller handles it
723
891
  if (ctx.error) {
@@ -745,6 +913,10 @@ function shouldRetry(cfg, ctx) {
745
913
  return false;
746
914
  case "EREDIRECT":
747
915
  return false;
916
+ // A blocked host is a permanent refusal, not a transport hiccup; retrying
917
+ // resolves the same name to the same blocked address.
918
+ case "EBLOCKEDHOST":
919
+ return false;
748
920
  case "EUNKNOWN":
749
921
  return false;
750
922
  default: {
@@ -775,7 +947,13 @@ function computeRetryDelay(cfg, attempt, retryAfterMs) {
775
947
  return cfg.maxDelayMs;
776
948
  }
777
949
  const capped = Math.min(exp, cfg.maxDelayMs);
778
- return Math.floor(capped + capped * cfg.jitter * Math.random());
950
+ const jittered = capped + capped * cfg.jitter * Math.random();
951
+ // `maxDelayMs` is documented as a maximum, so the cap must be re-applied
952
+ // *after* jitter. It was not: base 1000, cap 1200, jitter 1 returned 2000ms,
953
+ // so the option never bounded worst-case latency at all — it bounded only the
954
+ // pre-jitter base. With the default jitter of 0.3 a 30 s cap still allowed
955
+ // 39 s.
956
+ return Math.min(Math.floor(jittered), cfg.maxDelayMs);
779
957
  }
780
958
  /**
781
959
  * Extract and parse the Retry-After header value.
@@ -787,15 +965,21 @@ function getRetryAfterMs(headers) {
787
965
  const ra = headers["retry-after"];
788
966
  if (!ra)
789
967
  return null;
790
- if (/^\d+$/.test(ra.trim())) {
791
- const seconds = parseInt(ra, 10);
792
- if (!isFinite(seconds) || seconds < 0)
793
- return null;
968
+ // Delegate to the single RFC 7231 §7.1.1 parser instead of a second,
969
+ // looser copy: Date.parse() accepts "-5", "1.5" and "+5" as dates, which
970
+ // used to yield a 0 ms (i.e. "ignore Retry-After") back-off on a 429.
971
+ const parsed = parseRetryAfter(ra);
972
+ if (parsed.delay !== null) {
794
973
  const MAX_RETRY_AFTER_SEC = 86_400; // 24 hours
795
- return Math.min(seconds, MAX_RETRY_AFTER_SEC) * 1000;
974
+ return Math.min(parsed.delay, MAX_RETRY_AFTER_SEC) * 1000;
796
975
  }
797
- const ms = Date.parse(ra);
798
- return isNaN(ms) ? null : Math.max(0, Math.min(ms - Date.now(), 86_400_000));
976
+ if (parsed.date) {
977
+ // A date in the past genuinely means "retry now" — 0 is the answer, not a
978
+ // parse failure. Cap at 24 h so a bogus far-future date cannot stall.
979
+ const MAX_RETRY_AFTER_MS = 86_400_000;
980
+ return Math.max(0, Math.min(parsed.date.getTime() - Date.now(), MAX_RETRY_AFTER_MS));
981
+ }
982
+ return null;
799
983
  }
800
984
  // ============================================================================
801
985
  // §8 MAIN KINETEX CLASS
@@ -850,6 +1034,14 @@ export class Kinetex {
850
1034
  _circuitBreakerKeyFn = null;
851
1035
  /** Active WebSocket connections tracked for cleanup on destroy(). */
852
1036
  _wsClients = new Set();
1037
+ /**
1038
+ * Optional bulkhead bounding in-flight requests. `null` when
1039
+ * `concurrencyLimit` is not configured, which keeps the hot path free of a
1040
+ * limiter check.
1041
+ */
1042
+ _concurrencyLimiter;
1043
+ /** SigV4 signer kept so a clock-skew correction survives across retries. */
1044
+ _awsSigner = null;
853
1045
  /**
854
1046
  * @param config - Global client configuration.
855
1047
  */
@@ -868,10 +1060,17 @@ export class Kinetex {
868
1060
  const rlInterceptor = createRateLimitInterceptor(config.rateLimit);
869
1061
  this.interceptors.addRequest(rlInterceptor);
870
1062
  }
1063
+ // Concurrency limiter (bulkhead). Held as an object rather than a request
1064
+ // interceptor because a permit must survive until the request settles,
1065
+ // and interceptors cannot wrap the downstream call.
1066
+ this._concurrencyLimiter = config.concurrencyLimit
1067
+ ? new ConcurrencyLimiter(config.concurrencyLimit)
1068
+ : null;
871
1069
  // AWS SigV4 request signing — registered synchronously (static import).
872
1070
  // Active immediately; no race between first request and interceptor registration.
873
1071
  if (config.awsSigning) {
874
1072
  const signer = new SigV4Signer(config.awsSigning);
1073
+ this._awsSigner = signer;
875
1074
  this.interceptors.addRequest(async (ctx) => {
876
1075
  const req = ctx.request;
877
1076
  let signableBody = null;
@@ -889,7 +1088,19 @@ export class Kinetex {
889
1088
  });
890
1089
  }
891
1090
  // Transport — pass strictHeaders option through to FetchTransport
892
- this.transport = createTransport(config.fetch, config.httpVersion !== "HTTP/1.1", undefined, config.strictHeaders ? { strict: true } : undefined);
1091
+ this.transport = createTransport(config.fetch, config.httpVersion !== "HTTP/1.1",
1092
+ // The HTTP/2 session pool was configurable on the transport but the
1093
+ // client always passed `undefined` here, so `sessionPool` on the client
1094
+ // config could not tune it.
1095
+ config.sessionPool, {
1096
+ ...(config.strictHeaders ? { strict: true } : {}),
1097
+ ...(config.dispatcher !== undefined ? { dispatcher: config.dispatcher } : {}),
1098
+ ...(config.proxy !== undefined ? { proxy: config.proxy } : {}),
1099
+ ...(config.allowPrivateNetwork !== undefined
1100
+ ? { allowPrivateNetwork: config.allowPrivateNetwork }
1101
+ : {}),
1102
+ ...(config.ca !== undefined ? { ca: config.ca } : {}),
1103
+ });
893
1104
  // Register config-level interceptors
894
1105
  if (config.interceptors) {
895
1106
  config.interceptors.request?.forEach((fn) => this.interceptors.addRequest(fn));
@@ -992,7 +1203,7 @@ export class Kinetex {
992
1203
  status: ctx.response.status,
993
1204
  statusText: ctx.response.statusText,
994
1205
  headers: ctx.response.headers,
995
- body: ctx.response.rawBody ?? null,
1206
+ body: ctx.response.body ?? null,
996
1207
  request: {
997
1208
  url: ctx.request.url,
998
1209
  method: ctx.request.method,
@@ -1293,20 +1504,19 @@ export class Kinetex {
1293
1504
  /**
1294
1505
  * Returns deduplication metrics.
1295
1506
  *
1296
- * @returns An object with `hits` (coalesced request count), `misses` (actual network request count),
1297
- * and `inFlightCount` (currently in-flight requests), or `null` if dedup is not enabled.
1507
+ * The full `DedupMap.getStats()` snapshot, so a caller has to reach for one
1508
+ * shape rather than two. The wrapper used to expose only `hits`, `misses`
1509
+ * and `inFlightCount` — which meant the hit rate, the only figure anyone
1510
+ * actually wants from a dedup map, was unavailable on the client even
1511
+ * though the map computed it.
1512
+ *
1513
+ * @returns `{ hits, misses, totalRequests, hitRate, inFlightCount, trackedKeys }`,
1514
+ * or `null` if dedup is not enabled.
1298
1515
  */
1299
1516
  get dedupMetrics() {
1300
1517
  if (!this._dedup)
1301
1518
  return null;
1302
- return {
1303
- /** Number of requests that shared an in-flight or windowed response. */
1304
- hits: this._dedup.hits,
1305
- /** Number of requests that triggered a real network call. */
1306
- misses: this._dedup.misses,
1307
- /** Number of currently in-flight requests. */
1308
- inFlightCount: this._dedup.inFlightCount,
1309
- };
1519
+ return this._dedup.getStats();
1310
1520
  }
1311
1521
  // ── §8.5d Circuit Breaker ────────────────────────────────────────────────
1312
1522
  /**
@@ -1382,7 +1592,15 @@ export class Kinetex {
1382
1592
  * ```
1383
1593
  */
1384
1594
  async ws(url, options = {}) {
1385
- const fullURL = buildURL(this.cfg.baseURL, url, this.cfg.params);
1595
+ // `ws://` / `wss://` have to be allowed here, and only here. The default
1596
+ // `["http", "https"]` is what stops an ordinary HTTP request from being
1597
+ // pointed at a WebSocket scheme, and every other buildURL caller keeps it.
1598
+ // Without this opt-in the documented `client.ws("wss://…")` form — the one
1599
+ // in the README and in this method's own JSDoc — failed the SSRF scheme
1600
+ // check and threw EVALIDATION on every call, so `client.ws()` could not
1601
+ // connect to anything. Only the scheme list is widened: the loopback and
1602
+ // private-range checks still apply to WebSocket URLs.
1603
+ const fullURL = buildURL(this.cfg.baseURL, url, this.cfg.params, ["http", "https", "ws", "wss"], this.cfg.allowPrivateNetwork);
1386
1604
  const headers = mergeHeaders(this.cfg.headers, options.headers);
1387
1605
  // Apply auth headers manually since WS handshake goes through the browser
1388
1606
  // WS API which doesn't use the kinetex transport pipeline.
@@ -1415,7 +1633,11 @@ export class Kinetex {
1415
1633
  // Inject cookies from cookie jar into WS handshake
1416
1634
  const jar = await this.getCookieJar();
1417
1635
  if (jar) {
1418
- const cookieHeader = jar.getCookieHeader({ url: fullURL, http: false });
1636
+ const cookieHeader = jar.getCookieHeader({
1637
+ url: fullURL,
1638
+ http: false,
1639
+ sameSiteContext: "strict",
1640
+ });
1419
1641
  if (cookieHeader)
1420
1642
  headers["cookie"] = cookieHeader;
1421
1643
  }
@@ -1424,8 +1646,20 @@ export class Kinetex {
1424
1646
  const wsUrl = new URL(fullURL);
1425
1647
  if (this.cfg.baseURL) {
1426
1648
  const baseUrl = new URL(this.cfg.baseURL);
1427
- const wsIsSecure = wsUrl.protocol === "wss:";
1428
- const httpIsSecure = baseUrl.protocol === "https:";
1649
+ // `https:` counts as secure here, not just `wss:`. `fullURL` comes
1650
+ // from `buildURL` against the same baseURL, so it carries the baseURL's
1651
+ // own scheme — `https:` — and the WebSocket upgrade happens later, in
1652
+ // the runtime's `WebSocket`. Testing for `wss:` alone therefore made
1653
+ // `wsIsSecure` false for every https baseURL while `httpIsSecure` was
1654
+ // true, so `client.ws()` rejected its own configuration and could not
1655
+ // open a socket at all. The error even printed two identical origins.
1656
+ const wsIsSecure = wsUrl.protocol === "wss:" || wsUrl.protocol === "https:";
1657
+ // A baseURL may itself be a WebSocket URL — `kinetex({ baseURL:
1658
+ // "wss://…" })` then `client.ws("/path")` is the natural spelling, and
1659
+ // it is what the README's origin-validation section shows. Comparing
1660
+ // `wss:` only against `https:` rejected that pairing outright, so a
1661
+ // client configured with a WebSocket baseURL could never open a socket.
1662
+ const httpIsSecure = baseUrl.protocol === "https:" || baseUrl.protocol === "wss:";
1429
1663
  if (wsIsSecure !== httpIsSecure || wsUrl.host !== baseUrl.host) {
1430
1664
  throw new KinetexError(`WebSocket origin ${wsUrl.origin} does not match baseURL origin ${baseUrl.origin}`, "EVALIDATION");
1431
1665
  }
@@ -1478,6 +1712,12 @@ export class Kinetex {
1478
1712
  this._wsClients.add(client);
1479
1713
  await client.connect().catch((err) => {
1480
1714
  this._wsClients.delete(client);
1715
+ // Destroy, not just deregister. A handshake that fails before `open`
1716
+ // rejects with the socket still referenced by the WSClient — its
1717
+ // handlers, its buffer and the underlying handle — so dropping the
1718
+ // registry entry alone left it alive and holding a socket for the life of
1719
+ // the process. A caller that retried on failure leaked one per attempt.
1720
+ client.destroy();
1481
1721
  throw err;
1482
1722
  });
1483
1723
  return client;
@@ -1501,7 +1741,23 @@ export class Kinetex {
1501
1741
  // ── Resolve timeout ────────────────────────────────────────────────────
1502
1742
  const timeoutMs = options.timeout !== undefined ? options.timeout : (this.cfg.timeout ?? 30_000);
1503
1743
  // ── Resolve retry ──────────────────────────────────────────────────────
1504
- const retryCfg = options.retry === false ? false : { ...this.retryConfig, ...(options.retry ?? {}) };
1744
+ // `totalTimeoutMs` defaults to the request timeout, so `timeout` bounds the
1745
+ // whole logical request rather than each attempt. It applied per attempt
1746
+ // only, while the backoff between attempts was bounded by nothing: with
1747
+ // `timeout: 1_000`, five retries and a growing `baseDelayMs`, an instantly
1748
+ // failing endpoint held the caller for ~37s. Anyone who reads `timeout` as
1749
+ // "this call must not take longer than this" — most people — was wrong.
1750
+ // `timeout: 0` (no timeout) likewise yields no budget, and an explicit
1751
+ // `totalTimeoutMs: 0` opts back out.
1752
+ const retryCfg = options.retry === false
1753
+ ? false
1754
+ : {
1755
+ ...this.retryConfig,
1756
+ ...(options.retry ?? {}),
1757
+ ...(options.retry?.totalTimeoutMs !== undefined
1758
+ ? {}
1759
+ : { totalTimeoutMs: options.totalTimeoutMs ?? this.cfg.totalTimeoutMs ?? timeoutMs }),
1760
+ };
1505
1761
  // ── Validate HTTP method ────────────────────────────────────────────────
1506
1762
  const VALID_METHODS = [
1507
1763
  "GET",
@@ -1519,16 +1775,18 @@ export class Kinetex {
1519
1775
  throw new KinetexError(`Invalid HTTP method '${method}'. Valid methods: ${VALID_METHODS.join(", ")}`, "EVALIDATION");
1520
1776
  }
1521
1777
  // ── Build initial request ─────────────────────────────────────────────
1522
- const fullUrl = buildURL(options.baseURL ?? this.cfg.baseURL, url, mergeParams(this.cfg.params, options.params));
1523
- // FIX (M7): proxy configuration was stored but never consumed — a silent
1524
- // no-op that sent traffic directly to the target, bypassing the user's
1525
- // proxy entirely. Fail fast with actionable guidance instead.
1526
- const proxy = options.proxy ?? this.cfg.proxy;
1527
- if (proxy) {
1528
- throw new KinetexError("proxy is configured but kinetex's built-in transports cannot route through it: " +
1529
- "HTTP(S) proxies require a custom fetch with a proxy agent (e.g. undici ProxyAgent " +
1530
- "passed via the `fetch` option), and SOCKS5 requires createSocks5Tunnel() from " +
1531
- "kinetex/socks5. Set up one of those instead of relying on `proxy` silently doing nothing.", "EVALIDATION");
1778
+ const fullUrl = buildURL(options.baseURL ?? this.cfg.baseURL, url, mergeParams(this.cfg.params, options.params), undefined, this.cfg.allowPrivateNetwork === true);
1779
+ // A client-level `proxy` is honoured by the Node transport, which tunnels
1780
+ // every connection through it with CONNECT. A per-request `proxy` cannot be:
1781
+ // the transport pools one connection per origin, so honouring a per-request
1782
+ // proxy would mean tearing the pool down mid-flight. Reject it with an
1783
+ // accurate reason rather than silently ignoring it.
1784
+ const perRequestProxy = options.proxy;
1785
+ if (perRequestProxy) {
1786
+ throw new KinetexError("A per-request `proxy` is not supported because the transport pools one connection " +
1787
+ "per origin — set `proxy` on the client instead, or use a custom `fetch` with a " +
1788
+ "proxy agent for per-request routing. SOCKS5 requires createSocks5Tunnel() from " +
1789
+ "kinetex/socks5.", "EVALIDATION");
1532
1790
  }
1533
1791
  // Enforce HTTPS-only if configured
1534
1792
  if (this.cfg.httpsOnly) {
@@ -1544,6 +1802,14 @@ export class Kinetex {
1544
1802
  throw new KinetexError(`Invalid URL: ${err}`, "EVALIDATION");
1545
1803
  }
1546
1804
  }
1805
+ // A multipart body has no size until it is encoded, and the encoding is
1806
+ // the same call the dispatch path makes below, so it is done once here and
1807
+ // the result reused. The guard used to estimate it with a flat 76 bytes per
1808
+ // part, which is less than the framing `encodeMultipart` actually writes
1809
+ // for its 70-character generated boundary: a form sized to exactly the
1810
+ // estimate passed this check and then went out roughly 45 bytes per part
1811
+ // over the limit the caller had set.
1812
+ let preEncodedForm;
1547
1813
  // Enforce request size limit if configured
1548
1814
  const maxRequestSize = options.maxRequestSize ?? this.cfg.maxRequestSize ?? 0;
1549
1815
  if (maxRequestSize > 0 && options.body) {
@@ -1571,19 +1837,13 @@ export class Kinetex {
1571
1837
  bodySize = new TextEncoder().encode(options.body.toString()).byteLength;
1572
1838
  }
1573
1839
  else if (options.body instanceof FormData) {
1574
- // FIX (H4): estimate multipart size instead of skipping entirely —
1575
- // the old skip allowed unbounded uploads past the configured limit.
1576
- const boundaryOverhead = 76; // per part: --boundary, headers, CRLF (conservative)
1577
- for (const [name, value] of options.body) {
1578
- bodySize += new TextEncoder().encode(name).byteLength + boundaryOverhead;
1579
- if (typeof value === "string") {
1580
- bodySize += new TextEncoder().encode(value).byteLength;
1581
- }
1582
- else {
1583
- bodySize += value.size;
1584
- }
1585
- }
1586
- bodySize += boundaryOverhead; // final boundary
1840
+ // FIX (H4): the old branch skipped FormData entirely, which allowed
1841
+ // unbounded uploads past the configured limit; it then replaced the
1842
+ // skip with an estimate whose per-part constant was smaller than the
1843
+ // framing it stood in for. The exact bytes are the only honest
1844
+ // answer, and they are needed a few lines below anyway.
1845
+ preEncodedForm = await encodeMultipart(options.body);
1846
+ bodySize = preEncodedForm.bytes.byteLength;
1587
1847
  }
1588
1848
  else if (options.body instanceof ReadableStream) {
1589
1849
  // FIX (H4): a stream's size cannot be known without consuming it —
@@ -1630,6 +1890,25 @@ export class Kinetex {
1630
1890
  meta: { ...options.meta },
1631
1891
  httpVersion: options.httpVersion ?? this.cfg.httpVersion ?? "HTTP/2",
1632
1892
  };
1893
+ // A `FormData` body is encoded here rather than handed to the transport,
1894
+ // because the encoding and the `Content-Type` that describes it have to be
1895
+ // produced together. The raw Node transports bypass fetch, and
1896
+ // `serializeRawBody` — the function written so they would not send an empty
1897
+ // body — covered `URLSearchParams` and `Blob` but not `FormData`, so on the
1898
+ // default transport on Node a form upload went out with no body and no
1899
+ // `Content-Type` and the server recorded an empty form. The response was an
1900
+ // ordinary 200, so nothing downstream could tell.
1901
+ if (req.body !== null && req.body instanceof FormData && !req.headers["content-type"]) {
1902
+ const encoded = preEncodedForm ?? (await encodeMultipart(req.body));
1903
+ req = {
1904
+ ...req,
1905
+ headers: {
1906
+ ...req.headers,
1907
+ "content-type": `multipart/form-data; boundary=${encoded.boundary}`,
1908
+ },
1909
+ body: encoded.bytes,
1910
+ };
1911
+ }
1633
1912
  // Default Content-Type for JSON bodies
1634
1913
  if (req.body !== null &&
1635
1914
  typeof req.body === "object" &&
@@ -1668,7 +1947,11 @@ export class Kinetex {
1668
1947
  // ── Cookie jar (outgoing cookies) ──────────────────────────────────────
1669
1948
  const jar = await this.getCookieJar();
1670
1949
  if (jar) {
1671
- const cookieHeader = jar.getCookieHeader({ url: req.url, http: true });
1950
+ const cookieHeader = jar.getCookieHeader({
1951
+ url: req.url,
1952
+ http: true,
1953
+ sameSiteContext: "strict",
1954
+ });
1672
1955
  if (cookieHeader)
1673
1956
  req = { ...req, headers: { ...req.headers, cookie: cookieHeader } };
1674
1957
  }
@@ -1684,6 +1967,7 @@ export class Kinetex {
1684
1967
  // abandoning it — an unended span is never exported and never reports the
1685
1968
  // error, and holds its attributes in the tracer's memory.
1686
1969
  let _otelSpan = null;
1970
+ let _dedupKey = "";
1687
1971
  try {
1688
1972
  if (this._otelTracer) {
1689
1973
  _otelSpan = this._otelTracer.startSpan(`HTTP ${req.method}`, { kind: 3 /* CLIENT */ });
@@ -1712,6 +1996,24 @@ export class Kinetex {
1712
1996
  meta: { ...req.meta, spanId },
1713
1997
  };
1714
1998
  }
1999
+ // Dedup key. Computed inside this try, not below it, so that a failure
2000
+ // here ends the span instead of abandoning it. The auth fingerprint needs
2001
+ // Web Crypto, so it can throw on a runtime without `crypto.subtle` — and
2002
+ // because it is an `await`, the throw resumes here, outside the span's
2003
+ // lifecycle when this block sat below the catch. The span was then never
2004
+ // ended, never exported, and held its attributes in the tracer's memory.
2005
+ if (this._dedup) {
2006
+ // Fingerprint *every* header, not just the credential ones. Keying on
2007
+ // `Authorization`/`Cookie` alone is what the comment above promised,
2008
+ // and it held — for the two headers it covered. It did not cover the
2009
+ // header a multi-tenant gateway actually partitions on: two concurrent
2010
+ // GETs to one URL differing only in `X-Tenant` shared a single response,
2011
+ // so one tenant was handed another's data. Hashing keeps the values out
2012
+ // of the key itself, and the trace-id headers are excluded so they
2013
+ // cannot silently disable coalescing.
2014
+ const identity = await identityFingerprint(req.headers);
2015
+ _dedupKey = dedupKeyFrom(req.method, req.url, identity);
2016
+ }
1715
2017
  }
1716
2018
  catch (tracingErr) {
1717
2019
  if (_otelSpan) {
@@ -1752,16 +2054,12 @@ export class Kinetex {
1752
2054
  const _execFactory = () => this._executeWithRetry(req, retryCfg, timeoutMs, options, startMs, wallClockMs);
1753
2055
  // Dedup: coalesce identical concurrent GET/HEAD requests into one network
1754
2056
  // call. All callers share the same KinetexResponse object once resolved.
1755
- // SECURITY: The dedup key includes a fingerprint of auth-sensitive headers
1756
- // so requests from different users (different Authorization / Cookie) are
1757
- // NEVER coalesced — each user gets their own isolated in-flight slot.
1758
- // Fingerprinting hashes the credential headers with SHA-256, so it is only
1759
- // paid when dedup is actually enabled.
1760
- let _dedupKey = "";
1761
- if (this._dedup) {
1762
- const authFp = await getAuthFingerprint(req.headers ?? {});
1763
- _dedupKey = `${req.method}:${req.url}${authFp ? ":" + authFp : ""}`;
1764
- }
2057
+ // SECURITY: the dedup key fingerprints every request header, so two
2058
+ // requests are only ever coalesced when they would have received the same
2059
+ // response — including when they differ only by tenant or session header.
2060
+ // Fingerprinting hashes the header set with SHA-256, so credentials never
2061
+ // sit in the key, and it is only paid when dedup is actually enabled. The
2062
+ // key is computed inside the tracing try above, where a throw ends the span.
1765
2063
  const _dedupedFactory = this._dedup
1766
2064
  ? () => this._dedup
1767
2065
  .execute(req.method, _dedupKey, _execFactory)
@@ -1801,6 +2099,82 @@ export class Kinetex {
1801
2099
  * and retries on failure per the retry config.
1802
2100
  */
1803
2101
  async _executeWithRetry(req, retryCfg, timeout, options, startMs, wallClockMs) {
2102
+ // A permit is held for the whole logical request — including every retry
2103
+ // attempt — and released in `finally`, so a throw or an exhausted retry
2104
+ // budget can never leak one and permanently shrink the pool.
2105
+ const run = async () => {
2106
+ if (!this._concurrencyLimiter) {
2107
+ return await this._executeWithRetryInner(req, retryCfg, timeout, options, startMs, wallClockMs);
2108
+ }
2109
+ await this._concurrencyLimiter.acquire(req.signal);
2110
+ try {
2111
+ return await this._executeWithRetryInner(req, retryCfg, timeout, options, startMs, wallClockMs);
2112
+ }
2113
+ finally {
2114
+ this._concurrencyLimiter.release();
2115
+ }
2116
+ };
2117
+ // Metrics measure the whole logical request, retries included, and are
2118
+ // emitted from `finally` so failures and aborts are counted too. The
2119
+ // no-tracer case is not short-circuited here: `_recordMetrics` returns
2120
+ // immediately when telemetry is off, and this client then pays nothing
2121
+ // beyond the call it already makes on every request.
2122
+ const metricsStart = Date.now();
2123
+ let status;
2124
+ let errorCode;
2125
+ try {
2126
+ const res = await run();
2127
+ status = res.status;
2128
+ return res;
2129
+ }
2130
+ catch (err) {
2131
+ errorCode = err.code;
2132
+ throw err;
2133
+ }
2134
+ finally {
2135
+ this._recordMetrics(req, status, errorCode, Date.now() - metricsStart);
2136
+ }
2137
+ }
2138
+ /**
2139
+ * Emit request metrics to the configured tracer, if it supports them.
2140
+ *
2141
+ * Failures here are swallowed on purpose: telemetry must never be able to
2142
+ * fail a request that otherwise succeeded.
2143
+ *
2144
+ * @param req - The originating request.
2145
+ * @param status - Final HTTP status, or undefined if the request threw.
2146
+ * @param errorCode - `KinetexError` code, or undefined on success.
2147
+ * @param durationMs - Wall-clock duration of the logical request.
2148
+ */
2149
+ _recordMetrics(req, status, errorCode, durationMs) {
2150
+ const tracer = this._otelTracer;
2151
+ if (!tracer)
2152
+ return;
2153
+ // `req.url` is always an absolute, already-parsed URL by the time a
2154
+ // request reaches here: `buildURL()` constructs it and the SSRF safety
2155
+ // check parses it again before dispatch, so there is nothing to guard.
2156
+ const attributes = {
2157
+ "http.request.method": req.method,
2158
+ "server.address": new URL(req.url).hostname,
2159
+ };
2160
+ if (status !== undefined)
2161
+ attributes["http.response.status_code"] = status;
2162
+ if (errorCode !== undefined)
2163
+ attributes["error.type"] = errorCode;
2164
+ try {
2165
+ // Seconds, per OTel semantic conventions for http.client.request.duration.
2166
+ tracer.recordHistogram?.("http.client.request.duration", durationMs / 1000, attributes);
2167
+ tracer.incrementCounter?.("http.client.request.count", 1, attributes);
2168
+ if (errorCode !== undefined) {
2169
+ tracer.incrementCounter?.("http.client.error.count", 1, attributes);
2170
+ }
2171
+ }
2172
+ catch {
2173
+ // Swallowed — see the doc comment.
2174
+ }
2175
+ }
2176
+ /** Retry loop body. See {@link _executeWithRetry} for the concurrency gate. */
2177
+ async _executeWithRetryInner(req, retryCfg, timeout, options, startMs, wallClockMs) {
1804
2178
  let attempt = 0;
1805
2179
  while (true) {
1806
2180
  attempt++;
@@ -1831,6 +2205,14 @@ export class Kinetex {
1831
2205
  : shouldRetry(retryCfg, retryCtx);
1832
2206
  if (doRetry) {
1833
2207
  const delay = computeRetryDelay(retryCfg, attempt, getRetryAfterMs(res.headers));
2208
+ // Total budget: a backoff that would run past it is not worth
2209
+ // sleeping through. The caller gets the response they already have
2210
+ // (a retry-worthy status) instead of a manufactured timeout, which
2211
+ // keeps the real reason visible.
2212
+ if (retryCfg.totalTimeoutMs !== undefined && retryCfg.totalTimeoutMs > 0) {
2213
+ if (perfNow() - startMs + delay >= retryCfg.totalTimeoutMs)
2214
+ return res;
2215
+ }
1834
2216
  await retryCfg.onRetry?.(retryCtx, delay);
1835
2217
  await this.cfg.hooks?.onRetry?.reduce(async (p, fn) => {
1836
2218
  await p;
@@ -1852,20 +2234,67 @@ export class Kinetex {
1852
2234
  method: req.method,
1853
2235
  });
1854
2236
  }
1855
- if (retryCfg && attempt <= retryCfg.maxRetries) {
2237
+ // Clock-skew correction. `SigV4Signer.handleClockSkewError` existed and
2238
+ // `detectClockSkew` was a public export, but nothing in the library
2239
+ // ever called them: a client with a wrong clock got a 403
2240
+ // `RequestTimeTooSkewed`, retried if 403 happened to be in `statuses`,
2241
+ // and re-signed the identical wrong timestamp every attempt. Correcting
2242
+ // here is bounded to one attempt per logical request, so a server that
2243
+ // keeps reporting a different time cannot spin.
2244
+ let clockSkewCorrected = false;
2245
+ const skewResponse = err.response;
2246
+ if (this._awsSigner && skewResponse && err.code === "EHTTPSTATUS") {
2247
+ // `data` is the parsed body: a string for text, a Uint8Array for any
2248
+ // other content type (which is what an XML error body arrives as),
2249
+ // and an object only for JSON.
2250
+ const raw = skewResponse.data;
2251
+ const body = typeof raw === "string"
2252
+ ? raw
2253
+ : raw instanceof Uint8Array
2254
+ ? new TextDecoder().decode(raw)
2255
+ : raw instanceof ArrayBuffer
2256
+ ? new TextDecoder().decode(new Uint8Array(raw))
2257
+ : JSON.stringify(raw ?? "");
2258
+ if (this._awsSigner.handleClockSkewError(skewResponse.status, body, skewResponse.headers)) {
2259
+ clockSkewCorrected = !req.meta[AWS_SKEW_CORRECTED];
2260
+ if (clockSkewCorrected)
2261
+ req.meta[AWS_SKEW_CORRECTED] = true;
2262
+ }
2263
+ }
2264
+ if (retryCfg && (attempt <= retryCfg.maxRetries || clockSkewCorrected)) {
2265
+ // A thrown HTTPStatusError already carries the full response. This
2266
+ // path used to hand hooks a context with `response: null` and a hard
2267
+ // `null` Retry-After, so the *default* case — throwOnError: true,
2268
+ // which is what every 429/503 goes through — ignored the server's
2269
+ // Retry-After entirely and never fired lifecycle onRetry hooks.
2270
+ const errResponse = skewResponse ?? null;
1856
2271
  const retryCtx = {
1857
2272
  request: req,
1858
- response: null,
2273
+ response: errResponse,
1859
2274
  error: err,
1860
2275
  attempt,
1861
2276
  maxRetries: retryCfg.maxRetries,
1862
2277
  };
1863
- const doRetry = retryCfg.shouldRetry
1864
- ? await retryCfg.shouldRetry(retryCtx)
1865
- : shouldRetry(retryCfg, retryCtx);
2278
+ const doRetry = clockSkewCorrected
2279
+ ? true
2280
+ : retryCfg.shouldRetry
2281
+ ? await retryCfg.shouldRetry(retryCtx)
2282
+ : shouldRetry(retryCfg, retryCtx);
1866
2283
  if (doRetry) {
1867
- const delay = computeRetryDelay(retryCfg, attempt, null);
2284
+ const delay = computeRetryDelay(retryCfg, attempt, errResponse ? getRetryAfterMs(errResponse.headers) : null);
2285
+ // Total budget, as above: do not sleep past the caller's ceiling.
2286
+ // Here there is no response to fall back on, so the original error
2287
+ // stands and is rethrown below.
2288
+ if (retryCfg.totalTimeoutMs !== undefined && retryCfg.totalTimeoutMs > 0) {
2289
+ if (perfNow() - startMs + delay >= retryCfg.totalTimeoutMs) {
2290
+ throw err;
2291
+ }
2292
+ }
1868
2293
  await retryCfg.onRetry?.(retryCtx, delay);
2294
+ await this.cfg.hooks?.onRetry?.reduce(async (p, fn) => {
2295
+ await p;
2296
+ await fn(retryCtx);
2297
+ }, Promise.resolve());
1869
2298
  await sleep(delay, req.signal);
1870
2299
  continue;
1871
2300
  }
@@ -1971,9 +2400,7 @@ export class Kinetex {
1971
2400
  if (isRedirect) {
1972
2401
  // Check for redirect loops
1973
2402
  if (visited.has(raw.url)) {
1974
- throw new KinetexError(`Redirect loop detected: ${raw.url}`, "ENETWORK", {
1975
- request: req,
1976
- });
2403
+ throw new RedirectError(`Redirect loop detected: ${raw.url}`, req);
1977
2404
  }
1978
2405
  visited.add(raw.url);
1979
2406
  // Capture cookies from this redirect hop
@@ -1982,13 +2409,26 @@ export class Kinetex {
1982
2409
  });
1983
2410
  // `followRedirects: false` (or `maxRedirects: 0`) hands the 3xx back to
1984
2411
  // the caller instead of chasing it — the same shape fetch() returns for
1985
- // `redirect: "manual"`.
2412
+ // `redirect: "manual"`. It reported `redirected: true`, which is the one
2413
+ // value `redirected` must never take: no hop was taken, `res.url` is
2414
+ // still the request's own URL, and the whole point of the option is that
2415
+ // the caller now has to read `Location` and decide for itself. A caller
2416
+ // branching on `if (res.redirected)` to detect a cross-origin bounce was
2417
+ // told it had already been redirected to a URL it never requested.
1986
2418
  if (!followRedirects)
1987
- return { ...raw, redirected: true };
2419
+ return { ...raw, redirected: false };
1988
2420
  if (hop === maxRedirects) {
1989
- throw new KinetexError(`Too many redirects (exceeded ${maxRedirects})`, "ENETWORK", {
1990
- request: req,
1991
- });
2421
+ // `EREDIRECT`, not `ENETWORK`. A redirect chain that has run out of
2422
+ // hops is a deterministic answer from the origin: the same request
2423
+ // produces the same chain. As an `ENETWORK` it fell into
2424
+ // `shouldRetry`'s network-error case and the whole chain was replayed
2425
+ // once per attempt — 16 requests against a server already looping,
2426
+ // under a `maxRedirects: 3` the caller had set, and after the whole
2427
+ // backoff schedule before the error they asked for finally arrived.
2428
+ // `shouldRetry` already had a non-retryable `EREDIRECT` case, and
2429
+ // `RedirectError` was already exported and documented; nothing
2430
+ // constructed it.
2431
+ throw new RedirectError(`Too many redirects (exceeded ${maxRedirects})`, req);
1992
2432
  }
1993
2433
  // Drain the redirect body (usually empty, but must be cancelled)
1994
2434
  if (raw.body) {
@@ -2016,7 +2456,9 @@ export class Kinetex {
2016
2456
  // public host could 302 the client straight at link-local/loopback
2017
2457
  // addresses (169.254.169.254, 127.0.0.1, 10/8, ::1, …) and the whole
2018
2458
  // private-network block list would be bypassable. Re-validate every hop.
2019
- if (!isSafeURL(nextUrl)) {
2459
+ if (!isSafeURL(nextUrl, undefined, {
2460
+ allowPrivateNetwork: this.cfg.allowPrivateNetwork === true,
2461
+ })) {
2020
2462
  throw new KinetexError(`Unsafe redirect target blocked: ${redactUserInfo(location)}`, "EVALIDATION", { request: req });
2021
2463
  }
2022
2464
  // httpsOnly must hold for redirect legs too, otherwise a redirect is a
@@ -2039,6 +2481,15 @@ export class Kinetex {
2039
2481
  ? "GET"
2040
2482
  : currentReq.method;
2041
2483
  const nextBody = nextMethod === "GET" || nextMethod === "HEAD" ? null : currentReq.body;
2484
+ // A 307/308 keeps the method and the body, so the hop needs the body
2485
+ // again — and a stream or Blob body is spent: the first attempt consumed
2486
+ // and locked it. The retry loop already refuses this case with a clear
2487
+ // EVALIDATION, but the follower had no equivalent check, so it handed
2488
+ // the dead stream to the transport and the caller got an opaque ENETWORK
2489
+ // "Response body object should not be disturbed or locked".
2490
+ if (nextBody !== null && isNonReplayableBody(nextBody)) {
2491
+ throw new KinetexError("Cannot follow this redirect: a 307/308 preserves the method and body, and this request's body is a stream or Blob that the first attempt already consumed. Buffer it first, or disable redirects for this request.", "EVALIDATION", { request: currentReq });
2492
+ }
2042
2493
  const nextHeaders = { ...currentReq.headers };
2043
2494
  // FIX H2: When the redirect crosses origins, strip credential-bearing
2044
2495
  // headers (Authorization, Cookie, proxy auth, API keys) so secrets are
@@ -2070,7 +2521,13 @@ export class Kinetex {
2070
2521
  // Jar scoping guarantees only cookies that match the NEW origin are
2071
2522
  // attached, which is exactly the post-strip state we want.
2072
2523
  if (jar) {
2073
- const cookieHeader = jar.getCookieHeader({ url: nextUrl, http: true });
2524
+ // Same context as the initial hop: a redirect followed to an origin the
2525
+ // client chose is still a same-site request for cookie purposes.
2526
+ const cookieHeader = jar.getCookieHeader({
2527
+ url: nextUrl,
2528
+ http: true,
2529
+ sameSiteContext: "strict",
2530
+ });
2074
2531
  if (cookieHeader) {
2075
2532
  nextHeaders["cookie"] = cookieHeader;
2076
2533
  }
@@ -2100,7 +2557,7 @@ export class Kinetex {
2100
2557
  return hop > 0 ? { ...raw, redirected: true } : raw;
2101
2558
  }
2102
2559
  // Unreachable
2103
- throw new KinetexError("Redirect loop", "ENETWORK", { request: req });
2560
+ throw new RedirectError("Redirect loop", req);
2104
2561
  }
2105
2562
  // ── §8.8 Single attempt ──────────────────────────────────────────────────
2106
2563
  /**
@@ -2136,10 +2593,30 @@ export class Kinetex {
2136
2593
  }
2137
2594
  }
2138
2595
  this._trace(_traceId, "lifecycle_before", "end", startMs, attempt);
2596
+ // ── Logger: request ────────────────────────────────────────────────────
2597
+ // `HTTPLogger.logRequest` was fully implemented — it registers the id so
2598
+ // the matching `logResponse` can recover the method, URL and duration —
2599
+ // and the client never called it. Nothing had ever been registered, so
2600
+ // every response entry came back with `url: ""`, `method: "GET"`,
2601
+ // `durationMs: 0` and `requestId: "unknown"`: the request line was simply
2602
+ // missing, and a structured log that cannot name the request it describes
2603
+ // is not much use for diagnosing one. Registered here, before dispatch, so
2604
+ // it covers the same span the response entry closes.
2605
+ const _logger = await this.getLogger();
2606
+ if (_logger) {
2607
+ _logger.logRequest(_traceId, req.method, req.url, req.headers, loggableBody(req.body), attempt);
2608
+ }
2139
2609
  // ── Cache lookup ───────────────────────────────────────────────────────
2610
+ // `noCache()` sets `cache: { forceRefresh: true }`, and `forceRefresh` was
2611
+ // declared on `CacheRequestConfig` and read *nowhere*: the lookup below ran
2612
+ // exactly as if no option had been passed, so a warm entry was served and
2613
+ // the fluent method documented as "Force a fresh fetch, bypassing any cached
2614
+ // response" did not fetch. The fix is to skip the read for this request —
2615
+ // the write still happens, which is what "refresh" means.
2616
+ const forceRefresh = options.cache !== false && options.cache?.forceRefresh === true;
2140
2617
  if (options.cache !== false && this.cfg.cache) {
2141
2618
  const cache = await this.getCache();
2142
- if (cache) {
2619
+ if (cache && !forceRefresh) {
2143
2620
  const cacheReq = { url: req.url, method: req.method, headers: req.headers };
2144
2621
  const hit = await cache.get(cacheReq);
2145
2622
  if (hit && !hit.stale) {
@@ -2310,22 +2787,34 @@ export class Kinetex {
2310
2787
  };
2311
2788
  }
2312
2789
  // ── Dispatch ───────────────────────────────────────────────────────────
2313
- // Redirects are followed by hand when EITHER:
2314
- // - a cookie jar is active, so every intermediate Set-Cookie is captured
2315
- // (fetch() silently drops them), or
2316
- // - the request carries a credential header that fetch() would NOT strip on
2317
- // a cross-origin redirect. Per the Fetch spec only `authorization`,
2318
- // `cookie` and `proxy-authorization` are dropped, so a custom API-key
2319
- // header would otherwise be forwarded verbatim to a foreign origin.
2790
+ // Redirects are ALWAYS followed by hand, and that is not a preference.
2791
+ //
2792
+ // `_sendFollowingRedirects` is the only place kinetex checks a redirect
2793
+ // *target*: the SSRF gate (`isSafeURL` on every hop), the `httpsOnly`
2794
+ // policy, the redirect-loop detector, the `maxRedirects` cap, and the
2795
+ // RFC 7231 method downgrade for 301/302/303. It also exists to capture
2796
+ // intermediate Set-Cookie and to strip credentials across origins, which is
2797
+ // why it used to be entered only when a cookie jar or a forwarded credential
2798
+ // header happened to be configured.
2799
+ //
2800
+ // An ordinary GET is neither. So an ordinary GET's redirects were chased by
2801
+ // the transport instead, and every check above was skipped: the HTTP/2
2802
+ // transport's own loop resolved any `Location` and dialled it, and
2803
+ // `FetchTransport` handed fetch `redirect: "follow"`, which does the same.
2804
+ // A 302 to `http://127.0.0.1:9/` opened the socket (`ECONNREFUSED` came
2805
+ // back from the loopback port, which is the point — nothing refused the
2806
+ // connection first), and a 302 to `http://169.254.169.254/` was answered by
2807
+ // the cloud metadata service. `httpsOnly: true` changed nothing on either
2808
+ // path; both reported the same opaque `Protocol error`.
2809
+ //
2810
+ // The two reasons the follower was originally introduced are reasons it is
2811
+ // *necessary*, not an exhaustive list of when it applies.
2320
2812
  const dispatchJar = await this.getCookieJar();
2321
2813
  const effectiveAuth = options.auth !== false ? (options.auth ?? this.cfg.auth) : undefined;
2322
- const needsManualRedirects = dispatchJar !== null || hasForwardedCredentials(req.headers, effectiveAuth);
2323
2814
  this._trace(_traceId, "transport_send", "start", startMs, attempt);
2324
2815
  let raw;
2325
2816
  try {
2326
- raw = needsManualRedirects
2327
- ? await this._sendFollowingRedirects(req, timeout, dispatchJar ?? undefined, effectiveAuth)
2328
- : await sendWithTimeout(this.transport, req, timeout);
2817
+ raw = await this._sendFollowingRedirects(req, timeout, dispatchJar ?? undefined, effectiveAuth);
2329
2818
  }
2330
2819
  catch (err) {
2331
2820
  // Cancel the progress-tracking ReadableStream to release the underlying
@@ -2343,6 +2832,20 @@ export class Kinetex {
2343
2832
  throw err;
2344
2833
  }
2345
2834
  this._trace(_traceId, "transport_send", "end", startMs, attempt);
2835
+ // ── Lifecycle: after request ───────────────────────────────────────────
2836
+ // "After the request is sent (before response is processed)" — the window
2837
+ // between the transport answering and the response being built, and the
2838
+ // only place a caller can observe that the wire round trip is over without
2839
+ // also having to see the parsed response. It was declared on `LifecycleHooks`
2840
+ // and documented twice in the README, and never invoked: a caller who
2841
+ // registered it got silence, on every code path, for every status.
2842
+ //
2843
+ // Deliberately *not* fired on the throw above: the request was not sent.
2844
+ if (this.cfg.hooks?.onAfterRequest) {
2845
+ for (const fn of this.cfg.hooks.onAfterRequest) {
2846
+ await fn(req, this._hookCtx(ctx));
2847
+ }
2848
+ }
2346
2849
  // ── Handle 304 Not Modified ────────────────────────────────────────────
2347
2850
  if (raw.status === 304) {
2348
2851
  const cache = await this.getCache();
@@ -2424,7 +2927,11 @@ export class Kinetex {
2424
2927
  });
2425
2928
  }
2426
2929
  // ── Read body with size limit ──────────────────────────────────────────
2427
- const maxSize = options.maxResponseSize ?? this.cfg.maxResponseSize ?? 0;
2930
+ // 100 MB by default rather than unbounded: without a cap a hostile or
2931
+ // simply broken server can drive this process out of memory, because the
2932
+ // body is read in full before the caller sees a byte of it. An explicit
2933
+ // `0` opts back out, which is what that value has always meant.
2934
+ const maxSize = options.maxResponseSize ?? this.cfg.maxResponseSize ?? DEFAULT_MAX_RESPONSE_SIZE;
2428
2935
  const rawBody = await readRawBody(bodyStream, maxSize, raw.url, req.signal);
2429
2936
  // ── Cookie jar (incoming Set-Cookie) ──────────────────────────────────
2430
2937
  const jar = await this.getCookieJar();
@@ -2448,6 +2955,9 @@ export class Kinetex {
2448
2955
  // the real response object instead of the `{}` placeholder it used to get.
2449
2956
  data,
2450
2957
  rawBody,
2958
+ // The same bytes in the shape an interceptor sees, so one response
2959
+ // object satisfies both the public and the pipeline views.
2960
+ body: rawBody,
2451
2961
  url: raw.url,
2452
2962
  cached: false,
2453
2963
  redirected: raw.redirected,
@@ -2472,9 +2982,9 @@ export class Kinetex {
2472
2982
  }
2473
2983
  }
2474
2984
  // ── Logger ─────────────────────────────────────────────────────────────
2475
- const logger = await this.getLogger();
2985
+ const logger = _logger ?? (await this.getLogger());
2476
2986
  if (logger) {
2477
- const requestId = ctx.store.get("requestId") ?? req.meta["requestId"] ?? "unknown";
2987
+ const requestId = ctx.store.get("requestId") ?? req.meta["requestId"] ?? _traceId;
2478
2988
  logger.logResponse(requestId, res.status, res.statusText, res.headers, rawBody, attempt, false);
2479
2989
  }
2480
2990
  // ── HAR recording ──────────────────────────────────────────────────────
@@ -2635,6 +3145,8 @@ export class Kinetex {
2635
3145
  status: raw.status,
2636
3146
  statusText: raw.statusText,
2637
3147
  headers: raw.headers,
3148
+ // Present so the object satisfies both response views at once.
3149
+ body: rawBody,
2638
3150
  data,
2639
3151
  rawBody,
2640
3152
  url: responseURL,
@@ -2753,7 +3265,7 @@ export class Kinetex {
2753
3265
  async sse(url, options = {}) {
2754
3266
  const { SSEClient } = await import("./sse.js");
2755
3267
  const headers = mergeHeaders(this.cfg.headers, options.headers);
2756
- const fullURL = buildURL(this.cfg.baseURL, url, this.cfg.params);
3268
+ const fullURL = buildURL(this.cfg.baseURL, url, this.cfg.params, undefined, this.cfg.allowPrivateNetwork);
2757
3269
  // Route through the full kinetex pipeline (auth, interceptors, rate-limit, CB, etc.)
2758
3270
  // by providing a pipeline-aware fetch function.
2759
3271
  const pipeFetch = async (input, init) => {
@@ -2794,7 +3306,7 @@ export class Kinetex {
2794
3306
  async graphql(url, options = {}) {
2795
3307
  const { GraphQLClient } = await import("./graphql.js");
2796
3308
  const headers = mergeHeaders(this.cfg.headers, options.headers);
2797
- const fullURL = buildURL(this.cfg.baseURL, url, this.cfg.params);
3309
+ const fullURL = buildURL(this.cfg.baseURL, url, this.cfg.params, undefined, this.cfg.allowPrivateNetwork);
2798
3310
  // Route through the full kinetex pipeline (auth, interceptors, rate-limit, CB, OTel, etc.)
2799
3311
  const pipeFetch = async (input, init) => {
2800
3312
  const reqUrl = typeof input === "string" ? input : input.url;
@@ -2833,7 +3345,7 @@ export class Kinetex {
2833
3345
  */
2834
3346
  async paginate(url, options) {
2835
3347
  const { createPagePaginator } = await import("./pagination.js");
2836
- const fullURL = buildURL(this.cfg.baseURL, url, this.cfg.params);
3348
+ const fullURL = buildURL(this.cfg.baseURL, url, this.cfg.params, undefined, this.cfg.allowPrivateNetwork);
2837
3349
  // Route through the full kinetex pipeline
2838
3350
  const pipeFetch = async (input, init) => {
2839
3351
  const reqUrl = typeof input === "string" ? input : input.url;
@@ -2886,6 +3398,8 @@ export class Kinetex {
2886
3398
  if (IS_NODE && this.transport && "destroy" in this.transport) {
2887
3399
  this.transport.destroy();
2888
3400
  }
3401
+ // Reject anyone still parked on the queue — it can never drain now.
3402
+ this._concurrencyLimiter?.drain();
2889
3403
  this._cookieJar = null;
2890
3404
  this._logger = null;
2891
3405
  this._dedup?.clear();
@@ -2940,6 +3454,34 @@ export class FluentRequest {
2940
3454
  };
2941
3455
  return this;
2942
3456
  }
3457
+ /**
3458
+ * Attach an `Idempotency-Key`, generating one when none is given.
3459
+ *
3460
+ * Lets a retried `POST` be recognised as the same logical operation by the
3461
+ * server instead of creating a duplicate. Because the header is set on the
3462
+ * request options — not regenerated per attempt — every retry of this
3463
+ * request carries the same key, which is the entire point.
3464
+ *
3465
+ * @param value - An explicit key, or `undefined` to generate a v4 UUID.
3466
+ * @throws {TypeError} If `value` is not a valid key (see `isValidIdempotencyKey`).
3467
+ *
3468
+ * @example
3469
+ * ```ts
3470
+ * await client.POST("/charges").withJSON(body).idempotencyKey().json();
3471
+ * ```
3472
+ */
3473
+ idempotencyKey(value) {
3474
+ const key = value === undefined ? generateIdempotencyKey() : value;
3475
+ if (!isValidIdempotencyKey(key)) {
3476
+ throw new TypeError(`idempotencyKey: ${typeof key === "string" ? JSON.stringify(key) : typeof key} is not a ` +
3477
+ "valid Idempotency-Key — expected 1-255 visible ASCII characters");
3478
+ }
3479
+ this._options.headers = {
3480
+ ...this._options.headers,
3481
+ "idempotency-key": key,
3482
+ };
3483
+ return this;
3484
+ }
2943
3485
  /** Merge a headers map. */
2944
3486
  headers(headers) {
2945
3487
  this._options.headers = {
@@ -3225,10 +3767,6 @@ function isNonReplayableBody(body) {
3225
3767
  return true;
3226
3768
  return typeof Blob !== "undefined" && body instanceof Blob;
3227
3769
  }
3228
- /** Cross-runtime performance.now() — falls back to Date.now(). */
3229
- function perfNow() {
3230
- return typeof performance !== "undefined" ? performance.now() : Date.now();
3231
- }
3232
3770
  // ============================================================================
3233
3771
  // §16 ENTERPRISE HELPERS
3234
3772
  // ============================================================================