@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.
- package/README.md +134 -66
- package/dist/reqkit.mjs +2429 -2020
- package/package.json +11 -5
- package/src/{createFetch.js → http/createFetch.js} +203 -26
- package/src/http/latency.js +41 -0
- package/src/index.js +20 -18
- package/src/index.mjs +1 -0
- package/src/{paginate.js → pagination/paginate.js} +2 -2
- package/src/resilience/hedge.js +168 -0
- package/src/{createRateLimiter.js → resilience/rateLimiter.js} +7 -4
- package/src/{withRetry.js → resilience/retry.js} +2 -2
- package/src/{retryAfterAware.js → resilience/retryAfterAware.js} +3 -3
- package/src/{downloadResumable.js → streams/download.js} +5 -5
- package/src/{streamSSE.js → streams/streamSSE.js} +5 -5
- package/src/{createMockFetch.js → testing/createMockFetch.js} +1 -1
- package/types/errors.d.ts +24 -0
- package/types/http.d.ts +209 -0
- package/types/index.d.ts +7 -538
- package/types/pagination.d.ts +62 -0
- package/types/resilience.d.ts +194 -0
- package/types/streams.d.ts +105 -0
- package/types/testing.d.ts +44 -0
- /package/{RECIPES.md → docs/recipes.md} +0 -0
- /package/src/{HTTPError.js → errors/HTTPError.js} +0 -0
- /package/src/{normalizeError.js → errors/normalizeError.js} +0 -0
- /package/src/{buildQuery.js → http/buildQuery.js} +0 -0
- /package/src/{_cache.js → http/cache/entry.js} +0 -0
- /package/src/{createMemoryCache.js → http/cache/memoryCache.js} +0 -0
- /package/src/{parseJsonSafe.js → http/parseJsonSafe.js} +0 -0
- /package/src/{_http.js → internal/http.js} +0 -0
- /package/src/{_sleep.js → internal/sleep.js} +0 -0
- /package/src/{createCircuitBreaker.js → resilience/circuitBreaker.js} +0 -0
- /package/src/{withDedupe.js → resilience/dedupe.js} +0 -0
- /package/src/{createLimiter.js → resilience/limiter.js} +0 -0
- /package/src/{withTimeout.js → resilience/timeout.js} +0 -0
- /package/src/{withProgress.js → streams/progress.js} +0 -0
- /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.
|
|
4
|
-
"description": "Small, composable HTTP helpers
|
|
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
|
-
"
|
|
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("
|
|
2
|
-
const { withTimeout } = require("
|
|
3
|
-
const { withDedupe } = require("
|
|
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("
|
|
7
|
-
const { HTTPError } = require("
|
|
8
|
-
const { createRateLimiter } = require("
|
|
9
|
-
const { createCircuitBreaker } = require("
|
|
10
|
-
const { createMemoryCache } = require("./
|
|
11
|
-
const { cacheKey, snapshotResponse, entryToResponse } = require("./
|
|
12
|
-
const { discardBody, getOrigin } = require("
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
314
|
-
|
|
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:
|
|
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("./
|
|
2
|
-
const { withTimeout, TimeoutError } = require("./
|
|
3
|
-
const {
|
|
4
|
-
const {
|
|
5
|
-
const {
|
|
6
|
-
const {
|
|
7
|
-
const {
|
|
8
|
-
const {
|
|
9
|
-
const {
|
|
10
|
-
const {
|
|
11
|
-
const {
|
|
12
|
-
const {
|
|
13
|
-
const {
|
|
14
|
-
const {
|
|
15
|
-
const {
|
|
16
|
-
const {
|
|
17
|
-
const {
|
|
18
|
-
const {
|
|
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