@boy-offi9-inc/reqkit 0.7.1 → 0.9.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.
Files changed (37) hide show
  1. package/README.md +134 -66
  2. package/dist/reqkit.mjs +2429 -2020
  3. package/package.json +11 -5
  4. package/src/{createFetch.js → http/createFetch.js} +203 -26
  5. package/src/http/latency.js +41 -0
  6. package/src/index.js +20 -18
  7. package/src/index.mjs +1 -0
  8. package/src/{paginate.js → pagination/paginate.js} +2 -2
  9. package/src/resilience/hedge.js +168 -0
  10. package/src/{createRateLimiter.js → resilience/rateLimiter.js} +7 -4
  11. package/src/{withRetry.js → resilience/retry.js} +2 -2
  12. package/src/{retryAfterAware.js → resilience/retryAfterAware.js} +3 -3
  13. package/src/{downloadResumable.js → streams/download.js} +5 -5
  14. package/src/{streamSSE.js → streams/streamSSE.js} +5 -5
  15. package/src/{createMockFetch.js → testing/createMockFetch.js} +1 -1
  16. package/types/errors.d.ts +24 -0
  17. package/types/http.d.ts +209 -0
  18. package/types/index.d.ts +7 -538
  19. package/types/pagination.d.ts +62 -0
  20. package/types/resilience.d.ts +194 -0
  21. package/types/streams.d.ts +105 -0
  22. package/types/testing.d.ts +44 -0
  23. /package/{RECIPES.md → docs/recipes.md} +0 -0
  24. /package/src/{HTTPError.js → errors/HTTPError.js} +0 -0
  25. /package/src/{normalizeError.js → errors/normalizeError.js} +0 -0
  26. /package/src/{buildQuery.js → http/buildQuery.js} +0 -0
  27. /package/src/{_cache.js → http/cache/entry.js} +0 -0
  28. /package/src/{createMemoryCache.js → http/cache/memoryCache.js} +0 -0
  29. /package/src/{parseJsonSafe.js → http/parseJsonSafe.js} +0 -0
  30. /package/src/{_http.js → internal/http.js} +0 -0
  31. /package/src/{_sleep.js → internal/sleep.js} +0 -0
  32. /package/src/{createCircuitBreaker.js → resilience/circuitBreaker.js} +0 -0
  33. /package/src/{withDedupe.js → resilience/dedupe.js} +0 -0
  34. /package/src/{createLimiter.js → resilience/limiter.js} +0 -0
  35. /package/src/{withTimeout.js → resilience/timeout.js} +0 -0
  36. /package/src/{withProgress.js → streams/progress.js} +0 -0
  37. /package/src/{streams.js → streams/readers.js} +0 -0
