@boy-offi9-inc/reqkit 0.7.1 → 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.
Files changed (37) hide show
  1. package/README.md +134 -66
  2. package/dist/reqkit.mjs +2429 -2020
  3. package/package.json +11 -5
  4. package/src/{createFetch.js → http/createFetch.js} +203 -26
  5. package/src/http/latency.js +41 -0
  6. package/src/index.js +20 -18
  7. package/src/index.mjs +1 -0
  8. package/src/{paginate.js → pagination/paginate.js} +2 -2
  9. package/src/resilience/hedge.js +168 -0
  10. package/src/{createRateLimiter.js → resilience/rateLimiter.js} +7 -4
  11. package/src/{withRetry.js → resilience/retry.js} +2 -2
  12. package/src/{retryAfterAware.js → resilience/retryAfterAware.js} +3 -3
  13. package/src/{downloadResumable.js → streams/download.js} +5 -5
  14. package/src/{streamSSE.js → streams/streamSSE.js} +5 -5
  15. package/src/{createMockFetch.js → testing/createMockFetch.js} +1 -1
  16. package/types/errors.d.ts +24 -0
  17. package/types/http.d.ts +209 -0
  18. package/types/index.d.ts +7 -538
  19. package/types/pagination.d.ts +62 -0
  20. package/types/resilience.d.ts +194 -0
  21. package/types/streams.d.ts +105 -0
  22. package/types/testing.d.ts +44 -0
  23. /package/{RECIPES.md → docs/recipes.md} +0 -0
  24. /package/src/{HTTPError.js → errors/HTTPError.js} +0 -0
  25. /package/src/{normalizeError.js → errors/normalizeError.js} +0 -0
  26. /package/src/{buildQuery.js → http/buildQuery.js} +0 -0
  27. /package/src/{_cache.js → http/cache/entry.js} +0 -0
  28. /package/src/{createMemoryCache.js → http/cache/memoryCache.js} +0 -0
  29. /package/src/{parseJsonSafe.js → http/parseJsonSafe.js} +0 -0
  30. /package/src/{_http.js → internal/http.js} +0 -0
  31. /package/src/{_sleep.js → internal/sleep.js} +0 -0
  32. /package/src/{createCircuitBreaker.js → resilience/circuitBreaker.js} +0 -0
  33. /package/src/{withDedupe.js → resilience/dedupe.js} +0 -0
  34. /package/src/{createLimiter.js → resilience/limiter.js} +0 -0
  35. /package/src/{withTimeout.js → resilience/timeout.js} +0 -0
  36. /package/src/{withProgress.js → streams/progress.js} +0 -0
  37. /package/src/{streams.js → streams/readers.js} +0 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@boy-offi9-inc/reqkit",
3
- "version": "0.7.1",
4
- "description": "Small, composable HTTP helpers — 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.",
3
+ "version": "0.9.0",
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",
7
7
  "exports": {
@@ -27,7 +27,8 @@
27
27
  "test": "node --test",
28
28
  "typecheck": "tsc -p tsconfig.json",
29
29
  "smoke": "node smoke/run.mjs",
30
- "prepublishOnly": "npm run build && npm test && npm run typecheck"
30
+ "check": "npm run build && npm test && npm run typecheck && npm run smoke",
31
+ "prepublishOnly": "npm run check"
31
32
  },
32
33
  "keywords": [
33
34
  "http",
@@ -61,7 +62,12 @@
61
62
  "pagination",
62
63
  "auth",
63
64
  "token-refresh",
64
- "link-header"
65
+ "link-header",
66
+ "stale-if-error",
67
+ "observability",
68
+ "logging",
69
+ "hedged-requests",
70
+ "tail-latency"
65
71
  ],
66
72
  "author": "Boy Offi9",
67
73
  "license": "MIT",
@@ -83,8 +89,8 @@
83
89
  "src",
84
90
  "types",
85
91
  "dist",
92
+ "docs/recipes.md",
86
93
  "README.md",
87
- "RECIPES.md",
88
94
  "LICENSE"
89
95
  ],
