@boy-offi9-inc/reqkit 0.7.1 → 0.8.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 (34) hide show
  1. package/README.md +92 -66
  2. package/dist/reqkit.mjs +2136 -2014
  3. package/package.json +9 -5
  4. package/src/{createFetch.js → http/createFetch.js} +130 -22
  5. package/src/index.js +18 -18
  6. package/src/{paginate.js → pagination/paginate.js} +2 -2
  7. package/src/{createRateLimiter.js → resilience/rateLimiter.js} +7 -4
  8. package/src/{withRetry.js → resilience/retry.js} +2 -2
  9. package/src/{retryAfterAware.js → resilience/retryAfterAware.js} +3 -3
  10. package/src/{downloadResumable.js → streams/download.js} +5 -5
  11. package/src/{streamSSE.js → streams/streamSSE.js} +5 -5
  12. package/src/{createMockFetch.js → testing/createMockFetch.js} +1 -1
  13. package/types/errors.d.ts +24 -0
  14. package/types/http.d.ts +180 -0
  15. package/types/index.d.ts +7 -538
  16. package/types/pagination.d.ts +62 -0
  17. package/types/resilience.d.ts +171 -0
  18. package/types/streams.d.ts +105 -0
  19. package/types/testing.d.ts +44 -0
  20. /package/{RECIPES.md → docs/recipes.md} +0 -0
  21. /package/src/{HTTPError.js → errors/HTTPError.js} +0 -0
  22. /package/src/{normalizeError.js → errors/normalizeError.js} +0 -0
  23. /package/src/{buildQuery.js → http/buildQuery.js} +0 -0
  24. /package/src/{_cache.js → http/cache/entry.js} +0 -0
  25. /package/src/{createMemoryCache.js → http/cache/memoryCache.js} +0 -0
  26. /package/src/{parseJsonSafe.js → http/parseJsonSafe.js} +0 -0
  27. /package/src/{_http.js → internal/http.js} +0 -0
  28. /package/src/{_sleep.js → internal/sleep.js} +0 -0
  29. /package/src/{createCircuitBreaker.js → resilience/circuitBreaker.js} +0 -0
  30. /package/src/{withDedupe.js → resilience/dedupe.js} +0 -0
  31. /package/src/{createLimiter.js → resilience/limiter.js} +0 -0
  32. /package/src/{withTimeout.js → resilience/timeout.js} +0 -0
  33. /package/src/{withProgress.js → streams/progress.js} +0 -0
  34. /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.8.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,10 @@
61
62
  "pagination",
62
63
  "auth",
63
64
  "token-refresh",
64
- "link-header"
65
+ "link-header",
66
+ "stale-if-error",
67
+ "observability",
68
+ "logging"
65
69
  ],
66
70
  "author": "Boy Offi9",
67
71
  "license": "MIT",
@@ -83,8 +87,8 @@
83
87
  "src",
84
88
  "types",
85
89
  "dist",
90
+ "docs/recipes.md",
86
91
  "README.md",
87
- "RECIPES.md",
88
92
  "LICENSE"
89
93
  ],
