@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.
@@ -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
- * If the server doesn't send Content-Length, `total` will be null in the
8
- * progress callback — the loaded count is still accurate.
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 total = contentLength ? Number(contentLength) : null;
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
- for (;;) {
33
- const { done, value } = await reader.read();
34
- if (done) break;
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
- chunks.push(value);
37
- loaded += value.byteLength;
86
+ chunks.push(value);
87
+ loaded += value.byteLength;
38
88
 
39
- if (onProgress) {
40
- onProgress({
41
- loaded,
42
- total,
43
- percent: total ? Math.round((loaded / total) * 100) : null,
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 network-level failures by default. HTTP status handling is the
3
- // caller's job (they know what "failure" means for their response shape) —
4
- // 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`.
5
8
  return Boolean(err) && err.name !== "AbortError";
6
9
  }
7
10
 
8
- class RetryAbortedError extends Error {
9
- constructor() {
10
- super("Retry loop was aborted");
11
- this.name = "RetryAbortedError";
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] - delay ceiling
43
- * @param {(err: any, attempt: number) => boolean} [opts.shouldRetry]
44
- * @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
45
52
  * @param {AbortSignal} [opts.signal] - cancels the whole retry loop,
46
- * including any pending backoff wait, throwing RetryAbortedError. This is
47
- * separate from a per-call AbortSignal you might pass into `fn` yourself —
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; attempt <= retries; attempt++) {
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
- return await fn(attempt);
117
+ result = await fn(attempt);
67
118
  } catch (err) {
119
+ if (err instanceof StopRetry) throw err.original ?? err;
68
120
  lastErr = err;
69
- const isLastAttempt = attempt === retries;
70
- if (isLastAttempt || !shouldRetry(err, attempt)) {
71
- throw err;
72
- }
73
- const exponential = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
74
- const jitter = Math.random() * exponential * 0.2;
75
- const waitMs = Math.round(exponential + jitter);
76
- if (onRetry) onRetry(err, attempt, waitMs);
77
- await delay(waitMs, signal);
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 };
@@ -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
 
@@ -0,0 +1,3 @@
1
+ import * as reqkit from "./index.js";
2
+ export * from "./index.js";
3
+ export default reqkit;
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
- 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";
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 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>;
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;