@@ -0,0 +1,168 @@
1
+ const { discardBody } = require("../internal/http");
2
+
3
+ function defaultIsFailure(result) {
4
+ return Boolean(result) && typeof result.status === "number" && result.status >= 500;
5
+ }
6
+
7
+ function lostReason() {
8
+ const reason = new Error("Request cancelled: a faster hedged request won");
9
+ reason.name = "AbortError";
10
+ return reason;
11
+ }
12
+
13
+ /**
14
+ * Hedged requests: start `fn`; if it hasn't answered within `delayMs`, start
15
+ * a backup (a "hedge"); whichever succeeds first wins and the others are
16
+ * aborted. It trims the slow tail of latency — the occasional request that
17
+ * takes 10× longer than the rest — at the cost of a little extra load.
18
+ *
19
+ * ```js
20
+ * const res = await withHedge((signal) => fetch(url, { signal }), { delayMs: 200 });
21
+ * ```
22
+ *
23
+ * Only hedge requests that are safe to run twice (GETs, idempotent calls).
24
+ *
25
+ * Details:
26
+ * - `fn(signal, index)` — `index` is 0 for the original and 1.. for backups.
27
+ * Abort-aware code (like `fetch`) is actually cancelled when it loses.
28
+ * - An attempt that throws, or returns a failed result (status >= 500 by
29
+ * default), launches the next backup immediately instead of waiting.
30
+ * - If every attempt fails: resolves with the last failed *result* if there was
31
+ * one (a 503 `Response` isn't thrown), otherwise rejects with the last error.
32
+ * - The bodies of responses that lose are cancelled so connections are freed.
33
+ *
34
+ * @template T
35
+ * @param {(signal: AbortSignal, index: number) => Promise<T>} fn
36
+ * @param {object} opts
37
+ * @param {number | ((index: number) => number)} opts.delayMs - wait before launching backup #index (1-based)
38
+ * @param {number} [opts.maxHedges=1] - how many backups at most
39
+ * @param {(result: T) => boolean} [opts.isFailure] - results that count as failed. Default: `result.status >= 500`
40
+ * @param {boolean} [opts.hedgeOnError=true] - launch the next backup right away when an attempt throws
41
+ * @param {(info: { index: number, afterMs: number }) => void} [opts.onHedge] - a backup was launched
42
+ * @param {(index: number) => void} [opts.onWinner] - which attempt produced the result (0 = the original)
43
+ * @param {AbortSignal} [opts.signal] - cancels everything; rejects with the signal's reason
44
+ * @returns {Promise<T>}
45
+ */
46
+ function withHedge(fn, opts = {}) {
47
+ const { delayMs, maxHedges = 1, isFailure = defaultIsFailure, hedgeOnError = true, onHedge, onWinner, signal } = opts;
48
+
49
+ const delayFor = typeof delayMs === "function" ? delayMs : () => delayMs;
50
+ if (typeof delayMs !== "function" && !(typeof delayMs === "number" && delayMs >= 0 && Number.isFinite(delayMs))) {
51
+ return Promise.reject(new TypeError("withHedge: `delayMs` must be a non-negative number or a function returning one"));
52
+ }
53
+ if (!(Number.isInteger(maxHedges) && maxHedges >= 0)) {
54
+ return Promise.reject(new TypeError("withHedge: `maxHedges` must be an integer >= 0"));
55
+ }
56
+ if (signal && signal.aborted) return Promise.reject(signal.reason);
57
+
58
+ return new Promise((resolve, reject) => {
59
+ const startedAt = Date.now();
60
+ const attempts = []; // { controller, done }
61
+ let launched = 0;
62
+ let pending = 0;
63
+ let settled = false;
64
+ let timer = null;
65
+ let lastError;
66
+ let hasError = false;
67
+ let failedResult;
68
+ let hasFailedResult = false;
69
+
70
+ const onAbort = () => finish(reject, signal.reason, -1);
71
+ if (signal) signal.addEventListener("abort", onAbort, { once: true });
72
+
73
+ function finish(settle, value, winnerIndex) {
74
+ if (settled) return;
75
+ settled = true;
76
+ clearTimeout(timer);
77
+ if (signal) signal.removeEventListener("abort", onAbort);
78
+ attempts.forEach((attempt, index) => {
79
+ if (index !== winnerIndex && !attempt.done) attempt.controller.abort(lostReason());
80
+ });
81
+ if (hasFailedResult && failedResult !== value) discardBody(failedResult);
82
+ if (winnerIndex >= 0 && onWinner) {
83
+ try {
84
+ onWinner(winnerIndex);
85
+ } catch {
86
+ // ignore
87
+ }
88
+ }
89
+ settle(value);
90
+ }
91
+
92
+ function scheduleBackup() {
93
+ clearTimeout(timer);
94
+ if (launched > maxHedges) return; // `launched` counts the original too
95
+ timer = setTimeout(() => launch(), Math.max(0, Number(delayFor(launched)) || 0));
96
+ }
97
+
98
+ function launch() {
99
+ if (settled) return;
100
+ const index = launched++;
101
+ const controller = new AbortController();
102
+ const attempt = { controller, done: false };
103
+ attempts[index] = attempt;
104
+ pending++;
105
+
106
+ if (index > 0 && onHedge) {
107
+ try {
108
+ onHedge({ index, afterMs: Date.now() - startedAt });
109
+ } catch {
110
+ // ignore
111
+ }
112
+ }
113
+
114
+ let promise;
115
+ try {
116
+ promise = Promise.resolve(fn(controller.signal, index));
117
+ } catch (error) {
118
+ promise = Promise.reject(error);
119
+ }
120
+
121
+ promise.then(
122
+ (result) => {
123
+ attempt.done = true;
124
+ pending--;
125
+ if (settled) return discardBody(result); // too late — it lost
126
+ if (isFailure(result)) {
127
+ if (hasFailedResult) discardBody(failedResult);
128
+ failedResult = result;
129
+ hasFailedResult = true;
130
+ return next();
131
+ }
132
+ finish(resolve, result, index);
133
+ },
134
+ (error) => {
135
+ attempt.done = true;
136
+ pending--;
137
+ if (settled) return;
138
+ lastError = error;
139
+ hasError = true;
140
+ if (hedgeOnError) next();
141
+ else if (pending === 0 && launched > maxHedges) giveUp();
142
+ }
143
+ );
144
+
145
+ scheduleBackup();
146
+ }
147
+
148
+ // An attempt failed: start the next backup now, or give up if there are none left.
149
+ function next() {
150
+ if (settled) return;
151
+ if (launched <= maxHedges) {
152
+ clearTimeout(timer);
153
+ launch();
154
+ } else if (pending === 0) {
155
+ giveUp();
156
+ }
157
+ }
158
+
159
+ function giveUp() {
160
+ if (hasFailedResult) finish(resolve, failedResult, -1);
161
+ else finish(reject, hasError ? lastError : new Error("withHedge: no attempt succeeded"), -1);
162
+ }
163
+
164
+ launch();
165
+ });
166
+ }
167
+
168
+ module.exports = { withHedge };
@@ -1,5 +1,5 @@
1
- const { abortableSleep } = require("./_sleep");
2
- const { getRateLimitInfo } = require("./_http");
1
+ const { abortableSleep } = require("../internal/sleep");
2
+ const { getRateLimitInfo } = require("../internal/http");
3
3
 