90
94
  "devDependencies": {
@@ -1,15 +1,15 @@
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
4
  const { buildQuery } = require("./buildQuery");
5
5
  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");
6
+ const { normalizeError } = require("../errors/normalizeError");
7
+ const { HTTPError } = require("../errors/HTTPError");
8
+ const { createRateLimiter } = require("../resilience/rateLimiter");
9
+ const { createCircuitBreaker } = require("../resilience/circuitBreaker");
10
+ const { createMemoryCache } = require("./cache/memoryCache");
11
+ const { cacheKey, snapshotResponse, entryToResponse } = require("./cache/entry");
12
+ const { discardBody, getOrigin } = require("../internal/http");
13
13
 
14
14
  const DEFAULT_RETRY_STATUS_CODES = [408, 425, 429, 500, 502, 503, 504];
15
15
  // Safe to repeat without side effects. POST/PATCH are deliberately absent.
@@ -94,8 +94,11 @@ function mergeDefaults(base, extra) {
94
94
  * - `rateLimit`: stay under a budget and obey X-RateLimit-* / Retry-After
95
95
  * *before* the server has to say 429
96
96
  * - `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)
97
+ * - `cache`: in-memory GET cache with TTL, stale-while-revalidate,
98
+ * stale-if-error and ETag / Last-Modified revalidation (304s)
99
+ * - `onEvent`: one callback for everything that happens (request, attempt,
100
+ * retry, response, error, cache, rate-limit waits, circuit changes, token
101
+ * refreshes) — plug it into your logger or metrics
99
102
  * - concurrent GET de-duplication (withDedupe)
100
103
  * - `auth`: attach a token, and on a 401 refresh it ONCE (shared by all
101
104
  * concurrent requests) and replay the request
@@ -117,8 +120,9 @@ function mergeDefaults(base, extra) {
117
120
  * createRateLimiter() instance to share one budget
118
121
  * @param {boolean | object | Function} [defaults.circuitBreaker] - options for
119
122
  * 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
123
+ * @param {boolean | object} [defaults.cache] - `{ ttl, staleWhileRevalidate, staleIfError,
124
+ * maxEntries, maxBodyBytes, store, onEvent }`; `true` = 60s TTL. `staleIfError` (ms) lets a
125
+ * cached copy be served for that long past its TTL when the network fails or the server answers 5xx.
122
126
  * @param {object} [defaults.auth] - `{ getToken, refresh, header?, scheme?, shouldRefresh? }`.
123
127
  * `getToken()` supplies the current token (sync or async); on a 401 (or
124
128
  * whatever `shouldRefresh(response)` says) `refresh({ token, response })` is
@@ -133,6 +137,9 @@ function mergeDefaults(base, extra) {
133
137
  * @param {(ctx: {url: string, init: object}) => void | Promise<void>} [defaults.onRequest] -
134
138
  * runs once per call; may mutate ctx.url / ctx.init (e.g. add a header)
135
139
  * @param {(ctx: {url: string, init: object, response: Response}) => void | Promise<void>} [defaults.onResponse]
140
+ * @param {(event: object) => void | Promise<void>} [defaults.onEvent] - receives `{ type, ... }` events:
141
+ * "request", "attempt", "retry", "response", "error", "cache", "ratelimit", "circuit", "auth".
142
+ * Errors thrown by the handler are swallowed — logging must never break a request.
136
143
  */
137
144
  function createFetch(defaults = {}) {
138
145
  const {
@@ -150,10 +157,22 @@ function createFetch(defaults = {}) {
150
157
  fetch: customFetch,
151
158
  onRequest,
152
159
  onResponse,
160
+ onEvent,
153
161
  } = defaults;
154
162
 
155
163
  const doFetch = (...args) => (customFetch || globalThis.fetch)(...args);
156
164
 
165
+ // A throwing (or rejecting) handler must never break a request.
166
+ const emitEvent = (event) => {
167
+ if (!onEvent) return;
168
+ try {
169
+ const result = onEvent(event);
170
+ if (result && typeof result.catch === "function") result.catch(() => {});
171
+ } catch {
172
+ // ignore
173
+ }
174
+ };
175
+
157
176
  // Origins allowed to receive default credentials. Empty = no baseUrl to compare against = unrestricted.
158
177
  const trusted = new Set();
159
178
  for (const o of [baseUrl, ...(trustedOrigins || [])]) {
@@ -183,7 +202,13 @@ function createFetch(defaults = {}) {
183
202
  : (url) => {
184
203
  const origin = originOf(url);
185
204
  if (!breakerRegistry.has(origin)) {
186
- breakerRegistry.set(origin, createCircuitBreaker(circuitBreaker === true ? {} : circuitBreaker));
205
+ const options = circuitBreaker === true ? {} : { ...circuitBreaker };
206
+ const userOnStateChange = options.onStateChange;
207
+ options.onStateChange = (state, previous) => {
208
+ if (userOnStateChange) userOnStateChange(state, previous);
209
+ emitEvent({ type: "circuit", origin, state, previous });
210
+ };
211
+ breakerRegistry.set(origin, createCircuitBreaker(options));
187
212
  }
188
213
  return breakerRegistry.get(origin);
189
214
  };
@@ -193,6 +218,7 @@ function createFetch(defaults = {}) {
193
218
  : {
194
219
  ttl: 60000,
195
220
  staleWhileRevalidate: 0,
221
+ staleIfError: 0,
196
222
  maxEntries: 100,
197
223
  maxBodyBytes: undefined,
198
224
  ...(cache === true ? {} : cache),
@@ -206,7 +232,10 @@ function createFetch(defaults = {}) {
206
232
  }
207
233
  const pendingStores = cacheCfg ? cacheCfg.pending : null;
208
234
  const revalidating = cacheCfg ? cacheCfg.revalidating : null;
209
- const emit = (type, url) => cacheCfg && cacheCfg.onEvent && cacheCfg.onEvent({ type, url });
235
+ const emit = (type, url) => {
236
+ if (cacheCfg && cacheCfg.onEvent) cacheCfg.onEvent({ type, url });
237
+ emitEvent({ type: "cache", url, method: "GET", event: type });
238
+ };
210
239
 
211
240
  // Shared in-flight GETs. Every caller gets its own clone of the response so
212
241
  // body reads don't fight each other.
@@ -230,9 +259,12 @@ function createFetch(defaults = {}) {
230
259
  // Returns a token newer than `usedToken`, refreshing at most once no matter
231
260
  // how many requests were rejected at the same time. If someone else already
232
261
  // refreshed (the current token isn't the one we used), just use that.
233
- async function obtainFreshToken(authCfg, tokenUsed, response) {
262
+ async function obtainFreshToken(authCfg, tokenUsed, response, info) {
234
263
  const current = await authCfg.getToken();
235
- if (current && current !== tokenUsed) return current;
264
+ if (current && current !== tokenUsed) {
265
+ emitEvent({ type: "auth", ...info, outcome: "reused" });
266
+ return current;
267
+ }
236
268
 
237
269
  const state = refreshStateFor(authCfg);
238
270
  if (!state.inflight) {
@@ -245,10 +277,21 @@ function createFetch(defaults = {}) {
245
277
  }
246
278
  })();
247
279
  }
248
- return state.inflight;
280
+ try {
281
+ const token = await state.inflight;
282
+ emitEvent({ type: "auth", ...info, outcome: "refreshed" });
283
+ return token;
284
+ } catch (error) {
285
+ emitEvent({ type: "auth", ...info, outcome: "failed", error });
286
+ throw error;
287
+ }
249
288
  }
250
289
 
251
- async function request(input, init = {}) {
290
+ // May a cached copy be served because the origin is failing?
291
+ const withinStaleIfError = (entry) =>
292
+ cacheCfg && cacheCfg.staleIfError > 0 && Date.now() - entry.storedAt < cacheCfg.ttl + cacheCfg.staleIfError;
293
+
294
+ async function execute(input, init, state) {
252
295
  const {
253
296
  query,
254
297
  json,
@@ -263,6 +306,8 @@ function createFetch(defaults = {}) {
263
306
 
264
307
  const method = String(fetchInit.method || "GET").toUpperCase();
265
308
  const url = joinUrl(baseUrl, input, query);
309
+ state.url = url;
310
+ state.method = method;
266
311
  const credentialsAllowed = isTrustedTarget(url);
267
312
 
268
313
  const defaultHeaderSet = await resolveHeaders(defaultHeaders);
@@ -285,6 +330,9 @@ function createFetch(defaults = {}) {
285
330
  if (onRequest) await onRequest(ctx);
286
331
  const finalHeaders = ctx.init.headers instanceof Headers ? ctx.init.headers : new Headers(ctx.init.headers);
287
332
  const finalMethod = String(ctx.init.method || method).toUpperCase();
333
+ state.url = ctx.url;
334
+ state.method = finalMethod;
335
+ emitEvent({ type: "request", url: ctx.url, method: finalMethod });
288
336
 
289
337
  // --- auth: attach the current token (unless the caller set the header) ----
290
338
  const authHeader = auth ? String(auth.header || "authorization").toLowerCase() : null;
@@ -310,8 +358,13 @@ function createFetch(defaults = {}) {
310
358
  // `netSignal` are parameters so background revalidation can reuse this.
311
359
  function network(netHeaders, netSignal) {
312
360
  const attemptOnce = () => {
361
+ state.attempts++;
362
+ emitEvent({ type: "attempt", url: ctx.url, method: finalMethod, attempt: state.attempts });
313
363
  const attempt = async () => {
314
- if (limiter) await limiter.acquire({ signal: netSignal });
364
+ if (limiter) {
365
+ const waitedMs = await limiter.acquire({ signal: netSignal });
366
+ if (waitedMs > 0) emitEvent({ type: "ratelimit", url: ctx.url, method: finalMethod, waitedMs });
367
+ }
315
368
  const res = await withTimeout(
316
369
  (attemptSignal) => {
317
370
  const attemptInit = { ...ctx.init, method: finalMethod, headers: netHeaders, signal: attemptSignal };
@@ -343,6 +396,19 @@ function createFetch(defaults = {}) {
343
396
  ...retryRest,
344
397
  retries,
345
398
  retryStatusCodes: statusCodes || DEFAULT_RETRY_STATUS_CODES,
399
+ onRetry: (err, attempt, delayMs, rctx) => {
400
+ if (retryRest.onRetry) retryRest.onRetry(err, attempt, delayMs, rctx);
401
+ emitEvent({
402
+ type: "retry",
403
+ url: ctx.url,
404
+ method: finalMethod,
405
+ attempt: attempt + 1,
406
+ delayMs,
407
+ reason: rctx.reason === "result" ? "status" : "error",
408
+ status: rctx.status,
409
+ error: err,
410
+ });
411
+ },
346
412
  shouldRetry:
347
413
  customShouldRetry ||
348
414
  ((err) => {
@@ -374,6 +440,17 @@ function createFetch(defaults = {}) {
374
440
  let entry = null;
375
441
 
376
442
  const finish = async (response) => {
443
+ state.responded = true;
444
+ emitEvent({
445
+ type: "response",
446
+ url: ctx.url,
447
+ method: finalMethod,
448
+ status: response.status,
449
+ ok: response.ok,
450
+ durationMs: Date.now() - state.startedAt,
451
+ attempts: state.attempts,
452
+ cache: state.cache,
453
+ });
377
454
  if (onResponse) await onResponse({ url: ctx.url, init: ctx.init, response });
378
455
  if (throwHttpErrors && !response.ok) {
379
456
  throw new HTTPError(response, { method: finalMethod, url: ctx.url });
@@ -388,10 +465,12 @@ function createFetch(defaults = {}) {
388
465
  const age = Date.now() - entry.storedAt;
389
466
  if (age < cacheCfg.ttl) {
390
467
  emit("hit", ctx.url);
468
+ state.cache = "hit";
391
469
  return finish(entryToResponse(entry));
392
470
  }
393
471
  if (age < cacheCfg.ttl + cacheCfg.staleWhileRevalidate) {
394
472
  emit("stale", ctx.url);
473
+ state.cache = "stale";
395
474
  if (!revalidating.has(key)) {
396
475
  revalidating.add(key);
397
476
  const staleEntry = entry;
@@ -428,13 +507,19 @@ function createFetch(defaults = {}) {
428
507
  } catch (err) {
429
508
  // Present a user abort as the plain AbortError they caused, not as retry internals.
430
509
  if (err instanceof RetryAbortedError && signal && signal.aborted) throw signal.reason ?? err;
510
+ // The network failed but we still hold a usable copy: serve it (never for a deliberate abort).
511
+ if (entry && withinStaleIfError(entry) && !(signal && signal.aborted) && !(err && err.name === "AbortError")) {
512
+ emit("stale-if-error", ctx.url);
513
+ state.cache = "stale-if-error";
514
+ return finish(entryToResponse(entry));
515
+ }
431
516
  throw err;
432
517
  }
433
518
 
434
519
  // --- auth: a rejected token is refreshed once, then the request replays ----
435
520
  const needsRefresh = auth && (auth.shouldRefresh ? auth.shouldRefresh(response) : response.status === 401);
436
521
  if (usedToken && needsRefresh && !isStreamBody(ctx.init.body)) {
437
- const fresh = await obtainFreshToken(auth, usedToken, response);
522
+ const fresh = await obtainFreshToken(auth, usedToken, response, { url: ctx.url, method: finalMethod });
438
523
  if (fresh) {
439
524
  await discardBody(response);
440
525
  finalHeaders.set(authHeader, formatToken(fresh));
@@ -449,15 +534,25 @@ function createFetch(defaults = {}) {
449
534
  }
450
535
  }
451
536
 
537
+ // The server answered, but with an error, and we still hold a usable copy.
538
+ if (useCache && entry && response.status >= 500 && withinStaleIfError(entry)) {
539
+ await discardBody(response);
540
+ emit("stale-if-error", ctx.url);
541
+ state.cache = "stale-if-error";
542
+ return finish(entryToResponse(entry));
543
+ }
544
+
452
545
  if (useCache) {
453
546
  if (response.status === 304 && entry) {
454
547
  entry.storedAt = Date.now();
455
548
  await cacheCfg.store.set(key, entry);
456
549
  emit("revalidated", ctx.url);
550
+ state.cache = "revalidated";
457
551
  await discardBody(response);
458
552
  response = entryToResponse(entry);
459
553
  } else {
460
554
  emit("miss", ctx.url);
555
+ state.cache = "miss";
461
556
  if (response.status === 200) scheduleStore(key, response, ctx.url);
462
557
  }
463
558
  }
@@ -465,6 +560,19 @@ function createFetch(defaults = {}) {
465
560
  return finish(response);
466
561
  }
467
562
 
563
+ async function request(input, init = {}) {
564
+ const state = { startedAt: Date.now(), attempts: 0, responded: false, cache: undefined, url: undefined, method: undefined };
565
+ try {
566
+ return await execute(input, init, state);
567
+ } catch (error) {
568
+ // A non-2xx HTTPError was already reported as a "response" event.
569
+ if (!state.responded) {
570
+ emitEvent({ type: "error", url: state.url, method: state.method, error, durationMs: Date.now() - state.startedAt, attempts: state.attempts });
571
+ }
572
+ throw error;
573
+ }
574
+ }
575
+
468
576
  /** The `baseUrl` this instance was created with (paginateLinks uses it to stay on-origin). */
469
577
  request.baseUrl = baseUrl;
470
578
 
package/src/index.js CHANGED
@@ -1,21 +1,21 @@
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 { normalizeError } = require("./errors/normalizeError");
4
+ const { parseJsonSafe } = require("./http/parseJsonSafe");
5
+ const { buildQuery } = require("./http/buildQuery");
6
+ const { retryAfterAware, parseRetryAfter, getWaitMsFromHeaders } = require("./resilience/retryAfterAware");
7
+ const { withProgress, BodyTooLargeError } = require("./streams/progress");
8
+ const { withDedupe } = require("./resilience/dedupe");
9
+ const { createLimiter } = require("./resilience/limiter");
10
+ const { createFetch, HTTPError } = require("./http/createFetch");
11
+ const { createRateLimiter } = require("./resilience/rateLimiter");
12
+ const { createCircuitBreaker, CircuitOpenError } = require("./resilience/circuitBreaker");
13
+ const { createMemoryCache } = require("./http/cache/memoryCache");
14
+ const { createMockFetch } = require("./testing/createMockFetch");
15
+ const { downloadResumable } = require("./streams/download");
16
+ const { readLines, readNDJSON, readSSE } = require("./streams/readers");
17
+ const { streamSSE } = require("./streams/streamSSE");
18
+ const { paginate, paginatePages, paginateLinks, parseLinkHeader } = require("./pagination/paginate");
19
19
 
20
20
  module.exports = {
21
21
  withRetry,
@@ -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
 
@@ -1,5 +1,5 @@
1
- const { abortableSleep } = require("./_sleep");
2
- const { getRateLimitInfo } = require("./_http");
1
+ const { abortableSleep } = require("../internal/sleep");
2
+ const { getRateLimitInfo } = require("../internal/http");
3
3
 
4
4
  function abortable(promise, signal) {
5
5
  if (!signal) return promise;
@@ -50,6 +50,7 @@ function createRateLimiter(opts = {}) {
50
50
  let tail = Promise.resolve();
51
51
 
52
52
  async function take(signal) {
53
+ let waitedMs = 0;
53
54
  for (;;) {
54
55
  signal?.throwIfAborted?.();
55
56
  const now = Date.now();
@@ -61,13 +62,15 @@ function createRateLimiter(opts = {}) {
61
62
 
62
63
  if (waitMs <= 0) {
63
64
  stamps.push(now);
64
- return;
65
+ return waitedMs;
65
66
  }
67
+ const waitStartedAt = Date.now();
66
68
  await abortableSleep(waitMs, signal);
69
+ waitedMs += Date.now() - waitStartedAt;
67
70
  }
68
71
  }
69
72
 
70
- /** Resolves when it's your turn. Rejects with the signal's reason if aborted while waiting. */
73
+ /** Resolves with the time spent waiting. Rejects if aborted while waiting. */
71
74
  function acquire({ signal } = {}) {
72
75
  const turn = tail.then(() => take(signal));
73
76
  tail = turn.catch(() => {});
@@ -1,5 +1,5 @@
1
- const { RetryAbortedError, sleep } = require("./_sleep");
2
- const { getWaitMsFromHeaders, discardBody } = require("./_http");
1
+ const { RetryAbortedError, sleep } = require("../internal/sleep");
2
+ const { getWaitMsFromHeaders, discardBody } = require("../internal/http");
3
3
 
4
4
  function defaultShouldRetry(err) {
5
5
  // Retry thrown errors except deliberate aborts. Narrow it with `shouldRetry`
@@ -1,6 +1,6 @@
1
- const { withRetry } = require("./withRetry");
2
- const { normalizeError } = require("./normalizeError");
3
- const { parseRetryAfter, getWaitMsFromHeaders } = require("./_http");
1
+ const { withRetry } = require("./retry");
2
+ const { normalizeError } = require("../errors/normalizeError");
3
+ const { parseRetryAfter, getWaitMsFromHeaders } = require("../internal/http");
4
4
 
5
5
  function defaultShouldRetryError(err) {
6
6
  const normalized = normalizeError(err);
@@ -1,8 +1,8 @@
1
- const { HTTPError } = require("./HTTPError");
2
- const { BodyTooLargeError } = require("./withProgress");
3
- const { normalizeError } = require("./normalizeError");
4
- const { getWaitMsFromHeaders } = require("./_http");
5
- const { abortableSleep } = require("./_sleep");
1
+ const { HTTPError } = require("../errors/HTTPError");
2
+ const { BodyTooLargeError } = require("./progress");
3
+ const { normalizeError } = require("../errors/normalizeError");
4
+ const { getWaitMsFromHeaders } = require("../internal/http");
5
+ const { abortableSleep } = require("../internal/sleep");
6
6
 
7
7
  const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
8
8
 
@@ -1,8 +1,8 @@
1
- const { readSSE } = require("./streams");
2
- const { HTTPError } = require("./HTTPError");
3
- const { normalizeError } = require("./normalizeError");
4
- const { getWaitMsFromHeaders, discardBody } = require("./_http");
5
- const { abortableSleep } = require("./_sleep");
1
+ const { readSSE } = require("./readers");
2
+ const { HTTPError } = require("../errors/HTTPError");
3
+ const { normalizeError } = require("../errors/normalizeError");
4
+ const { getWaitMsFromHeaders, discardBody } = require("../internal/http");
5
+ const { abortableSleep } = require("../internal/sleep");
6
6
 
7
7
  function isRetryableStatus(status) {
8
8
  return status === 408 || status === 425 || status === 429 || status >= 500;
@@ -1,4 +1,4 @@
1
- const { abortableSleep } = require("./_sleep");
1
+ const { abortableSleep } = require("../internal/sleep");
2
2
 
3
3
  const BODYLESS = new Set([101, 204, 205, 304]);
4
4
 
@@ -0,0 +1,24 @@
1
+ // Error types and classification — src/errors/
2
+
3
+ export interface NormalizedError {
4
+ message: string;
5
+ status: number | null;
6
+ code: string | null;
7
+ isNetworkError: boolean;
8
+ isTimeout: boolean;
9
+ /** True when the request was cancelled on purpose (AbortController, axios cancel). */
10
+ isAborted: boolean;
11
+ cause: unknown;
12
+ }
13
+
14
+ export function normalizeError(err: unknown): NormalizedError;
15
+
16
+ export class HTTPError extends Error {
17
+ name: "HTTPError";
18
+ status: number;
19
+ statusText: string;
20
+ response: Response;
21
+ url: string;
22
+ method: string;
23
+ constructor(response: Response, request: { method: string; url: string });
24
+ }