@boy-offi9-inc/reqkit 0.1.2 → 0.2.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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Boy Offi9
3
+ Copyright (c) 2026 Boy Offi9 Inc
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -33,6 +33,7 @@ Most HTTP retry/timeout wrappers bundle everything into a client you have to ful
33
33
 
34
34
  - **`retryAfterAware`** — actually reads `Retry-After`/`X-RateLimit-Reset` headers instead of blind backoff on 429s. Most retry libraries treat rate-limit responses like any other failure and ignore the server's suggested retry time.
35
35
  - **`withProgress`** — progress callback for any streamed response body (downloads, large payloads, anything), not tied to a specific HTTP client.
36
+ - **`withDedupe`** — coalesces concurrent calls with the same key into one in-flight call, so simultaneous requests for the same resource don't each hit the network.
36
37
 
37
38
  Zero dependencies. CJS + ESM. TypeScript types included.
38
39
 
@@ -66,6 +67,16 @@ const data = await withRetry(
66
67
  | `maxDelayMs` | `10000` | delay ceiling |
67
68
  | `shouldRetry` | retries anything but AbortError | `(err, attempt) => boolean` |
68
69
  | `onRetry` | — | `(err, attempt, delayMs) => void` |
70
+ | `signal` | — | `AbortSignal` — cancels the whole retry loop, including any pending backoff wait, throwing `RetryAbortedError` |
71
+
72
+ ```js
73
+ // Cancel a long retry/backoff sequence externally — e.g. the user navigated away
74
+ const controller = new AbortController();
75
+ const promise = withRetry(() => fetch(url), { retries: 5, signal: controller.signal });
76
+ controller.abort(); // rejects with RetryAbortedError, even mid-backoff
77
+ ```
78
+
79
+ > **Heads up:** `fetch` doesn't throw on 4xx/5xx, so `withRetry` alone won't retry a `500`. Throw inside `fn` for the statuses you care about (`if (!r.ok) throw ...`), or use `retryAfterAware` below, which retries on status codes.
69
80
 
70
81
  ### `withTimeout(fn, ms)`
71
82
 
@@ -84,9 +95,11 @@ const data = await withTimeout(
84
95
  One consistent error shape regardless of source (fetch, axios, node http, generic):
85
96
 
86
97
  ```js
87
- const { message, status, code, isNetworkError, isTimeout } = normalizeError(err);
98
+ const { message, status, code, isNetworkError, isTimeout, isAborted } = normalizeError(err);
88
99
  ```
89
100
 
101
+ Understands Node's `TypeError: fetch failed` (real code read from `err.cause`), browser `Failed to fetch`, undici timeouts, and axios. `isTimeout` is only for real timeouts; a deliberate `AbortController.abort()` sets `isAborted` instead.
102
+
90
103
  Every field is always present (`null`/`false` when not applicable) — no existence checks needed.
91
104
 
92
105
  ### `parseJsonSafe(input)`
@@ -107,6 +120,8 @@ buildQuery({ q: "hello world", tag: ["a", "b"] });
107
120
  // "q=hello%20world&tag=a&tag=b"
108
121
  ```
109
122
 
123
+ `Date` values are serialized as ISO 8601. Nested objects throw a `TypeError` (flatten or `JSON.stringify` them first) instead of silently becoming `[object Object]`.
124
+
110
125
  For full parsing/nested-object support, use [`query-string`](https://www.npmjs.com/package/query-string) instead — this only covers the common flat-object case with zero dependencies.
111
126
 
112
127
  ### `retryAfterAware(fn, opts?)`
@@ -127,6 +142,11 @@ const response = await retryAfterAware(
127
142
  | `fallbackDelayMs` | `1000` | used when no usable header is present |
128
143
  | `maxDelayMs` | `60000` | |
129
144
  | `onRetry` | — | `(status, waitMs, attempt) => void` |
145
+ | `shouldRetryError` | network errors + timeouts | `(err, attempt) => boolean` — decides whether a *thrown* error is retried |
146
+ | `onRetryError` | — | `(err, waitMs, attempt) => void` |
147
+ | `signal` | — | `AbortSignal` — cancels the loop (even mid-wait), throwing `RetryAbortedError` |
148
+
149
+ The body of every response that gets retried is cancelled so the connection is freed.
130
150
 
131
151
  ### `withProgress(response, onProgress?)`
132
152
 
@@ -138,7 +158,38 @@ const bytes = await withProgress(response, ({ loaded, total, percent }) => {
138
158
  });
139
159
  ```
140
160
 
141
- Works without `Content-Length` too — `total`/`percent` are just `null` in that case, `loaded` is still accurate. Falls back to a single-shot read (still calling `onProgress` once) if the response body is not a stream.
161
+ Works without `Content-Length` too — `total`/`percent` are just `null` in that case, `loaded` is still accurate. They're also `null` when a `Content-Encoding` is applied (the header is the compressed size, but you receive decompressed bytes), and `percent` never exceeds 100. Falls back to a single-shot read (still calling `onProgress` once) if the response body is not a stream.
162
+
163
+ ```js
164
+ const bytes = await withProgress(response, onProgress, {
165
+ maxBytes: 50 * 1024 * 1024, // throws BodyTooLargeError (code "EBODYTOOLARGE") past this
166
+ signal: controller.signal, // abort the read
167
+ });
168
+ ```
169
+
170
+ ### `withDedupe(fn, opts?)`
171
+
172
+ Coalesces concurrent calls with the same arguments into a single in-flight call — every caller gets the same result, but the underlying request only fires once. Useful when several parts of a UI ask for the same resource around the same time.
173
+
174
+ ```js
175
+ const getUser = withDedupe((id) => fetch(`/api/users/${id}`).then(r => r.json()));
176
+
177
+ // Only one network request goes out — both callers share it.
178
+ const [a, b] = await Promise.all([getUser(1), getUser(1)]);
179
+ ```
180
+
181
+ | Option | Default | Notes |
182
+ | --- | --- | --- |
183
+ | `keyFn` | `(...args) => JSON.stringify(args)` | derives the dedupe key from call args |
184
+
185
+ Plain arguments are compared by value (object key order doesn't matter); anything else — `AbortSignal`s, `Response`s, class instances, functions — is compared by identity, so calls with different `AbortSignal`s are never merged. Note that merged callers share one promise, so aborting the request affects everyone waiting on it.
186
+
187
+ This is deduplication of *concurrent* calls, not a cache — once a call settles (success or failure), the next call with the same key starts fresh.
188
+
189
+ ```js
190
+ // Custom key when the default arg-based key isn't quite right
191
+ const fn = withDedupe((req) => doFetch(req), { keyFn: (req) => req.url });
192
+ ```
142
193
 
143
194
  ---
144
195
 
package/package.json CHANGED
@@ -1,20 +1,26 @@
1
1
  {
2
2
  "name": "@boy-offi9-inc/reqkit",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Small, composable HTTP helper functions — retry with backoff, timeouts, normalized errors, safe JSON parsing, rate-limit-aware retry, and download progress. Not a client, works alongside fetch/axios/anything.",
5
5
  "main": "./src/index.js",
6
6
  "types": "./types/index.d.ts",
7
7
  "exports": {
8
8
  ".": {
9
- "types": "./types/index.d.ts",
10
- "require": "./src/index.js",
11
- "import": "./src/index.mjs"
9
+ "import": {
10
+ "types": "./types/index.d.mts",
11
+ "default": "./src/index.mjs"
12
+ },
13
+ "require": {
14
+ "types": "./types/index.d.ts",
15
+ "default": "./src/index.js"
16
+ }
12
17
  },
13
18
  "./package.json": "./package.json"
14
19
  },
15
20
  "sideEffects": false,
16
21
  "scripts": {
17
- "test": "node --test"
22
+ "test": "node --test",
23
+ "typecheck": "tsc -p tsconfig.json"
18
24
  },
19
25
  "keywords": [
20
26
  "http",
@@ -30,6 +36,14 @@
30
36
  ],
31
37
  "author": "Boy Offi9",
32
38
  "license": "MIT",
39
+ "homepage": "https://github.com/boy-offi9-inc/reqkit#readme",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/boy-offi9-inc/reqkit.git"
43
+ },
44
+ "bugs": {
45
+ "url": "https://github.com/boy-offi9-inc/reqkit/issues"
46
+ },
33
47
  "publishConfig": {
34
48
  "access": "public"
35
49
  },
@@ -40,7 +54,9 @@
40
54
  "src",
41
55
  "types",
42
56
  "README.md",
43
- "LICENSE",
44
- "assets"
45
- ]
57
+ "LICENSE"
58
+ ],
59
+ "devDependencies": {
60
+ "typescript": "^5.6.0"
61
+ }
46
62
  }
