@boy-offi9-inc/reqkit 0.1.7 → 0.3.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.
@@ -0,0 +1,218 @@
1
+ const { withRetry, RetryAbortedError } = require("./withRetry");
2
+ const { withTimeout } = require("./withTimeout");
3
+ const { withDedupe } = require("./withDedupe");
4
+ const { buildQuery } = require("./buildQuery");
5
+ const { parseJsonSafe } = require("./parseJsonSafe");
6
+ const { normalizeError } = require("./normalizeError");
7
+
8
+ const DEFAULT_RETRY_STATUS_CODES = [408, 425, 429, 500, 502, 503, 504];
9
+ // Safe to repeat without side effects. POST/PATCH are deliberately absent.
10
+ const DEFAULT_RETRY_METHODS = ["GET", "HEAD", "OPTIONS", "PUT", "DELETE"];
11
+
12
+ class HTTPError extends Error {
13
+ constructor(response, request) {
14
+ const statusText = response.statusText ? ` ${response.statusText}` : "";
15
+ super(`Request failed with status ${response.status}${statusText}: ${request.method} ${request.url}`);
16
+ this.name = "HTTPError";
17
+ this.status = response.status;
18
+ this.statusText = response.statusText || "";
19
+ this.response = response;
20
+ this.url = request.url;
21
+ this.method = request.method;
22
+ }
23
+ }
24
+
25
+ function isAbsoluteUrl(url) {
26
+ return /^[a-z][a-z0-9+.-]*:/i.test(url);
27
+ }
28
+
29
+ function joinUrl(baseUrl, url, query) {
30
+ let full = String(url);
31
+ if (baseUrl && !isAbsoluteUrl(full)) {
32
+ full = `${String(baseUrl).replace(/\/+$/, "")}/${full.replace(/^\/+/, "")}`;
33
+ }
34
+ const qs = query ? buildQuery(query) : "";
35
+ if (qs) full += (full.includes("?") ? "&" : "?") + qs;
36
+ return full;
37
+ }
38
+
39
+ async function resolveHeaders(source) {
40
+ const value = typeof source === "function" ? await source() : source;
41
+ return new Headers(value || undefined);
42
+ }
43
+
44
+ function mergeHeaders(...headerSets) {
45
+ const out = new Headers();
46
+ for (const set of headerSets) {
47
+ set.forEach((value, key) => out.set(key, value));
48
+ }
49
+ return out;
50
+ }
51
+
52
+ function isStreamBody(body) {
53
+ return Boolean(body) && typeof body === "object" && typeof body.getReader === "function";
54
+ }
55
+
56
+ function mergeDefaults(base, extra) {
57
+ const headersOf = (d) => d.headers;
58
+ const merged = { ...base, ...extra };
59
+
60
+ if (base.headers || extra.headers) {
61
+ merged.headers = async () => {
62
+ const a = await resolveHeaders(headersOf(base));
63
+ const b = await resolveHeaders(headersOf(extra));
64
+ return mergeHeaders(a, b);
65
+ };
66
+ }
67
+ if (base.retry !== false && extra.retry !== false && (base.retry || extra.retry)) {
68
+ merged.retry = { ...base.retry, ...extra.retry };
69
+ }
70
+ return merged;
71
+ }
72
+
73
+ /**
74
+ * Builds a fetch-like function from the other reqkit pieces — only if you
75
+ * want that. It's still plain `fetch` underneath: you get a standard
76
+ * `Response` back, and `fetch` itself stays swappable.
77
+ *
78
+ * What you get on top of fetch:
79
+ * - `baseUrl`, `query` (via buildQuery), `json` request bodies
80
+ * - default headers (an object, or a function for tokens that change)
81
+ * - per-attempt `timeout` (withTimeout) and external `signal`
82
+ * - retries (withRetry) on network errors/timeouts and retryable statuses,
83
+ * honoring Retry-After; only for idempotent methods unless you opt in
84
+ * - concurrent GET de-duplication (withDedupe)
85
+ * - `HTTPError` on non-2xx (turn off with `throwHttpErrors: false`)
86
+ * - `onRequest` / `onResponse` hooks
87
+ *
88
+ * @param {object} [defaults]
89
+ * @param {string} [defaults.baseUrl]
90
+ * @param {HeadersInit | (() => HeadersInit | Promise<HeadersInit>)} [defaults.headers]
91
+ * @param {number | false} [defaults.timeout=30000] - per attempt, ms
92
+ * @param {false | object} [defaults.retry] - false disables. Options:
93
+ * `retries`(2), `statusCodes`, `methods`, `baseDelayMs`, `maxDelayMs`,
94
+ * `factor`, `jitter`, `maxRetryTime`, `onRetry`, `calculateDelay`
95
+ * @param {boolean} [defaults.dedupe=false] - merge identical concurrent GET/HEAD calls
96
+ * @param {boolean} [defaults.throwHttpErrors=true]
97
+ * @param {typeof fetch} [defaults.fetch=globalThis.fetch]
98
+ * @param {(ctx: {url: string, init: object}) => void | Promise<void>} [defaults.onRequest] -
99
+ * runs once per call; may mutate ctx.url / ctx.init (e.g. add a header)
100
+ * @param {(ctx: {url: string, init: object, response: Response}) => void | Promise<void>} [defaults.onResponse]
101
+ */
102
+ function createFetch(defaults = {}) {
103
+ const {
104
+ baseUrl,
105
+ headers: defaultHeaders,
106
+ timeout: defaultTimeout = 30000,
107
+ retry: defaultRetry = {},
108
+ dedupe = false,
109
+ throwHttpErrors: defaultThrow = true,
110
+ fetch: customFetch,
111
+ onRequest,
112
+ onResponse,
113
+ } = defaults;
114
+
115
+ const doFetch = (...args) => (customFetch || globalThis.fetch)(...args);
116
+
117
+ // Shared in-flight GETs. Every caller gets its own clone of the response so
118
+ // body reads don't fight each other.
119
+ const sharedFetch = withDedupe((key, url, init) => doFetch(url, init), { keyFn: (key) => key });
120
+
121
+ async function request(input, init = {}) {
122
+ const {
123
+ query,
124
+ json,
125
+ timeout = defaultTimeout,
126
+ retry: retryOpt,
127
+ throwHttpErrors = defaultThrow,
128
+ signal,
129
+ headers: initHeaders,
130
+ ...fetchInit
131
+ } = init;
132
+
133
+ const method = String(fetchInit.method || "GET").toUpperCase();
134
+ const headers = mergeHeaders(await resolveHeaders(defaultHeaders), new Headers(initHeaders || undefined));
135
+
136
+ let body = fetchInit.body;
137
+ if (json !== undefined) {
138
+ body = JSON.stringify(json);
139
+ if (!headers.has("content-type")) headers.set("content-type", "application/json");
140
+ }
141
+
142
+ const ctx = { url: joinUrl(baseUrl, input, query), init: { ...fetchInit, method, headers, body } };
143
+ if (onRequest) await onRequest(ctx);
144
+ const finalHeaders = ctx.init.headers instanceof Headers ? ctx.init.headers : new Headers(ctx.init.headers);
145
+ const finalMethod = String(ctx.init.method || method).toUpperCase();
146
+
147
+ // --- retry policy ------------------------------------------------------
148
+ const retryCfg = retryOpt === false || defaultRetry === false ? false : { ...defaultRetry, ...retryOpt };
149
+ const methods = (retryCfg && retryCfg.methods) || DEFAULT_RETRY_METHODS;
150
+ const hasIdempotencyKey = finalHeaders.has("idempotency-key");
151
+ const canRetry =
152
+ retryCfg !== false &&
153
+ !isStreamBody(ctx.init.body) && // a consumed stream can't be replayed
154
+ (methods.map((m) => m.toUpperCase()).includes(finalMethod) || hasIdempotencyKey);
155
+
156
+ const attemptOnce = () =>
157
+ withTimeout(
158
+ (attemptSignal) => {
159
+ const attemptInit = { ...ctx.init, method: finalMethod, headers: finalHeaders, signal: attemptSignal };
160
+ const useDedupe = dedupe && !signal && !ctx.init.body && (finalMethod === "GET" || finalMethod === "HEAD");
161
+ if (!useDedupe) return doFetch(ctx.url, attemptInit);
162
+ // No per-attempt signal on a shared request: aborting one caller
163
+ // must never cancel everyone else's.
164
+ // It still gets its own timeout so a hung server can't leak the request.
165
+ const sharedInit = { ...attemptInit, signal: timeout === false ? undefined : AbortSignal.timeout(timeout) };
166
+ const key = `${finalMethod} ${ctx.url} ${JSON.stringify([...finalHeaders.entries()].sort())}`;
167
+ return sharedFetch(key, ctx.url, sharedInit).then((res) => res.clone());
168
+ },
169
+ timeout === false ? Infinity : timeout,
170
+ { signal }
171
+ );
172
+
173
+ const { statusCodes, methods: _methods, retries = 2, shouldRetry: customShouldRetry, ...retryRest } = retryCfg || {};
174
+
175
+ let response;
176
+ try {
177
+ response = canRetry
178
+ ? await withRetry(attemptOnce, {
179
+ ...retryRest,
180
+ retries,
181
+ retryStatusCodes: statusCodes || DEFAULT_RETRY_STATUS_CODES,
182
+ shouldRetry:
183
+ customShouldRetry ||
184
+ ((err) => {
185
+ const n = normalizeError(err);
186
+ return n.isNetworkError || n.isTimeout;
187
+ }),
188
+ signal,
189
+ })
190
+ : await attemptOnce();
191
+ } catch (err) {
192
+ // Present a user abort as the plain AbortError they caused, not as retry internals.
193
+ if (err instanceof RetryAbortedError && signal && signal.aborted) throw signal.reason ?? err;
194
+ throw err;
195
+ }
196
+
197
+ if (onResponse) await onResponse({ url: ctx.url, init: ctx.init, response });
198
+ if (throwHttpErrors && !response.ok) {
199
+ throw new HTTPError(response, { method: finalMethod, url: ctx.url });
200
+ }
201
+ return response;
202
+ }
203
+
204
+ /** Like `request`, but resolves to the parsed JSON body (null for an empty body). */
205
+ request.json = async (input, init) => {
206
+ const response = await request(input, init);
207
+ const { data, error } = await parseJsonSafe(response);
208
+ if (error) throw error;
209
+ return data;
210
+ };
211
+
212
+ /** A new fetch with these defaults merged over the current ones (headers and retry are merged, the rest replaced). */
213
+ request.extend = (overrides = {}) => createFetch(mergeDefaults(defaults, overrides));
214
+
215
+ return request;
216
+ }
217
+
218
+ module.exports = { createFetch, HTTPError };
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Creates a concurrency limiter: at most `concurrency` calls run at once,
3
+ * the rest wait in a FIFO queue. Handy for "download these 200 files, 4 at a
4
+ * time" without pulling in a queue library. (Same idea as p-limit.)
5
+ *
6
+ * ```js
7
+ * const limit = createLimiter(4);
8
+ * const bytes = await Promise.all(urls.map((u) => limit(() => download(u))));
9
+ * // or: await limit.map(urls, (u) => download(u));
10
+ * ```
11
+ *
12
+ * @param {number} [concurrency=Infinity] - integer >= 1, or Infinity
13
+ */
14
+ function createLimiter(concurrency = Infinity) {
15
+ if (concurrency !== Infinity && !(Number.isInteger(concurrency) && concurrency >= 1)) {
16
+ throw new TypeError("createLimiter: `concurrency` must be an integer >= 1 (or Infinity)");
17
+ }
18
+
19
+ const queue = [];
20
+ let active = 0;
21
+
22
+ function next() {
23
+ while (active < concurrency && queue.length > 0) {
24
+ active++;
25
+ queue.shift()();
26
+ }
27
+ }
28
+
29
+ function limit(fn, ...args) {
30
+ return new Promise((resolve, reject) => {
31
+ queue.push(() => {
32
+ Promise.resolve()
33
+ .then(() => fn(...args))
34
+ .then(resolve, reject)
35
+ .finally(() => {
36
+ active--;
37
+ next();
38
+ });
39
+ });
40
+ next();
41
+ });
42
+ }
43
+
44
+ Object.defineProperties(limit, {
45
+ activeCount: { get: () => active },
46
+ pendingCount: { get: () => queue.length },
47
+ });
48
+
49
+ /** Runs `fn(item, index)` for every item through the limiter. Results keep input order. */
50
+ limit.map = (items, fn) => Promise.all(Array.from(items, (item, i) => limit(fn, item, i)));
51
+
52
+ return limit;
53
+ }
54
+
55
+ module.exports = { createLimiter };
package/src/index.js CHANGED
@@ -1,15 +1,18 @@
1
- const { withRetry, RetryAbortedError } = require("./withRetry");
1
+ const { withRetry, RetryAbortedError, StopRetry } = 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
8
  const { withDedupe } = require("./withDedupe");
