@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.
- package/LICENSE +1 -1
- package/README.md +181 -22
- package/package.json +32 -8
- package/src/_http.js +59 -0
- package/src/_sleep.js +32 -0
- package/src/buildQuery.js +22 -8
- package/src/createFetch.js +218 -0
- package/src/createLimiter.js +55 -0
- package/src/index.js +9 -2
- package/src/index.mjs +5 -0
- package/src/normalizeError.js +83 -38
- package/src/retryAfterAware.js +45 -60
- package/src/withDedupe.js +43 -5
- package/src/withProgress.js +76 -17
- package/src/withRetry.js +113 -49
- package/src/withTimeout.js +43 -15
- package/types/index.d.mts +3 -0
- package/types/index.d.ts +159 -3
|
@@ -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;
|
package/src/normalizeError.js
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
isNetworkError: false,
|
|
40
|
-
isTimeout: false,
|
|
41
|
-
cause: err,
|
|
42
|
-
};
|
|
83
|
+
});
|
|
43
84
|
}
|
|
44
85
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
code: "ETIMEDOUT",
|
|
51
|
-
isNetworkError: false,
|
|
93
|
+
code: chainCode || "ETIMEDOUT",
|
|
52
94
|
isTimeout: true,
|
|
53
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
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
|
-
|
|
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
|
-
|
|
74
|
-
isTimeout: false,
|
|
75
|
-
cause: err,
|
|
76
|
-
};
|
|
121
|
+
});
|
|
77
122
|
}
|
|
78
123
|
|
|
79
124
|
module.exports = { normalizeError };
|
package/src/retryAfterAware.js
CHANGED
|
@@ -1,49 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
6
|
-
|
|
7
|
-
|
|
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.
|
|
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 = {}) {
|