@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/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 network-level failures by default. HTTP status handling is the
5
- // caller's job (they know what "failure" means for their response shape) —
6
- // see retryAfterAware.js for a version that understands status codes.
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
- * Retries an async function with exponential backoff + jitter.
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] - delay ceiling
18
- * @param {(err: any, attempt: number) => boolean} [opts.shouldRetry]
19
- * @param {(err: any, attempt: number, delayMs: number) => void} [opts.onRetry]
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. This is
22
- * separate from a per-call AbortSignal you might pass into `fn` yourself —
23
- * this one governs whether *another attempt starts at all*.
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; attempt <= retries; attempt++) {
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
- return await fn(attempt);
117
+ result = await fn(attempt);
42
118
  } catch (err) {
119
+ if (err instanceof StopRetry) throw err.original ?? err;
43
120
  lastErr = err;
44
- const isLastAttempt = attempt === retries;
45
- if (isLastAttempt || !shouldRetry(err, attempt)) {
46
- throw err;
47
- }
48
- const exponential = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
49
- const jitter = Math.random() * exponential * 0.2;
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 };
@@ -7,34 +7,62 @@ class TimeoutError extends Error {
7
7
  }
8
8
 
9
9
  /**
10
- * Races an async function against a timeout. If `fn` accepts an AbortSignal
11
- * as its argument, it's passed one so the underlying request can actually be
12
- * cancelled (not just abandoned) — e.g. `withTimeout(signal => fetch(url, { signal }), 5000)`.
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
- const timer = setTimeout(() => controller.abort(), ms);
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 timeoutPromise = new Promise((_, reject) => {
22
- controller.signal.addEventListener("abort", () => reject(new TimeoutError(ms)));
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), timeoutPromise]);
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 timeout promise "won"
30
- // the race (that's a listener-registration-order detail callers shouldn't
31
- // have to think about).
32
- if (controller.signal.aborted) {
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
- onRetry?: (err: unknown, attempt: number, delayMs: number) => void;
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 function withTimeout<T>(fn: (signal: AbortSignal) => Promise<T>, ms: number): Promise<T>;
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>;