4
4
  function abortable(promise, signal) {
5
5
  if (!signal) return promise;
@@ -50,6 +50,7 @@ function createRateLimiter(opts = {}) {
50
50
  let tail = Promise.resolve();
51
51
 
52
52
  async function take(signal) {
53
+ let waitedMs = 0;
53
54
  for (;;) {
54
55
  signal?.throwIfAborted?.();
55
56
  const now = Date.now();
@@ -61,13 +62,15 @@ function createRateLimiter(opts = {}) {
61
62
 
62
63
  if (waitMs <= 0) {
63
64
  stamps.push(now);
64
- return;
65
+ return waitedMs;
65
66
  }
67
+ const waitStartedAt = Date.now();
66
68
  await abortableSleep(waitMs, signal);
69
+ waitedMs += Date.now() - waitStartedAt;
67
70
  }
68
71
  }
69
72
 
70
- /** Resolves when it's your turn. Rejects with the signal's reason if aborted while waiting. */
73
+ /** Resolves with the time spent waiting. Rejects if aborted while waiting. */
71
74
  function acquire({ signal } = {}) {
72
75
  const turn = tail.then(() => take(signal));
73
76
  tail = turn.catch(() => {});
@@ -1,5 +1,5 @@
1
- const { RetryAbortedError, sleep } = require("./_sleep");
2
- const { getWaitMsFromHeaders, discardBody } = require("./_http");
1
+ const { RetryAbortedError, sleep } = require("../internal/sleep");
2
+ const { getWaitMsFromHeaders, discardBody } = require("../internal/http");
3
3
 
4
4
  function defaultShouldRetry(err) {
5
5
  // Retry thrown errors except deliberate aborts. Narrow it with `shouldRetry`
@@ -1,6 +1,6 @@
1
- const { withRetry } = require("./withRetry");
2
- const { normalizeError } = require("./normalizeError");
3
- const { parseRetryAfter, getWaitMsFromHeaders } = require("./_http");
1
+ const { withRetry } = require("./retry");
2
+ const { normalizeError } = require("../errors/normalizeError");
3
+ const { parseRetryAfter, getWaitMsFromHeaders } = require("../internal/http");
4
4
 
5
5
  function defaultShouldRetryError(err) {
6
6
  const normalized = normalizeError(err);
@@ -1,8 +1,8 @@
1
- const { HTTPError } = require("./HTTPError");
2
- const { BodyTooLargeError } = require("./withProgress");
3
- const { normalizeError } = require("./normalizeError");
4
- const { getWaitMsFromHeaders } = require("./_http");
5
- const { abortableSleep } = require("./_sleep");
1
+ const { HTTPError } = require("../errors/HTTPError");
2
+ const { BodyTooLargeError } = require("./progress");
3
+ const { normalizeError } = require("../errors/normalizeError");
4
+ const { getWaitMsFromHeaders } = require("../internal/http");
5
+ const { abortableSleep } = require("../internal/sleep");
6
6
 
7
7
  const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
8
8
 
@@ -1,8 +1,8 @@
1
- const { readSSE } = require("./streams");
2
- const { HTTPError } = require("./HTTPError");
3
- const { normalizeError } = require("./normalizeError");
4
- const { getWaitMsFromHeaders, discardBody } = require("./_http");
5
- const { abortableSleep } = require("./_sleep");
1
+ const { readSSE } = require("./readers");
2
+ const { HTTPError } = require("../errors/HTTPError");
3
+ const { normalizeError } = require("../errors/normalizeError");
4
+ const { getWaitMsFromHeaders, discardBody } = require("../internal/http");
5
+ const { abortableSleep } = require("../internal/sleep");
6
6
 
7
7
  function isRetryableStatus(status) {
8
8
  return status === 408 || status === 425 || status === 429 || status >= 500;
@@ -1,4 +1,4 @@
1
- const { abortableSleep } = require("./_sleep");
1
+ const { abortableSleep } = require("../internal/sleep");
2
2
 
3
3
  const BODYLESS = new Set([101, 204, 205, 304]);
4
4
 
@@ -0,0 +1,24 @@
1
+ // Error types and classification — src/errors/
2
+
3
+ export interface NormalizedError {
4
+ message: string;
5
+ status: number | null;
6
+ code: string | null;
7
+ isNetworkError: boolean;
8
+ isTimeout: boolean;
9
+ /** True when the request was cancelled on purpose (AbortController, axios cancel). */
10
+ isAborted: boolean;
11
+ cause: unknown;
12
+ }
13
+
14
+ export function normalizeError(err: unknown): NormalizedError;
15
+
16
+ export class HTTPError extends Error {
17
+ name: "HTTPError";
18
+ status: number;
19
+ statusText: string;
20
+ response: Response;
21
+ url: string;
22
+ method: string;
23
+ constructor(response: Response, request: { method: string; url: string });
24
+ }
@@ -0,0 +1,209 @@
1
+ // createFetch and the request/response helpers — src/http/
2
+ import type { CircuitBreaker, CircuitBreakerOptions, CircuitState, RateLimiter, RateLimiterOptions, RetryOptions } from "./resilience";
3
+
4
+ export interface JsonSafeResult<T = unknown> {
5
+ data: T | null;
6
+ error: Error | null;
7
+ }
8
+
9
+ export function parseJsonSafe<T = unknown>(input: Response | string): Promise<JsonSafeResult<T>>;
10
+
11
+ export function buildQuery(params: Record<string, unknown> | null | undefined): string;
12
+
13
+ export interface CreateFetchRetryOptions {
14
+ /** Default 2. */
15
+ retries?: number;
16
+ /** Default [408, 425, 429, 500, 502, 503, 504]. */
17
+ statusCodes?: number[];
18
+ /** Methods that may be retried. Default GET, HEAD, OPTIONS, PUT, DELETE. */
19
+ methods?: string[];
20
+ baseDelayMs?: number;
21
+ maxDelayMs?: number;
22
+ factor?: number;
23
+ jitter?: number | "full";
24
+ maxRetryTime?: number;
25
+ shouldRetry?: (err: unknown, attempt: number) => boolean;
26
+ calculateDelay?: RetryOptions["calculateDelay"];
27
+ onRetry?: RetryOptions["onRetry"];
28
+ }
29
+
30
+ export interface FetchHookContext {
31
+ url: string;
32
+ init: RequestInit & { headers: Headers };
33
+ }
34
+
35
+ export interface CreateFetchDefaults {
36
+ baseUrl?: string;
37
+ headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
38
+ /** Per attempt, in ms. false disables. Default 30000. */
39
+ timeout?: number | false;
40
+ /** false disables retrying. */
41
+ retry?: false | CreateFetchRetryOptions;
42
+ /**
43
+ * `true` only learns from X-RateLimit-* / Retry-After headers; `{ requests, perMs }`
44
+ * also enforces a budget; or share a createRateLimiter() instance.
45
+ */
46
+ rateLimit?: boolean | RateLimiterOptions | RateLimiter;
47
+ /** Options for a per-origin breaker, a shared breaker instance, or `(url) => breaker`. */
48
+ circuitBreaker?: boolean | CircuitBreakerOptions | CircuitBreaker | ((url: string) => CircuitBreaker);
49
+ /**
50
+ * Extra origins ("https://cdn.example.com") that may receive the default credentials
51
+ * (`auth` and default Authorization/Cookie/X-API-Key headers), besides `baseUrl`'s.
52
+ * Absolute URLs to any other origin don't get them. With neither `baseUrl` nor
53
+ * `trustedOrigins` there's nothing to compare against and credentials go everywhere.
54
+ */
55
+ trustedOrigins?: string[];
56
+ /**
57
+ * Send a backup request when an attempt is slow and take whichever answers first
58
+ * (idempotent methods, or requests with an Idempotency-Key). `true` = delayMs "auto".
59
+ */
60
+ hedge?: boolean | HedgeConfig;
61
+ /** Attach a token and, on a 401, refresh it once (shared by all concurrent requests) and replay. */
62
+ auth?: AuthOptions;
63
+ /** In-memory GET cache with TTL, stale-while-revalidate and ETag/Last-Modified revalidation. `true` = 60s TTL. */
64
+ cache?: boolean | CacheOptions;
65
+ /** Merge identical concurrent GET/HEAD calls. Default false. */
66
+ dedupe?: boolean;
67
+ /** Throw HTTPError on non-2xx. Default true. */
68
+ throwHttpErrors?: boolean;
69
+ fetch?: typeof fetch;
70
+ onRequest?: (ctx: FetchHookContext) => void | Promise<void>;
71
+ onResponse?: (ctx: FetchHookContext & { response: Response }) => void | Promise<void>;
72
+ /** One callback for everything that happens. Errors thrown by the handler are ignored. */
73
+ onEvent?: (event: FetchEvent) => void | Promise<void>;
74
+ }
75
+
76
+ export interface ReqkitRequestInit extends Omit<RequestInit, "headers" | "cache"> {
77
+ headers?: HeadersInit;
78
+ query?: Record<string, unknown>;
79
+ /** Serialized as the body with a JSON content-type. */
80
+ json?: unknown;
81
+ timeout?: number | false;
82
+ retry?: false | CreateFetchRetryOptions;
83
+ throwHttpErrors?: boolean;
84
+ /** `false` disables hedging for this call; an object overrides the defaults. */
85
+ hedge?: boolean | HedgeConfig;
86
+ /** `false`/"no-store" bypass the cache; "reload" skips the read but refreshes the entry. */
87
+ cache?: false | "no-store" | "reload";
88
+ }
89
+
90
+ export interface ReqkitFetch {
91
+ (input: string, init?: ReqkitRequestInit): Promise<Response>;
92
+ /** The `baseUrl` this instance was created with. */
93
+ readonly baseUrl: string | undefined;
94
+ /** Resolves to the parsed JSON body (null for an empty body). */
95
+ json<T = unknown>(input: string, init?: ReqkitRequestInit): Promise<T>;
96
+ /** New instance with these defaults merged over the current ones. */
97
+ extend(overrides?: CreateFetchDefaults): ReqkitFetch;
98
+ }
99
+
100
+ export function createFetch(defaults?: CreateFetchDefaults): ReqkitFetch;
101
+
102
+ export interface CacheStore {
103
+ get(key: string): any | Promise<any>;
104
+ set(key: string, value: any): void | Promise<void>;
105
+ delete(key: string): any;
106
+ }
107
+
108
+ export interface MemoryCache extends CacheStore {
109
+ clear(): void;
110
+ readonly size: number;
111
+ }
112
+
113
+ export function createMemoryCache(opts?: { maxEntries?: number }): MemoryCache;
114
+
115
+ export interface HedgeConfig {
116
+ /** Milliseconds before a backup is sent, or "auto" (default) to use your own recent latency. */
117
+ delayMs?: number | "auto";
118
+ /** Backups per attempt. Default 1. */
119
+ maxHedges?: number;
120
+ /** Methods that may be hedged. Default: the idempotent ones (GET, HEAD, OPTIONS, PUT, DELETE). */
121
+ methods?: string[];
122
+ /** For "auto": which percentile of recent latency to wait for. Default 0.95. */
123
+ percentile?: number;
124
+ /** For "auto": never hedge sooner than this. Default 20. */
125
+ minDelayMs?: number;
126
+ /** For "auto": the delay used until `minSamples` responses were seen. Default 300. */
127
+ fallbackDelayMs?: number;
128
+ /** For "auto": samples needed before trusting the percentile. Default 10. */
129
+ minSamples?: number;
130
+ }
131
+
132
+ export interface CacheEvent {
133
+ type: "hit" | "stale" | "revalidated" | "miss" | "store" | "stale-if-error";
134
+ url: string;
135
+ }
136
+
137
+ export interface CacheOptions {
138
+ /** How long an entry is served without asking the server, in ms. Default 60000. */
139
+ ttl?: number;
140
+ /** After the TTL, keep serving the stale copy for this long while refreshing in the background. Default 0. */
141
+ staleWhileRevalidate?: number;
142
+ /**
143
+ * How long past the TTL a cached copy may still be served when the origin is failing
144
+ * (network error, timeout, open circuit, or a 5xx). 4xx responses and aborts are never masked. Default 0 (off).
145
+ */
146
+ staleIfError?: number;
147
+ /** For the built-in memory store. Default 100. */
148
+ maxEntries?: number;
149
+ /** Responses larger than this aren't cached. Default 1 MiB. */
150
+ maxBodyBytes?: number;
151
+ /** Bring your own store (Redis, IndexedDB, a Map...). */
152
+ store?: CacheStore;
153
+ onEvent?: (event: CacheEvent) => void;
154
+ }
155
+
156
+ export interface AuthOptions {
157
+ /** The current token (sync or async). Falsy = send no auth header. */
158
+ getToken(): string | null | undefined | Promise<string | null | undefined>;
159
+ /**
160
+ * Called ONCE when requests are rejected (401 by default), no matter how many
161
+ * are waiting. Return the new token, or update your store and return nothing
162
+ * (reqkit then calls getToken() again). Throwing rejects every waiting request.
163
+ */
164
+ refresh(ctx: { token: string; response: Response }): string | void | undefined | Promise<string | void | undefined>;
165
+ /** Default "authorization". */
166
+ header?: string;
167
+ /** Default "Bearer". Use "" to send the bare token (e.g. for an X-API-Key header). */
168
+ scheme?: string;
169
+ /** Default: `res.status === 401`. */
170
+ shouldRefresh?: (response: Response) => boolean;
171
+ }
172
+
173
+ /** Events delivered to `createFetch({ onEvent })`. */
174
+ export type FetchEvent =
175
+ | { type: "request"; url: string; method: string }
176
+ | { type: "attempt"; url: string; method: string; /** 1-based */ attempt: number }
177
+ | {
178
+ type: "retry";
179
+ url: string;
180
+ method: string;
181
+ /** The attempt that failed (1-based). */
182
+ attempt: number;
183
+ delayMs: number;
184
+ reason: "error" | "status";
185
+ status?: number;
186
+ error?: unknown;
187
+ }
188
+ | {
189
+ type: "response";
190
+ url: string;
191
+ method: string;
192
+ status: number;
193
+ ok: boolean;
194
+ durationMs: number;
195
+ /** Network attempts made (0 for a cache hit). */
196
+ attempts: number;
197
+ /** How the cache was involved, if it was. */
198
+ cache?: "hit" | "stale" | "revalidated" | "miss" | "stale-if-error";
199
+ /** Backup requests sent for this call. */
200
+ hedges: number;
201
+ /** True when a backup request produced the response. */
202
+ hedgeWon: boolean;
203
+ }
204
+ | { type: "error"; url?: string; method?: string; error: unknown; durationMs: number; attempts: number; hedges: number }
205
+ | { type: "hedge"; url: string; method: string; /** 1-based backup number */ index: number; /** ms since the call's attempt began */ afterMs: number }
206
+ | { type: "cache"; url: string; method: "GET"; event: CacheEvent["type"] }
207
+ | { type: "ratelimit"; url: string; method: string; waitedMs: number }
208
+ | { type: "circuit"; origin: string; state: CircuitState; previous: CircuitState }
209
+ | { type: "auth"; url: string; method: string; outcome: "refreshed" | "reused" | "failed"; error?: unknown };