kinetex 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +246 -9
  2. package/dist/browser/kinetex.esm.js +38 -22
  3. package/dist/browser/kinetex.js +2545 -550
  4. package/dist/browser/kinetex.min.js +38 -22
  5. package/dist/cjs/aws-sigv4.js +133 -19
  6. package/dist/cjs/cache.js +49 -7
  7. package/dist/cjs/circuit-breaker.js +45 -3
  8. package/dist/cjs/client.js +387 -104
  9. package/dist/cjs/cookie-parser.js +103 -5
  10. package/dist/cjs/cookie-store.js +125 -28
  11. package/dist/cjs/core.js +465 -66
  12. package/dist/cjs/dedup.js +49 -11
  13. package/dist/cjs/digest.js +160 -24
  14. package/dist/cjs/graphql.js +164 -24
  15. package/dist/cjs/headers.js +303 -45
  16. package/dist/cjs/interceptors.js +221 -7
  17. package/dist/cjs/lifecycle.js +89 -40
  18. package/dist/cjs/logging.js +168 -15
  19. package/dist/cjs/mod.js +3 -2
  20. package/dist/cjs/pagination.js +247 -22
  21. package/dist/cjs/progress.js +177 -27
  22. package/dist/cjs/proxy.js +412 -0
  23. package/dist/cjs/response.js +316 -47
  24. package/dist/cjs/socks5.js +131 -15
  25. package/dist/cjs/sse.js +173 -43
  26. package/dist/cjs/url.js +191 -45
  27. package/dist/cjs/utils.js +222 -48
  28. package/dist/cjs/ws.js +19 -10
  29. package/dist/esm/aws-sigv4.js +133 -19
  30. package/dist/esm/aws-sigv4.js.map +1 -1
  31. package/dist/esm/cache.js +49 -7
  32. package/dist/esm/cache.js.map +1 -1
  33. package/dist/esm/circuit-breaker.js +45 -3
  34. package/dist/esm/circuit-breaker.js.map +1 -1
  35. package/dist/esm/client.js +387 -104
  36. package/dist/esm/client.js.map +1 -1
  37. package/dist/esm/cookie-parser.js +103 -5
  38. package/dist/esm/cookie-parser.js.map +1 -1
  39. package/dist/esm/cookie-store.js +125 -28
  40. package/dist/esm/cookie-store.js.map +1 -1
  41. package/dist/esm/core.js +465 -66
  42. package/dist/esm/core.js.map +1 -1
  43. package/dist/esm/dedup.js +49 -11
  44. package/dist/esm/dedup.js.map +1 -1
  45. package/dist/esm/digest.js +160 -24
  46. package/dist/esm/digest.js.map +1 -1
  47. package/dist/esm/graphql.js +164 -24
  48. package/dist/esm/graphql.js.map +1 -1
  49. package/dist/esm/headers.js +303 -45
  50. package/dist/esm/headers.js.map +1 -1
  51. package/dist/esm/interceptors.js +221 -7
  52. package/dist/esm/interceptors.js.map +1 -1
  53. package/dist/esm/lifecycle.js +89 -40
  54. package/dist/esm/lifecycle.js.map +1 -1
  55. package/dist/esm/logging.js +168 -15
  56. package/dist/esm/logging.js.map +1 -1
  57. package/dist/esm/mod.js +3 -2
  58. package/dist/esm/mod.js.map +1 -1
  59. package/dist/esm/pagination.js +247 -22
  60. package/dist/esm/pagination.js.map +1 -1
  61. package/dist/esm/progress.js +177 -27
  62. package/dist/esm/progress.js.map +1 -1
  63. package/dist/esm/proxy.js +413 -0
  64. package/dist/esm/proxy.js.map +1 -0
  65. package/dist/esm/response.js +316 -47
  66. package/dist/esm/response.js.map +1 -1
  67. package/dist/esm/socks5.js +131 -15
  68. package/dist/esm/socks5.js.map +1 -1
  69. package/dist/esm/sse.js +173 -43
  70. package/dist/esm/sse.js.map +1 -1
  71. package/dist/esm/types.js.map +1 -1
  72. package/dist/esm/url.js +191 -45
  73. package/dist/esm/url.js.map +1 -1
  74. package/dist/esm/utils.js +222 -48
  75. package/dist/esm/utils.js.map +1 -1
  76. package/dist/esm/ws.js +19 -10
  77. package/dist/esm/ws.js.map +1 -1
  78. package/dist/types/aws-sigv4.d.ts.map +1 -1
  79. package/dist/types/cache.d.ts +19 -1
  80. package/dist/types/cache.d.ts.map +1 -1
  81. package/dist/types/circuit-breaker.d.ts +14 -1
  82. package/dist/types/circuit-breaker.d.ts.map +1 -1
  83. package/dist/types/client.d.ts +69 -11
  84. package/dist/types/client.d.ts.map +1 -1
  85. package/dist/types/cookie-parser.d.ts +0 -17
  86. package/dist/types/cookie-parser.d.ts.map +1 -1
  87. package/dist/types/cookie-store.d.ts.map +1 -1
  88. package/dist/types/core.d.ts +103 -25
  89. package/dist/types/core.d.ts.map +1 -1
  90. package/dist/types/dedup.d.ts.map +1 -1
  91. package/dist/types/digest.d.ts +17 -37
  92. package/dist/types/digest.d.ts.map +1 -1
  93. package/dist/types/graphql.d.ts.map +1 -1
  94. package/dist/types/headers.d.ts +45 -27
  95. package/dist/types/headers.d.ts.map +1 -1
  96. package/dist/types/interceptors.d.ts +102 -0
  97. package/dist/types/interceptors.d.ts.map +1 -1
  98. package/dist/types/lifecycle.d.ts +19 -2
  99. package/dist/types/lifecycle.d.ts.map +1 -1
  100. package/dist/types/logging.d.ts +22 -3
  101. package/dist/types/logging.d.ts.map +1 -1
  102. package/dist/types/mod.d.ts +5 -3
  103. package/dist/types/mod.d.ts.map +1 -1
  104. package/dist/types/pagination.d.ts +0 -25
  105. package/dist/types/pagination.d.ts.map +1 -1
  106. package/dist/types/progress.d.ts +1 -1
  107. package/dist/types/progress.d.ts.map +1 -1
  108. package/dist/types/proxy.d.ts +50 -0
  109. package/dist/types/proxy.d.ts.map +1 -0
  110. package/dist/types/response.d.ts +7 -1
  111. package/dist/types/response.d.ts.map +1 -1
  112. package/dist/types/socks5.d.ts.map +1 -1
  113. package/dist/types/sse.d.ts.map +1 -1
  114. package/dist/types/types.d.ts +114 -3
  115. package/dist/types/types.d.ts.map +1 -1
  116. package/dist/types/url.d.ts +0 -14
  117. package/dist/types/url.d.ts.map +1 -1
  118. package/dist/types/utils.d.ts.map +1 -1
  119. package/dist/types/ws.d.ts.map +1 -1
  120. package/package.json +1 -1
