@boy-offi9-inc/reqkit 0.8.0 → 0.9.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.
package/README.md CHANGED
@@ -76,6 +76,7 @@ Requires Node 18+. TypeScript types are included.
76
76
  | Slow down *before* the server says 429 (obey `X-RateLimit-*`) | [`createRateLimiter`](#createratelimiteropts) |
77
77
  | Stop hammering a service that keeps failing | [`createCircuitBreaker`](#createcircuitbreakeropts) |
78
78
  | Cache GETs (TTL, stale-while-revalidate, stale-if-error, ETag/304) | [`createFetch` → `cache`](#caching) |
79
+ | Cut tail latency with backup requests | [`withHedge`](#withhedgefn-opts) / [`createFetch` → `hedge`](#hedged-requests) |
79
80
  | Log or measure every request, retry and cache hit | [`createFetch` → `onEvent`](#events-and-logging) |
80
81
  | Attach a token and refresh it on 401 — once, even with many requests in flight | [`createFetch` → `auth`](#auth-token-refresh) |
81
82
  | Walk a paginated API (cursor, offset, `Link` headers) | [`paginate` / `paginateLinks`](#pagination-paginate-paginatepages-paginatelinks) |
@@ -201,6 +202,30 @@ Pass your own `signal` to cancel from the outside too — it's linked into the o
201
202
  await withTimeout(signal => fetch(url, { signal }), 5000, { signal: userController.signal });
202
203
  ```
203
204
 
205
+ ### `withHedge(fn, opts)`
206
+
207
+ Insurance against the occasional slow request. It starts `fn`; if that hasn't answered after `delayMs`, it starts a backup (a *hedge*) and takes whichever succeeds first. The others are aborted, so connections are freed.
208
+
209
+ ```js
210
+ import { withHedge } from "@boy-offi9-inc/reqkit";
211
+
212
+ const res = await withHedge((signal) => fetch(url, { signal }), { delayMs: 150 });
213
+ ```
214
+
215
+ | Option | Default | Description |
216
+ |---|---|---|
217
+ | `delayMs` | — (required) | wait before launching backup *n*: a number, or `(n) => ms` |
218
+ | `maxHedges` | `1` | how many backups at most |
219
+ | `isFailure` | `status >= 500` | results that count as failed — the next backup launches immediately |
220
+ | `hedgeOnError` | `true` | when an attempt *throws*, launch the next backup immediately |
221
+ | `onHedge` | — | `({ index, afterMs }) => void` — a backup was launched |
222
+ | `onWinner` | — | `(index) => void` — which attempt won (`0` = the original) |
223
+ | `signal` | — | cancels every attempt and rejects with the signal's reason |
224
+
225
+ `fn(signal, index)` receives an `AbortSignal` (pass it to `fetch`) and the attempt number (`0` = the original). If every attempt fails, the last failed *result* is returned (a 503 `Response` isn't thrown); if none produced a result, the last error is thrown. The bodies of responses that lose are cancelled.
226
+
227
+ Hedging sends extra requests, so only use it for calls that are safe to run twice (GETs and other idempotent calls).
228
+
204
229
  ### `normalizeError(err)`
205
230
 
206
231
  One consistent error shape regardless of source (fetch, axios, node http, generic):
@@ -511,6 +536,7 @@ const admin = api.extend({ headers: { "x-role": "admin" } }); // merges over the
511
536
  | `circuitBreaker` | — | `true` / options → one breaker **per origin**; or pass a shared [`createCircuitBreaker`](#createcircuitbreakeropts), or `(url) => breaker`. An open circuit stops retries immediately. |
512
537
  | `trustedOrigins` | — | extra origins that may receive the default credentials — see [Security](#security) |
513
538
  | `auth` | — | attach a token and refresh it on 401 — see [Auth token refresh](#auth-token-refresh) |
539
+ | `hedge` | — | send a backup request when an attempt is slow — see [Hedged requests](#hedged-requests). `true` = hedge at your own recent p95 |
514
540
  | `cache` | — | in-memory GET cache — see [Caching](#caching). `true` = 60s TTL. Per request: `cache: false` bypasses, `"reload"` refreshes |
515
541
  | `dedupe` | `false` | merge identical concurrent `GET`/`HEAD` calls; every caller gets its own readable clone. Requests with their own `signal` are never merged. |
516
542
  | `throwHttpErrors` | `true` | throw `HTTPError` for non-2xx (`err.status`, `err.response`, `err.url`, `err.method`) |
@@ -541,6 +567,21 @@ await Promise.all(urls.map((u) => api.json(u))); // 20 requests, all 401 → one
541
567
  - **Options:** `header` (default `"authorization"`), `scheme` (default `"Bearer"`, `""` for a bare token such as an `X-API-Key`), `shouldRefresh(response)` (default: status 401). `refresh({ token, response })` may return the new token or just update your store. If `refresh` throws, every waiting request rejects with that error — and the next request tries again.
542
568
  - `extend()` children that share the same `auth` object share one refresh; the cache keys entries per token.
543
569
 
570
+ #### Hedged requests
571
+
572
+ ```js
573
+ const api = createFetch({ baseUrl: "https://api.example.com", hedge: true }); // hedge at your own recent p95
574
+ const api2 = createFetch({ baseUrl: "https://api.example.com", hedge: { delayMs: 200 } }); // or a fixed delay
575
+ ```
576
+
577
+ If an attempt hasn't answered after the delay, a backup request goes out and the first good response wins; the slower request is cancelled.
578
+
579
+ - **Idempotent only.** GET, HEAD, OPTIONS, PUT and DELETE are hedged; a POST isn't unless it carries an `Idempotency-Key` (or you list the method in `methods`). Stream bodies are never hedged.
580
+ - **`delayMs: "auto"`** (the default for `hedge: true`) waits for your own recent `percentile` latency (default 0.95) per origin, never less than `minDelayMs` (20), and uses `fallbackDelayMs` (300) until `minSamples` (10) responses have been seen. At p95 that's roughly 5% extra requests for a much shorter tail.
581
+ - **Failures don't wait.** A 5xx response or a thrown error launches the backup immediately.
582
+ - **Backups are real requests:** each one goes through the rate limiter. Hedging is applied per attempt, inside retries, and turns off request de-duplication for the hedged call.
583
+ - Override per call with `hedge: false` or `hedge: { delayMs: 100 }`. `maxHedges` (default 1) allows more backups.
584
+
544
585
  #### Events and logging
545
586
 
546
587
  One callback receives everything that happens, so you can plug reqkit into your logger or metrics without wrapping anything.
@@ -561,8 +602,9 @@ const api = createFetch({
561
602
  | `request` | a call starts | `url`, `method` |
562
603
  | `attempt` | a network attempt starts | `url`, `method`, `attempt` (1-based) |
563
604
  | `retry` | an attempt failed and another will follow | `url`, `method`, `attempt`, `delayMs`, `reason` (`"error"` \| `"status"`), `status?`, `error?` |
564
- | `response` | the final response arrives (including non-2xx, before `HTTPError` is thrown) | `url`, `method`, `status`, `ok`, `durationMs`, `attempts`, `cache?` (`"hit"` \| `"stale"` \| `"revalidated"` \| `"miss"` \| `"stale-if-error"`) |
605
+ | `response` | the final response arrives (including non-2xx, before `HTTPError` is thrown) | `url`, `method`, `status`, `ok`, `durationMs`, `attempts`, `hedges`, `hedgeWon`, `cache?` (`"hit"` \| `"stale"` \| `"revalidated"` \| `"miss"` \| `"stale-if-error"`) |
565
606
  | `error` | the call fails without a response — network error, timeout, abort, open circuit | `url?`, `method?`, `error`, `durationMs`, `attempts` |
607
+ | `hedge` | a backup request was sent | `url`, `method`, `index`, `afterMs` |
566
608
  | `cache` | cache activity | `url`, `method`, `event` (`hit`, `stale`, `revalidated`, `miss`, `store`, `stale-if-error`) |
567
609
  | `ratelimit` | a call waited on the rate limiter | `url`, `method`, `waitedMs` |
568
610
  | `circuit` | a circuit breaker changed state (breakers created from options — one per origin) | `origin`, `state`, `previous` |
package/dist/reqkit.mjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! @boy-offi9-inc/reqkit v0.8.0 — ESM bundle (generated by scripts/build-esm.js, do not edit) */
1
+ /*! @boy-offi9-inc/reqkit v0.9.0 — ESM bundle (generated by scripts/build-esm.js, do not edit) */
2
2
  const __defs = {
3
3
  "errors/HTTPError": function (module, exports, require) {
4
4
  class HTTPError extends Error {
@@ -297,6 +297,8 @@ module.exports = { createMemoryCache };
297
297
  const { withRetry, RetryAbortedError } = require("../resilience/retry");
298
298
  const { withTimeout } = require("../resilience/timeout");
299
299
  const { withDedupe } = require("../resilience/dedupe");
300
+ const { withHedge } = require("../resilience/hedge");
301
+ const { createLatencyTracker } = require("./latency");
300
302
  const { buildQuery } = require("./buildQuery");
301
303
  const { parseJsonSafe } = require("./parseJsonSafe");
302
304
  const { normalizeError } = require("../errors/normalizeError");
@@ -395,6 +397,8 @@ function mergeDefaults(base, extra) {
395
397
  * - `onEvent`: one callback for everything that happens (request, attempt,
396
398
  * retry, response, error, cache, rate-limit waits, circuit changes, token
397
399
  * refreshes) — plug it into your logger or metrics
400
+ * - `hedge`: if a request is slow, send a backup and take whichever answers
401
+ * first (idempotent requests only) — trims tail latency
398
402
  * - concurrent GET de-duplication (withDedupe)
399
403
  * - `auth`: attach a token, and on a 401 refresh it ONCE (shared by all
400
404
  * concurrent requests) and replay the request
@@ -427,6 +431,11 @@ function mergeDefaults(base, extra) {
427
431
  * @param {string[]} [defaults.trustedOrigins] - extra origins ("https://cdn.example.com") that may
428
432
  * receive the default credentials, besides `baseUrl`'s. With neither `baseUrl` nor
429
433
  * `trustedOrigins` set there is nothing to compare against and credentials go everywhere.
434
+ * @param {boolean | object} [defaults.hedge] - `{ delayMs, maxHedges, methods }` (or `true`). If an attempt
435
+ * hasn't answered after `delayMs`, a backup request is sent and the first good answer wins. `delayMs`
436
+ * is a number, or "auto" (default) to hedge at your own recent p95 latency per origin (tuned with
437
+ * `percentile` = 0.95, `minDelayMs` = 20, `fallbackDelayMs` = 300 until 10 samples exist). Applies to
438
+ * idempotent methods (or requests with an `Idempotency-Key`) only. Disables `dedupe` for hedged requests.
430
439
  * @param {boolean} [defaults.dedupe=false] - merge identical concurrent GET/HEAD calls
431
440
  * @param {boolean} [defaults.throwHttpErrors=true]
432
441
  * @param {typeof fetch} [defaults.fetch=globalThis.fetch]
@@ -448,6 +457,7 @@ function createFetch(defaults = {}) {
448
457
  cache,
449
458
  auth,
450
459
  trustedOrigins,
460
+ hedge,
451
461
  dedupe = false,
452
462
  throwHttpErrors: defaultThrow = true,
453
463
  fetch: customFetch,
@@ -533,6 +543,12 @@ function createFetch(defaults = {}) {
533
543
  emitEvent({ type: "cache", url, method: "GET", event: type });
534
544
  };
535
545
 
546
+ const hedgeDefaults = !hedge
547
+ ? null
548
+ : { delayMs: "auto", maxHedges: 1, ...(hedge === true ? {} : hedge) };
549
+ if (hedgeDefaults && !hedgeDefaults.tracker) hedgeDefaults.tracker = createLatencyTracker();
550
+ const fallbackTracker = hedgeDefaults ? null : createLatencyTracker(); // for per-request `hedge` on an instance with no defaults
551
+
536
552
  // Shared in-flight GETs. Every caller gets its own clone of the response so
537
553
  // body reads don't fight each other.
538
554
  const sharedFetch = withDedupe((key, url, init) => doFetch(url, init), { keyFn: (key) => key });
@@ -597,6 +613,7 @@ function createFetch(defaults = {}) {
597
613
  signal,
598
614
  headers: initHeaders,
599
615
  cache: cacheMode,
616
+ hedge: hedgeOpt,
600
617
  ...fetchInit
601
618
  } = init;
602
619
 
@@ -649,6 +666,26 @@ function createFetch(defaults = {}) {
649
666
  (methods.map((m) => m.toUpperCase()).includes(finalMethod) || hasIdempotencyKey);
650
667
  const { statusCodes, methods: _methods, retries = 2, shouldRetry: customShouldRetry, ...retryRest } = retryCfg || {};
651
668
 
669
+ // --- hedging policy ----------------------------------------------------
670
+ const hedgeCfg =
671
+ hedgeOpt === false || (!hedgeDefaults && !hedgeOpt)
672
+ ? null
673
+ : { delayMs: "auto", maxHedges: 1, ...(hedgeDefaults || {}), ...(hedgeOpt && hedgeOpt !== true ? hedgeOpt : {}) };
674
+ const hedgeMethods = ((hedgeCfg && hedgeCfg.methods) || DEFAULT_RETRY_METHODS).map((m) => m.toUpperCase());
675
+ const canHedge =
676
+ hedgeCfg !== null &&
677
+ hedgeCfg.maxHedges >= 1 &&
678
+ !isStreamBody(ctx.init.body) &&
679
+ (hedgeMethods.includes(finalMethod) || hasIdempotencyKey);
680
+ const tracker = canHedge ? (hedgeDefaults ? hedgeDefaults.tracker : fallbackTracker) : null;
681
+ const trackerKey = originOf(ctx.url);
682
+ const hedgeDelay = () => {
683
+ if (typeof hedgeCfg.delayMs === "number") return hedgeCfg.delayMs;
684
+ const { percentile = 0.95, minDelayMs = 20, fallbackDelayMs = 300, minSamples = 10 } = hedgeCfg;
685
+ if (tracker.count(trackerKey) < minSamples) return fallbackDelayMs;
686
+ return Math.max(minDelayMs, tracker.percentile(trackerKey, percentile));
687
+ };
688
+
652
689
  // One trip to the network: rate limit → circuit breaker → per-attempt
653
690
  // timeout → fetch, retried per the policy above. `netHeaders` and
654
691
  // `netSignal` are parameters so background revalidation can reuse this.
@@ -656,16 +693,21 @@ function createFetch(defaults = {}) {
656
693
  const attemptOnce = () => {
657
694
  state.attempts++;
658
695
  emitEvent({ type: "attempt", url: ctx.url, method: finalMethod, attempt: state.attempts });
659
- const attempt = async () => {
696
+ // A single request: rate limit → per-attempt timeout → fetch. `sig` is what cancels it —
697
+ // the caller's signal normally, or the hedge's own signal when it can lose a race.
698
+ const runOnce = async (sig) => {
660
699
  if (limiter) {
661
- const waitedMs = await limiter.acquire({ signal: netSignal });
662
- if (waitedMs > 0) emitEvent({ type: "ratelimit", url: ctx.url, method: finalMethod, waitedMs });
700
+ const waitStart = Date.now();
701
+ await limiter.acquire({ signal: sig });
702
+ const waitedMs = Date.now() - waitStart;
703
+ // Only report waits that mean something; scheduling jitter on an immediate acquire is not a wait.
704
+ if (waitedMs >= 5) emitEvent({ type: "ratelimit", url: ctx.url, method: finalMethod, waitedMs });
663
705
  }
706
+ const startedOne = Date.now();
664
707
  const res = await withTimeout(
665
708
  (attemptSignal) => {
666
709
  const attemptInit = { ...ctx.init, method: finalMethod, headers: netHeaders, signal: attemptSignal };
667
- const useDedupe =
668
- dedupe && !netSignal && !ctx.init.body && (finalMethod === "GET" || finalMethod === "HEAD");
710
+ const useDedupe = dedupe && !sig && !ctx.init.body && (finalMethod === "GET" || finalMethod === "HEAD");
669
711
  if (!useDedupe) return doFetch(ctx.url, attemptInit);
670
712
  // No per-attempt signal on a shared request: aborting one caller
671
713
  // must never cancel everyone else's. It still gets its own
@@ -678,11 +720,27 @@ function createFetch(defaults = {}) {
678
720
  return sharedFetch(key, ctx.url, sharedInit).then((res) => res.clone());
679
721
  },
680
722
  timeout === false ? Infinity : timeout,
681
- { signal: netSignal }
723
+ { signal: sig }
682
724
  );
683
725
  if (limiter) limiter.update(res);
726
+ if (tracker && res.status < 500) tracker.record(trackerKey, Date.now() - startedOne);
684
727
  return res;
685
728
  };
729
+ const attempt = canHedge
730
+ ? () =>
731
+ withHedge((hedgeSignal) => runOnce(hedgeSignal), {
732
+ delayMs: hedgeDelay,
733
+ maxHedges: hedgeCfg.maxHedges,
734
+ signal: netSignal,
735
+ onHedge: ({ index, afterMs }) => {
736
+ state.hedges++;
737
+ emitEvent({ type: "hedge", url: ctx.url, method: finalMethod, index, afterMs });
738
+ },
739
+ onWinner: (index) => {
740
+ state.hedgeWon = index > 0;
741
+ },
742
+ })
743
+ : () => runOnce(netSignal);
686
744
  const breaker = breakerFor && breakerFor(ctx.url);
687
745
  return breaker ? breaker.run(attempt) : attempt();
688
746
  };
@@ -746,6 +804,8 @@ function createFetch(defaults = {}) {
746
804
  durationMs: Date.now() - state.startedAt,
747
805
  attempts: state.attempts,
748
806
  cache: state.cache,
807
+ hedges: state.hedges,
808
+ hedgeWon: state.hedgeWon,
749
809
  });
750
810
  if (onResponse) await onResponse({ url: ctx.url, init: ctx.init, response });
751
811
  if (throwHttpErrors && !response.ok) {
@@ -857,13 +917,21 @@ function createFetch(defaults = {}) {
857
917
  }
858
918
 
859
919
  async function request(input, init = {}) {
860
- const state = { startedAt: Date.now(), attempts: 0, responded: false, cache: undefined, url: undefined, method: undefined };
920
+ const state = { startedAt: Date.now(), attempts: 0, hedges: 0, hedgeWon: false, responded: false, cache: undefined, url: undefined, method: undefined };
861
921
  try {
862
922
  return await execute(input, init, state);
863
923
  } catch (error) {
864
924
  // A non-2xx HTTPError was already reported as a "response" event.
865
925
  if (!state.responded) {
866
- emitEvent({ type: "error", url: state.url, method: state.method, error, durationMs: Date.now() - state.startedAt, attempts: state.attempts });
926
+ emitEvent({
927
+ type: "error",
928
+ url: state.url,
929
+ method: state.method,
930
+ error,
931
+ durationMs: Date.now() - state.startedAt,
932
+ attempts: state.attempts,
933
+ hedges: state.hedges,
934
+ });
867
935
  }
868
936
  throw error;
869
937
  }
@@ -890,6 +958,7 @@ function createFetch(defaults = {}) {
890
958
  rateLimit: limiter || undefined,
891
959
  circuitBreaker: breakerFor || undefined,
892
960
  cache: cacheCfg || undefined,
961
+ hedge: hedgeDefaults || undefined,
893
962
  },
894
963
  overrides
895
964
  )
@@ -900,6 +969,50 @@ function createFetch(defaults = {}) {
900
969
 
901
970
  module.exports = { createFetch, HTTPError };
902
971
 
972
+ },
973
+ "http/latency": function (module, exports, require) {
974
+ /**
975
+ * Remembers recent response times per key (an origin) so `createFetch` can
976
+ * hedge at "my usual slow" instead of a hand-tuned constant.
977
+ */
978
+ function createLatencyTracker({ maxSamples = 100, maxKeys = 200 } = {}) {
979
+ const byKey = new Map();
980
+
981
+ return {
982
+ record(key, ms) {
983
+ if (!Number.isFinite(ms) || ms < 0) return;
984
+ let samples = byKey.get(key);
985
+ if (!samples) {
986
+ samples = [];
987
+ byKey.set(key, samples);
988
+ if (byKey.size > maxKeys) byKey.delete(byKey.keys().next().value); // forget the oldest origin
989
+ }
990
+ samples.push(ms);
991
+ if (samples.length > maxSamples) samples.shift();
992
+ },
993
+
994
+ count(key) {
995
+ const samples = byKey.get(key);
996
+ return samples ? samples.length : 0;
997
+ },
998
+
999
+ /** The p-th percentile (0–1) of the recent samples for `key`, or null when there are none. */
1000
+ percentile(key, p) {
1001
+ const samples = byKey.get(key);
1002
+ if (!samples || samples.length === 0) return null;
1003
+ const sorted = [...samples].sort((a, b) => a - b);
1004
+ const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(p * sorted.length) - 1));
1005
+ return sorted[index];
1006
+ },
1007
+
1008
+ clear() {
1009
+ byKey.clear();
1010
+ },
1011
+ };
1012
+ }
1013
+
1014
+ module.exports = { createLatencyTracker };
1015
+
903
1016
  },
904
1017
  "http/parseJsonSafe": function (module, exports, require) {
905
1018
  /**
@@ -933,6 +1046,7 @@ module.exports = { parseJsonSafe };
933
1046
  "index": function (module, exports, require) {
934
1047
  const { withRetry, RetryAbortedError, StopRetry } = require("./resilience/retry");
935
1048
  const { withTimeout, TimeoutError } = require("./resilience/timeout");
1049
+ const { withHedge } = require("./resilience/hedge");
936
1050
  const { normalizeError } = require("./errors/normalizeError");
937
1051
  const { parseJsonSafe } = require("./http/parseJsonSafe");
938
1052
  const { buildQuery } = require("./http/buildQuery");
@@ -955,6 +1069,7 @@ module.exports = {
955
1069
  RetryAbortedError,
956
1070
  StopRetry,
957
1071
  withTimeout,
1072
+ withHedge,
958
1073
  TimeoutError,
959
1074
  normalizeError,
960
1075
  parseJsonSafe,
@@ -1557,6 +1672,177 @@ function withDedupe(fn, opts = {}) {
1557
1672
 
1558
1673
  module.exports = { withDedupe };
1559
1674
 
1675
+ },
1676
+ "resilience/hedge": function (module, exports, require) {
1677
+ const { discardBody } = require("../internal/http");
1678
+
1679
+ function defaultIsFailure(result) {
1680
+ return Boolean(result) && typeof result.status === "number" && result.status >= 500;
1681
+ }
1682
+
1683
+ function lostReason() {
1684
+ const reason = new Error("Request cancelled: a faster hedged request won");
1685
+ reason.name = "AbortError";
1686
+ return reason;
1687
+ }
1688
+
1689
+ /**
1690
+ * Hedged requests: start `fn`; if it hasn't answered within `delayMs`, start
1691
+ * a backup (a "hedge"); whichever succeeds first wins and the others are
1692
+ * aborted. It trims the slow tail of latency — the occasional request that
1693
+ * takes 10× longer than the rest — at the cost of a little extra load.
1694
+ *
1695
+ * ```js
1696
+ * const res = await withHedge((signal) => fetch(url, { signal }), { delayMs: 200 });
1697
+ * ```
1698
+ *
1699
+ * Only hedge requests that are safe to run twice (GETs, idempotent calls).
1700
+ *
1701
+ * Details:
1702
+ * - `fn(signal, index)` — `index` is 0 for the original and 1.. for backups.
1703
+ * Abort-aware code (like `fetch`) is actually cancelled when it loses.
1704
+ * - An attempt that throws, or returns a failed result (status >= 500 by
1705
+ * default), launches the next backup immediately instead of waiting.
1706
+ * - If every attempt fails: resolves with the last failed *result* if there was
1707
+ * one (a 503 `Response` isn't thrown), otherwise rejects with the last error.
1708
+ * - The bodies of responses that lose are cancelled so connections are freed.
1709
+ *
1710
+ * @template T
1711
+ * @param {(signal: AbortSignal, index: number) => Promise<T>} fn
1712
+ * @param {object} opts
1713
+ * @param {number | ((index: number) => number)} opts.delayMs - wait before launching backup #index (1-based)
1714
+ * @param {number} [opts.maxHedges=1] - how many backups at most
1715
+ * @param {(result: T) => boolean} [opts.isFailure] - results that count as failed. Default: `result.status >= 500`
1716
+ * @param {boolean} [opts.hedgeOnError=true] - launch the next backup right away when an attempt throws
1717
+ * @param {(info: { index: number, afterMs: number }) => void} [opts.onHedge] - a backup was launched
1718
+ * @param {(index: number) => void} [opts.onWinner] - which attempt produced the result (0 = the original)
1719
+ * @param {AbortSignal} [opts.signal] - cancels everything; rejects with the signal's reason
1720
+ * @returns {Promise<T>}
1721
+ */
1722
+ function withHedge(fn, opts = {}) {
1723
+ const { delayMs, maxHedges = 1, isFailure = defaultIsFailure, hedgeOnError = true, onHedge, onWinner, signal } = opts;
1724
+
1725
+ const delayFor = typeof delayMs === "function" ? delayMs : () => delayMs;
1726
+ if (typeof delayMs !== "function" && !(typeof delayMs === "number" && delayMs >= 0 && Number.isFinite(delayMs))) {
1727
+ return Promise.reject(new TypeError("withHedge: `delayMs` must be a non-negative number or a function returning one"));
1728
+ }
1729
+ if (!(Number.isInteger(maxHedges) && maxHedges >= 0)) {
1730
+ return Promise.reject(new TypeError("withHedge: `maxHedges` must be an integer >= 0"));
1731
+ }
1732
+ if (signal && signal.aborted) return Promise.reject(signal.reason);
1733
+
1734
+ return new Promise((resolve, reject) => {
1735
+ const startedAt = Date.now();
1736
+ const attempts = []; // { controller, done }
1737
+ let launched = 0;
1738
+ let pending = 0;
1739
+ let settled = false;
1740
+ let timer = null;
1741
+ let lastError;
1742
+ let hasError = false;
1743
+ let failedResult;
1744
+ let hasFailedResult = false;
1745
+
1746
+ const onAbort = () => finish(reject, signal.reason, -1);
1747
+ if (signal) signal.addEventListener("abort", onAbort, { once: true });
1748
+
1749
+ function finish(settle, value, winnerIndex) {
1750
+ if (settled) return;
1751
+ settled = true;
1752
+ clearTimeout(timer);
1753
+ if (signal) signal.removeEventListener("abort", onAbort);
1754
+ attempts.forEach((attempt, index) => {
1755
+ if (index !== winnerIndex && !attempt.done) attempt.controller.abort(lostReason());
1756
+ });
1757
+ if (hasFailedResult && failedResult !== value) discardBody(failedResult);
1758
+ if (winnerIndex >= 0 && onWinner) {
1759
+ try {
1760
+ onWinner(winnerIndex);
1761
+ } catch {
1762
+ // ignore
1763
+ }
1764
+ }
1765
+ settle(value);
1766
+ }
1767
+
1768
+ function scheduleBackup() {
1769
+ clearTimeout(timer);
1770
+ if (launched > maxHedges) return; // `launched` counts the original too
1771
+ timer = setTimeout(() => launch(), Math.max(0, Number(delayFor(launched)) || 0));
1772
+ }
1773
+
1774
+ function launch() {
1775
+ if (settled) return;
1776
+ const index = launched++;
1777
+ const controller = new AbortController();
1778
+ const attempt = { controller, done: false };
1779
+ attempts[index] = attempt;
1780
+ pending++;
1781
+
1782
+ if (index > 0 && onHedge) {
1783
+ try {
1784
+ onHedge({ index, afterMs: Date.now() - startedAt });
1785
+ } catch {
1786
+ // ignore
1787
+ }
1788
+ }
1789
+
1790
+ let promise;
1791
+ try {
1792
+ promise = Promise.resolve(fn(controller.signal, index));
1793
+ } catch (error) {
1794
+ promise = Promise.reject(error);
1795
+ }
1796
+
1797
+ promise.then(
1798
+ (result) => {
1799
+ attempt.done = true;
1800
+ pending--;
1801
+ if (settled) return discardBody(result); // too late — it lost
1802
+ if (isFailure(result)) {
1803
+ if (hasFailedResult) discardBody(failedResult);
1804
+ failedResult = result;
1805
+ hasFailedResult = true;
1806
+ return next();
1807
+ }
1808
+ finish(resolve, result, index);
1809
+ },
1810
+ (error) => {
1811
+ attempt.done = true;
1812
+ pending--;
1813
+ if (settled) return;
1814
+ lastError = error;
1815
+ hasError = true;
1816
+ if (hedgeOnError) next();
1817
+ else if (pending === 0 && launched > maxHedges) giveUp();
1818
+ }
1819
+ );
1820
+
1821
+ scheduleBackup();
1822
+ }
1823
+
1824
+ // An attempt failed: start the next backup now, or give up if there are none left.
1825
+ function next() {
1826
+ if (settled) return;
1827
+ if (launched <= maxHedges) {
1828
+ clearTimeout(timer);
1829
+ launch();
1830
+ } else if (pending === 0) {
1831
+ giveUp();
1832
+ }
1833
+ }
1834
+
1835
+ function giveUp() {
1836
+ if (hasFailedResult) finish(resolve, failedResult, -1);
1837
+ else finish(reject, hasError ? lastError : new Error("withHedge: no attempt succeeded"), -1);
1838
+ }
1839
+
1840
+ launch();
1841
+ });
1842
+ }
1843
+
1844
+ module.exports = { withHedge };
1845
+
1560
1846
  },
1561
1847
  "resilience/limiter": function (module, exports, require) {
1562
1848
  /**
@@ -2982,6 +3268,7 @@ export const {
2982
3268
  retryAfterAware,
2983
3269
  streamSSE,
2984
3270
  withDedupe,
3271
+ withHedge,
2985
3272
  withProgress,
2986
3273
  withRetry,
2987
3274
  withTimeout,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boy-offi9-inc/reqkit",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Small, composable HTTP helpers \u2014 retry with backoff and Retry-After, timeouts, normalized errors, rate limiter, circuit breaker, response cache, resumable downloads, SSE/NDJSON streams, mock fetch, and an opt-in createFetch. Not a client; works alongside fetch/axios/anything.",
5
5
  "main": "./src/index.js",
6
6
  "types": "./types/index.d.ts",
@@ -65,7 +65,9 @@
65
65
  "link-header",
66
66
  "stale-if-error",
67
67
  "observability",
68
- "logging"
68
+ "logging",
69
+ "hedged-requests",
70
+ "tail-latency"
69
71
  ],
70
72
  "author": "Boy Offi9",
71
73
  "license": "MIT",
@@ -1,6 +1,8 @@
1
1
  const { withRetry, RetryAbortedError } = require("../resilience/retry");
2
2
  const { withTimeout } = require("../resilience/timeout");
3
3
  const { withDedupe } = require("../resilience/dedupe");
4
+ const { withHedge } = require("../resilience/hedge");
5
+ const { createLatencyTracker } = require("./latency");
4
6
  const { buildQuery } = require("./buildQuery");
5
7
  const { parseJsonSafe } = require("./parseJsonSafe");
6
8
  const { normalizeError } = require("../errors/normalizeError");
@@ -99,6 +101,8 @@ function mergeDefaults(base, extra) {
99
101
  * - `onEvent`: one callback for everything that happens (request, attempt,
100
102
  * retry, response, error, cache, rate-limit waits, circuit changes, token
101
103
  * refreshes) — plug it into your logger or metrics
104
+ * - `hedge`: if a request is slow, send a backup and take whichever answers
105
+ * first (idempotent requests only) — trims tail latency
102
106
  * - concurrent GET de-duplication (withDedupe)
103
107
  * - `auth`: attach a token, and on a 401 refresh it ONCE (shared by all
104
108
  * concurrent requests) and replay the request
@@ -131,6 +135,11 @@ function mergeDefaults(base, extra) {
131
135
  * @param {string[]} [defaults.trustedOrigins] - extra origins ("https://cdn.example.com") that may
132
136
  * receive the default credentials, besides `baseUrl`'s. With neither `baseUrl` nor
133
137
  * `trustedOrigins` set there is nothing to compare against and credentials go everywhere.
138
+ * @param {boolean | object} [defaults.hedge] - `{ delayMs, maxHedges, methods }` (or `true`). If an attempt
139
+ * hasn't answered after `delayMs`, a backup request is sent and the first good answer wins. `delayMs`
140
+ * is a number, or "auto" (default) to hedge at your own recent p95 latency per origin (tuned with
141
+ * `percentile` = 0.95, `minDelayMs` = 20, `fallbackDelayMs` = 300 until 10 samples exist). Applies to
142
+ * idempotent methods (or requests with an `Idempotency-Key`) only. Disables `dedupe` for hedged requests.
134
143
  * @param {boolean} [defaults.dedupe=false] - merge identical concurrent GET/HEAD calls
135
144
  * @param {boolean} [defaults.throwHttpErrors=true]
136
145
  * @param {typeof fetch} [defaults.fetch=globalThis.fetch]
@@ -152,6 +161,7 @@ function createFetch(defaults = {}) {
152
161
  cache,
153
162
  auth,
154
163
  trustedOrigins,
164
+ hedge,
155
165
  dedupe = false,
156
166
  throwHttpErrors: defaultThrow = true,
157
167
  fetch: customFetch,
@@ -237,6 +247,12 @@ function createFetch(defaults = {}) {
237
247
  emitEvent({ type: "cache", url, method: "GET", event: type });
238
248
  };
239
249
 
250
+ const hedgeDefaults = !hedge
251
+ ? null
252
+ : { delayMs: "auto", maxHedges: 1, ...(hedge === true ? {} : hedge) };
253
+ if (hedgeDefaults && !hedgeDefaults.tracker) hedgeDefaults.tracker = createLatencyTracker();
254
+ const fallbackTracker = hedgeDefaults ? null : createLatencyTracker(); // for per-request `hedge` on an instance with no defaults
255
+
240
256
  // Shared in-flight GETs. Every caller gets its own clone of the response so
241
257
  // body reads don't fight each other.
242
258
  const sharedFetch = withDedupe((key, url, init) => doFetch(url, init), { keyFn: (key) => key });
@@ -301,6 +317,7 @@ function createFetch(defaults = {}) {
301
317
  signal,
302
318
  headers: initHeaders,
303
319
  cache: cacheMode,
320
+ hedge: hedgeOpt,
304
321
  ...fetchInit
305
322
  } = init;
306
323
 
@@ -353,6 +370,26 @@ function createFetch(defaults = {}) {
353
370
  (methods.map((m) => m.toUpperCase()).includes(finalMethod) || hasIdempotencyKey);
354
371
  const { statusCodes, methods: _methods, retries = 2, shouldRetry: customShouldRetry, ...retryRest } = retryCfg || {};
355
372
 
373
+ // --- hedging policy ----------------------------------------------------
374
+ const hedgeCfg =
375
+ hedgeOpt === false || (!hedgeDefaults && !hedgeOpt)
376
+ ? null
377
+ : { delayMs: "auto", maxHedges: 1, ...(hedgeDefaults || {}), ...(hedgeOpt && hedgeOpt !== true ? hedgeOpt : {}) };
378
+ const hedgeMethods = ((hedgeCfg && hedgeCfg.methods) || DEFAULT_RETRY_METHODS).map((m) => m.toUpperCase());
379
+ const canHedge =
380
+ hedgeCfg !== null &&
381
+ hedgeCfg.maxHedges >= 1 &&
382
+ !isStreamBody(ctx.init.body) &&
383
+ (hedgeMethods.includes(finalMethod) || hasIdempotencyKey);
384
+ const tracker = canHedge ? (hedgeDefaults ? hedgeDefaults.tracker : fallbackTracker) : null;
385
+ const trackerKey = originOf(ctx.url);
386
+ const hedgeDelay = () => {
387
+ if (typeof hedgeCfg.delayMs === "number") return hedgeCfg.delayMs;
388
+ const { percentile = 0.95, minDelayMs = 20, fallbackDelayMs = 300, minSamples = 10 } = hedgeCfg;
389
+ if (tracker.count(trackerKey) < minSamples) return fallbackDelayMs;
390
+ return Math.max(minDelayMs, tracker.percentile(trackerKey, percentile));
391
+ };
392
+
356
393
  // One trip to the network: rate limit → circuit breaker → per-attempt
357
394
  // timeout → fetch, retried per the policy above. `netHeaders` and
358
395
  // `netSignal` are parameters so background revalidation can reuse this.
@@ -360,16 +397,21 @@ function createFetch(defaults = {}) {
360
397
  const attemptOnce = () => {
361
398
  state.attempts++;
362
399
  emitEvent({ type: "attempt", url: ctx.url, method: finalMethod, attempt: state.attempts });
363
- const attempt = async () => {
400
+ // A single request: rate limit → per-attempt timeout → fetch. `sig` is what cancels it —
401
+ // the caller's signal normally, or the hedge's own signal when it can lose a race.
402
+ const runOnce = async (sig) => {
364
403
  if (limiter) {
365
- const waitedMs = await limiter.acquire({ signal: netSignal });
366
- if (waitedMs > 0) emitEvent({ type: "ratelimit", url: ctx.url, method: finalMethod, waitedMs });
404
+ const waitStart = Date.now();
405
+ await limiter.acquire({ signal: sig });
406
+ const waitedMs = Date.now() - waitStart;
407
+ // Only report waits that mean something; scheduling jitter on an immediate acquire is not a wait.
408
+ if (waitedMs >= 5) emitEvent({ type: "ratelimit", url: ctx.url, method: finalMethod, waitedMs });
367
409
  }
410
+ const startedOne = Date.now();
368
411
  const res = await withTimeout(
369
412
  (attemptSignal) => {
370
413
  const attemptInit = { ...ctx.init, method: finalMethod, headers: netHeaders, signal: attemptSignal };
371
- const useDedupe =
372
- dedupe && !netSignal && !ctx.init.body && (finalMethod === "GET" || finalMethod === "HEAD");
414
+ const useDedupe = dedupe && !sig && !ctx.init.body && (finalMethod === "GET" || finalMethod === "HEAD");
373
415
  if (!useDedupe) return doFetch(ctx.url, attemptInit);
374
416
  // No per-attempt signal on a shared request: aborting one caller
375
417
  // must never cancel everyone else's. It still gets its own
@@ -382,11 +424,27 @@ function createFetch(defaults = {}) {
382
424
  return sharedFetch(key, ctx.url, sharedInit).then((res) => res.clone());
383
425
  },
384
426
  timeout === false ? Infinity : timeout,
385
- { signal: netSignal }
427
+ { signal: sig }
386
428
  );
387
429
  if (limiter) limiter.update(res);
430
+ if (tracker && res.status < 500) tracker.record(trackerKey, Date.now() - startedOne);
388
431
  return res;
389
432
  };
433
+ const attempt = canHedge
434
+ ? () =>
435
+ withHedge((hedgeSignal) => runOnce(hedgeSignal), {
436
+ delayMs: hedgeDelay,
437
+ maxHedges: hedgeCfg.maxHedges,
438
+ signal: netSignal,
439
+ onHedge: ({ index, afterMs }) => {
440
+ state.hedges++;
441
+ emitEvent({ type: "hedge", url: ctx.url, method: finalMethod, index, afterMs });
442
+ },
443
+ onWinner: (index) => {
444
+ state.hedgeWon = index > 0;
445
+ },
446
+ })
447
+ : () => runOnce(netSignal);
390
448
  const breaker = breakerFor && breakerFor(ctx.url);
391
449
  return breaker ? breaker.run(attempt) : attempt();
392
450
  };
@@ -450,6 +508,8 @@ function createFetch(defaults = {}) {
450
508
  durationMs: Date.now() - state.startedAt,
451
509
  attempts: state.attempts,
452
510
  cache: state.cache,
511
+ hedges: state.hedges,
512
+ hedgeWon: state.hedgeWon,
453
513
  });
454
514
  if (onResponse) await onResponse({ url: ctx.url, init: ctx.init, response });
455
515
  if (throwHttpErrors && !response.ok) {
@@ -561,13 +621,21 @@ function createFetch(defaults = {}) {
561
621
  }
562
622
 
563
623
  async function request(input, init = {}) {
564
- const state = { startedAt: Date.now(), attempts: 0, responded: false, cache: undefined, url: undefined, method: undefined };
624
+ const state = { startedAt: Date.now(), attempts: 0, hedges: 0, hedgeWon: false, responded: false, cache: undefined, url: undefined, method: undefined };
565
625
  try {
566
626
  return await execute(input, init, state);
567
627
  } catch (error) {
568
628
  // A non-2xx HTTPError was already reported as a "response" event.
569
629
  if (!state.responded) {
570
- emitEvent({ type: "error", url: state.url, method: state.method, error, durationMs: Date.now() - state.startedAt, attempts: state.attempts });
630
+ emitEvent({
631
+ type: "error",
632
+ url: state.url,
633
+ method: state.method,
634
+ error,
635
+ durationMs: Date.now() - state.startedAt,
636
+ attempts: state.attempts,
637
+ hedges: state.hedges,
638
+ });
571
639
  }
572
640
  throw error;
573
641
  }
@@ -594,6 +662,7 @@ function createFetch(defaults = {}) {
594
662
  rateLimit: limiter || undefined,
595
663
  circuitBreaker: breakerFor || undefined,
596
664
  cache: cacheCfg || undefined,
665
+ hedge: hedgeDefaults || undefined,
597
666
  },
598
667
  overrides
599
668
  )
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Remembers recent response times per key (an origin) so `createFetch` can
3
+ * hedge at "my usual slow" instead of a hand-tuned constant.
4
+ */
5
+ function createLatencyTracker({ maxSamples = 100, maxKeys = 200 } = {}) {
6
+ const byKey = new Map();
7
+
8
+ return {
9
+ record(key, ms) {
10
+ if (!Number.isFinite(ms) || ms < 0) return;
11
+ let samples = byKey.get(key);
12
+ if (!samples) {
13
+ samples = [];
14
+ byKey.set(key, samples);
15
+ if (byKey.size > maxKeys) byKey.delete(byKey.keys().next().value); // forget the oldest origin
16
+ }
17
+ samples.push(ms);
18
+ if (samples.length > maxSamples) samples.shift();
19
+ },
20
+
21
+ count(key) {
22
+ const samples = byKey.get(key);
23
+ return samples ? samples.length : 0;
24
+ },
25
+
26
+ /** The p-th percentile (0–1) of the recent samples for `key`, or null when there are none. */
27
+ percentile(key, p) {
28
+ const samples = byKey.get(key);
29
+ if (!samples || samples.length === 0) return null;
30
+ const sorted = [...samples].sort((a, b) => a - b);
31
+ const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(p * sorted.length) - 1));
32
+ return sorted[index];
33
+ },
34
+
35
+ clear() {
36
+ byKey.clear();
37
+ },
38
+ };
39
+ }
40
+
41
+ module.exports = { createLatencyTracker };
package/src/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const { withRetry, RetryAbortedError, StopRetry } = require("./resilience/retry");
2
2
  const { withTimeout, TimeoutError } = require("./resilience/timeout");
3
+ const { withHedge } = require("./resilience/hedge");
3
4
  const { normalizeError } = require("./errors/normalizeError");
4
5
  const { parseJsonSafe } = require("./http/parseJsonSafe");
5
6
  const { buildQuery } = require("./http/buildQuery");
@@ -22,6 +23,7 @@ module.exports = {
22
23
  RetryAbortedError,
23
24
  StopRetry,
24
25
  withTimeout,
26
+ withHedge,
25
27
  TimeoutError,
26
28
  normalizeError,
27
29
  parseJsonSafe,
package/src/index.mjs CHANGED
@@ -5,6 +5,7 @@ export const {
5
5
  RetryAbortedError,
6
6
  StopRetry,
7
7
  withTimeout,
8
+ withHedge,
8
9
  TimeoutError,
9
10
  normalizeError,
10
11
  parseJsonSafe,
@@ -0,0 +1,168 @@
1
+ const { discardBody } = require("../internal/http");
2
+
3
+ function defaultIsFailure(result) {
4
+ return Boolean(result) && typeof result.status === "number" && result.status >= 500;
5
+ }
6
+
7
+ function lostReason() {
8
+ const reason = new Error("Request cancelled: a faster hedged request won");
9
+ reason.name = "AbortError";
10
+ return reason;
11
+ }
12
+
13
+ /**
14
+ * Hedged requests: start `fn`; if it hasn't answered within `delayMs`, start
15
+ * a backup (a "hedge"); whichever succeeds first wins and the others are
16
+ * aborted. It trims the slow tail of latency — the occasional request that
17
+ * takes 10× longer than the rest — at the cost of a little extra load.
18
+ *
19
+ * ```js
20
+ * const res = await withHedge((signal) => fetch(url, { signal }), { delayMs: 200 });
21
+ * ```
22
+ *
23
+ * Only hedge requests that are safe to run twice (GETs, idempotent calls).
24
+ *
25
+ * Details:
26
+ * - `fn(signal, index)` — `index` is 0 for the original and 1.. for backups.
27
+ * Abort-aware code (like `fetch`) is actually cancelled when it loses.
28
+ * - An attempt that throws, or returns a failed result (status >= 500 by
29
+ * default), launches the next backup immediately instead of waiting.
30
+ * - If every attempt fails: resolves with the last failed *result* if there was
31
+ * one (a 503 `Response` isn't thrown), otherwise rejects with the last error.
32
+ * - The bodies of responses that lose are cancelled so connections are freed.
33
+ *
34
+ * @template T
35
+ * @param {(signal: AbortSignal, index: number) => Promise<T>} fn
36
+ * @param {object} opts
37
+ * @param {number | ((index: number) => number)} opts.delayMs - wait before launching backup #index (1-based)
38
+ * @param {number} [opts.maxHedges=1] - how many backups at most
39
+ * @param {(result: T) => boolean} [opts.isFailure] - results that count as failed. Default: `result.status >= 500`
40
+ * @param {boolean} [opts.hedgeOnError=true] - launch the next backup right away when an attempt throws
41
+ * @param {(info: { index: number, afterMs: number }) => void} [opts.onHedge] - a backup was launched
42
+ * @param {(index: number) => void} [opts.onWinner] - which attempt produced the result (0 = the original)
43
+ * @param {AbortSignal} [opts.signal] - cancels everything; rejects with the signal's reason
44
+ * @returns {Promise<T>}
45
+ */
46
+ function withHedge(fn, opts = {}) {
47
+ const { delayMs, maxHedges = 1, isFailure = defaultIsFailure, hedgeOnError = true, onHedge, onWinner, signal } = opts;
48
+
49
+ const delayFor = typeof delayMs === "function" ? delayMs : () => delayMs;
50
+ if (typeof delayMs !== "function" && !(typeof delayMs === "number" && delayMs >= 0 && Number.isFinite(delayMs))) {
51
+ return Promise.reject(new TypeError("withHedge: `delayMs` must be a non-negative number or a function returning one"));
52
+ }
53
+ if (!(Number.isInteger(maxHedges) && maxHedges >= 0)) {
54
+ return Promise.reject(new TypeError("withHedge: `maxHedges` must be an integer >= 0"));
55
+ }
56
+ if (signal && signal.aborted) return Promise.reject(signal.reason);
57
+
58
+ return new Promise((resolve, reject) => {
59
+ const startedAt = Date.now();
60
+ const attempts = []; // { controller, done }
61
+ let launched = 0;
62
+ let pending = 0;
63
+ let settled = false;
64
+ let timer = null;
65
+ let lastError;
66
+ let hasError = false;
67
+ let failedResult;
68
+ let hasFailedResult = false;
69
+
70
+ const onAbort = () => finish(reject, signal.reason, -1);
71
+ if (signal) signal.addEventListener("abort", onAbort, { once: true });
72
+
73
+ function finish(settle, value, winnerIndex) {
74
+ if (settled) return;
75
+ settled = true;
76
+ clearTimeout(timer);
77
+ if (signal) signal.removeEventListener("abort", onAbort);
78
+ attempts.forEach((attempt, index) => {
79
+ if (index !== winnerIndex && !attempt.done) attempt.controller.abort(lostReason());
80
+ });
81
+ if (hasFailedResult && failedResult !== value) discardBody(failedResult);
82
+ if (winnerIndex >= 0 && onWinner) {
83
+ try {
84
+ onWinner(winnerIndex);
85
+ } catch {
86
+ // ignore
87
+ }
88
+ }
89
+ settle(value);
90
+ }
91
+
92
+ function scheduleBackup() {
93
+ clearTimeout(timer);
94
+ if (launched > maxHedges) return; // `launched` counts the original too
95
+ timer = setTimeout(() => launch(), Math.max(0, Number(delayFor(launched)) || 0));
96
+ }
97
+
98
+ function launch() {
99
+ if (settled) return;
100
+ const index = launched++;
101
+ const controller = new AbortController();
102
+ const attempt = { controller, done: false };
103
+ attempts[index] = attempt;
104
+ pending++;
105
+
106
+ if (index > 0 && onHedge) {
107
+ try {
108
+ onHedge({ index, afterMs: Date.now() - startedAt });
109
+ } catch {
110
+ // ignore
111
+ }
112
+ }
113
+
114
+ let promise;
115
+ try {
116
+ promise = Promise.resolve(fn(controller.signal, index));
117
+ } catch (error) {
118
+ promise = Promise.reject(error);
119
+ }
120
+
121
+ promise.then(
122
+ (result) => {
123
+ attempt.done = true;
124
+ pending--;
125
+ if (settled) return discardBody(result); // too late — it lost
126
+ if (isFailure(result)) {
127
+ if (hasFailedResult) discardBody(failedResult);
128
+ failedResult = result;
129
+ hasFailedResult = true;
130
+ return next();
131
+ }
132
+ finish(resolve, result, index);
133
+ },
134
+ (error) => {
135
+ attempt.done = true;
136
+ pending--;
137
+ if (settled) return;
138
+ lastError = error;
139
+ hasError = true;
140
+ if (hedgeOnError) next();
141
+ else if (pending === 0 && launched > maxHedges) giveUp();
142
+ }
143
+ );
144
+
145
+ scheduleBackup();
146
+ }
147
+
148
+ // An attempt failed: start the next backup now, or give up if there are none left.
149
+ function next() {
150
+ if (settled) return;
151
+ if (launched <= maxHedges) {
152
+ clearTimeout(timer);
153
+ launch();
154
+ } else if (pending === 0) {
155
+ giveUp();
156
+ }
157
+ }
158
+
159
+ function giveUp() {
160
+ if (hasFailedResult) finish(resolve, failedResult, -1);
161
+ else finish(reject, hasError ? lastError : new Error("withHedge: no attempt succeeded"), -1);
162
+ }
163
+
164
+ launch();
165
+ });
166
+ }
167
+
168
+ module.exports = { withHedge };
package/types/http.d.ts CHANGED
@@ -53,6 +53,11 @@ export interface CreateFetchDefaults {
53
53
  * `trustedOrigins` there's nothing to compare against and credentials go everywhere.
54
54
  */
55
55
  trustedOrigins?: string[];
56
+ /**
57
+ * Send a backup request when an attempt is slow and take whichever answers first
58
+ * (idempotent methods, or requests with an Idempotency-Key). `true` = delayMs "auto".
59
+ */
60
+ hedge?: boolean | HedgeConfig;
56
61
  /** Attach a token and, on a 401, refresh it once (shared by all concurrent requests) and replay. */
57
62
  auth?: AuthOptions;
58
63
  /** In-memory GET cache with TTL, stale-while-revalidate and ETag/Last-Modified revalidation. `true` = 60s TTL. */
@@ -76,6 +81,8 @@ export interface ReqkitRequestInit extends Omit<RequestInit, "headers" | "cache"
76
81
  timeout?: number | false;
77
82
  retry?: false | CreateFetchRetryOptions;
78
83
  throwHttpErrors?: boolean;
84
+ /** `false` disables hedging for this call; an object overrides the defaults. */
85
+ hedge?: boolean | HedgeConfig;
79
86
  /** `false`/"no-store" bypass the cache; "reload" skips the read but refreshes the entry. */
80
87
  cache?: false | "no-store" | "reload";
81
88
  }
@@ -105,6 +112,23 @@ export interface MemoryCache extends CacheStore {
105
112
 
106
113
  export function createMemoryCache(opts?: { maxEntries?: number }): MemoryCache;
107
114
 
115
+ export interface HedgeConfig {
116
+ /** Milliseconds before a backup is sent, or "auto" (default) to use your own recent latency. */
117
+ delayMs?: number | "auto";
118
+ /** Backups per attempt. Default 1. */
119
+ maxHedges?: number;
120
+ /** Methods that may be hedged. Default: the idempotent ones (GET, HEAD, OPTIONS, PUT, DELETE). */
121
+ methods?: string[];
122
+ /** For "auto": which percentile of recent latency to wait for. Default 0.95. */
123
+ percentile?: number;
124
+ /** For "auto": never hedge sooner than this. Default 20. */
125
+ minDelayMs?: number;
126
+ /** For "auto": the delay used until `minSamples` responses were seen. Default 300. */
127
+ fallbackDelayMs?: number;
128
+ /** For "auto": samples needed before trusting the percentile. Default 10. */
129
+ minSamples?: number;
130
+ }
131
+
108
132
  export interface CacheEvent {
109
133
  type: "hit" | "stale" | "revalidated" | "miss" | "store" | "stale-if-error";
110
134
  url: string;
@@ -172,8 +196,13 @@ export type FetchEvent =
172
196
  attempts: number;
173
197
  /** How the cache was involved, if it was. */
174
198
  cache?: "hit" | "stale" | "revalidated" | "miss" | "stale-if-error";
199
+ /** Backup requests sent for this call. */
200
+ hedges: number;
201
+ /** True when a backup request produced the response. */
202
+ hedgeWon: boolean;
175
203
  }
176
- | { type: "error"; url?: string; method?: string; error: unknown; durationMs: number; attempts: number }
204
+ | { type: "error"; url?: string; method?: string; error: unknown; durationMs: number; attempts: number; hedges: number }
205
+ | { type: "hedge"; url: string; method: string; /** 1-based backup number */ index: number; /** ms since the call's attempt began */ afterMs: number }
177
206
  | { type: "cache"; url: string; method: "GET"; event: CacheEvent["type"] }
178
207
  | { type: "ratelimit"; url: string; method: string; waitedMs: number }
179
208
  | { type: "circuit"; origin: string; state: CircuitState; previous: CircuitState }
@@ -169,3 +169,26 @@ export interface CircuitBreaker {
169
169
  }
170
170
 
171
171
  export function createCircuitBreaker(opts?: CircuitBreakerOptions): CircuitBreaker;
172
+
173
+ export interface HedgeOptions<T = any> {
174
+ /** Wait before launching backup #index (1-based): a number of ms, or a function of the index. */
175
+ delayMs: number | ((index: number) => number);
176
+ /** How many backups at most. Default 1. */
177
+ maxHedges?: number;
178
+ /** Results that count as failed (and launch the next backup at once). Default: `result.status >= 500`. */
179
+ isFailure?: (result: T) => boolean;
180
+ /** Launch the next backup immediately when an attempt throws. Default true. */
181
+ hedgeOnError?: boolean;
182
+ /** A backup was launched. */
183
+ onHedge?: (info: { index: number; afterMs: number }) => void;
184
+ /** Which attempt produced the result (0 = the original). */
185
+ onWinner?: (index: number) => void;
186
+ /** Cancels everything; rejects with the signal's reason. */
187
+ signal?: AbortSignal;
188
+ }
189
+
190
+ /**
191
+ * Hedged requests: if `fn` hasn't answered after `delayMs`, start a backup and take whichever
192
+ * succeeds first; the others are aborted. Only hedge calls that are safe to run twice.
193
+ */
194
+ export function withHedge<T>(fn: (signal: AbortSignal, index: number) => Promise<T> | T, opts: HedgeOptions<T>): Promise<T>;