@boy-offi9-inc/reqkit 0.2.0 → 0.5.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/README.md +305 -21
- package/RECIPES.md +243 -0
- package/dist/reqkit.mjs +2304 -0
- package/package.json +31 -4
- package/src/HTTPError.js +14 -0
- package/src/_cache.js +55 -0
- package/src/_http.js +90 -0
- package/src/_sleep.js +20 -1
- package/src/createCircuitBreaker.js +124 -0
- package/src/createFetch.js +398 -0
- package/src/createLimiter.js +55 -0
- package/src/createMemoryCache.js +40 -0
- package/src/createMockFetch.js +184 -0
- package/src/createRateLimiter.js +111 -0
- package/src/downloadResumable.js +193 -0
- package/src/index.js +22 -1
- package/src/index.mjs +13 -0
- package/src/retryAfterAware.js +26 -88
- package/src/streams.js +149 -0
- package/src/withRetry.js +112 -23
- package/src/withTimeout.js +43 -15
- package/types/index.d.ts +308 -2
package/src/withRetry.js
CHANGED
|
@@ -1,58 +1,147 @@
|
|
|
1
1
|
const { RetryAbortedError, sleep } = require("./_sleep");
|
|
2
|
+
const { getWaitMsFromHeaders, discardBody } = require("./_http");
|
|
2
3
|
|
|
3
4
|
function defaultShouldRetry(err) {
|
|
4
|
-
// Retry
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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`.
|
|
7
8
|
return Boolean(err) && err.name !== "AbortError";
|
|
8
9
|
}
|
|
9
10
|
|
|
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;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
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.
|
|
12
30
|
*
|
|
13
31
|
* @param {(attempt: number) => Promise<any>} fn
|
|
14
32
|
* @param {object} [opts]
|
|
15
|
-
* @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)
|
|
16
34
|
* @param {number} [opts.baseDelayMs=300] - initial delay
|
|
17
|
-
* @param {number} [opts.maxDelayMs=10000] -
|
|
18
|
-
* @param {
|
|
19
|
-
* @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
|
|
20
52
|
* @param {AbortSignal} [opts.signal] - cancels the whole retry loop,
|
|
21
|
-
* including any pending backoff wait, throwing RetryAbortedError
|
|
22
|
-
*
|
|
23
|
-
* 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*.
|
|
24
56
|
*/
|
|
25
57
|
async function withRetry(fn, opts = {}) {
|
|
26
58
|
const {
|
|
27
59
|
retries = 3,
|
|
28
60
|
baseDelayMs = 300,
|
|
29
61
|
maxDelayMs = 10000,
|
|
62
|
+
factor = 2,
|
|
63
|
+
jitter = 0.2,
|
|
64
|
+
maxRetryTime = Infinity,
|
|
30
65
|
shouldRetry = defaultShouldRetry,
|
|
66
|
+
retryStatusCodes = [],
|
|
67
|
+
shouldRetryResult,
|
|
68
|
+
respectRetryAfter = true,
|
|
69
|
+
maxRetryAfterMs = 60000,
|
|
70
|
+
calculateDelay,
|
|
31
71
|
onRetry,
|
|
32
72
|
signal,
|
|
33
73
|
} = opts;
|
|
34
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
|
+
|
|
35
109
|
let lastErr;
|
|
36
|
-
for (let attempt = 0;
|
|
110
|
+
for (let attempt = 0; ; attempt++) {
|
|
37
111
|
if (signal?.aborted) {
|
|
38
112
|
throw new RetryAbortedError(lastErr);
|
|
39
113
|
}
|
|
114
|
+
|
|
115
|
+
let result;
|
|
40
116
|
try {
|
|
41
|
-
|
|
117
|
+
result = await fn(attempt);
|
|
42
118
|
} catch (err) {
|
|
119
|
+
if (err instanceof StopRetry) throw err.original ?? err;
|
|
43
120
|
lastErr = err;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const waitMs = Math.round(exponential + jitter);
|
|
51
|
-
if (onRetry) onRetry(err, attempt, waitMs);
|
|
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" });
|
|
52
127
|
await sleep(waitMs, signal, err);
|
|
128
|
+
continue;
|
|
53
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);
|
|
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);
|
|
54
144
|
}
|
|
55
|
-
throw lastErr;
|
|
56
145
|
}
|
|
57
146
|
|
|
58
|
-
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,12 +1,56 @@
|
|
|
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";
|
|
12
56
|
/** The last failure before the loop was aborted, if there was one. */
|
|
@@ -20,7 +64,17 @@ export class TimeoutError extends Error {
|
|
|
20
64
|
constructor(ms: number);
|
|
21
65
|
}
|
|
22
66
|
|
|
23
|
-
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>;
|
|
24
78
|
|
|
25
79
|
export interface NormalizedError {
|
|
26
80
|
message: string;
|
|
@@ -99,3 +153,255 @@ export function withDedupe<Fn extends (...args: any[]) => Promise<any>>(
|
|
|
99
153
|
fn: Fn,
|
|
100
154
|
opts?: DedupeOptions
|
|
101
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
|
+
/**
|
|
207
|
+
* `true` only learns from X-RateLimit-* / Retry-After headers; `{ requests, perMs }`
|
|
208
|
+
* also enforces a budget; or share a createRateLimiter() instance.
|
|
209
|
+
*/
|
|
210
|
+
rateLimit?: boolean | RateLimiterOptions | RateLimiter;
|
|
211
|
+
/** Options for a per-origin breaker, a shared breaker instance, or `(url) => breaker`. */
|
|
212
|
+
circuitBreaker?: boolean | CircuitBreakerOptions | CircuitBreaker | ((url: string) => CircuitBreaker);
|
|
213
|
+
/** In-memory GET cache with TTL, stale-while-revalidate and ETag/Last-Modified revalidation. `true` = 60s TTL. */
|
|
214
|
+
cache?: boolean | CacheOptions;
|
|
215
|
+
/** Merge identical concurrent GET/HEAD calls. Default false. */
|
|
216
|
+
dedupe?: boolean;
|
|
217
|
+
/** Throw HTTPError on non-2xx. Default true. */
|
|
218
|
+
throwHttpErrors?: boolean;
|
|
219
|
+
fetch?: typeof fetch;
|
|
220
|
+
onRequest?: (ctx: FetchHookContext) => void | Promise<void>;
|
|
221
|
+
onResponse?: (ctx: FetchHookContext & { response: Response }) => void | Promise<void>;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export interface ReqkitRequestInit extends Omit<RequestInit, "headers" | "cache"> {
|
|
225
|
+
headers?: HeadersInit;
|
|
226
|
+
query?: Record<string, unknown>;
|
|
227
|
+
/** Serialized as the body with a JSON content-type. */
|
|
228
|
+
json?: unknown;
|
|
229
|
+
timeout?: number | false;
|
|
230
|
+
retry?: false | CreateFetchRetryOptions;
|
|
231
|
+
throwHttpErrors?: boolean;
|
|
232
|
+
/** `false`/"no-store" bypass the cache; "reload" skips the read but refreshes the entry. */
|
|
233
|
+
cache?: false | "no-store" | "reload";
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export interface ReqkitFetch {
|
|
237
|
+
(input: string, init?: ReqkitRequestInit): Promise<Response>;
|
|
238
|
+
/** Resolves to the parsed JSON body (null for an empty body). */
|
|
239
|
+
json<T = unknown>(input: string, init?: ReqkitRequestInit): Promise<T>;
|
|
240
|
+
/** New instance with these defaults merged over the current ones. */
|
|
241
|
+
extend(overrides?: CreateFetchDefaults): ReqkitFetch;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function createFetch(defaults?: CreateFetchDefaults): ReqkitFetch;
|
|
245
|
+
|
|
246
|
+
export interface RateLimiterOptions {
|
|
247
|
+
/** Max requests per window. Default Infinity. */
|
|
248
|
+
requests?: number;
|
|
249
|
+
/** Window length in ms. Default 1000. */
|
|
250
|
+
perMs?: number;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export interface RateLimiter {
|
|
254
|
+
/** Resolves when it's your turn (FIFO). Rejects with the signal's reason if aborted while waiting. */
|
|
255
|
+
acquire(opts?: { signal?: AbortSignal }): Promise<void>;
|
|
256
|
+
/** Learn from a response's X-RateLimit-* / RateLimit-* / Retry-After headers. */
|
|
257
|
+
update(response: { status?: number; headers?: { get(name: string): string | null } } | null | undefined): void;
|
|
258
|
+
/** acquire → fn() → update(result). */
|
|
259
|
+
run<T>(fn: () => Promise<T>, opts?: { signal?: AbortSignal }): Promise<T>;
|
|
260
|
+
/** Ms until a server-imposed pause ends (0 if none). */
|
|
261
|
+
readonly blockedForMs: number;
|
|
262
|
+
reset(): void;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function createRateLimiter(opts?: RateLimiterOptions): RateLimiter;
|
|
266
|
+
|
|
267
|
+
export class CircuitOpenError extends Error {
|
|
268
|
+
name: "CircuitOpenError";
|
|
269
|
+
code: "CIRCUIT_OPEN";
|
|
270
|
+
/** Ms until the breaker will let a probe through. */
|
|
271
|
+
retryAfterMs: number;
|
|
272
|
+
constructor(retryAfterMs: number);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export interface CircuitBreakerOptions {
|
|
276
|
+
/** Consecutive failures before opening. Default 5. */
|
|
277
|
+
failureThreshold?: number;
|
|
278
|
+
/** How long to stay open before allowing a probe. Default 30000. */
|
|
279
|
+
resetTimeoutMs?: number;
|
|
280
|
+
/** Concurrent probes allowed while half-open. Default 1. */
|
|
281
|
+
halfOpenMax?: number;
|
|
282
|
+
/** Default: any thrown error except AbortError, or a result with status >= 500. */
|
|
283
|
+
isFailure?: (valueOrError: any, isError: boolean) => boolean;
|
|
284
|
+
onStateChange?: (state: CircuitState, previous: CircuitState) => void;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export type CircuitState = "closed" | "open" | "half-open";
|
|
288
|
+
|
|
289
|
+
export interface CircuitBreaker {
|
|
290
|
+
run<T>(fn: () => Promise<T>): Promise<T>;
|
|
291
|
+
readonly state: CircuitState;
|
|
292
|
+
readonly failures: number;
|
|
293
|
+
reset(): void;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function createCircuitBreaker(opts?: CircuitBreakerOptions): CircuitBreaker;
|
|
297
|
+
|
|
298
|
+
export interface CacheStore {
|
|
299
|
+
get(key: string): any | Promise<any>;
|
|
300
|
+
set(key: string, value: any): void | Promise<void>;
|
|
301
|
+
delete(key: string): any;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export interface MemoryCache extends CacheStore {
|
|
305
|
+
clear(): void;
|
|
306
|
+
readonly size: number;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function createMemoryCache(opts?: { maxEntries?: number }): MemoryCache;
|
|
310
|
+
|
|
311
|
+
export interface CacheEvent {
|
|
312
|
+
type: "hit" | "stale" | "revalidated" | "miss" | "store";
|
|
313
|
+
url: string;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export interface CacheOptions {
|
|
317
|
+
/** How long an entry is served without asking the server, in ms. Default 60000. */
|
|
318
|
+
ttl?: number;
|
|
319
|
+
/** After the TTL, keep serving the stale copy for this long while refreshing in the background. Default 0. */
|
|
320
|
+
staleWhileRevalidate?: number;
|
|
321
|
+
/** For the built-in memory store. Default 100. */
|
|
322
|
+
maxEntries?: number;
|
|
323
|
+
/** Responses larger than this aren't cached. Default 1 MiB. */
|
|
324
|
+
maxBodyBytes?: number;
|
|
325
|
+
/** Bring your own store (Redis, IndexedDB, a Map...). */
|
|
326
|
+
store?: CacheStore;
|
|
327
|
+
onEvent?: (event: CacheEvent) => void;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export interface MockCall {
|
|
331
|
+
url: string;
|
|
332
|
+
method: string;
|
|
333
|
+
headers: Record<string, string>;
|
|
334
|
+
body: BodyInit | null | undefined;
|
|
335
|
+
signal?: AbortSignal;
|
|
336
|
+
/** The body parsed as JSON, if it was a JSON string. */
|
|
337
|
+
readonly json: any;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export interface MockReplyOptions {
|
|
341
|
+
headers?: HeadersInit;
|
|
342
|
+
/** Wait this long before answering; respects the request's AbortSignal. */
|
|
343
|
+
delayMs?: number;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export interface MockRoute {
|
|
347
|
+
reply(status?: number, body?: unknown, opts?: MockReplyOptions): MockRoute;
|
|
348
|
+
replyOnce(status?: number, body?: unknown, opts?: MockReplyOptions): MockRoute;
|
|
349
|
+
replyWith(fn: (call: MockCall) => Response | { status?: number; body?: unknown; headers?: HeadersInit } | Promise<Response | { status?: number; body?: unknown; headers?: HeadersInit }>): MockRoute;
|
|
350
|
+
replyWithOnce(fn: (call: MockCall) => Response | { status?: number; body?: unknown; headers?: HeadersInit } | Promise<Response | { status?: number; body?: unknown; headers?: HeadersInit }>): MockRoute;
|
|
351
|
+
fail(err?: unknown): MockRoute;
|
|
352
|
+
failOnce(err?: unknown): MockRoute;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export type MockMatcher = string | RegExp | ((url: string) => boolean);
|
|
356
|
+
|
|
357
|
+
export interface MockFetch {
|
|
358
|
+
fetch: typeof fetch;
|
|
359
|
+
on(method: string, matcher: MockMatcher): MockRoute;
|
|
360
|
+
get(matcher: MockMatcher): MockRoute;
|
|
361
|
+
post(matcher: MockMatcher): MockRoute;
|
|
362
|
+
put(matcher: MockMatcher): MockRoute;
|
|
363
|
+
patch(matcher: MockMatcher): MockRoute;
|
|
364
|
+
delete(matcher: MockMatcher): MockRoute;
|
|
365
|
+
/** A TypeError("fetch failed") whose `cause.code` is set, like Node's fetch throws. */
|
|
366
|
+
networkError(code?: string): TypeError;
|
|
367
|
+
readonly calls: MockCall[];
|
|
368
|
+
reset(): void;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function createMockFetch(): MockFetch;
|
|
372
|
+
|
|
373
|
+
export interface DownloadResumableOptions {
|
|
374
|
+
fetch?: typeof fetch;
|
|
375
|
+
headers?: HeadersInit;
|
|
376
|
+
/** Consecutive attempts without progress before giving up. Default 5. */
|
|
377
|
+
retries?: number;
|
|
378
|
+
baseDelayMs?: number;
|
|
379
|
+
maxDelayMs?: number;
|
|
380
|
+
onProgress?: (progress: DownloadProgress) => void;
|
|
381
|
+
/** Throws BodyTooLargeError past this many bytes. */
|
|
382
|
+
maxBytes?: number;
|
|
383
|
+
signal?: AbortSignal;
|
|
384
|
+
onRetry?: (err: unknown, attempt: number, delayMs: number, info: { resumeFrom: number }) => void;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function downloadResumable(url: string, opts?: DownloadResumableOptions): Promise<Uint8Array>;
|
|
388
|
+
|
|
389
|
+
export type StreamSource = Response | ReadableStream<Uint8Array> | AsyncIterable<Uint8Array | string>;
|
|
390
|
+
|
|
391
|
+
export interface StreamOptions {
|
|
392
|
+
signal?: AbortSignal;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export interface SSEEvent {
|
|
396
|
+
event: string;
|
|
397
|
+
data: string;
|
|
398
|
+
id: string;
|
|
399
|
+
retry: number | undefined;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function readLines(source: StreamSource, opts?: StreamOptions): AsyncGenerator<string, void, undefined>;
|
|
403
|
+
export function readNDJSON<T = unknown>(
|
|
404
|
+
source: StreamSource,
|
|
405
|
+
opts?: StreamOptions & { skipInvalid?: boolean }
|
|
406
|
+
): AsyncGenerator<T, void, undefined>;
|
|
407
|
+
export function readSSE(source: StreamSource, opts?: StreamOptions): AsyncGenerator<SSEEvent, void, undefined>;
|