@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
package/src/withProgress.js
CHANGED
|
@@ -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
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
|
|
86
|
+
chunks.push(value);
|
|
87
|
+
loaded += value.byteLength;
|
|
38
88
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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,83 +1,147 @@
|
|
|
1
|
+
const { RetryAbortedError, sleep } = require("./_sleep");
|
|
2
|
+
const { getWaitMsFromHeaders, discardBody } = require("./_http");
|
|
3
|
+
|
|
1
4
|
function defaultShouldRetry(err) {
|
|
2
|
-
// Retry
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
+
// Retry thrown errors except deliberate aborts. Narrow it with `shouldRetry`
|
|
6
|
+
// (e.g. network errors only). HTTP *statuses* aren't errors with fetch, so
|
|
7
|
+
// opt in with `retryStatusCodes` / `shouldRetryResult`.
|
|
5
8
|
return Boolean(err) && err.name !== "AbortError";
|
|
6
9
|
}
|
|
7
10
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Throw this from inside `fn` to stop retrying immediately. If you pass an
|
|
13
|
+
* Error, that exact error is what withRetry rethrows; with a string, the
|
|
14
|
+
* StopRetry itself is thrown. (Same idea as p-retry's AbortError.)
|
|
15
|
+
*/
|
|
16
|
+
class StopRetry extends Error {
|
|
17
|
+
constructor(reasonOrMessage) {
|
|
18
|
+
const isError = reasonOrMessage instanceof Error;
|
|
19
|
+
super(isError ? reasonOrMessage.message : String(reasonOrMessage ?? "Retrying was stopped"));
|
|
20
|
+
this.name = "StopRetry";
|
|
21
|
+
this.original = isError ? reasonOrMessage : null;
|
|
12
22
|
}
|
|
13
23
|
}
|
|
14
24
|
|
|
15
|
-
function delay(ms, signal) {
|
|
16
|
-
return new Promise((resolve, reject) => {
|
|
17
|
-
if (signal?.aborted) {
|
|
18
|
-
reject(new RetryAbortedError());
|
|
19
|
-
return;
|
|
20
|
-
}
|
|
21
|
-
const timer = setTimeout(resolve, ms);
|
|
22
|
-
if (signal) {
|
|
23
|
-
signal.addEventListener(
|
|
24
|
-
"abort",
|
|
25
|
-
() => {
|
|
26
|
-
clearTimeout(timer);
|
|
27
|
-
reject(new RetryAbortedError());
|
|
28
|
-
},
|
|
29
|
-
{ once: true }
|
|
30
|
-
);
|
|
31
|
-
}
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
|
|
35
25
|
/**
|
|
36
|
-
* Retries an async function with exponential backoff + jitter.
|
|
26
|
+
* Retries an async function with exponential backoff + jitter. Retries on
|
|
27
|
+
* thrown errors, and — opt-in — on *results* too (HTTP status codes, or any
|
|
28
|
+
* predicate), honoring Retry-After / X-RateLimit-Reset when the result has
|
|
29
|
+
* headers.
|
|
37
30
|
*
|
|
38
31
|
* @param {(attempt: number) => Promise<any>} fn
|
|
39
32
|
* @param {object} [opts]
|
|
40
|
-
* @param {number} [opts.retries=3] - max retry attempts after the first try
|
|
33
|
+
* @param {number} [opts.retries=3] - max retry attempts after the first try (Infinity is fine with maxRetryTime)
|
|
41
34
|
* @param {number} [opts.baseDelayMs=300] - initial delay
|
|
42
|
-
* @param {number} [opts.maxDelayMs=10000] -
|
|
43
|
-
* @param {
|
|
44
|
-
* @param {
|
|
35
|
+
* @param {number} [opts.maxDelayMs=10000] - backoff ceiling
|
|
36
|
+
* @param {number} [opts.factor=2] - backoff multiplier
|
|
37
|
+
* @param {number|"full"} [opts.jitter=0.2] - fraction of extra random delay
|
|
38
|
+
* added on top (0 disables); "full" picks uniformly in [0, delay] — the
|
|
39
|
+
* AWS "full jitter" strategy, best when many clients retry at once
|
|
40
|
+
* @param {number} [opts.maxRetryTime=Infinity] - total time budget in ms
|
|
41
|
+
* across all attempts and waits; once the next wait would blow it, stop
|
|
42
|
+
* and surface the last error/result
|
|
43
|
+
* @param {(err: any, attempt: number) => boolean} [opts.shouldRetry] - for thrown errors
|
|
44
|
+
* @param {number[]} [opts.retryStatusCodes=[]] - retry when the result's `.status` is in this list
|
|
45
|
+
* @param {(result: any, attempt: number) => boolean} [opts.shouldRetryResult] - retry on any result predicate
|
|
46
|
+
* @param {boolean} [opts.respectRetryAfter=true] - use Retry-After/X-RateLimit-Reset from `result.headers`
|
|
47
|
+
* @param {number} [opts.maxRetryAfterMs=60000] - cap on a header-provided wait
|
|
48
|
+
* @param {(info: {attempt: number, error: any, result: any, delayMs: number}) => number|false} [opts.calculateDelay]
|
|
49
|
+
* - override the wait; return a number of ms, or false to stop retrying
|
|
50
|
+
* @param {(err: any, attempt: number, delayMs: number, ctx: {reason: "error"|"result", result?: any, status?: number}) => void} [opts.onRetry]
|
|
51
|
+
* - `err` is undefined when the retry was triggered by a result
|
|
45
52
|
* @param {AbortSignal} [opts.signal] - cancels the whole retry loop,
|
|
46
|
-
* including any pending backoff wait, throwing RetryAbortedError
|
|
47
|
-
*
|
|
48
|
-
* this one governs whether *another attempt starts at all*.
|
|
53
|
+
* including any pending backoff wait, throwing RetryAbortedError (with the
|
|
54
|
+
* last failure as `cause`). Separate from a per-call signal you pass into
|
|
55
|
+
* `fn` yourself — this one governs whether *another attempt starts at all*.
|
|
49
56
|
*/
|
|
50
57
|
async function withRetry(fn, opts = {}) {
|
|
51
58
|
const {
|
|
52
59
|
retries = 3,
|
|
53
60
|
baseDelayMs = 300,
|
|
54
61
|
maxDelayMs = 10000,
|
|
62
|
+
factor = 2,
|
|
63
|
+
jitter = 0.2,
|
|
64
|
+
maxRetryTime = Infinity,
|
|
55
65
|
shouldRetry = defaultShouldRetry,
|
|
66
|
+
retryStatusCodes = [],
|
|
67
|
+
shouldRetryResult,
|
|
68
|
+
respectRetryAfter = true,
|
|
69
|
+
maxRetryAfterMs = 60000,
|
|
70
|
+
calculateDelay,
|
|
56
71
|
onRetry,
|
|
57
72
|
signal,
|
|
58
73
|
} = opts;
|
|
59
74
|
|
|
75
|
+
const startedAt = Date.now();
|
|
76
|
+
|
|
77
|
+
function backoff(attempt) {
|
|
78
|
+
const exponential = Math.min(baseDelayMs * factor ** attempt, maxDelayMs);
|
|
79
|
+
if (jitter === "full") return Math.round(Math.random() * exponential);
|
|
80
|
+
const extra = Math.random() * exponential * (Number(jitter) || 0);
|
|
81
|
+
return Math.round(exponential + extra);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Returns the wait in ms, or null if retrying should stop (custom
|
|
85
|
+
// calculateDelay said so, or the time budget would be exceeded).
|
|
86
|
+
function planWait(defaultMs, attempt, error, result) {
|
|
87
|
+
let waitMs = defaultMs;
|
|
88
|
+
if (calculateDelay) {
|
|
89
|
+
const custom = calculateDelay({ attempt, error, result, delayMs: defaultMs });
|
|
90
|
+
if (custom === false || custom === null || custom === undefined) return null;
|
|
91
|
+
waitMs = Math.max(0, Number(custom) || 0);
|
|
92
|
+
}
|
|
93
|
+
if (Date.now() - startedAt + waitMs > maxRetryTime) return null;
|
|
94
|
+
return waitMs;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function wantsResultRetry(result, attempt) {
|
|
98
|
+
if (
|
|
99
|
+
retryStatusCodes.length > 0 &&
|
|
100
|
+
result &&
|
|
101
|
+
typeof result.status === "number" &&
|
|
102
|
+
retryStatusCodes.includes(result.status)
|
|
103
|
+
) {
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
return shouldRetryResult ? Boolean(shouldRetryResult(result, attempt)) : false;
|
|
107
|
+
}
|
|
108
|
+
|
|
60
109
|
let lastErr;
|
|
61
|
-
for (let attempt = 0;
|
|
110
|
+
for (let attempt = 0; ; attempt++) {
|
|
62
111
|
if (signal?.aborted) {
|
|
63
|
-
throw new RetryAbortedError();
|
|
112
|
+
throw new RetryAbortedError(lastErr);
|
|
64
113
|
}
|
|
114
|
+
|
|
115
|
+
let result;
|
|
65
116
|
try {
|
|
66
|
-
|
|
117
|
+
result = await fn(attempt);
|
|
67
118
|
} catch (err) {
|
|
119
|
+
if (err instanceof StopRetry) throw err.original ?? err;
|
|
68
120
|
lastErr = err;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
121
|
+
if (attempt >= retries || !shouldRetry(err, attempt)) throw err;
|
|
122
|
+
|
|
123
|
+
const waitMs = planWait(backoff(attempt), attempt, err, undefined);
|
|
124
|
+
if (waitMs === null) throw err;
|
|
125
|
+
|
|
126
|
+
if (onRetry) onRetry(err, attempt, waitMs, { reason: "error" });
|
|
127
|
+
await sleep(waitMs, signal, err);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (attempt >= retries || !wantsResultRetry(result, attempt)) return result;
|
|
132
|
+
|
|
133
|
+
let defaultMs = backoff(attempt);
|
|
134
|
+
if (respectRetryAfter) {
|
|
135
|
+
const headerMs = getWaitMsFromHeaders(result && result.headers);
|
|
136
|
+
if (headerMs !== null) defaultMs = Math.min(headerMs, maxRetryAfterMs);
|
|
78
137
|
}
|
|
138
|
+
const waitMs = planWait(defaultMs, attempt, undefined, result);
|
|
139
|
+
if (waitMs === null) return result;
|
|
140
|
+
|
|
141
|
+
await discardBody(result);
|
|
142
|
+
if (onRetry) onRetry(undefined, attempt, waitMs, { reason: "result", result, status: result && result.status });
|
|
143
|
+
await sleep(waitMs, signal);
|
|
79
144
|
}
|
|
80
|
-
throw lastErr;
|
|
81
145
|
}
|
|
82
146
|
|
|
83
|
-
module.exports = { withRetry, RetryAbortedError };
|
|
147
|
+
module.exports = { withRetry, RetryAbortedError, StopRetry };
|
package/src/withTimeout.js
CHANGED
|
@@ -7,34 +7,62 @@ class TimeoutError extends Error {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* Races an async function against a timeout.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Races an async function against a timeout. `fn` is handed an AbortSignal
|
|
11
|
+
* so the underlying request can actually be cancelled (not just abandoned) —
|
|
12
|
+
* e.g. `withTimeout(signal => fetch(url, { signal }), 5000)`.
|
|
13
|
+
*
|
|
14
|
+
* Pass your own `signal` to also cancel from the outside (user navigated
|
|
15
|
+
* away, shutdown...). That signal is linked into the one `fn` receives, and
|
|
16
|
+
* aborting it rejects with its reason even if `fn` ignores the signal.
|
|
17
|
+
* `ms: Infinity` disables the timer (handy to only link a signal).
|
|
13
18
|
*
|
|
14
19
|
* @param {(signal: AbortSignal) => Promise<any>} fn
|
|
15
20
|
* @param {number} ms
|
|
21
|
+
* @param {object} [opts]
|
|
22
|
+
* @param {AbortSignal} [opts.signal] - external cancellation
|
|
16
23
|
*/
|
|
17
|
-
async function withTimeout(fn, ms) {
|
|
24
|
+
async function withTimeout(fn, ms, opts = {}) {
|
|
25
|
+
const { signal } = opts;
|
|
26
|
+
|
|
27
|
+
if (typeof ms !== "number" || Number.isNaN(ms) || ms < 0) {
|
|
28
|
+
throw new TypeError("withTimeout: `ms` must be a non-negative number (or Infinity)");
|
|
29
|
+
}
|
|
30
|
+
signal?.throwIfAborted?.();
|
|
31
|
+
|
|
18
32
|
const controller = new AbortController();
|
|
19
|
-
|
|
33
|
+
let timedOut = false;
|
|
34
|
+
|
|
35
|
+
const timer =
|
|
36
|
+
ms === Infinity
|
|
37
|
+
? null
|
|
38
|
+
: setTimeout(() => {
|
|
39
|
+
timedOut = true;
|
|
40
|
+
controller.abort(new TimeoutError(ms));
|
|
41
|
+
}, ms);
|
|
42
|
+
|
|
43
|
+
const onExternalAbort = () => controller.abort(signal.reason);
|
|
44
|
+
signal?.addEventListener("abort", onExternalAbort, { once: true });
|
|
20
45
|
|
|
21
|
-
const
|
|
22
|
-
controller.signal.addEventListener(
|
|
46
|
+
const guard = new Promise((_, reject) => {
|
|
47
|
+
controller.signal.addEventListener(
|
|
48
|
+
"abort",
|
|
49
|
+
() => reject(timedOut ? new TimeoutError(ms) : controller.signal.reason),
|
|
50
|
+
{ once: true }
|
|
51
|
+
);
|
|
23
52
|
});
|
|
24
53
|
|
|
25
54
|
try {
|
|
26
|
-
return await Promise.race([fn(controller.signal),
|
|
55
|
+
return await Promise.race([fn(controller.signal), guard]);
|
|
27
56
|
} catch (err) {
|
|
28
57
|
// If our timeout is what triggered the abort, always surface TimeoutError —
|
|
29
|
-
// regardless of whether fn's own rejection or our
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
if (
|
|
33
|
-
throw new TimeoutError(ms);
|
|
34
|
-
}
|
|
58
|
+
// regardless of whether fn's own rejection or our guard "won" the race
|
|
59
|
+
// (that's a listener-registration-order detail callers shouldn't have to
|
|
60
|
+
// think about).
|
|
61
|
+
if (timedOut) throw new TimeoutError(ms);
|
|
35
62
|
throw err;
|
|
36
63
|
} finally {
|
|
37
|
-
clearTimeout(timer);
|
|
64
|
+
if (timer) clearTimeout(timer);
|
|
65
|
+
signal?.removeEventListener("abort", onExternalAbort);
|
|
38
66
|
}
|
|
39
67
|
}
|
|
40
68
|
|
package/types/index.d.ts
CHANGED
|
@@ -1,14 +1,60 @@
|
|
|
1
|
+
export interface RetryContext {
|
|
2
|
+
reason: "error" | "result";
|
|
3
|
+
/** The result that triggered the retry (reason "result"). */
|
|
4
|
+
result?: unknown;
|
|
5
|
+
/** The result's HTTP status (reason "result"). */
|
|
6
|
+
status?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface CalculateDelayInfo {
|
|
10
|
+
attempt: number;
|
|
11
|
+
error: unknown;
|
|
12
|
+
result: unknown;
|
|
13
|
+
/** The delay reqkit would have used. */
|
|
14
|
+
delayMs: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
1
17
|
export interface RetryOptions {
|
|
18
|
+
/** Max retries after the first try. Infinity is fine together with maxRetryTime. Default 3. */
|
|
2
19
|
retries?: number;
|
|
20
|
+
/** Default 300. */
|
|
3
21
|
baseDelayMs?: number;
|
|
22
|
+
/** Backoff ceiling. Default 10000. */
|
|
4
23
|
maxDelayMs?: number;
|
|
24
|
+
/** Backoff multiplier. Default 2. */
|
|
25
|
+
factor?: number;
|
|
26
|
+
/** Extra random fraction (default 0.2), or "full" for uniform [0, delay]. */
|
|
27
|
+
jitter?: number | "full";
|
|
28
|
+
/** Total time budget across attempts and waits, in ms. */
|
|
29
|
+
maxRetryTime?: number;
|
|
30
|
+
/** For thrown errors. */
|
|
5
31
|
shouldRetry?: (err: unknown, attempt: number) => boolean;
|
|
6
|
-
|
|
32
|
+
/** Retry when the result's `.status` is in this list. */
|
|
33
|
+
retryStatusCodes?: number[];
|
|
34
|
+
/** Retry on any result predicate. */
|
|
35
|
+
shouldRetryResult?: (result: any, attempt: number) => boolean;
|
|
36
|
+
/** Use Retry-After / X-RateLimit-Reset from `result.headers`. Default true. */
|
|
37
|
+
respectRetryAfter?: boolean;
|
|
38
|
+
/** Cap on a header-provided wait. Default 60000. */
|
|
39
|
+
maxRetryAfterMs?: number;
|
|
40
|
+
/** Return ms to wait, or false to stop retrying. */
|
|
41
|
+
calculateDelay?: (info: CalculateDelayInfo) => number | false | null | undefined;
|
|
42
|
+
/** `err` is undefined when a result (not an error) triggered the retry. */
|
|
43
|
+
onRetry?: (err: unknown, attempt: number, delayMs: number, ctx: RetryContext) => void;
|
|
7
44
|
signal?: AbortSignal;
|
|
8
45
|
}
|
|
9
46
|
|
|
47
|
+
/** Throw inside `fn` to stop retrying. An Error argument is rethrown as-is. */
|
|
48
|
+
export class StopRetry extends Error {
|
|
49
|
+
name: "StopRetry";
|
|
50
|
+
original: Error | null;
|
|
51
|
+
constructor(reasonOrMessage?: unknown);
|
|
52
|
+
}
|
|
53
|
+
|
|
10
54
|
export class RetryAbortedError extends Error {
|
|
11
55
|
name: "RetryAbortedError";
|
|
56
|
+
/** The last failure before the loop was aborted, if there was one. */
|
|
57
|
+
cause?: unknown;
|
|
12
58
|
}
|
|
13
59
|
|
|
14
60
|
export function withRetry<T>(fn: (attempt: number) => Promise<T>, opts?: RetryOptions): Promise<T>;
|
|
@@ -18,7 +64,17 @@ export class TimeoutError extends Error {
|
|
|
18
64
|
constructor(ms: number);
|
|
19
65
|
}
|
|
20
66
|
|
|
21
|
-
export
|
|
67
|
+
export interface TimeoutOptions {
|
|
68
|
+
/** External cancellation, linked into the signal `fn` receives. */
|
|
69
|
+
signal?: AbortSignal;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** `ms` may be Infinity to disable the timer (only link `signal`). */
|
|
73
|
+
export function withTimeout<T>(
|
|
74
|
+
fn: (signal: AbortSignal) => Promise<T>,
|
|
75
|
+
ms: number,
|
|
76
|
+
opts?: TimeoutOptions
|
|
77
|
+
): Promise<T>;
|
|
22
78
|
|
|
23
79
|
export interface NormalizedError {
|
|
24
80
|
message: string;
|
|
@@ -26,6 +82,8 @@ export interface NormalizedError {
|
|
|
26
82
|
code: string | null;
|
|
27
83
|
isNetworkError: boolean;
|
|
28
84
|
isTimeout: boolean;
|
|
85
|
+
/** True when the request was cancelled on purpose (AbortController, axios cancel). */
|
|
86
|
+
isAborted: boolean;
|
|
29
87
|
cause: unknown;
|
|
30
88
|
}
|
|
31
89
|
|
|
@@ -46,6 +104,11 @@ export interface RetryAfterAwareOptions {
|
|
|
46
104
|
fallbackDelayMs?: number;
|
|
47
105
|
maxDelayMs?: number;
|
|
48
106
|
onRetry?: (status: number, waitMs: number, attempt: number) => void;
|
|
107
|
+
/** Decide whether a thrown error is retried. Default: network errors and timeouts only. */
|
|
108
|
+
shouldRetryError?: (err: unknown, attempt: number) => boolean;
|
|
109
|
+
onRetryError?: (err: unknown, waitMs: number, attempt: number) => void;
|
|
110
|
+
/** Cancels the retry loop, including any pending wait. Throws RetryAbortedError. */
|
|
111
|
+
signal?: AbortSignal;
|
|
49
112
|
}
|
|
50
113
|
|
|
51
114
|
export function retryAfterAware(
|
|
@@ -62,9 +125,24 @@ export interface DownloadProgress {
|
|
|
62
125
|
percent: number | null;
|
|
63
126
|
}
|
|
64
127
|
|
|
128
|
+
export interface WithProgressOptions {
|
|
129
|
+
/** Reject with BodyTooLargeError once more than this many bytes arrive. */
|
|
130
|
+
maxBytes?: number;
|
|
131
|
+
/** Cancels the read; rejects with the signal's reason. */
|
|
132
|
+
signal?: AbortSignal;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export class BodyTooLargeError extends Error {
|
|
136
|
+
name: "BodyTooLargeError";
|
|
137
|
+
code: "EBODYTOOLARGE";
|
|
138
|
+
maxBytes: number;
|
|
139
|
+
constructor(maxBytes: number);
|
|
140
|
+
}
|
|
141
|
+
|
|
65
142
|
export function withProgress(
|
|
66
143
|
response: Response,
|
|
67
|
-
onProgress?: (progress: DownloadProgress) => void
|
|
144
|
+
onProgress?: (progress: DownloadProgress) => void,
|
|
145
|
+
opts?: WithProgressOptions
|
|
68
146
|
): Promise<Uint8Array>;
|
|
69
147
|
|
|
70
148
|
export interface DedupeOptions {
|
|
@@ -75,3 +153,81 @@ export function withDedupe<Fn extends (...args: any[]) => Promise<any>>(
|
|
|
75
153
|
fn: Fn,
|
|
76
154
|
opts?: DedupeOptions
|
|
77
155
|
): Fn;
|
|
156
|
+
|
|
157
|
+
export interface Limiter {
|
|
158
|
+
<A extends any[], R>(fn: (...args: A) => R | Promise<R>, ...args: A): Promise<R>;
|
|
159
|
+
readonly activeCount: number;
|
|
160
|
+
readonly pendingCount: number;
|
|
161
|
+
/** Runs fn(item, index) for each item through the limiter; keeps input order. */
|
|
162
|
+
map<T, R>(items: Iterable<T>, fn: (item: T, index: number) => R | Promise<R>): Promise<R[]>;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function createLimiter(concurrency?: number): Limiter;
|
|
166
|
+
|
|
167
|
+
export class HTTPError extends Error {
|
|
168
|
+
name: "HTTPError";
|
|
169
|
+
status: number;
|
|
170
|
+
statusText: string;
|
|
171
|
+
response: Response;
|
|
172
|
+
url: string;
|
|
173
|
+
method: string;
|
|
174
|
+
constructor(response: Response, request: { method: string; url: string });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface CreateFetchRetryOptions {
|
|
178
|
+
/** Default 2. */
|
|
179
|
+
retries?: number;
|
|
180
|
+
/** Default [408, 425, 429, 500, 502, 503, 504]. */
|
|
181
|
+
statusCodes?: number[];
|
|
182
|
+
/** Methods that may be retried. Default GET, HEAD, OPTIONS, PUT, DELETE. */
|
|
183
|
+
methods?: string[];
|
|
184
|
+
baseDelayMs?: number;
|
|
185
|
+
maxDelayMs?: number;
|
|
186
|
+
factor?: number;
|
|
187
|
+
jitter?: number | "full";
|
|
188
|
+
maxRetryTime?: number;
|
|
189
|
+
shouldRetry?: (err: unknown, attempt: number) => boolean;
|
|
190
|
+
calculateDelay?: RetryOptions["calculateDelay"];
|
|
191
|
+
onRetry?: RetryOptions["onRetry"];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface FetchHookContext {
|
|
195
|
+
url: string;
|
|
196
|
+
init: RequestInit & { headers: Headers };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export interface CreateFetchDefaults {
|
|
200
|
+
baseUrl?: string;
|
|
201
|
+
headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
|
|
202
|
+
/** Per attempt, in ms. false disables. Default 30000. */
|
|
203
|
+
timeout?: number | false;
|
|
204
|
+
/** false disables retrying. */
|
|
205
|
+
retry?: false | CreateFetchRetryOptions;
|
|
206
|
+
/** Merge identical concurrent GET/HEAD calls. Default false. */
|
|
207
|
+
dedupe?: boolean;
|
|
208
|
+
/** Throw HTTPError on non-2xx. Default true. */
|
|
209
|
+
throwHttpErrors?: boolean;
|
|
210
|
+
fetch?: typeof fetch;
|
|
211
|
+
onRequest?: (ctx: FetchHookContext) => void | Promise<void>;
|
|
212
|
+
onResponse?: (ctx: FetchHookContext & { response: Response }) => void | Promise<void>;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export interface ReqkitRequestInit extends Omit<RequestInit, "headers"> {
|
|
216
|
+
headers?: HeadersInit;
|
|
217
|
+
query?: Record<string, unknown>;
|
|
218
|
+
/** Serialized as the body with a JSON content-type. */
|
|
219
|
+
json?: unknown;
|
|
220
|
+
timeout?: number | false;
|
|
221
|
+
retry?: false | CreateFetchRetryOptions;
|
|
222
|
+
throwHttpErrors?: boolean;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export interface ReqkitFetch {
|
|
226
|
+
(input: string, init?: ReqkitRequestInit): Promise<Response>;
|
|
227
|
+
/** Resolves to the parsed JSON body (null for an empty body). */
|
|
228
|
+
json<T = unknown>(input: string, init?: ReqkitRequestInit): Promise<T>;
|
|
229
|
+
/** New instance with these defaults merged over the current ones. */
|
|
230
|
+
extend(overrides?: CreateFetchDefaults): ReqkitFetch;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function createFetch(defaults?: CreateFetchDefaults): ReqkitFetch;
|