@boy-offi9-inc/reqkit 0.3.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 CHANGED
@@ -48,6 +48,30 @@ const pages = await limit.map(urls, (url) =>
48
48
  )
49
49
  );
50
50
  ```
51
+ ---
52
+
53
+ ## Playground
54
+
55
+ Try everything in your browser — retries obeying `Retry-After`, rate limiting, the circuit breaker, caching, SSE streaming and a resumable download that survives a dropped connection. It runs against a fake in-memory server, so there's nothing to install and no network involved.
56
+
57
+ Run it locally: `npm run build && python3 -m http.server -d docs` → open `http://localhost:8000`. (ES modules don't load from `file://`, so it needs a server.) The repo's `pages.yml` workflow publishes the same folder to GitHub Pages — enable it once under Settings → Pages → Source: "GitHub Actions".
58
+
59
+ ## Works with what you already use
60
+
61
+ Recipes for **axios, ky, got, undici/node-fetch**, testing, and LLM streaming live in [RECIPES.md](./RECIPES.md): keep your client, bolt on just the piece you're missing (`Retry-After`-aware retries, a rate limiter, a circuit breaker).
62
+
63
+ ## Runtime support
64
+
65
+ | Runtime | Status |
66
+ |---|---|
67
+ | **Node 18, 20, 22, 24** | Primary target. Full test suite runs in CI on all four. |
68
+ | **Deno, Bun, browsers, Cloudflare Workers** | Use the dependency-free ESM bundle (`@boy-offi9-inc/reqkit/browser`, or `import … from "npm:@boy-offi9-inc/reqkit"` in Deno). Only web-standard APIs are used. CI smoke-tests the bundle on Deno, Bun and headless Chromium — **experimental** until those jobs have a track record; please open an issue if one misbehaves. |
69
+
70
+ ```html
71
+ <script type="module">
72
+ import { createFetch } from "https://cdn.jsdelivr.net/npm/@boy-offi9-inc/reqkit@0.5.0/dist/reqkit.mjs";
73
+ </script>
74
+ ```
51
75
 
52
76
  ---
53
77
 