9
+ const { createLimiter } = require("./createLimiter");
10
+ const { createFetch, HTTPError } = require("./createFetch");
9
11
 
10
12
  module.exports = {
11
13
  withRetry,
12
14
  RetryAbortedError,
15
+ StopRetry,
13
16
  withTimeout,
14
17
  TimeoutError,
15
18
  normalizeError,
@@ -19,5 +22,9 @@ module.exports = {
19
22
  parseRetryAfter,
20
23
  getWaitMsFromHeaders,
21
24
  withProgress,
25
+ BodyTooLargeError,
22
26
  withDedupe,
27
+ createLimiter,
28
+ createFetch,
29
+ HTTPError,
23
30
  };
package/src/index.mjs CHANGED
@@ -3,6 +3,7 @@ import reqkit from "./index.js";
3
3
  export const {
4
4
  withRetry,
5
5
  RetryAbortedError,
6
+ StopRetry,
6
7
  withTimeout,
7
8
  TimeoutError,
8
9
  normalizeError,
@@ -12,7 +13,11 @@ export const {
12
13
  parseRetryAfter,
13
14
  getWaitMsFromHeaders,
14
15
  withProgress,
16
+ BodyTooLargeError,
15
17
  withDedupe,
18
+ createLimiter,
19
+ createFetch,
20
+ HTTPError,
16
21
  } = reqkit;
17
22
 
18
23
  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,49 +1,10 @@
1
- function delay(ms) {
2
- return new Promise((resolve) => setTimeout(resolve, ms));
3
- }
4
-
5
- /**
6
- * Parses a Retry-After header value into milliseconds. Per HTTP spec it's
7
- * either an integer number of seconds, or an HTTP-date.
8
- */
9
- function parseRetryAfter(headerValue) {
10
- if (!headerValue) return null;
11
-
12
- const seconds = Number(headerValue);
13
- if (!Number.isNaN(seconds)) {
14
- return Math.max(0, seconds * 1000);
15
- }
16
-
17
- const dateMs = Date.parse(headerValue);
18
- if (!Number.isNaN(dateMs)) {
19
- return Math.max(0, dateMs - Date.now());
20
- }
21
-
22
- return null;
23
- }
24
-
25
- /**
26
- * Reads a wait time from common rate-limit headers, trying Retry-After first,
27
- * then falling back to X-RateLimit-Reset (epoch seconds or ms) if present.
28
- */
29
- function getWaitMsFromHeaders(headers) {
30
- if (!headers || typeof headers.get !== "function") return null;
31
-
32
- const retryAfter = parseRetryAfter(headers.get("retry-after"));
33
- if (retryAfter !== null) return retryAfter;
1
+ const { withRetry } = require("./withRetry");
2
+ const { normalizeError } = require("./normalizeError");
3
+ const { parseRetryAfter, getWaitMsFromHeaders } = require("./_http");
34
4
 
35
- const resetHeader = headers.get("x-ratelimit-reset") || headers.get("x-rate-limit-reset");
36
- if (resetHeader) {
37
- const resetValue = Number(resetHeader);
38
- if (!Number.isNaN(resetValue)) {
39
- // Could be epoch seconds or epoch ms — treat values below a
40
- // year-2100-in-seconds threshold as seconds.
41
- const resetMs = resetValue > 4102444800 ? resetValue : resetValue * 1000;
42
- return Math.max(0, resetMs - Date.now());
43
- }
44
- }
45
-
46
- return null;
5
+ function defaultShouldRetryError(err) {
6
+ const normalized = normalizeError(err);
7
+ return normalized.isNetworkError || normalized.isTimeout;
47
8
  }
48
9
 
49
10
  /**
@@ -52,39 +13,63 @@ function getWaitMsFromHeaders(headers) {
52
13
  * generic retry libraries treat rate-limit responses like any other failure
53
14
  * and ignore the header the server is explicitly giving you.
54
15
  *
16
+ * This is a thin preset over `withRetry` (retryStatusCodes + Retry-After,
17
+ * no jitter, network errors/timeouts only for thrown errors). Reach for
18
+ * `withRetry` directly when you need jitter, `maxRetryTime`, `StopRetry`,
19
+ * `calculateDelay`, ...
20
+ *
21
+ * Also retries thrown network errors / timeouts (see `shouldRetryError`),
22
+ * and the whole loop — including any backoff wait — can be cancelled with
23
+ * `signal`. The body of each response that gets retried is cancelled so the
24
+ * connection is released.
25
+ *
55
26
  * `fn` should return a fetch Response (or anything with `.status` and
56
27
  * `.headers.get()`).
57
28
  *
58
- * @param {() => Promise<Response>} fn
29
+ * @param {(attempt: number) => Promise<Response>} fn
59
30
  * @param {object} [opts]
60
31
  * @param {number} [opts.retries=3]
61
32
  * @param {number[]} [opts.retryStatusCodes=[429, 503]]
62
33
  * @param {number} [opts.fallbackDelayMs=1000] - used when no usable header is present
63
34
  * @param {number} [opts.maxDelayMs=60000]
64
35
  * @param {(status: number, waitMs: number, attempt: number) => void} [opts.onRetry]
36
+ * @param {(err: any, attempt: number) => boolean} [opts.shouldRetryError] -
37
+ * decides whether a thrown error is retried. Default: network errors and
38
+ * timeouts only.
39
+ * @param {(err: any, waitMs: number, attempt: number) => void} [opts.onRetryError]
40
+ * @param {AbortSignal} [opts.signal] - cancels the retry loop, throwing
41
+ * RetryAbortedError
65
42
  */
66
- async function retryAfterAware(fn, opts = {}) {
43
+ function retryAfterAware(fn, opts = {}) {
67
44
  const {
68
45
  retries = 3,
69
46
  retryStatusCodes = [429, 503],
70
47
  fallbackDelayMs = 1000,
71
48
  maxDelayMs = 60000,
72
49
  onRetry,
50
+ shouldRetryError = defaultShouldRetryError,
51
+ onRetryError,
52
+ signal,
73
53
  } = opts;
74
54
 
75
- for (let attempt = 0; attempt <= retries; attempt++) {
76
- const response = await fn(attempt);
77
-
78
- if (!retryStatusCodes.includes(response.status) || attempt === retries) {
79
- return response;
80
- }
81
-
82
- const headerWaitMs = getWaitMsFromHeaders(response.headers);
83
- const waitMs = Math.min(headerWaitMs ?? fallbackDelayMs * 2 ** attempt, maxDelayMs);
84
-
85
- if (onRetry) onRetry(response.status, waitMs, attempt);
86
- await delay(waitMs);
87
- }
55
+ return withRetry(fn, {
56
+ retries,
57
+ retryStatusCodes,
58
+ baseDelayMs: fallbackDelayMs,
59
+ maxDelayMs,
60
+ maxRetryAfterMs: maxDelayMs,
61
+ factor: 2,
62
+ jitter: 0,
63
+ shouldRetry: shouldRetryError,
64
+ onRetry: (err, attempt, waitMs, ctx) => {
65
+ if (ctx.reason === "result") {
66
+ if (onRetry) onRetry(ctx.status, waitMs, attempt);
67
+ } else if (onRetryError) {
68
+ onRetryError(err, waitMs, attempt);
69
+ }
70
+ },
71
+ signal,
72
+ });
88
73
  }
89
74
 
90
75
  module.exports = { retryAfterAware, parseRetryAfter, getWaitMsFromHeaders };
package/src/withDedupe.js CHANGED
@@ -1,10 +1,45 @@
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
+
1
35
  function defaultKeyFn(...args) {
2
36
  try {
3
- return JSON.stringify(args);
37
+ return JSON.stringify(args, keyReplacer);
4
38
  } catch {
5
- // Fall back to a stable-ish key if args aren't JSON-serializable
6
- // (e.g. contain a function or circular ref).
7
- return args.map((a) => String(a)).join("|");
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("|");
8
43
  }
9
44
  }
10
45
 
@@ -22,7 +57,10 @@ function defaultKeyFn(...args) {
22
57
  * @param {(...args: any[]) => Promise<any>} fn
23
58
  * @param {object} [opts]
24
59
  * @param {(...args: any[]) => string} [opts.keyFn] - derives a dedupe key
25
- * from the call arguments. Defaults to JSON.stringify(args).
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.
26
64
  * @returns {(...args: any[]) => Promise<any>}
27
65
  */
28
66
  function withDedupe(fn, opts = {}) {