@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 +43 -1
- package/dist/reqkit.mjs +296 -9
- package/package.json +4 -2
- package/src/http/createFetch.js +77 -8
- package/src/http/latency.js +41 -0
- package/src/index.js +2 -0
- package/src/index.mjs +1 -0
- package/src/resilience/hedge.js +168 -0
- package/types/http.d.ts +30 -1
- package/types/resilience.d.ts +23 -0
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.
|
|
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
|
-
|
|
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
|
|
662
|
-
|
|
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:
|
|
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({
|
|
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.
|
|
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",
|
package/src/http/createFetch.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
366
|
-
|
|
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:
|
|
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({
|
|
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
|
@@ -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 }
|
package/types/resilience.d.ts
CHANGED
|
@@ -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>;
|