package/src/_sleep.js ADDED
@@ -0,0 +1,32 @@
1
+ class RetryAbortedError extends Error {
2
+ constructor(cause) {
3
+ super("Retry loop was aborted");
4
+ this.name = "RetryAbortedError";
5
+ if (cause !== undefined) this.cause = cause;
6
+ }
7
+ }
8
+
9
+ /**
10
+ * Abortable sleep shared by withRetry and retryAfterAware. Rejects with
11
+ * RetryAbortedError (carrying `cause`, usually the last failure) if the
12
+ * signal aborts before or during the wait.
13
+ */
14
+ function sleep(ms, signal, cause) {
15
+ return new Promise((resolve, reject) => {
16
+ if (signal?.aborted) {
17
+ reject(new RetryAbortedError(cause));
18
+ return;
19
+ }
20
+ const onAbort = () => {
21
+ clearTimeout(timer);
22
+ reject(new RetryAbortedError(cause));
23
+ };
24
+ const timer = setTimeout(() => {
25
+ signal?.removeEventListener("abort", onAbort);
26
+ resolve();
27
+ }, ms);
28
+ signal?.addEventListener("abort", onAbort, { once: true });
29
+ });
30
+ }
31
+
32
+ module.exports = { RetryAbortedError, sleep };
package/src/buildQuery.js CHANGED
@@ -1,6 +1,23 @@
1
+ function toParam(key, value) {
2
+ if (value instanceof Date) {
3
+ if (Number.isNaN(value.getTime())) {
4
+ throw new TypeError(`buildQuery: value for "${key}" is an invalid Date`);
5
+ }
6
+ return value.toISOString();
7
+ }
8
+ if (typeof value === "object" || typeof value === "function" || typeof value === "symbol") {
9
+ throw new TypeError(
10
+ `buildQuery: value for "${key}" is not a primitive, Date or array of those — flatten it or JSON.stringify it first`
11
+ );
12
+ }
13
+ return String(value);
14
+ }
15
+
1
16
  /**
2
17
  * Builds a query string from a params object. Skips null/undefined values,
3
- * repeats the key for arrays (`?tag=a&tag=b`), and encodes everything.
18
+ * repeats the key for arrays (`?tag=a&tag=b`), serializes Dates as ISO 8601,
19
+ * and encodes everything. Nested objects throw a TypeError instead of
20
+ * silently becoming "[object Object]".
4
21
  *
5
22
  * Deliberately minimal — for full query-string parsing/nested-object support,
6
23
  * reach for `query-string` (sindresorhus) instead. This just covers the
@@ -15,13 +32,10 @@ function buildQuery(params) {
15
32
  for (const [key, value] of Object.entries(params)) {
16
33
  if (value === null || value === undefined) continue;
17
34
 
18
- if (Array.isArray(value)) {
19
- for (const v of value) {
20
- if (v === null || v === undefined) continue;
21
- pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(v))}`);
22
- }
23
- } else {
24
- pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
35
+ const values = Array.isArray(value) ? value : [value];
36
+ for (const v of values) {
37
+ if (v === null || v === undefined) continue;
38
+ pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(toParam(key, v))}`);
25
39
  }
26
40
  }
27
41
 
package/src/index.js CHANGED
@@ -1,13 +1,15 @@
1
- const { withRetry } = require("./withRetry");
1
+ const { withRetry, RetryAbortedError } = require("./withRetry");
2
2
  const { withTimeout, TimeoutError } = require("./withTimeout");
3
3
  const { normalizeError } = require("./normalizeError");
4
4
  const { parseJsonSafe } = require("./parseJsonSafe");
5
5
  const { buildQuery } = require("./buildQuery");
6
6
  const { retryAfterAware, parseRetryAfter, getWaitMsFromHeaders } = require("./retryAfterAware");
7
- const { withProgress } = require("./withProgress");
7
+ const { withProgress, BodyTooLargeError } = require("./withProgress");
8
+ const { withDedupe } = require("./withDedupe");
8
9
 
9
10
  module.exports = {
10
11
  withRetry,
12
+ RetryAbortedError,
11
13
  withTimeout,
12
14
  TimeoutError,
13
15
  normalizeError,
@@ -17,4 +19,6 @@ module.exports = {
17
19
  parseRetryAfter,
18
20
  getWaitMsFromHeaders,
19
21
  withProgress,
22
+ BodyTooLargeError,
23
+ withDedupe,
20
24
  };
package/src/index.mjs CHANGED
@@ -2,6 +2,7 @@ import reqkit from "./index.js";
2
2
 
3
3
  export const {
4
4
  withRetry,
5
+ RetryAbortedError,
5
6
  withTimeout,
6
7
  TimeoutError,
7
8
  normalizeError,
@@ -11,6 +12,8 @@ export const {
11
12
  parseRetryAfter,
12
13
  getWaitMsFromHeaders,
13
14
  withProgress,
15
+ BodyTooLargeError,
16
+ withDedupe,
14
17
  } = reqkit;
15
18
 
16
19
  export default reqkit;
@@ -1,79 +1,124 @@
1
1
  /**
2
- * Normalizes errors from fetch, axios, node's http/https, and generic
2
+ * Normalizes errors from fetch, axios, node's http/https, undici and generic
3
3
  * Error objects into one consistent shape:
4
- * { message, status, code, isNetworkError, isTimeout, cause }
4
+ * { message, status, code, isNetworkError, isTimeout, isAborted, cause }
5
5
  *
6
6
  * Every field is always present (null/false when not applicable) so callers
7
7
  * can destructure without existence checks.
8
+ *
9
+ * - `isTimeout` is true only for real timeouts (reqkit's TimeoutError,
10
+ * AbortSignal.timeout(), ETIMEDOUT, undici timeouts) — not for a plain
11
+ * user-initiated abort.
12
+ * - `isAborted` is true when the request was cancelled on purpose
13
+ * (AbortController.abort(), axios cancel).
8
14
  */