@@ -63,6 +87,12 @@ const pages = await limit.map(urls, (url) =>
63
87
  | Download progress and size caps for any stream | [`withProgress`](#withprogressresponse-onprogress-opts) |
64
88
  | Merge identical concurrent calls into one | [`withDedupe`](#withdedupefn-opts) |
65
89
  | Run N things at a time | [`createLimiter`](#createlimiterconcurrency) |
90
+ | Slow down *before* the server says 429 (obey `X-RateLimit-*`) | [`createRateLimiter`](#createratelimiteropts) |
91
+ | Stop hammering a service that keeps failing | [`createCircuitBreaker`](#createcircuitbreakeropts) |
92
+ | Cache GETs (TTL, stale-while-revalidate, ETag/304) | [`createFetch` → `cache`](#caching) |
93
+ | Resume a download after the connection drops | [`downloadResumable`](#downloadresumableurl-opts) |
94
+ | Read SSE / NDJSON / lines from a streamed body (LLM tokens!) | [`readSSE` / `readNDJSON` / `readLines`](#streaming-readsse-readndjson-readlines) |
95
+ | Test retry/timeout code without a network | [`createMockFetch`](#createmockfetch) |
66
96
  | All of the above, wired up | [`createFetch`](#createfetchdefaults) |
67
97
 
68
98
  ---
@@ -280,6 +310,93 @@ limit.pendingCount; // waiting
280
310
 
281
311
  A task that throws or rejects only fails its own promise — the queue keeps going. `concurrency` must be an integer ≥ 1 (or `Infinity`).
282
312
 
313
+ ### `createRateLimiter(opts?)`
314
+
315
+ A *proactive* limiter: it slows you down before the server has to say 429. It's fed by your own budget (`requests` per `perMs`, sliding window) **and** by what the server tells you — `X-RateLimit-Remaining` / `-Reset`, `RateLimit-*`, and `Retry-After` on 429/503. Once the server says `remaining: 0`, everything pauses until the reset. Callers are served FIFO.
316
+
317
+ ```js
318
+ const rl = createRateLimiter({ requests: 10, perMs: 1000 });
319
+
320
+ const res = await rl.run(() => fetch(url)); // acquire → call → learn from the response headers
321
+ // or by hand:
322
+ await rl.acquire({ signal }); // rejects with the signal's reason if aborted while waiting
323
+ rl.update(response);
324
+ rl.blockedForMs; // time left on a server-imposed pause
325
+ ```
326
+
327
+ Reset headers are understood in both flavors: delta seconds (`30`) and absolute epoch (seconds or ms). In `createFetch`, waiting for the budget never counts against the per-attempt timeout.
328
+
329
+ ### `createCircuitBreaker(opts?)`
330
+
331
+ After `failureThreshold` consecutive failures the circuit **opens** and calls fail instantly with `CircuitOpenError` for `resetTimeoutMs` — so a dying service isn't hammered and your callers don't sit waiting on it. Then it goes **half-open**, lets a probe through, and closes again on success.
332
+
333
+ ```js
334
+ const breaker = createCircuitBreaker({ failureThreshold: 5, resetTimeoutMs: 30_000 });
335
+
336
+ try {
337
+ const res = await breaker.run(() => fetch(url));
338
+ } catch (err) {
339
+ if (err instanceof CircuitOpenError) console.log(`try again in ${err.retryAfterMs}ms`);
340
+ }
341
+ breaker.state; // "closed" | "open" | "half-open"
342
+ ```
343
+
344
+ By default a thrown error (except `AbortError`) or a result with `status >= 500` counts as a failure; override with `isFailure(valueOrError, isError)`. `CircuitOpenError` has `code: "CIRCUIT_OPEN"` and is never classified as a network error, so retries stop the moment the circuit opens.
345
+
346
+ ### Streaming: `readSSE`, `readNDJSON`, `readLines`
347
+
348
+ Async iterators over a streamed body — a `Response`, a web `ReadableStream`, or any async iterable (Node streams). Early `break` and `signal` abort both cancel the stream so the connection is released.
349
+
350
+ ```js
351
+ // LLM-style token stream (Server-Sent Events)
352
+ for await (const { event, data, id } of readSSE(response, { signal })) {
353
+ if (data === "[DONE]") break;
354
+ process.stdout.write(JSON.parse(data).token);
355
+ }
356
+
357
+ for await (const row of readNDJSON(response)) console.log(row); // newline-delimited JSON
358
+ for await (const line of readLines(response)) console.log(line);
359
+ ```
360
+
361
+ `readSSE` follows the spec: multi-line `data:` is joined with `\n`, comments are ignored, `event` defaults to `"message"`, `id` carries over, `retry` is parsed, and an event cut off by the end of the stream is dropped. `readNDJSON` skips blank lines and names the bad line on a parse error (or pass `skipInvalid: true`). Line splitting handles `\n`, `\r\n`, `\r`, a BOM, and multi-byte characters split across chunks.
362
+
363
+ ### `downloadResumable(url, opts?)`
364
+
365
+ Downloads into memory and, if the connection drops mid-body, **resumes with an HTTP `Range` request** instead of starting over. It sends `If-Range` with the first response's strong ETag (or `Last-Modified`), so a file that changed on the server is restarted rather than stitched together wrongly; if the server ignores `Range`, it restarts from zero.
366
+
367
+ ```js
368
+ const bytes = await downloadResumable(url, {
369
+ onProgress: ({ loaded, total, percent }) => console.log(percent),
370
+ maxBytes: 200 * 1024 * 1024, // BodyTooLargeError past this
371
+ retries: 5, // consecutive attempts *without progress*
372
+ signal,
373
+ onRetry: (err, attempt, delayMs, { resumeFrom }) => console.log(`resuming from byte ${resumeFrom}`),
374
+ });
375
+ ```
376
+
377
+ It honors `Retry-After` on 429/503, never retries a 4xx (other than 408/425/429) or a size-limit error, and asks for `Accept-Encoding: identity` so byte offsets line up. Pass `fetch: myCreateFetchInstance` to route it through your own client.
378
+
379
+ ### `createMockFetch()`
380
+
381
+ A scriptable, fetch-compatible test double — for testing code with retries, timeouts, and rate limits without a network.
382
+
383
+ ```js
384
+ const mock = createMockFetch();
385
+
386
+ mock.on("GET", "/users/1")
387
+ .replyOnce(503, "busy", { headers: { "retry-after": "0" } }) // consumed in order…
388
+ .reply(200, { id: 1 }); // …then this, forever
389
+ mock.post(/orders/).failOnce(mock.networkError("ECONNRESET")); // reject like a real network failure
390
+ mock.get("/slow").reply(200, "late", { delayMs: 500 }); // respects AbortSignal → test timeouts
391
+
392
+ const api = createFetch({ fetch: mock.fetch, baseUrl: "https://api.test" });
393
+ await api.json("/users/1"); // { id: 1 } — after one retry
394
+ mock.calls; // [{ url, method, headers, body, json, signal }, ...]
395
+ mock.reset();
396
+ ```
397
+
398
+ String matchers match the full URL, `pathname + search`, or the bare `pathname`; RegExp and `(url) => boolean` work too, and `"*"` matches any method. `replyWith(call => …)` returns a `Response` or `{ status, body, headers }`. Unmatched requests reject with a clear error (and are still recorded in `mock.calls`).
399
+
283
400
  ### `createFetch(defaults)`
284
401
 
285
402
  Opt-in assembly of the pieces above into a fetch-like function. It's still plain `fetch` underneath: you get a standard `Response`, and you can pass your own `fetch`.
@@ -316,12 +433,40 @@ const admin = api.extend({ headers: { "x-role": "admin" } }); // merges over the
316
433
  | `timeout` | `30000` | per attempt, in ms; `false` disables |
317
434
  | `signal` *(per request)* | — | external abort; surfaces as a plain `AbortError`, never retried |
318
435
  | `retry` | see below | `false` disables; otherwise `{ retries, statusCodes, methods, baseDelayMs, maxDelayMs, factor, jitter, maxRetryTime, shouldRetry, calculateDelay, onRetry }` |
436
+ | `rateLimit` | — | `true` just learns from `X-RateLimit-*` / `Retry-After`; `{ requests, perMs }` also enforces a budget; or pass a shared [`createRateLimiter`](#createratelimiteropts) |
437
+ | `circuitBreaker` | — | `true` / options → one breaker **per origin**; or pass a shared [`createCircuitBreaker`](#createcircuitbreakeropts), or `(url) => breaker`. An open circuit stops retries immediately. |
438
+ | `cache` | — | in-memory GET cache — see [Caching](#caching). `true` = 60s TTL. Per request: `cache: false` bypasses, `"reload"` refreshes |
319
439
  | `dedupe` | `false` | merge identical concurrent `GET`/`HEAD` calls; every caller gets its own readable clone. Requests with their own `signal` are never merged. |
320
440
  | `throwHttpErrors` | `true` | throw `HTTPError` for non-2xx (`err.status`, `err.response`, `err.url`, `err.method`) |
321
441
  | `fetch` | `globalThis.fetch` | swap in undici, node-fetch, a test double... |
322
442
  | `onRequest(ctx)` | — | once per call; may mutate `ctx.url` / `ctx.init` (e.g. add a header) |
323
443
  | `onResponse(ctx)` | — | once per call, with the final response, before `HTTPError` is thrown |
324
444
 
445
+ #### Caching
446
+
447
+ ```js
448
+ const api = createFetch({
449
+ baseUrl: "https://api.example.com",
450
+ cache: { ttl: 30_000, staleWhileRevalidate: 5 * 60_000 },
451
+ });
452
+
453
+ await api.json("/config"); // network
454
+ await api.json("/config"); // fresh → served from memory, no request
455
+ // after 30s: stale → returned instantly, refreshed in the background
456
+ // after 5m30s: revalidated with If-None-Match / If-Modified-Since — a 304 reuses the cached body
457
+ ```
458
+
459
+ | `cache` option | Default | Description |
460
+ |---|---|---|
461
+ | `ttl` | `60000` | serve without asking the server for this long |
462
+ | `staleWhileRevalidate` | `0` | after the TTL, keep serving the stale copy this long while refreshing in the background |
463
+ | `maxEntries` | `100` | size of the built-in LRU store |
464
+ | `maxBodyBytes` | `1 MiB` | bigger responses aren't cached |
465
+ | `store` | built-in LRU | bring your own (`get`/`set`/`delete`, sync or async) — Redis, IndexedDB, a `Map`… |
466
+ | `onEvent` | — | `({ type: "hit" \| "stale" \| "revalidated" \| "miss" \| "store", url })` for logging/metrics |
467
+
468
+ Only `GET` `200` responses are cached; `Cache-Control: no-store` and `Vary: *` are respected. **Every request header is part of the cache key**, so a response fetched with one user's `Authorization` can never be served to another. Each hit is a fresh `Response`, so bodies can be read as many times as you like. `extend()` children share the parent's cache, limiter and breakers.
469
+
325
470
  **Retry defaults:** 2 retries on network errors, timeouts, and status `408, 425, 429, 500, 502, 503, 504`, honoring `Retry-After`. Only **idempotent methods** (`GET, HEAD, OPTIONS, PUT, DELETE`) are retried — `POST`/`PATCH` aren't, so a timeout can't double-charge anyone. Opt a request in by sending an `Idempotency-Key` header, or by listing the method in `retry.methods`. Stream bodies are never retried (they can't be replayed).
326
471
 
327
472
  ---
@@ -337,4 +482,4 @@ const admin = api.extend({ headers: { "x-role": "admin" } }); // merges over the
337
482
 
338
483
  ## Prior art
339
484
 
340
- reqkit's building blocks are small, from-scratch implementations of ideas that proved themselves in popular libraries — go look at them, they're great: [`p-retry`](https://github.com/sindresorhus/p-retry) (`StopRetry`, time budgets), [`ky`](https://github.com/sindresorhus/ky) and [`ofetch`](https://github.com/unjs/ofetch) (`HTTPError`, `json`/`query`/`baseUrl`, `extend`, retry statuses), [`got`](https://github.com/sindresorhus/got) and [`axios-retry`](https://github.com/softonic/axios-retry) (retry only idempotent methods, `calculateDelay`), [`p-limit`](https://github.com/sindresorhus/p-limit) (concurrency limiter), and the AWS Architecture Blog's "full jitter" backoff.
485
+ reqkit's building blocks are small, from-scratch implementations of ideas that proved themselves in popular libraries — go look at them, they're great: [`p-retry`](https://github.com/sindresorhus/p-retry) (`StopRetry`, time budgets), [`ky`](https://github.com/sindresorhus/ky) and [`ofetch`](https://github.com/unjs/ofetch) (`HTTPError`, `json`/`query`/`baseUrl`, `extend`, retry statuses), [`got`](https://github.com/sindresorhus/got) and [`axios-retry`](https://github.com/softonic/axios-retry) (retry only idempotent methods, `calculateDelay`), [`p-limit`](https://github.com/sindresorhus/p-limit) (concurrency limiter), [`opossum`](https://github.com/nodeshift/opossum) (circuit breaker), [`bottleneck`](https://github.com/SGrondin/bottleneck) / [`p-throttle`](https://github.com/sindresorhus/p-throttle) (rate limiting), [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) (SSE), [`nock`](https://github.com/nock/nock) / undici's `MockAgent` (mocking), the HTTP caching RFCs (`stale-while-revalidate`, conditional requests), and the AWS Architecture Blog's "full jitter" backoff.
package/RECIPES.md ADDED
@@ -0,0 +1,243 @@
1
+ # Recipes
2
+
3
+ reqkit is a set of small functions, so it slots *around* the HTTP client you already use. Each recipe below keeps your client and adds just the piece you're missing.
4
+
5
+ > The snippets use only reqkit's documented API plus each library's documented hooks/interceptors. The reqkit side is covered by its test suite; the third-party libraries (axios, ky, got) aren't installed in reqkit's CI, so if a snippet drifts with a new major version of one of them, please open an issue.
6
+
7
+ - [axios](#axios) · [ky](#ky) · [got](#got) · [undici / node-fetch / any fetch](#undici--node-fetch--any-fetch)
8
+ - [Deno, Bun, Cloudflare Workers, browsers](#deno-bun-cloudflare-workers-browsers)
9
+ - [Testing your own code](#testing-your-own-code)
10
+ - [LLM streaming (SSE)](#llm-streaming-sse)
11
+
12
+ A tiny helper used in the axios and got recipes — reqkit reads headers through `.get(name)`, and those libraries hand you plain objects:
13
+
14
+ ```js
15
+ const asHeaders = (h) => ({
16
+ get: (name) => (typeof h?.get === "function" ? h.get(name) : h?.[name.toLowerCase()]) ?? null,
17
+ });
18
+ ```
19
+
20
+ ---
21
+
22
+ ## axios
23
+
24
+ axios throws on 4xx/5xx, so retry decisions go in `shouldRetry`. `normalizeError` already understands axios errors (`status`, `isNetworkError`, `isTimeout`, cancellation).
25
+
26
+ **Retries that honor `Retry-After`:**
27
+
28
+ ```js
29
+ import axios from "axios";
30
+ import { withRetry, normalizeError, getWaitMsFromHeaders } from "@boy-offi9-inc/reqkit";
31
+
32
+ const RETRY_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
33
+
34
+ export function getWithRetry(url, config) {
35
+ return withRetry(() => axios.get(url, config), {
36
+ retries: 3,
37
+ shouldRetry: (err) => {
38
+ const n = normalizeError(err);
39
+ return !n.isAborted && (n.isNetworkError || n.isTimeout || RETRY_STATUS.has(n.status));
40
+ },
41
+ // use the server's Retry-After when it sent one, otherwise reqkit's backoff
42
+ calculateDelay: ({ error, delayMs }) => {
43
+ const wait = getWaitMsFromHeaders(asHeaders(error?.response?.headers));
44
+ return wait === null ? delayMs : Math.min(wait, 60_000);
45
+ },
46
+ });
47
+ }
48
+ ```
49
+
50
+ Only retry idempotent requests (GET/PUT/DELETE) this way — a retried POST can run twice.
51
+
52
+ **Proactive rate limiting** (slow down *before* the 429) with interceptors:
53
+
54
+ ```js
55
+ import { createRateLimiter } from "@boy-offi9-inc/reqkit";
56
+
57
+ const rl = createRateLimiter({ requests: 10, perMs: 1000 });
58
+ const api = axios.create({ baseURL: "https://api.example.com" });
59
+
60
+ api.interceptors.request.use(async (config) => {
61
+ await rl.acquire({ signal: config.signal });
62
+ return config;
63
+ });
64
+ api.interceptors.response.use(
65
+ (res) => {
66
+ rl.update({ status: res.status, headers: asHeaders(res.headers) }); // learns X-RateLimit-* / Retry-After
67
+ return res;
68
+ },
69
+ (err) => {
70
+ if (err.response) rl.update({ status: err.response.status, headers: asHeaders(err.response.headers) });
71
+ throw err;
72
+ }
73
+ );
74
+ ```
75
+
76
+ **Circuit breaker:**
77
+
78
+ ```js
79
+ import { createCircuitBreaker, CircuitOpenError } from "@boy-offi9-inc/reqkit";
80
+
81
+ const breaker = createCircuitBreaker({
82
+ failureThreshold: 5,
83
+ resetTimeoutMs: 30_000,
84
+ // count network errors and 5xx — not 404s or user cancellations
85
+ isFailure: (err, isError) => isError && !axios.isCancel(err) && (!err.response || err.response.status >= 500),
86
+ });
87
+
88
+ try {
89
+ const { data } = await breaker.run(() => api.get("/users"));
90
+ } catch (err) {
91
+ if (err instanceof CircuitOpenError) showOfflineBanner(err.retryAfterMs);
92
+ }
93
+ ```
94
+
95
+ ---
96
+
97
+ ## ky
98
+
99
+ ky already retries, so the best additions are the pieces it doesn't have: a **rate limiter** and a **circuit breaker**. ky's hooks make the limiter a few lines:
100
+
101
+ ```js
102
+ import ky from "ky";
103
+ import { createRateLimiter, createCircuitBreaker, readSSE } from "@boy-offi9-inc/reqkit";
104
+
105
+ const rl = createRateLimiter({ requests: 5, perMs: 1000 });
106
+
107
+ const api = ky.create({
108
+ prefixUrl: "https://api.example.com",
109
+ hooks: {
110
+ beforeRequest: [async (request) => { await rl.acquire({ signal: request.signal }); }],
111
+ afterResponse: [(request, options, response) => { rl.update(response); }], // ky gives you a real Response
112
+ },
113
+ });
114
+
115
+ const breaker = createCircuitBreaker({
116
+ // ky throws HTTPError for non-2xx with `err.response`
117
+ isFailure: (err, isError) => isError && err.name !== "AbortError" && (!err.response || err.response.status >= 500),
118
+ });
119
+
120
+ const users = await breaker.run(() => api.get("users").json());
121
+ ```
122
+
123
+ Prefer reqkit's retries over ky's? Set `retry: 0` on the ky instance and wrap the call in `withRetry` (with `shouldRetry` using `normalizeError`, as in the axios recipe).
124
+
125
+ ---
126
+
127
+ ## got
128
+
129
+ ```js
130
+ import got from "got";
131
+ import { createRateLimiter } from "@boy-offi9-inc/reqkit";
132
+
133
+ const rl = createRateLimiter({ requests: 10, perMs: 1000 });
134
+
135
+ const api = got.extend({
136
+ prefixUrl: "https://api.example.com",
137
+ hooks: {
138
+ beforeRequest: [async () => { await rl.acquire(); }],
139
+ afterResponse: [
140
+ (response) => {
141
+ rl.update({ status: response.statusCode, headers: asHeaders(response.headers) });
142
+ return response;
143
+ },
144
+ ],
145
+ },
146
+ });
147
+ ```
148
+
149
+ got has its own retry logic (it only retries idempotent methods by default, and honors `Retry-After`), so there's usually no need to add reqkit's.
150
+
151
+ ---
152
+
153
+ ## undici / node-fetch / any fetch
154
+
155
+ `createFetch` takes any fetch-compatible function:
156
+
157
+ ```js
158
+ import { fetch as undiciFetch, Agent } from "undici";
159
+ import { createFetch } from "@boy-offi9-inc/reqkit";
160
+
161
+ const dispatcher = new Agent({ connections: 20 });
162
+ const api = createFetch({
163
+ baseUrl: "https://api.example.com",
164
+ fetch: (url, init) => undiciFetch(url, { ...init, dispatcher }),
165
+ retry: { retries: 3 },
166
+ cache: { ttl: 30_000 },
167
+ });
168
+ ```
169
+
170
+ The standalone helpers (`withRetry`, `withTimeout`, `createLimiter`, `createRateLimiter`, `downloadResumable`, …) don't care which client sits underneath.
171
+
172
+ ---
173
+
174
+ ## Deno, Bun, Cloudflare Workers, browsers
175
+
176
+ reqkit only uses web-standard APIs, and the package ships a dependency-free ESM bundle at `@boy-offi9-inc/reqkit/browser`.
177
+
178
+ ```js
179
+ // Deno
180
+ import { createFetch } from "npm:@boy-offi9-inc/reqkit";
181
+
182
+ // Bun / Node / Workers (via wrangler's bundler)
183
+ import { createFetch } from "@boy-offi9-inc/reqkit";
184
+
185
+ // Browser, no build step — pin the version
186
+ import { createFetch } from "https://cdn.jsdelivr.net/npm/@boy-offi9-inc/reqkit@0.5.0/dist/reqkit.mjs";
187
+ ```
188
+
189
+ Notes: in Workers the in-memory cache and rate limiter live **per isolate**, so treat them as best-effort or pass a `store` backed by KV / Durable Objects. In browsers, `cache` is separate from the HTTP cache the browser already keeps. Node 18+ is the primary, fully tested target; CI also smoke-tests the bundle on Deno, Bun and headless Chromium (those jobs are new — if one is red, open an issue).
190
+
191
+ ---
192
+
193
+ ## Testing your own code
194
+
195
+ Take `fetch` as a parameter (or pass it to `createFetch`) and hand in `createMockFetch` during tests:
196
+
197
+ ```js
198
+ import { createMockFetch, createFetch } from "@boy-offi9-inc/reqkit";
199
+
200
+ test("recovers from a flaky upstream", async () => {
201
+ const mock = createMockFetch();
202
+ mock.get("/profile")
203
+ .failOnce(mock.networkError()) // connection refused
204
+ .replyOnce(503, "busy", { headers: { "retry-after": "0" } }) // then overloaded
205
+ .reply(200, { name: "Ada" }); // then fine
206
+
207
+ const api = createFetch({ fetch: mock.fetch, baseUrl: "https://api.test", retry: { baseDelayMs: 1 } });
208
+
209
+ expect(await api.json("/profile")).toEqual({ name: "Ada" });
210
+ expect(mock.calls).toHaveLength(3);
211
+ });
212
+
213
+ test("gives up politely on a hanging server", async () => {
214
+ const mock = createMockFetch();
215
+ mock.get("/slow").reply(200, "late", { delayMs: 5_000 }); // delayMs respects AbortSignal
216
+ const api = createFetch({ fetch: mock.fetch, baseUrl: "https://api.test", timeout: 50, retry: false });
217
+ await expect(api("/slow")).rejects.toMatchObject({ name: "TimeoutError" });
218
+ });
219
+ ```
220
+
221
+ ---
222
+
223
+ ## LLM streaming (SSE)
224
+
225
+ ```js
226
+ import { readSSE } from "@boy-offi9-inc/reqkit";
227
+
228
+ const res = await fetch("https://api.example.com/v1/chat", {
229
+ method: "POST",
230
+ headers: { "content-type": "application/json", accept: "text/event-stream" },
231
+ body: JSON.stringify({ prompt: "Hello", stream: true }),
232
+ signal, // abort → the stream is cancelled and the connection released
233
+ });
234
+
235
+ let text = "";
236
+ for await (const { data } of readSSE(res, { signal })) {
237
+ if (data === "[DONE]") break;
238
+ text += JSON.parse(data).token;
239
+ render(text);
240
+ }
241
+ ```
242
+
243
+ Want the request itself to be retried, timed out and rate limited? Make it through `createFetch` instead — it returns the same `Response`, so `readSSE(res)` works unchanged.