@@ -7,13 +7,14 @@ 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, AbortError, 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
12
  import { getAuthFingerprint, CREDENTIAL_HEADERS } from "./cache.js";
13
- import { createRateLimitInterceptor } from "./interceptors.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.
@@ -170,6 +171,8 @@ const HAR_REDACT_HEADERS = new Set([
170
171
  * `meta` key carrying the number of response-interceptor re-sends so a
171
172
  * self-retriggering interceptor cannot loop forever.
172
173
  */
174
+ /** Request-meta key: a clock-skew correction has already been spent. */
175
+ const AWS_SKEW_CORRECTED = "__awsSkewCorrected";
173
176
  const INTERCEPTOR_RESEND_DEPTH = "__interceptorResendDepth";
174
177
  /** Hard cap on consecutive response-interceptor re-sends (digest refresh, etc.). */
175
178
  const MAX_INTERCEPTOR_RESENDS = 5;
@@ -247,6 +250,35 @@ function redactHARHeader(name, value) {
247
250
  * O(1) ring-buffer HAR entry recorder.
248
251
  * Stores up to `maxEntries` entries, evicting oldest first.
249
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
+ }
250
282
  class HARRecorder {
251
283
  /** Ring buffer of entries keyed by monotonic counter. */
252
284
  _buf = new Map();
@@ -338,6 +370,17 @@ class HARRecorder {
338
370
  return req.body.byteLength;
339
371
  return -1; // Unknown (stream, FormData, etc.)
340
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),
341
384
  },
342
385
  response: {
343
386
  status: res.status,
@@ -473,35 +516,6 @@ async function applyAuth(req, auth) {
473
516
  // ============================================================================
474
517
  // §5 URL BUILDING
475
518
  // ============================================================================
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
519
  /**
506
520
  * Headers stripped when a redirect crosses origins (FIX H2).
507
521
  * These carry credentials and must never be forwarded to a different origin.
@@ -545,7 +559,7 @@ function redactUserInfo(url) {
545
559
  * @returns Fully-qualified URL string.
546
560
  * @throws {KinetexError} EVALIDATION — if URL is unsafe, params exceed limits, or URL too long.
547
561
  */
548
- function buildURL(base, url, params) {
562
+ function buildURL(base, url, params, allowedSchemes = ["http", "https"]) {
549
563
  const MAX_QUERY_PARAM_COUNT = 100;
550
564
  const MAX_URL_LENGTH = 8192;
551
565
  let full;
@@ -574,13 +588,18 @@ function buildURL(base, url, params) {
574
588
  }
575
589
  }
576
590
  if (!params || Object.keys(params).length === 0) {
577
- if (!isSafeURL(full)) {
591
+ if (!isSafeURL(full, allowedSchemes)) {
578
592
  throw new KinetexError(`URL "${redactUserInfo(full)}" failed safety check — blocked private/loopback address or forbidden scheme`, "EVALIDATION");
579
593
  }
580
594
  return full;
581
595
  }
582
596
  try {
583
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
+ }
584
603
  let paramCount = 0;
585
604
  for (const [key, value] of Object.entries(params)) {
586
605
  if (value === null || value === undefined)
@@ -775,7 +794,13 @@ function computeRetryDelay(cfg, attempt, retryAfterMs) {
775
794
  return cfg.maxDelayMs;
776
795
  }
777
796
  const capped = Math.min(exp, cfg.maxDelayMs);
778
- 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);
779
804
  }
780
805
  /**
781
806
  * Extract and parse the Retry-After header value.
@@ -787,15 +812,21 @@ function getRetryAfterMs(headers) {
787
812
  const ra = headers["retry-after"];
788
813
  if (!ra)
789
814
  return null;
790
- if (/^\d+$/.test(ra.trim())) {
791
- const seconds = parseInt(ra, 10);
792
- if (!isFinite(seconds) || seconds < 0)
793
- 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) {
794
820
  const MAX_RETRY_AFTER_SEC = 86_400; // 24 hours
795
- 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));
796
828
  }
797
- const ms = Date.parse(ra);
798
- return isNaN(ms) ? null : Math.max(0, Math.min(ms - Date.now(), 86_400_000));
829
+ return null;
799
830
  }
800
831
  // ============================================================================
801
832
  // §8 MAIN KINETEX CLASS
@@ -850,6 +881,14 @@ export class Kinetex {
850
881
  _circuitBreakerKeyFn = null;
851
882
  /** Active WebSocket connections tracked for cleanup on destroy(). */
852
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;
853
892
  /**
854
893
  * @param config - Global client configuration.
855
894
  */
@@ -868,10 +907,17 @@ export class Kinetex {
868
907
  const rlInterceptor = createRateLimitInterceptor(config.rateLimit);
869
908
  this.interceptors.addRequest(rlInterceptor);
870
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;
871
916
  // AWS SigV4 request signing — registered synchronously (static import).
872
917
  // Active immediately; no race between first request and interceptor registration.
873
918
  if (config.awsSigning) {
874
919
  const signer = new SigV4Signer(config.awsSigning);
920
+ this._awsSigner = signer;
875
921
  this.interceptors.addRequest(async (ctx) => {
876
922
  const req = ctx.request;
877
923
  let signableBody = null;
@@ -889,7 +935,15 @@ export class Kinetex {
889
935
  });
890
936
  }
891
937
  // Transport — pass strictHeaders option through to FetchTransport
892
- 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
+ });
893
947
  // Register config-level interceptors
894
948
  if (config.interceptors) {
895
949
  config.interceptors.request?.forEach((fn) => this.interceptors.addRequest(fn));
@@ -1293,20 +1347,19 @@ export class Kinetex {
1293
1347
  /**
1294
1348
  * Returns deduplication metrics.
1295
1349
  *
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.
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.
1298
1358
  */
1299
1359
  get dedupMetrics() {
1300
1360
  if (!this._dedup)
1301
1361
  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
- };
1362
+ return this._dedup.getStats();
1310
1363
  }
1311
1364
  // ── §8.5d Circuit Breaker ────────────────────────────────────────────────
1312
1365
  /**
@@ -1382,7 +1435,20 @@ export class Kinetex {
1382
1435
  * ```
1383
1436
  */
1384
1437
  async ws(url, options = {}) {
1385
- 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
+ ]);
1386
1452
  const headers = mergeHeaders(this.cfg.headers, options.headers);
1387
1453
  // Apply auth headers manually since WS handshake goes through the browser
1388
1454
  // WS API which doesn't use the kinetex transport pipeline.
@@ -1425,7 +1491,12 @@ export class Kinetex {
1425
1491
  if (this.cfg.baseURL) {
1426
1492
  const baseUrl = new URL(this.cfg.baseURL);
1427
1493
  const wsIsSecure = wsUrl.protocol === "wss:";
1428
- 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:";
1429
1500
  if (wsIsSecure !== httpIsSecure || wsUrl.host !== baseUrl.host) {
1430
1501
  throw new KinetexError(`WebSocket origin ${wsUrl.origin} does not match baseURL origin ${baseUrl.origin}`, "EVALIDATION");
1431
1502
  }
@@ -1520,15 +1591,17 @@ export class Kinetex {
1520
1591
  }
1521
1592
  // ── Build initial request ─────────────────────────────────────────────
1522
1593
  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");
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");
1532
1605
  }
1533
1606
  // Enforce HTTPS-only if configured
1534
1607
  if (this.cfg.httpsOnly) {
@@ -1544,6 +1617,14 @@ export class Kinetex {
1544
1617
  throw new KinetexError(`Invalid URL: ${err}`, "EVALIDATION");
1545
1618
  }
1546
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;
1547
1628
  // Enforce request size limit if configured
1548
1629
  const maxRequestSize = options.maxRequestSize ?? this.cfg.maxRequestSize ?? 0;
1549
1630
  if (maxRequestSize > 0 && options.body) {
@@ -1571,19 +1652,13 @@ export class Kinetex {
1571
1652
  bodySize = new TextEncoder().encode(options.body.toString()).byteLength;
1572
1653
  }
1573
1654
  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
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;
1587
1662
  }
1588
1663
  else if (options.body instanceof ReadableStream) {
1589
1664
  // FIX (H4): a stream's size cannot be known without consuming it —
@@ -1630,6 +1705,25 @@ export class Kinetex {
1630
1705
  meta: { ...options.meta },
1631
1706
  httpVersion: options.httpVersion ?? this.cfg.httpVersion ?? "HTTP/2",
1632
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
+ }
1633
1727
  // Default Content-Type for JSON bodies
1634
1728
  if (req.body !== null &&
1635
1729
  typeof req.body === "object" &&
@@ -1801,6 +1895,82 @@ export class Kinetex {
1801
1895
  * and retries on failure per the retry config.
1802
1896
  */
1803
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) {
1804
1974
  let attempt = 0;
1805
1975
  while (true) {
1806
1976
  attempt++;
@@ -1852,20 +2022,59 @@ export class Kinetex {
1852
2022
  method: req.method,
1853
2023
  });
1854
2024
  }
1855
- if (retryCfg && attempt <= retryCfg.maxRetries) {
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;
1856
2059
  const retryCtx = {
1857
2060
  request: req,
1858
- response: null,
2061
+ response: errResponse,
1859
2062
  error: err,
1860
2063
  attempt,
1861
2064
  maxRetries: retryCfg.maxRetries,
1862
2065
  };
1863
- const doRetry = retryCfg.shouldRetry
1864
- ? await retryCfg.shouldRetry(retryCtx)
1865
- : shouldRetry(retryCfg, retryCtx);
2066
+ const doRetry = clockSkewCorrected
2067
+ ? true
2068
+ : retryCfg.shouldRetry
2069
+ ? await retryCfg.shouldRetry(retryCtx)
2070
+ : shouldRetry(retryCfg, retryCtx);
1866
2071
  if (doRetry) {
1867
- const delay = computeRetryDelay(retryCfg, attempt, null);
2072
+ const delay = computeRetryDelay(retryCfg, attempt, errResponse ? getRetryAfterMs(errResponse.headers) : null);
1868
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());
1869
2078
  await sleep(delay, req.signal);
1870
2079
  continue;
1871
2080
  }
@@ -1971,9 +2180,7 @@ export class Kinetex {
1971
2180
  if (isRedirect) {
1972
2181
  // Check for redirect loops
1973
2182
  if (visited.has(raw.url)) {
1974
- throw new KinetexError(`Redirect loop detected: ${raw.url}`, "ENETWORK", {
1975
- request: req,
1976
- });
2183
+ throw new RedirectError(`Redirect loop detected: ${raw.url}`, req);
1977
2184
  }
1978
2185
  visited.add(raw.url);
1979
2186
  // Capture cookies from this redirect hop
@@ -1982,13 +2189,26 @@ export class Kinetex {
1982
2189
  });
1983
2190
  // `followRedirects: false` (or `maxRedirects: 0`) hands the 3xx back to
1984
2191
  // the caller instead of chasing it — the same shape fetch() returns for
1985
- // `redirect: "manual"`.
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.
1986
2198
  if (!followRedirects)
1987
- return { ...raw, redirected: true };
2199
+ return { ...raw, redirected: false };
1988
2200
  if (hop === maxRedirects) {
1989
- throw new KinetexError(`Too many redirects (exceeded ${maxRedirects})`, "ENETWORK", {
1990
- request: req,
1991
- });
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);
1992
2212
  }
1993
2213
  // Drain the redirect body (usually empty, but must be cancelled)
1994
2214
  if (raw.body) {
@@ -2100,7 +2320,7 @@ export class Kinetex {
2100
2320
  return hop > 0 ? { ...raw, redirected: true } : raw;
2101
2321
  }
2102
2322
  // Unreachable
2103
- throw new KinetexError("Redirect loop", "ENETWORK", { request: req });
2323
+ throw new RedirectError("Redirect loop", req);
2104
2324
  }
2105
2325
  // ── §8.8 Single attempt ──────────────────────────────────────────────────
2106
2326
  /**
@@ -2137,9 +2357,16 @@ export class Kinetex {
2137
2357
  }
2138
2358
  this._trace(_traceId, "lifecycle_before", "end", startMs, attempt);
2139
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;
2140
2367
  if (options.cache !== false && this.cfg.cache) {
2141
2368
  const cache = await this.getCache();
2142
- if (cache) {
2369
+ if (cache && !forceRefresh) {
2143
2370
  const cacheReq = { url: req.url, method: req.method, headers: req.headers };
2144
2371
  const hit = await cache.get(cacheReq);
2145
2372
  if (hit && !hit.stale) {
@@ -2310,22 +2537,34 @@ export class Kinetex {
2310
2537
  };
2311
2538
  }
2312
2539
  // ── 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.
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.
2320
2562
  const dispatchJar = await this.getCookieJar();
2321
2563
  const effectiveAuth = options.auth !== false ? (options.auth ?? this.cfg.auth) : undefined;
2322
- const needsManualRedirects = dispatchJar !== null || hasForwardedCredentials(req.headers, effectiveAuth);
2323
2564
  this._trace(_traceId, "transport_send", "start", startMs, attempt);
2324
2565
  let raw;
2325
2566
  try {
2326
- raw = needsManualRedirects
2327
- ? await this._sendFollowingRedirects(req, timeout, dispatchJar ?? undefined, effectiveAuth)
2328
- : await sendWithTimeout(this.transport, req, timeout);
2567
+ raw = await this._sendFollowingRedirects(req, timeout, dispatchJar ?? undefined, effectiveAuth);
2329
2568
  }
2330
2569
  catch (err) {
2331
2570
  // Cancel the progress-tracking ReadableStream to release the underlying
@@ -2343,6 +2582,20 @@ export class Kinetex {
2343
2582
  throw err;
2344
2583
  }
2345
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
+ }
2346
2599
  // ── Handle 304 Not Modified ────────────────────────────────────────────
2347
2600
  if (raw.status === 304) {
2348
2601
  const cache = await this.getCache();
@@ -2886,6 +3139,8 @@ export class Kinetex {
2886
3139
  if (IS_NODE && this.transport && "destroy" in this.transport) {
2887
3140
  this.transport.destroy();
2888
3141
  }
3142
+ // Reject anyone still parked on the queue — it can never drain now.
3143
+ this._concurrencyLimiter?.drain();
2889
3144
  this._cookieJar = null;
2890
3145
  this._logger = null;
2891
3146
  this._dedup?.clear();
@@ -2940,6 +3195,34 @@ export class FluentRequest {
2940
3195
  };
2941
3196
  return this;
2942
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
+ }
2943
3226
  /** Merge a headers map. */
2944
3227
  headers(headers) {
2945
3228
  this._options.headers = {