90
96
  "devDependencies": {
@@ -1,15 +1,17 @@
1
- const { withRetry, RetryAbortedError } = require("./withRetry");
2
- const { withTimeout } = require("./withTimeout");
3
- const { withDedupe } = require("./withDedupe");
1
+ const { withRetry, RetryAbortedError } = require("../resilience/retry");
2
+ const { withTimeout } = require("../resilience/timeout");
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
- const { normalizeError } = require("./normalizeError");
7
- const { HTTPError } = require("./HTTPError");
8
- const { createRateLimiter } = require("./createRateLimiter");
9
- const { createCircuitBreaker } = require("./createCircuitBreaker");
10
- const { createMemoryCache } = require("./createMemoryCache");
11
- const { cacheKey, snapshotResponse, entryToResponse } = require("./_cache");
12
- const { discardBody, getOrigin } = require("./_http");
8
+ const { normalizeError } = require("../errors/normalizeError");
9
+ const { HTTPError } = require("../errors/HTTPError");
10
+ const { createRateLimiter } = require("../resilience/rateLimiter");
11
+ const { createCircuitBreaker } = require("../resilience/circuitBreaker");
12
+ const { createMemoryCache } = require("./cache/memoryCache");
13
+ const { cacheKey, snapshotResponse, entryToResponse } = require("./cache/entry");
14
+ const { discardBody, getOrigin } = require("../internal/http");
13
15
 
14
16
  const DEFAULT_RETRY_STATUS_CODES = [408, 425, 429, 500, 502, 503, 504];
15
17
  // Safe to repeat without side effects. POST/PATCH are deliberately absent.
@@ -94,8 +96,13 @@ function mergeDefaults(base, extra) {
94
96
  * - `rateLimit`: stay under a budget and obey X-RateLimit-* / Retry-After
95
97
  * *before* the server has to say 429
96
98
  * - `circuitBreaker`: fail fast against a service that keeps failing
97
- * - `cache`: in-memory GET cache with TTL, stale-while-revalidate and
98
- * ETag / Last-Modified revalidation (304s)
99
+ * - `cache`: in-memory GET cache with TTL, stale-while-revalidate,
100
+ * stale-if-error and ETag / Last-Modified revalidation (304s)
101
+ * - `onEvent`: one callback for everything that happens (request, attempt,
102
+ * retry, response, error, cache, rate-limit waits, circuit changes, token
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
99
106
  * - concurrent GET de-duplication (withDedupe)
100
107
  * - `auth`: attach a token, and on a 401 refresh it ONCE (shared by all
101
108
  * concurrent requests) and replay the request
@@ -117,8 +124,9 @@ function mergeDefaults(base, extra) {
117
124
  * createRateLimiter() instance to share one budget
118
125
  * @param {boolean | object | Function} [defaults.circuitBreaker] - options for
119
126
  * a per-origin breaker, a createCircuitBreaker() instance, or `(url) => breaker`
120
- * @param {boolean | object} [defaults.cache] - `{ ttl, staleWhileRevalidate,
121
- * maxEntries, maxBodyBytes, store, onEvent }`; `true` = 60s TTL
127
+ * @param {boolean | object} [defaults.cache] - `{ ttl, staleWhileRevalidate, staleIfError,
128
+ * maxEntries, maxBodyBytes, store, onEvent }`; `true` = 60s TTL. `staleIfError` (ms) lets a
129
+ * cached copy be served for that long past its TTL when the network fails or the server answers 5xx.
122
130
  * @param {object} [defaults.auth] - `{ getToken, refresh, header?, scheme?, shouldRefresh? }`.
123
131
  * `getToken()` supplies the current token (sync or async); on a 401 (or
124
132
  * whatever `shouldRefresh(response)` says) `refresh({ token, response })` is
@@ -127,12 +135,20 @@ function mergeDefaults(base, extra) {
127
135
  * @param {string[]} [defaults.trustedOrigins] - extra origins ("https://cdn.example.com") that may
128
136
  * receive the default credentials, besides `baseUrl`'s. With neither `baseUrl` nor
129
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.
130
143
  * @param {boolean} [defaults.dedupe=false] - merge identical concurrent GET/HEAD calls
131
144
  * @param {boolean} [defaults.throwHttpErrors=true]
132
145
  * @param {typeof fetch} [defaults.fetch=globalThis.fetch]
133
146
  * @param {(ctx: {url: string, init: object}) => void | Promise<void>} [defaults.onRequest] -
134
147
  * runs once per call; may mutate ctx.url / ctx.init (e.g. add a header)
135
148
  * @param {(ctx: {url: string, init: object, response: Response}) => void | Promise<void>} [defaults.onResponse]
149
+ * @param {(event: object) => void | Promise<void>} [defaults.onEvent] - receives `{ type, ... }` events:
150
+ * "request", "attempt", "retry", "response", "error", "cache", "ratelimit", "circuit", "auth".
151
+ * Errors thrown by the handler are swallowed — logging must never break a request.
136
152
  */
137
153
  function createFetch(defaults = {}) {
138
154
  const {
@@ -145,15 +161,28 @@ function createFetch(defaults = {}) {
145
161
  cache,
146
162
  auth,
147
163
  trustedOrigins,
164
+ hedge,
148
165
  dedupe = false,
149
166
  throwHttpErrors: defaultThrow = true,
150
167
  fetch: customFetch,
151
168
  onRequest,
152
169
  onResponse,
170
+ onEvent,
153
171
  } = defaults;
154
172
 
155
173
  const doFetch = (...args) => (customFetch || globalThis.fetch)(...args);
156
174
 
175
+ // A throwing (or rejecting) handler must never break a request.
176
+ const emitEvent = (event) => {
177
+ if (!onEvent) return;
178
+ try {
179
+ const result = onEvent(event);
180
+ if (result && typeof result.catch === "function") result.catch(() => {});
181
+ } catch {
182
+ // ignore
183
+ }
184
+ };
185
+
157
186
  // Origins allowed to receive default credentials. Empty = no baseUrl to compare against = unrestricted.
158
187
  const trusted = new Set();
159
188
  for (const o of [baseUrl, ...(trustedOrigins || [])]) {
@@ -183,7 +212,13 @@ function createFetch(defaults = {}) {
183
212
  : (url) => {
184
213
  const origin = originOf(url);
185
214
  if (!breakerRegistry.has(origin)) {
186
- breakerRegistry.set(origin, createCircuitBreaker(circuitBreaker === true ? {} : circuitBreaker));
215
+ const options = circuitBreaker === true ? {} : { ...circuitBreaker };
216
+ const userOnStateChange = options.onStateChange;
217
+ options.onStateChange = (state, previous) => {
218
+ if (userOnStateChange) userOnStateChange(state, previous);
219
+ emitEvent({ type: "circuit", origin, state, previous });
220
+ };
221
+ breakerRegistry.set(origin, createCircuitBreaker(options));
187
222
  }
188
223
  return breakerRegistry.get(origin);
189
224
  };
@@ -193,6 +228,7 @@ function createFetch(defaults = {}) {
193
228
  : {
194
229
  ttl: 60000,
195
230
  staleWhileRevalidate: 0,
231
+ staleIfError: 0,
196
232
  maxEntries: 100,
197
233
  maxBodyBytes: undefined,
198
234
  ...(cache === true ? {} : cache),
@@ -206,7 +242,16 @@ function createFetch(defaults = {}) {
206
242
  }
207
243
  const pendingStores = cacheCfg ? cacheCfg.pending : null;
208
244
  const revalidating = cacheCfg ? cacheCfg.revalidating : null;
209
- const emit = (type, url) => cacheCfg && cacheCfg.onEvent && cacheCfg.onEvent({ type, url });
245
+ const emit = (type, url) => {
246
+ if (cacheCfg && cacheCfg.onEvent) cacheCfg.onEvent({ type, url });
247
+ emitEvent({ type: "cache", url, method: "GET", event: type });
248
+ };
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
210
255
 
211
256
  // Shared in-flight GETs. Every caller gets its own clone of the response so
212
257
  // body reads don't fight each other.
@@ -230,9 +275,12 @@ function createFetch(defaults = {}) {
230
275
  // Returns a token newer than `usedToken`, refreshing at most once no matter
231
276
  // how many requests were rejected at the same time. If someone else already
232
277
  // refreshed (the current token isn't the one we used), just use that.
233
- async function obtainFreshToken(authCfg, tokenUsed, response) {
278
+ async function obtainFreshToken(authCfg, tokenUsed, response, info) {
234
279
  const current = await authCfg.getToken();
235
- if (current && current !== tokenUsed) return current;
280
+ if (current && current !== tokenUsed) {
281
+ emitEvent({ type: "auth", ...info, outcome: "reused" });
282
+ return current;
283
+ }
236
284
 
237
285
  const state = refreshStateFor(authCfg);
238
286
  if (!state.inflight) {
@@ -245,10 +293,21 @@ function createFetch(defaults = {}) {
245
293
  }
246
294
  })();
247
295
  }
248
- return state.inflight;
296
+ try {
297
+ const token = await state.inflight;
298
+ emitEvent({ type: "auth", ...info, outcome: "refreshed" });
299
+ return token;
300
+ } catch (error) {
301
+ emitEvent({ type: "auth", ...info, outcome: "failed", error });
302
+ throw error;
303
+ }
249
304
  }
250
305
 
251
- async function request(input, init = {}) {
306
+ // May a cached copy be served because the origin is failing?
307
+ const withinStaleIfError = (entry) =>
308
+ cacheCfg && cacheCfg.staleIfError > 0 && Date.now() - entry.storedAt < cacheCfg.ttl + cacheCfg.staleIfError;
309
+
310
+ async function execute(input, init, state) {
252
311
  const {
253
312
  query,
254
313
  json,
@@ -258,11 +317,14 @@ function createFetch(defaults = {}) {
258
317
  signal,
259
318
  headers: initHeaders,
260
319
  cache: cacheMode,
320
+ hedge: hedgeOpt,
261
321
  ...fetchInit
262
322
  } = init;
263
323
 
264
324
  const method = String(fetchInit.method || "GET").toUpperCase();
265
325
  const url = joinUrl(baseUrl, input, query);
326
+ state.url = url;
327
+ state.method = method;
266
328
  const credentialsAllowed = isTrustedTarget(url);
267
329
 
268
330
  const defaultHeaderSet = await resolveHeaders(defaultHeaders);
@@ -285,6 +347,9 @@ function createFetch(defaults = {}) {
285
347
  if (onRequest) await onRequest(ctx);
286
348
  const finalHeaders = ctx.init.headers instanceof Headers ? ctx.init.headers : new Headers(ctx.init.headers);
287
349
  const finalMethod = String(ctx.init.method || method).toUpperCase();
350
+ state.url = ctx.url;
351
+ state.method = finalMethod;
352
+ emitEvent({ type: "request", url: ctx.url, method: finalMethod });
288
353
 
289
354
  // --- auth: attach the current token (unless the caller set the header) ----
290
355
  const authHeader = auth ? String(auth.header || "authorization").toLowerCase() : null;
@@ -305,18 +370,48 @@ function createFetch(defaults = {}) {
305
370
  (methods.map((m) => m.toUpperCase()).includes(finalMethod) || hasIdempotencyKey);
306
371
  const { statusCodes, methods: _methods, retries = 2, shouldRetry: customShouldRetry, ...retryRest } = retryCfg || {};
307
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
+
308
393
  // One trip to the network: rate limit → circuit breaker → per-attempt
309
394
  // timeout → fetch, retried per the policy above. `netHeaders` and
310
395
  // `netSignal` are parameters so background revalidation can reuse this.
311
396
  function network(netHeaders, netSignal) {
312
397
  const attemptOnce = () => {
313
- const attempt = async () => {
314
- if (limiter) await limiter.acquire({ signal: netSignal });
398
+ state.attempts++;
399
+ emitEvent({ type: "attempt", url: ctx.url, method: finalMethod, attempt: state.attempts });
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) => {
403
+ if (limiter) {
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 });
409
+ }
410
+ const startedOne = Date.now();
315
411
  const res = await withTimeout(
316
412
  (attemptSignal) => {
317
413
  const attemptInit = { ...ctx.init, method: finalMethod, headers: netHeaders, signal: attemptSignal };
318
- const useDedupe =
319
- dedupe && !netSignal && !ctx.init.body && (finalMethod === "GET" || finalMethod === "HEAD");
414
+ const useDedupe = dedupe && !sig && !ctx.init.body && (finalMethod === "GET" || finalMethod === "HEAD");
320
415
  if (!useDedupe) return doFetch(ctx.url, attemptInit);
321
416
  // No per-attempt signal on a shared request: aborting one caller
322
417
  // must never cancel everyone else's. It still gets its own
@@ -329,11 +424,27 @@ function createFetch(defaults = {}) {
329
424
  return sharedFetch(key, ctx.url, sharedInit).then((res) => res.clone());
330
425
  },
331
426
  timeout === false ? Infinity : timeout,
332
- { signal: netSignal }
427
+ { signal: sig }
333
428
  );
334
429
  if (limiter) limiter.update(res);
430
+ if (tracker && res.status < 500) tracker.record(trackerKey, Date.now() - startedOne);
335
431
  return res;
336
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);
337
448
  const breaker = breakerFor && breakerFor(ctx.url);
338
449
  return breaker ? breaker.run(attempt) : attempt();
339
450
  };
@@ -343,6 +454,19 @@ function createFetch(defaults = {}) {
343
454
  ...retryRest,
344
455
  retries,
345
456
  retryStatusCodes: statusCodes || DEFAULT_RETRY_STATUS_CODES,
457
+ onRetry: (err, attempt, delayMs, rctx) => {
458
+ if (retryRest.onRetry) retryRest.onRetry(err, attempt, delayMs, rctx);
459
+ emitEvent({
460
+ type: "retry",
461
+ url: ctx.url,
462
+ method: finalMethod,
463
+ attempt: attempt + 1,
464
+ delayMs,
465
+ reason: rctx.reason === "result" ? "status" : "error",
466
+ status: rctx.status,
467
+ error: err,
468
+ });
469
+ },
346
470
  shouldRetry:
347
471
  customShouldRetry ||
348
472
  ((err) => {
@@ -374,6 +498,19 @@ function createFetch(defaults = {}) {
374
498
  let entry = null;
375
499
 
376
500
  const finish = async (response) => {
501
+ state.responded = true;
502
+ emitEvent({
503
+ type: "response",
504
+ url: ctx.url,
505
+ method: finalMethod,
506
+ status: response.status,
507
+ ok: response.ok,
508
+ durationMs: Date.now() - state.startedAt,
509
+ attempts: state.attempts,
510
+ cache: state.cache,
511
+ hedges: state.hedges,
512
+ hedgeWon: state.hedgeWon,
513
+ });
377
514
  if (onResponse) await onResponse({ url: ctx.url, init: ctx.init, response });
378
515
  if (throwHttpErrors && !response.ok) {
379
516
  throw new HTTPError(response, { method: finalMethod, url: ctx.url });
@@ -388,10 +525,12 @@ function createFetch(defaults = {}) {
388
525
  const age = Date.now() - entry.storedAt;
389
526
  if (age < cacheCfg.ttl) {
390
527
  emit("hit", ctx.url);
528
+ state.cache = "hit";
391
529
  return finish(entryToResponse(entry));
392
530
  }
393
531
  if (age < cacheCfg.ttl + cacheCfg.staleWhileRevalidate) {
394
532
  emit("stale", ctx.url);
533
+ state.cache = "stale";
395
534
  if (!revalidating.has(key)) {
396
535
  revalidating.add(key);
397
536
  const staleEntry = entry;
@@ -428,13 +567,19 @@ function createFetch(defaults = {}) {
428
567
  } catch (err) {
429
568
  // Present a user abort as the plain AbortError they caused, not as retry internals.
430
569
  if (err instanceof RetryAbortedError && signal && signal.aborted) throw signal.reason ?? err;
570
+ // The network failed but we still hold a usable copy: serve it (never for a deliberate abort).
571
+ if (entry && withinStaleIfError(entry) && !(signal && signal.aborted) && !(err && err.name === "AbortError")) {
572
+ emit("stale-if-error", ctx.url);
573
+ state.cache = "stale-if-error";
574
+ return finish(entryToResponse(entry));
575
+ }
431
576
  throw err;
432
577
  }
433
578
 
434
579
  // --- auth: a rejected token is refreshed once, then the request replays ----
435
580
  const needsRefresh = auth && (auth.shouldRefresh ? auth.shouldRefresh(response) : response.status === 401);
436
581
  if (usedToken && needsRefresh && !isStreamBody(ctx.init.body)) {
437
- const fresh = await obtainFreshToken(auth, usedToken, response);
582
+ const fresh = await obtainFreshToken(auth, usedToken, response, { url: ctx.url, method: finalMethod });
438
583
  if (fresh) {
439
584
  await discardBody(response);
440
585
  finalHeaders.set(authHeader, formatToken(fresh));
@@ -449,15 +594,25 @@ function createFetch(defaults = {}) {
449
594
  }
450
595
  }
451
596
 
597
+ // The server answered, but with an error, and we still hold a usable copy.
598
+ if (useCache && entry && response.status >= 500 && withinStaleIfError(entry)) {
599
+ await discardBody(response);
600
+ emit("stale-if-error", ctx.url);
601
+ state.cache = "stale-if-error";
602
+ return finish(entryToResponse(entry));
603
+ }
604
+
452
605
  if (useCache) {
453
606
  if (response.status === 304 && entry) {
454
607
  entry.storedAt = Date.now();
455
608
  await cacheCfg.store.set(key, entry);
456
609
  emit("revalidated", ctx.url);
610
+ state.cache = "revalidated";
457
611
  await discardBody(response);
458
612
  response = entryToResponse(entry);
459
613
  } else {
460
614
  emit("miss", ctx.url);
615
+ state.cache = "miss";
461
616
  if (response.status === 200) scheduleStore(key, response, ctx.url);
462
617
  }
463
618
  }
@@ -465,6 +620,27 @@ function createFetch(defaults = {}) {
465
620
  return finish(response);
466
621
  }
467
622
 
623
+ async function request(input, init = {}) {
624
+ const state = { startedAt: Date.now(), attempts: 0, hedges: 0, hedgeWon: false, responded: false, cache: undefined, url: undefined, method: undefined };
625
+ try {
626
+ return await execute(input, init, state);
627
+ } catch (error) {
628
+ // A non-2xx HTTPError was already reported as a "response" event.
629
+ if (!state.responded) {
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
+ });
639
+ }
640
+ throw error;
641
+ }
642
+ }
643
+
468
644
  /** The `baseUrl` this instance was created with (paginateLinks uses it to stay on-origin). */
469
645
  request.baseUrl = baseUrl;
470
646
 
@@ -486,6 +662,7 @@ function createFetch(defaults = {}) {
486
662
  rateLimit: limiter || undefined,
487
663
  circuitBreaker: breakerFor || undefined,
488
664
  cache: cacheCfg || undefined,
665
+ hedge: hedgeDefaults || undefined,
489
666
  },
490
667
  overrides
491
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,27 +1,29 @@
1
- const { withRetry, RetryAbortedError, StopRetry } = require("./withRetry");
2
- const { withTimeout, TimeoutError } = require("./withTimeout");
3
- const { normalizeError } = require("./normalizeError");
4
- const { parseJsonSafe } = require("./parseJsonSafe");
5
- const { buildQuery } = require("./buildQuery");
6
- const { retryAfterAware, parseRetryAfter, getWaitMsFromHeaders } = require("./retryAfterAware");
7
- const { withProgress, BodyTooLargeError } = require("./withProgress");
8
- const { withDedupe } = require("./withDedupe");
9
- const { createLimiter } = require("./createLimiter");
10
- const { createFetch, HTTPError } = require("./createFetch");
11
- const { createRateLimiter } = require("./createRateLimiter");
12
- const { createCircuitBreaker, CircuitOpenError } = require("./createCircuitBreaker");
13
- const { createMemoryCache } = require("./createMemoryCache");
14
- const { createMockFetch } = require("./createMockFetch");
15
- const { downloadResumable } = require("./downloadResumable");
16
- const { readLines, readNDJSON, readSSE } = require("./streams");
17
- const { streamSSE } = require("./streamSSE");
18
- const { paginate, paginatePages, paginateLinks, parseLinkHeader } = require("./paginate");
1
+ const { withRetry, RetryAbortedError, StopRetry } = require("./resilience/retry");
2
+ const { withTimeout, TimeoutError } = require("./resilience/timeout");
3
+ const { withHedge } = require("./resilience/hedge");
4
+ const { normalizeError } = require("./errors/normalizeError");
5
+ const { parseJsonSafe } = require("./http/parseJsonSafe");
6
+ const { buildQuery } = require("./http/buildQuery");
7
+ const { retryAfterAware, parseRetryAfter, getWaitMsFromHeaders } = require("./resilience/retryAfterAware");
8
+ const { withProgress, BodyTooLargeError } = require("./streams/progress");
9
+ const { withDedupe } = require("./resilience/dedupe");
10
+ const { createLimiter } = require("./resilience/limiter");
11
+ const { createFetch, HTTPError } = require("./http/createFetch");
12
+ const { createRateLimiter } = require("./resilience/rateLimiter");
13
+ const { createCircuitBreaker, CircuitOpenError } = require("./resilience/circuitBreaker");
14
+ const { createMemoryCache } = require("./http/cache/memoryCache");
15
+ const { createMockFetch } = require("./testing/createMockFetch");
16
+ const { downloadResumable } = require("./streams/download");
17
+ const { readLines, readNDJSON, readSSE } = require("./streams/readers");
18
+ const { streamSSE } = require("./streams/streamSSE");
19
+ const { paginate, paginatePages, paginateLinks, parseLinkHeader } = require("./pagination/paginate");
19
20
 
20
21
  module.exports = {
21
22
  withRetry,
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,
@@ -1,5 +1,5 @@
1
- const { HTTPError } = require("./HTTPError");
2
- const { getOrigin } = require("./_http");
1
+ const { HTTPError } = require("../errors/HTTPError");
2
+ const { getOrigin } = require("../internal/http");
3
3
 
4
4
  const MAX_LINK_HEADER_LENGTH = 64 * 1024;
5
5