9
- function normalizeError(err) {
10
- const base = {
15
+
16
+ function shape(err, overrides) {
17
+ return {
11
18
  message: "Unknown error",
12
19
  status: null,
13
20
  code: null,
14
21
  isNetworkError: false,
15
22
  isTimeout: false,
23
+ isAborted: false,
16
24
  cause: err,
25
+ ...overrides,
17
26
  };
27
+ }
28
+
29
+ // Node's fetch (undici) throws `TypeError: fetch failed` and puts the real
30
+ // reason (ECONNREFUSED, ENOTFOUND, UND_ERR_*...) on `err.cause`, sometimes
31
+ // inside an AggregateError. Walk the chain to find the first string code.
32
+ function findCode(err) {
33
+ const seen = new Set();
34
+ const queue = [err];
35
+ while (queue.length && seen.size < 10) {
36
+ const cur = queue.shift();
37
+ if (!cur || typeof cur !== "object" || seen.has(cur)) continue;
38
+ seen.add(cur);
39
+ if (typeof cur.code === "string") return cur.code;
40
+ queue.push(cur.cause);
41
+ if (Array.isArray(cur.errors)) queue.push(...cur.errors);
42
+ }
43
+ return null;
44
+ }
45
+
46
+ function isTimeoutCode(code) {
47
+ return code === "ETIMEDOUT" || code === "ESOCKETTIMEDOUT" || /^UND_ERR_.*TIMEOUT$/.test(code || "");
48
+ }
49
+
50
+ function isNetworkCode(code) {
51
+ if (!code) return false;
52
+ return /^E[A-Z0-9]+$/.test(code) || code === "EAI_AGAIN" || code === "EAI_FAIL" || code.startsWith("UND_ERR_");
53
+ }
18
54
 
19
- if (!err) return base;
55
+ // Browsers and Node throw a bare TypeError for network-level fetch failures.
56
+ const FETCH_NETWORK_MESSAGE =
57
+ /fetch failed|failed to fetch|network ?request failed|load failed|networkerror when attempting to fetch/i;
58
+
59
+ function isFetchNetworkTypeError(err) {
60
+ return err.name === "TypeError" && FETCH_NETWORK_MESSAGE.test(err.message || "");
61
+ }
62
+
63
+ function normalizeError(err) {
64
+ if (!err) return shape(err);
20
65
 
21
66
  // axios error shape: err.response.{status,data}, err.request, err.code
22
67
  if (err.isAxiosError || (err.response && typeof err.response.status === "number" && err.config)) {
23
- return {
68
+ return shape(err, {
24
69
  message: err.response?.data?.message || err.message || "Request failed",
25
70
  status: err.response?.status ?? null,
26
71
  code: err.code ?? null,
27
72
  isNetworkError: !err.response && Boolean(err.request),
28
- isTimeout: err.code === "ECONNABORTED" || /timeout/i.test(err.message || ""),
29
- cause: err,
30
- };
73
+ isTimeout: err.code === "ECONNABORTED" || err.code === "ETIMEDOUT" || /timeout/i.test(err.message || ""),
74
+ isAborted: err.code === "ERR_CANCELED" || err.name === "CanceledError",
75
+ });
31
76
  }
32
77
 
33
78
  // fetch Response passed in directly (not thrown, but common enough to handle)
34
79
  if (typeof err.status === "number" && typeof err.ok === "boolean") {
35
- return {
80
+ return shape(err, {
36
81
  message: err.statusText || `Request failed with status ${err.status}`,
37
82
  status: err.status,
38
- code: null,
39
- isNetworkError: false,
40
- isTimeout: false,
41
- cause: err,
42
- };
83
+ });
43
84
  }
44
85
 
45
- // our own TimeoutError, or DOMException from AbortController
46
- if (err.name === "AbortError" || err.name === "TimeoutError" || err.code === "ETIMEDOUT") {
47
- return {
86
+ const chainCode = findCode(err);
87
+
88
+ // reqkit's TimeoutError, AbortSignal.timeout() (DOMException TimeoutError),
89
+ // node ETIMEDOUT, undici connect/headers/body timeouts
90
+ if (err.name === "TimeoutError" || isTimeoutCode(chainCode)) {
91
+ return shape(err, {
48
92
  message: err.message || "Request timed out",
49
- status: null,
50
- code: "ETIMEDOUT",
51
- isNetworkError: false,
93
+ code: chainCode || "ETIMEDOUT",
52
94
  isTimeout: true,
53
- cause: err,
54
- };
95
+ });
96
+ }
97
+
98
+ // Deliberate cancellation via AbortController — not a timeout.
99
+ if (err.name === "AbortError") {
100
+ return shape(err, {
101
+ message: err.message || "Request aborted",
102
+ code: "ABORT_ERR",
103
+ isAborted: true,
104
+ });
55
105
  }
56
106
 
57
- // node http/https / undici network-level failures
58
- if (err.code && /^E[A-Z]+$/.test(err.code)) {
59
- return {
60
- message: err.message || `Network error (${err.code})`,
61
- status: null,
62
- code: err.code,
107
+ // node http/https / undici network-level failures, including
108
+ // `TypeError: fetch failed` with the real code on err.cause
109
+ if (isNetworkCode(chainCode) || isFetchNetworkTypeError(err)) {
110
+ return shape(err, {
111
+ message: err.message || `Network error (${chainCode})`,
112
+ code: chainCode,
63
113
  isNetworkError: true,
64
- isTimeout: false,
65
- cause: err,
66
- };
114
+ });
67
115
  }
68
116
 
69
- return {
117
+ return shape(err, {
70
118
  message: err.message || String(err),
71
- status: err.status ?? err.statusCode ?? null,
119
+ status: err.status ?? err.statusCode ?? err.response?.status ?? err.response?.statusCode ?? null,
72
120
  code: err.code ?? null,
73
- isNetworkError: false,
74
- isTimeout: false,
75
- cause: err,
76
- };
121
+ });
77
122
  }
78
123
 
79
124
  module.exports = { normalizeError };
@@ -1,20 +1,21 @@
1
- function delay(ms) {
2
- return new Promise((resolve) => setTimeout(resolve, ms));
3
- }
1
+ const { RetryAbortedError, sleep } = require("./_sleep");
2
+ const { normalizeError } = require("./normalizeError");
4
3
 
5
4
  /**
6
5
  * Parses a Retry-After header value into milliseconds. Per HTTP spec it's
7
6
  * either an integer number of seconds, or an HTTP-date.
8
7
  */
9
8
  function parseRetryAfter(headerValue) {
10
- if (!headerValue) return null;
9
+ if (headerValue === null || headerValue === undefined) return null;
10
+ const value = String(headerValue).trim();
11
+ if (!value) return null;
11
12
 
12
- const seconds = Number(headerValue);
13
- if (!Number.isNaN(seconds)) {
14
- return Math.max(0, seconds * 1000);
13
+ // delta-seconds (spec says integer; tolerate "1.5" from sloppy servers)
14
+ if (/^\d+(\.\d+)?$/.test(value)) {
15
+ return Number(value) * 1000;
15
16
  }
16
17
 
17
- const dateMs = Date.parse(headerValue);
18
+ const dateMs = Date.parse(value);
18
19
  if (!Number.isNaN(dateMs)) {
19
20
  return Math.max(0, dateMs - Date.now());
20
21
  }
@@ -46,22 +47,50 @@ function getWaitMsFromHeaders(headers) {
46
47
  return null;
47
48
  }
48
49
 
50
+ function defaultShouldRetryError(err) {
51
+ const normalized = normalizeError(err);
52
+ return normalized.isNetworkError || normalized.isTimeout;
53
+ }
54
+
55
+ async function discardBody(response) {
56
+ // An unread body keeps the underlying connection busy; cancel it before
57
+ // we sit through a backoff and fire the next request.
58
+ try {
59
+ if (response.body && typeof response.body.cancel === "function" && !response.bodyUsed) {
60
+ await response.body.cancel();
61
+ }
62
+ } catch {
63
+ // locked / already consumed / not a web stream — safe to ignore
64
+ }
65
+ }
66
+
49
67
  /**
50
68
  * Retries a fetch-like call, honoring Retry-After / X-RateLimit-Reset
51
69
  * headers on 429/503 responses instead of blind exponential backoff — most
52
70
  * generic retry libraries treat rate-limit responses like any other failure
53
71
  * and ignore the header the server is explicitly giving you.
54
72
  *
73
+ * Also retries thrown network errors / timeouts (see `shouldRetryError`),
74
+ * and the whole loop — including any backoff wait — can be cancelled with
75
+ * `signal`. The body of each response that gets retried is cancelled so the
76
+ * connection is released.
77
+ *
55
78
  * `fn` should return a fetch Response (or anything with `.status` and
56
79
  * `.headers.get()`).
57
80
  *
58
- * @param {() => Promise<Response>} fn
81
+ * @param {(attempt: number) => Promise<Response>} fn
59
82
  * @param {object} [opts]
60
83
  * @param {number} [opts.retries=3]
61
84
  * @param {number[]} [opts.retryStatusCodes=[429, 503]]
62
85
  * @param {number} [opts.fallbackDelayMs=1000] - used when no usable header is present
63
86
  * @param {number} [opts.maxDelayMs=60000]
64
87
  * @param {(status: number, waitMs: number, attempt: number) => void} [opts.onRetry]
88
+ * @param {(err: any, attempt: number) => boolean} [opts.shouldRetryError] -
89
+ * decides whether a thrown error is retried. Default: network errors and
90
+ * timeouts only.
91
+ * @param {(err: any, waitMs: number, attempt: number) => void} [opts.onRetryError]
92
+ * @param {AbortSignal} [opts.signal] - cancels the retry loop, throwing
93
+ * RetryAbortedError
65
94
  */
66
95
  async function retryAfterAware(fn, opts = {}) {
67
96
  const {
@@ -70,10 +99,27 @@ async function retryAfterAware(fn, opts = {}) {
70
99
  fallbackDelayMs = 1000,
71
100
  maxDelayMs = 60000,
72
101
  onRetry,
102
+ shouldRetryError = defaultShouldRetryError,
103
+ onRetryError,
104
+ signal,
73
105
  } = opts;
74
106
 
107
+ let lastErr;
75
108
  for (let attempt = 0; attempt <= retries; attempt++) {
76
- const response = await fn(attempt);
109
+ if (signal?.aborted) throw new RetryAbortedError(lastErr);
110
+
111
+ let response;
112
+ try {
113
+ response = await fn(attempt);
114
+ } catch (err) {
115
+ lastErr = err;
116
+ if (attempt === retries || !shouldRetryError(err, attempt)) throw err;
117
+
118
+ const waitMs = Math.min(fallbackDelayMs * 2 ** attempt, maxDelayMs);
119
+ if (onRetryError) onRetryError(err, waitMs, attempt);
120
+ await sleep(waitMs, signal, err);
121
+ continue;
122
+ }
77
123
 
78
124
  if (!retryStatusCodes.includes(response.status) || attempt === retries) {
79
125
  return response;
@@ -82,8 +128,9 @@ async function retryAfterAware(fn, opts = {}) {
82
128
  const headerWaitMs = getWaitMsFromHeaders(response.headers);
83
129
  const waitMs = Math.min(headerWaitMs ?? fallbackDelayMs * 2 ** attempt, maxDelayMs);
84
130
 
131
+ await discardBody(response);
85
132
  if (onRetry) onRetry(response.status, waitMs, attempt);
86
- await delay(waitMs);
133
+ await sleep(waitMs, signal);
87
134
  }
88
135
  }
89
136
 
@@ -0,0 +1,88 @@
1
+ // Non-plain objects (AbortSignal, Response, class instances, functions...)
2
+ // can't be meaningfully JSON-serialized — JSON.stringify turns every
3
+ // AbortSignal into "{}", which would make unrelated calls share one request.
4
+ // Give them a stable identity-based id instead.
5
+ const identityIds = new WeakMap();
6
+ let nextIdentityId = 1;
7
+
8
+ function identityOf(value) {
9
+ let id = identityIds.get(value);
10
+ if (id === undefined) {
11
+ id = nextIdentityId++;
12
+ identityIds.set(value, id);
13
+ }
14
+ return id;
15
+ }
16
+
17
+ function isPlainObject(value) {
18
+ const proto = Object.getPrototypeOf(value);
19
+ return proto === Object.prototype || proto === null;
20
+ }
21
+
22
+ function keyReplacer(_key, value) {
23
+ if (typeof value === "bigint") return { $bigint: String(value) };
24
+ if (typeof value === "function") return { $ref: identityOf(value) };
25
+ if (value === null || typeof value !== "object") return value;
26
+ if (Array.isArray(value)) return value;
27
+ if (!isPlainObject(value)) return { $ref: identityOf(value) };
28
+
29
+ // Sort plain-object keys so {a:1,b:2} and {b:2,a:1} dedupe together.
30
+ const sorted = {};
31
+ for (const k of Object.keys(value).sort()) sorted[k] = value[k];
32
+ return sorted;
33
+ }
34
+
35
+ function defaultKeyFn(...args) {
36
+ try {
37
+ return JSON.stringify(args, keyReplacer);
38
+ } catch {
39
+ // Circular structures etc. Fall back to identity for objects.
40
+ return args
41
+ .map((a) => ((a && typeof a === "object") || typeof a === "function" ? `#${identityOf(a)}` : String(a)))
42
+ .join("|");
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Wraps an async function so that concurrent calls with the same key share
48
+ * a single in-flight promise instead of firing duplicate requests. Common
49
+ * use case: several parts of a UI ask for the same resource at once (e.g. a
50
+ * user profile) — only one network request actually goes out, and every
51
+ * caller resolves with the same result.
52
+ *
53
+ * The in-flight entry is cleared as soon as the call settles (success or
54
+ * failure), so the next call — even with the same key — always starts a
55
+ * fresh request. This is deduplication of concurrent calls, not a cache.
56
+ *
57
+ * @param {(...args: any[]) => Promise<any>} fn
58
+ * @param {object} [opts]
59
+ * @param {(...args: any[]) => string} [opts.keyFn] - derives a dedupe key
60
+ * from the call arguments. By default plain data is compared by value
61
+ * (object key order doesn't matter) and anything else — AbortSignals,
62
+ * Responses, class instances, functions — by identity, so two calls with
63
+ * different AbortSignals are never merged.
64
+ * @returns {(...args: any[]) => Promise<any>}
65
+ */
66
+ function withDedupe(fn, opts = {}) {
67
+ const { keyFn = defaultKeyFn } = opts;
68
+ const inFlight = new Map();
69
+
70
+ return function deduped(...args) {
71
+ const key = keyFn(...args);
72
+
73
+ if (inFlight.has(key)) {
74
+ return inFlight.get(key);
75
+ }
76
+
77
+ const promise = Promise.resolve()
78
+ .then(() => fn(...args))
79
+ .finally(() => {
80
+ inFlight.delete(key);
81
+ });
82
+
83
+ inFlight.set(key, promise);
84
+ return promise;
85
+ };
86
+ }
87
+
88
+ module.exports = { withDedupe };
@@ -1,48 +1,107 @@
1
+ class BodyTooLargeError extends Error {
2
+ constructor(maxBytes) {
3
+ super(`Response body exceeded the ${maxBytes}-byte limit`);
4
+ this.name = "BodyTooLargeError";
5
+ this.code = "EBODYTOOLARGE";
6
+ this.maxBytes = maxBytes;
7
+ }
8
+ }
9
+
10
+ function cancelQuietly(reader) {
11
+ try {
12
+ const p = reader.cancel();
13
+ if (p && typeof p.catch === "function") p.catch(() => {});
14
+ } catch {
15
+ // already closed / released — nothing to do
16
+ }
17
+ }
18
+
1
19
  /**
2
20
  * Reads a fetch Response body while reporting progress, and returns the
3
21
  * fully collected bytes as a Uint8Array. Works for any streamed response,
4
22
  * not just file downloads — same shape whether it's a media file, a large
5
23
  * JSON payload, or anything else worth showing progress for.
6
24
  *
7
- * If the server doesn't send Content-Length, `total` will be null in the
8
- * progress callback — the loaded count is still accurate.
25
+ * `total` is null (and `percent` with it) when the size isn't trustworthy:
26
+ * no Content-Length, or a Content-Encoding is applied (Content-Length is then
27
+ * the *compressed* size while fetch hands you *decompressed* bytes, so
28
+ * loaded could exceed total). `percent` is always clamped to 0–100.
9
29
  *
10
30
  * @param {Response} response
11
- * @param {(progress: { loaded: number, total: number | null, percent: number | null }) => void} onProgress
31
+ * @param {(progress: { loaded: number, total: number | null, percent: number | null }) => void} [onProgress]
32
+ * @param {object} [opts]
33
+ * @param {number} [opts.maxBytes] - abort with BodyTooLargeError once more
34
+ * than this many bytes have been received
35
+ * @param {AbortSignal} [opts.signal] - cancels the read; rejects with the
36
+ * signal's reason (an AbortError by default)
12
37
  * @returns {Promise<Uint8Array>}
13
38
  */
14
- async function withProgress(response, onProgress) {
39
+ async function withProgress(response, onProgress, opts = {}) {
40
+ const { maxBytes, signal } = opts;
41
+ signal?.throwIfAborted?.();
42
+
15
43
  if (!response.body || typeof response.body.getReader !== "function") {
16
44
  // No streaming support available — fall back to a single-shot read,
17
45
  // still reporting one final progress event so callers don't need a branch.
18
46
  const buf = new Uint8Array(await response.arrayBuffer());
47
+ signal?.throwIfAborted?.();
48
+ if (maxBytes !== undefined && buf.byteLength > maxBytes) {
49
+ throw new BodyTooLargeError(maxBytes);
50
+ }
19
51
  if (onProgress) {
20
52
  onProgress({ loaded: buf.byteLength, total: buf.byteLength, percent: 100 });
21
53
  }
22
54
  return buf;
23
55
  }
24
56
 
57
+ const encoding = response.headers?.get?.("content-encoding");
58
+ const encoded = Boolean(encoding) && encoding.trim().toLowerCase() !== "identity";
25
59
  const contentLength = response.headers?.get?.("content-length");
26
- const total = contentLength ? Number(contentLength) : null;
60
+ const parsed = contentLength ? Number(contentLength) : NaN;
61
+ const total = !encoded && Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
27
62
 
28
63
  const reader = response.body.getReader();
64
+
65
+ // Fail fast when the declared size already exceeds the cap.
66
+ if (maxBytes !== undefined && total !== null && total > maxBytes) {
67
+ cancelQuietly(reader);
68
+ throw new BodyTooLargeError(maxBytes);
69
+ }
70
+
71
+ const onAbort = () => cancelQuietly(reader);
72
+ signal?.addEventListener("abort", onAbort, { once: true });
73
+
29
74
  const chunks = [];
30
75
  let loaded = 0;
76
+ let finished = false;
31
77
 
32
- for (;;) {
33
- const { done, value } = await reader.read();
34
- if (done) break;
78
+ try {
79
+ for (;;) {
80
+ const { done, value } = await reader.read();
81
+ // reader.cancel() on abort makes read() resolve as done — check the
82
+ // signal first so an aborted read never looks like a complete one.
83
+ signal?.throwIfAborted?.();
84
+ if (done) break;
35
85
 
36
- chunks.push(value);
37
- loaded += value.byteLength;
86
+ chunks.push(value);
87
+ loaded += value.byteLength;
38
88
 
39
- if (onProgress) {
40
- onProgress({
41
- loaded,
42
- total,
43
- percent: total ? Math.round((loaded / total) * 100) : null,
44
- });
89
+ if (maxBytes !== undefined && loaded > maxBytes) {
90
+ throw new BodyTooLargeError(maxBytes);
91
+ }
92
+
93
+ if (onProgress) {
94
+ onProgress({
95
+ loaded,
96
+ total,
97
+ percent: total ? Math.min(100, Math.round((loaded / total) * 100)) : null,
98
+ });
99
+ }
45
100
  }
101
+ finished = true;
102
+ } finally {
103
+ signal?.removeEventListener("abort", onAbort);
104
+ if (!finished) cancelQuietly(reader);
46
105
  }
47
106
 
48
107
  const result = new Uint8Array(loaded);
@@ -54,4 +113,4 @@ async function withProgress(response, onProgress) {
54
113
  return result;
55
114
  }
56
115
 
57
- module.exports = { withProgress };
116
+ module.exports = { withProgress, BodyTooLargeError };
package/src/withRetry.js CHANGED
@@ -1,3 +1,5 @@
1
+ const { RetryAbortedError, sleep } = require("./_sleep");
2
+
1
3
  function defaultShouldRetry(err) {
2
4
  // Retry network-level failures by default. HTTP status handling is the
3
5
  // caller's job (they know what "failure" means for their response shape) —
@@ -5,20 +7,20 @@ function defaultShouldRetry(err) {
5
7
  return Boolean(err) && err.name !== "AbortError";
6
8
  }
7
9
 
8
- function delay(ms) {
9
- return new Promise((resolve) => setTimeout(resolve, ms));
10
- }
11
-
12
10
  /**
13
11
  * Retries an async function with exponential backoff + jitter.
14
12
  *
15
- * @param {() => Promise<any>} fn
13
+ * @param {(attempt: number) => Promise<any>} fn
16
14
  * @param {object} [opts]
17
15
  * @param {number} [opts.retries=3] - max retry attempts after the first try
18
16
  * @param {number} [opts.baseDelayMs=300] - initial delay
19
17
  * @param {number} [opts.maxDelayMs=10000] - delay ceiling
20
18
  * @param {(err: any, attempt: number) => boolean} [opts.shouldRetry]
21
19
  * @param {(err: any, attempt: number, delayMs: number) => void} [opts.onRetry]
20
+ * @param {AbortSignal} [opts.signal] - cancels the whole retry loop,
21
+ * including any pending backoff wait, throwing RetryAbortedError. This is
22
+ * separate from a per-call AbortSignal you might pass into `fn` yourself —
23
+ * this one governs whether *another attempt starts at all*.
22
24
  */
23
25
  async function withRetry(fn, opts = {}) {
24
26
  const {
@@ -27,10 +29,14 @@ async function withRetry(fn, opts = {}) {
27
29
  maxDelayMs = 10000,
28
30
  shouldRetry = defaultShouldRetry,
29
31
  onRetry,
32
+ signal,
30
33
  } = opts;
31
34
 
32
35
  let lastErr;
33
36
  for (let attempt = 0; attempt <= retries; attempt++) {
37
+ if (signal?.aborted) {
38
+ throw new RetryAbortedError(lastErr);
39
+ }
34
40
  try {
35
41
  return await fn(attempt);
36
42
  } catch (err) {
@@ -43,10 +49,10 @@ async function withRetry(fn, opts = {}) {
43
49
  const jitter = Math.random() * exponential * 0.2;
44
50
  const waitMs = Math.round(exponential + jitter);
45
51
  if (onRetry) onRetry(err, attempt, waitMs);
46
- await delay(waitMs);
52
+ await sleep(waitMs, signal, err);
47
53
  }
48
54
  }
49
55
  throw lastErr;
50
56
  }
51
57
 
52
- module.exports = { withRetry };
58
+ module.exports = { withRetry, RetryAbortedError };
@@ -0,0 +1,3 @@
1
+ import * as reqkit from "./index.js";
2
+ export * from "./index.js";
3
+ export default reqkit;
package/types/index.d.ts CHANGED
@@ -4,6 +4,13 @@ export interface RetryOptions {
4
4
  maxDelayMs?: number;
5
5
  shouldRetry?: (err: unknown, attempt: number) => boolean;
6
6
  onRetry?: (err: unknown, attempt: number, delayMs: number) => void;
7
+ signal?: AbortSignal;
8
+ }
9
+
10
+ export class RetryAbortedError extends Error {
11
+ name: "RetryAbortedError";
12
+ /** The last failure before the loop was aborted, if there was one. */
13
+ cause?: unknown;
7
14
  }
8
15
 
9
16
  export function withRetry<T>(fn: (attempt: number) => Promise<T>, opts?: RetryOptions): Promise<T>;
@@ -21,6 +28,8 @@ export interface NormalizedError {
21
28
  code: string | null;
22
29
  isNetworkError: boolean;
23
30
  isTimeout: boolean;
31
+ /** True when the request was cancelled on purpose (AbortController, axios cancel). */
32
+ isAborted: boolean;
24
33
  cause: unknown;
25
34
  }
26
35
 
@@ -41,6 +50,11 @@ export interface RetryAfterAwareOptions {
41
50
  fallbackDelayMs?: number;
42
51
  maxDelayMs?: number;
43
52
  onRetry?: (status: number, waitMs: number, attempt: number) => void;
53
+ /** Decide whether a thrown error is retried. Default: network errors and timeouts only. */
54
+ shouldRetryError?: (err: unknown, attempt: number) => boolean;
55
+ onRetryError?: (err: unknown, waitMs: number, attempt: number) => void;
56
+ /** Cancels the retry loop, including any pending wait. Throws RetryAbortedError. */
57
+ signal?: AbortSignal;
44
58
  }
45
59
 
46
60
  export function retryAfterAware(
@@ -57,7 +71,31 @@ export interface DownloadProgress {
57
71
  percent: number | null;
58
72
  }
59
73
 
74
+ export interface WithProgressOptions {
75
+ /** Reject with BodyTooLargeError once more than this many bytes arrive. */
76
+ maxBytes?: number;
77
+ /** Cancels the read; rejects with the signal's reason. */
78
+ signal?: AbortSignal;
79
+ }
80
+
81
+ export class BodyTooLargeError extends Error {
82
+ name: "BodyTooLargeError";
83
+ code: "EBODYTOOLARGE";
84
+ maxBytes: number;
85
+ constructor(maxBytes: number);
86
+ }
87
+
60
88
  export function withProgress(
61
89
  response: Response,
62
- onProgress?: (progress: DownloadProgress) => void
90
+ onProgress?: (progress: DownloadProgress) => void,
91
+ opts?: WithProgressOptions
63
92
  ): Promise<Uint8Array>;
93
+
94
+ export interface DedupeOptions {
95
+ keyFn?: (...args: any[]) => string;
96
+ }
97
+
98
+ export function withDedupe<Fn extends (...args: any[]) => Promise<any>>(
99
+ fn: Fn,
100
+ opts?: DedupeOptions
101
+ ): Fn;