@marianmeres/http-utils 2.7.1 → 2.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.
package/API.md CHANGED
@@ -24,23 +24,24 @@ Creates an HTTP API client with convenient defaults and error handling.
24
24
 
25
25
  ```ts
26
26
  function createHttpApi(
27
- base?: string | null,
28
- defaults?: Partial<FetchParams> | (() => Promise<Partial<FetchParams>>),
29
- factoryErrorMessageExtractor?: ErrorMessageExtractor | null
30
- ): HttpApi
27
+ base?: string | null,
28
+ defaults?: Partial<FetchParams> | (() => Promise<Partial<FetchParams>>),
29
+ factoryErrorMessageExtractor?: ErrorMessageExtractor | null,
30
+ ): HttpApi;
31
31
  ```
32
32
 
33
33
  ### Parameters
34
34
 
35
- | Parameter | Type | Description |
36
- |-----------|------|-------------|
37
- | `base` | `string \| null` | Optional base URL to prepend to all requests. |
38
- | `defaults` | `object \| function` | Optional default parameters or async function returning defaults. |
35
+ | Parameter | Type | Description |
36
+ | ------------------------------ | ------------------------------- | ------------------------------------------------------------------ |
37
+ | `base` | `string \| null` | Optional base URL to prepend to all requests. |
38
+ | `defaults` | `object \| function` | Optional default parameters or async function returning defaults. |
39
39
  | `factoryErrorMessageExtractor` | `ErrorMessageExtractor \| null` | Optional function to extract error messages from failed responses. |
40
40
 
41
41
  ### Returns
42
42
 
43
- An `HttpApi` instance with methods: `get`, `post`, `put`, `patch`, `del`, `url`, and `base` property.
43
+ An `HttpApi` instance with methods: `get`, `post`, `put`, `patch`, `del`, `url`, and
44
+ `base` property.
44
45
 
45
46
  ### Example
46
47
 
@@ -52,18 +53,18 @@ const api = createHttpApi("https://api.example.com");
52
53
 
53
54
  // With default headers
54
55
  const api = createHttpApi("https://api.example.com", {
55
- headers: { "Authorization": "Bearer token" }
56
+ headers: { "Authorization": "Bearer token" },
56
57
  });
57
58
 
58
59
  // With dynamic defaults (e.g., for token refresh)
59
60
  const api = createHttpApi("https://api.example.com", async () => {
60
- const token = await getToken();
61
- return { headers: { "Authorization": `Bearer ${token}` } };
61
+ const token = await getToken();
62
+ return { headers: { "Authorization": `Bearer ${token}` } };
62
63
  });
63
64
 
64
65
  // With custom error extractor
65
66
  const api = createHttpApi("https://api.example.com", null, (body) => {
66
- return body?.error?.message || "Unknown error";
67
+ return body?.error?.message || "Unknown error";
67
68
  });
68
69
  ```
69
70
 
@@ -75,7 +76,7 @@ Global default error message extractor. Applied to all requests unless overridde
75
76
 
76
77
  ```ts
77
78
  createHttpApi.defaultErrorMessageExtractor = (body, response) => {
78
- return body?.error?.message || response.statusText;
79
+ return body?.error?.message || response.statusText;
79
80
  };
80
81
  ```
81
82
 
@@ -85,19 +86,22 @@ Priority order: per-request > per-instance > global > built-in fallback.
85
86
 
86
87
  ## opts
87
88
 
88
- Marks an options object for the options-based API. Without this wrapper, arguments are treated as legacy positional parameters.
89
+ Marks an options object for the options-based API. Without this wrapper, arguments are
90
+ treated as legacy positional parameters.
89
91
 
90
92
  ```ts
91
- function opts<T extends GetOptions | DataOptions>(options: T): T
93
+ function opts<T extends GetOptions | DataOptions>(options: T): T;
92
94
  ```
93
95
 
94
96
  ### Why `opts()`?
95
97
 
96
98
  The library supports two API styles:
99
+
97
100
  - **Legacy API**: Positional parameters (backward compatible)
98
101
  - **Options API**: Single options object with named properties
99
102
 
100
- The `opts()` wrapper explicitly indicates which style you're using, preventing ambiguity when your request data might look like an options object.
103
+ The `opts()` wrapper explicitly indicates which style you're using, preventing ambiguity
104
+ when your request data might look like an options object.
101
105
 
102
106
  ### Example
103
107
 
@@ -116,10 +120,13 @@ await api.post("/users", opts({ data: { name: "John" } }));
116
120
 
117
121
  // GET with options
118
122
  const respHeaders = {};
119
- await api.get("/users", opts({
120
- params: { headers: { "X-Custom": "value" } },
121
- respHeaders
122
- }));
123
+ await api.get(
124
+ "/users",
125
+ opts({
126
+ params: { headers: { "X-Custom": "value" } },
127
+ respHeaders,
128
+ }),
129
+ );
123
130
  ```
124
131
 
125
132
  ---
@@ -135,11 +142,13 @@ HTTP API client class. Usually created via `createHttpApi()`.
135
142
  Performs a GET request.
136
143
 
137
144
  **Options API (with `opts()` wrapper):**
145
+
138
146
  ```ts
139
147
  async get<T = unknown>(path: string, options?: GetOptions): Promise<T>
140
148
  ```
141
149
 
142
150
  **Legacy API (default behavior):**
151
+
143
152
  ```ts
144
153
  async get<T = unknown>(
145
154
  path: string,
@@ -150,13 +159,20 @@ async get<T = unknown>(
150
159
  ```
151
160
 
152
161
  **Example:**
162
+
153
163
  ```ts
154
164
  // Options API with type parameter (requires opts() wrapper)
155
- interface User { id: number; name: string; }
156
- const user = await api.get<User>("/users/1", opts({
157
- params: { headers: { "X-Custom": "value" } },
158
- respHeaders: {}
159
- }));
165
+ interface User {
166
+ id: number;
167
+ name: string;
168
+ }
169
+ const user = await api.get<User>(
170
+ "/users/1",
171
+ opts({
172
+ params: { headers: { "X-Custom": "value" } },
173
+ respHeaders: {},
174
+ }),
175
+ );
160
176
 
161
177
  // Without type parameter (returns unknown)
162
178
  const data = await api.get("/users");
@@ -170,11 +186,13 @@ const data = await api.get("/users", { headers: { "X-Custom": "value" } });
170
186
  Performs a POST request.
171
187
 
172
188
  **Options API (with `opts()` wrapper):**
189
+
173
190
  ```ts
174
191
  async post<T = unknown>(path: string, options?: DataOptions): Promise<T>
175
192
  ```
176
193
 
177
194
  **Legacy API (default behavior):**
195
+
178
196
  ```ts
179
197
  async post<T = unknown>(
180
198
  path: string,
@@ -186,13 +204,20 @@ async post<T = unknown>(
186
204
  ```
187
205
 
188
206
  **Example:**
207
+
189
208
  ```ts
190
209
  // Options API with type parameter (requires opts() wrapper)
191
- interface User { id: number; name: string; }
192
- const user = await api.post<User>("/users", opts({
193
- data: { name: "John" },
194
- params: { headers: { "X-Custom": "value" } }
195
- }));
210
+ interface User {
211
+ id: number;
212
+ name: string;
213
+ }
214
+ const user = await api.post<User>(
215
+ "/users",
216
+ opts({
217
+ data: { name: "John" },
218
+ params: { headers: { "X-Custom": "value" } },
219
+ }),
220
+ );
196
221
 
197
222
  // Legacy API (no opts() needed)
198
223
  const result = await api.post("/users", { name: "John" });
@@ -212,21 +237,23 @@ Performs a DELETE request. Same signature as `post<T>()`.
212
237
 
213
238
  #### `url(path)`
214
239
 
215
- Builds the full URL from a path. Base trailing slashes and missing path leading slashes are normalized so there is exactly one `/` between base and path.
240
+ Builds the full URL from a path. Base trailing slashes and missing path leading slashes
241
+ are normalized so there is exactly one `/` between base and path.
216
242
 
217
243
  ```ts
218
244
  url(path: string): string
219
245
  ```
220
246
 
221
247
  **Example:**
248
+
222
249
  ```ts
223
250
  const api = createHttpApi("https://api.example.com");
224
- api.url("/users"); // "https://api.example.com/users"
225
- api.url("users"); // "https://api.example.com/users" (leading slash added)
226
- api.url("https://other.com/path"); // "https://other.com/path" (absolute URLs returned as-is)
251
+ api.url("/users"); // "https://api.example.com/users"
252
+ api.url("users"); // "https://api.example.com/users" (leading slash added)
253
+ api.url("https://other.com/path"); // "https://other.com/path" (absolute URLs returned as-is)
227
254
 
228
255
  const api2 = createHttpApi("https://api.example.com/v1/");
229
- api2.url("/users"); // "https://api.example.com/v1/users" (no double slash)
256
+ api2.url("/users"); // "https://api.example.com/v1/users" (no double slash)
230
257
  ```
231
258
 
232
259
  #### `onRequest(interceptor)`
@@ -254,19 +281,21 @@ set base(v: string | null | undefined)
254
281
 
255
282
  The `data` parameter is serialized based on its runtime type:
256
283
 
257
- | Runtime type | Behavior | Content-Type |
258
- |---|---|---|
259
- | `null` / `undefined` | No body sent | — |
260
- | `string` | Sent as-is | Caller's (fetch may default to `text/plain;charset=UTF-8`) |
261
- | `number` / `boolean` | `JSON.stringify`'d (e.g. `0` → `"0"`) | `application/json` if not set |
262
- | Plain object / array | `JSON.stringify`'d | `application/json` if not set |
263
- | `FormData` | Passed to fetch unchanged | `multipart/form-data; boundary=…` (fetch auto-sets) |
264
- | `URLSearchParams` | Passed to fetch unchanged | `application/x-www-form-urlencoded;charset=UTF-8` (fetch auto-sets) |
265
- | `Blob` | Passed to fetch unchanged | From the Blob's `.type` |
266
- | `ArrayBuffer` / typed arrays | Passed to fetch unchanged | Caller's (none by default) |
267
- | `ReadableStream` | Passed to fetch unchanged | Caller's |
284
+ | Runtime type | Behavior | Content-Type |
285
+ | ---------------------------- | ------------------------------------- | ------------------------------------------------------------------- |
286
+ | `null` / `undefined` | No body sent | — |
287
+ | `string` | Sent as-is | Caller's (fetch may default to `text/plain;charset=UTF-8`) |
288
+ | `number` / `boolean` | `JSON.stringify`'d (e.g. `0` → `"0"`) | `application/json` if not set |
289
+ | Plain object / array | `JSON.stringify`'d | `application/json` if not set |
290
+ | `FormData` | Passed to fetch unchanged | `multipart/form-data; boundary=…` (fetch auto-sets) |
291
+ | `URLSearchParams` | Passed to fetch unchanged | `application/x-www-form-urlencoded;charset=UTF-8` (fetch auto-sets) |
292
+ | `Blob` | Passed to fetch unchanged | From the Blob's `.type` |
293
+ | `ArrayBuffer` / typed arrays | Passed to fetch unchanged | Caller's (none by default) |
294
+ | `ReadableStream` | Passed to fetch unchanged | Caller's |
268
295
 
269
- If you explicitly set `Content-Type` in `headers`, it is always respected — even for object data. Objects are still JSON-stringified; set your own body (e.g. via a string) if you need a non-JSON serialization.
296
+ If you explicitly set `Content-Type` in `headers`, it is always respected — even for
297
+ object data. Objects are still JSON-stringified; set your own body (e.g. via a string) if
298
+ you need a non-JSON serialization.
270
299
 
271
300
  ```ts
272
301
  // Plain object → JSON
@@ -274,7 +303,7 @@ await api.post("/users", { name: "John" });
274
303
 
275
304
  // Respect user Content-Type
276
305
  await api.post("/graph", { query: "{ me { id } }" }, {
277
- headers: { "content-type": "application/graphql+json" },
306
+ headers: { "content-type": "application/graphql+json" },
278
307
  });
279
308
 
280
309
  // FormData (file upload)
@@ -287,7 +316,7 @@ await api.post("/login", new URLSearchParams({ user: "a", pass: "b" }));
287
316
 
288
317
  // Raw string body
289
318
  await api.post("/logs", "raw log line", {
290
- headers: { "content-type": "text/plain" },
319
+ headers: { "content-type": "text/plain" },
291
320
  });
292
321
  ```
293
322
 
@@ -299,13 +328,13 @@ Use `params.query` to append query-string parameters to the URL.
299
328
 
300
329
  ```ts
301
330
  await api.get("/search", {
302
- query: {
303
- q: "hello world",
304
- page: 1,
305
- active: true,
306
- tag: ["a", "b"], // → ?tag=a&tag=b
307
- ignored: null, // null and undefined are skipped
308
- },
331
+ query: {
332
+ q: "hello world",
333
+ page: 1,
334
+ active: true,
335
+ tag: ["a", "b"], // → ?tag=a&tag=b
336
+ ignored: null, // null and undefined are skipped
337
+ },
309
338
  });
310
339
  // GET /search?q=hello+world&page=1&active=true&tag=a&tag=b
311
340
  ```
@@ -316,7 +345,8 @@ If the path already contains `?`, query params are appended with `&`.
316
345
 
317
346
  ## Timeouts and Cancellation
318
347
 
319
- `params.timeout` (in milliseconds) aborts the request via `AbortSignal.timeout`. It composes with a user-provided `signal` — whichever fires first aborts the request.
348
+ `params.timeout` (in milliseconds) aborts the request via `AbortSignal.timeout`. It
349
+ composes with a user-provided `signal` — whichever fires first aborts the request.
320
350
 
321
351
  ```ts
322
352
  // Simple timeout
@@ -325,13 +355,14 @@ await api.get("/slow", { timeout: 5000 });
325
355
  // Timeout + cancellable signal
326
356
  const ctrl = new AbortController();
327
357
  await api.get("/slow", {
328
- timeout: 10_000,
329
- signal: ctrl.signal,
358
+ timeout: 10_000,
359
+ signal: ctrl.signal,
330
360
  });
331
361
  // ctrl.abort() or timeout — whichever first — cancels the request.
332
362
  ```
333
363
 
334
- Uses `AbortSignal.any` when available (Node 20+, modern Deno). Falls back to manual composition otherwise.
364
+ Uses `AbortSignal.any` when available (Node 20+, modern Deno). Falls back to manual
365
+ composition otherwise.
335
366
 
336
367
  ---
337
368
 
@@ -341,28 +372,31 @@ Register per-instance hooks that run around each request.
341
372
 
342
373
  ### `onRequest(interceptor)`
343
374
 
344
- Called after defaults are merged, with the final `RequestInit` and resolved URL. Return a new `RequestInit` to replace the original, or `void` / `undefined` to keep it.
375
+ Called after defaults are merged, with the final `RequestInit` and resolved URL. Return a
376
+ new `RequestInit` to replace the original, or `void` / `undefined` to keep it.
345
377
 
346
378
  ```ts
347
379
  const api = createHttpApi("https://api.example.com").onRequest((init, ctx) => {
348
- console.log(`[http] → ${ctx.method} ${ctx.url}`);
349
- const h = new Headers(init.headers);
350
- h.set("x-trace-id", crypto.randomUUID());
351
- return { ...init, headers: h };
380
+ console.log(`[http] → ${ctx.method} ${ctx.url}`);
381
+ const h = new Headers(init.headers);
382
+ h.set("x-trace-id", crypto.randomUUID());
383
+ return { ...init, headers: h };
352
384
  });
353
385
  ```
354
386
 
355
387
  ### `onResponse(interceptor)`
356
388
 
357
- Called before the body is consumed. **Must not read the body.** Return a replacement `Response` (e.g. after a retry) or `void` to keep the original. If you return a replacement, the original's body is cancelled for you.
389
+ Called before the body is consumed. **Must not read the body.** Return a replacement
390
+ `Response` (e.g. after a retry) or `void` to keep the original. If you return a
391
+ replacement, the original's body is cancelled for you.
358
392
 
359
393
  ```ts
360
394
  api.onResponse(async (resp, ctx) => {
361
- console.log(`[http] ← ${ctx.method} ${ctx.url} ${resp.status}`);
362
- if (resp.status === 401) {
363
- await refreshToken();
364
- // return a new fetch() if you want to retry
365
- }
395
+ console.log(`[http] ← ${ctx.method} ${ctx.url} ${resp.status}`);
396
+ if (resp.status === 401) {
397
+ await refreshToken();
398
+ // return a new fetch() if you want to retry
399
+ }
366
400
  });
367
401
  ```
368
402
 
@@ -378,32 +412,33 @@ Request body data type. See [Request Bodies](#request-bodies) for serialization
378
412
 
379
413
  ```ts
380
414
  type RequestData =
381
- | Record<string, unknown>
382
- | unknown[]
383
- | FormData
384
- | Blob
385
- | ArrayBuffer
386
- | ArrayBufferView
387
- | URLSearchParams
388
- | ReadableStream
389
- | string
390
- | number
391
- | boolean
392
- | null;
415
+ | Record<string, unknown>
416
+ | unknown[]
417
+ | FormData
418
+ | Blob
419
+ | ArrayBuffer
420
+ | ArrayBufferView
421
+ | URLSearchParams
422
+ | ReadableStream
423
+ | string
424
+ | number
425
+ | boolean
426
+ | null;
393
427
  ```
394
428
 
395
429
  ### QueryValue
396
430
 
397
- A value for `FetchParams.query`. `null` / `undefined` entries are skipped; arrays emit repeated keys.
431
+ A value for `FetchParams.query`. `null` / `undefined` entries are skipped; arrays emit
432
+ repeated keys.
398
433
 
399
434
  ```ts
400
435
  type QueryValue =
401
- | string
402
- | number
403
- | boolean
404
- | (string | number | boolean)[]
405
- | null
406
- | undefined;
436
+ | string
437
+ | number
438
+ | boolean
439
+ | (string | number | boolean)[]
440
+ | null
441
+ | undefined;
407
442
  ```
408
443
 
409
444
  ### FetchParams
@@ -412,24 +447,24 @@ Parameters for fetch requests.
412
447
 
413
448
  ```ts
414
449
  interface FetchParams {
415
- /** Request body. See "Request Bodies" for serialization rules. */
416
- data?: RequestData;
417
- /** Bearer token (auto-adds `Authorization: Bearer {token}` header). */
418
- token?: string | null;
419
- /** Custom request headers. */
420
- headers?: HeadersInit | null;
421
- /** AbortSignal for request cancellation. Combined with `timeout` if both are set. */
422
- signal?: AbortSignal;
423
- /** Abort the request after this many milliseconds. Combined with `signal`. */
424
- timeout?: number | null;
425
- /** Query parameters appended to the URL. */
426
- query?: Record<string, QueryValue> | null;
427
- /** Credentials mode for the request. */
428
- credentials?: 'omit' | 'same-origin' | 'include' | null;
429
- /** If true, returns the raw Response object instead of parsed body. Caller must consume the body. */
430
- raw?: boolean | null;
431
- /** If false, does not throw on HTTP errors (default: true). */
432
- assert?: boolean | null;
450
+ /** Request body. See "Request Bodies" for serialization rules. */
451
+ data?: RequestData;
452
+ /** Bearer token (auto-adds `Authorization: Bearer {token}` header). */
453
+ token?: string | null;
454
+ /** Custom request headers. */
455
+ headers?: HeadersInit | null;
456
+ /** AbortSignal for request cancellation. Combined with `timeout` if both are set. */
457
+ signal?: AbortSignal;
458
+ /** Abort the request after this many milliseconds. Combined with `signal`. */
459
+ timeout?: number | null;
460
+ /** Query parameters appended to the URL. */
461
+ query?: Record<string, QueryValue> | null;
462
+ /** Credentials mode for the request. */
463
+ credentials?: "omit" | "same-origin" | "include" | null;
464
+ /** If true, returns the raw Response object instead of parsed body. Caller must consume the body. */
465
+ raw?: boolean | null;
466
+ /** If false, does not throw on HTTP errors (default: true). */
467
+ assert?: boolean | null;
433
468
  }
434
469
  ```
435
470
 
@@ -439,12 +474,12 @@ Options for HTTP GET requests (new API).
439
474
 
440
475
  ```ts
441
476
  interface GetOptions {
442
- /** Fetch parameters (headers, token, signal, credentials, raw, assert). */
443
- params?: FetchParams;
444
- /** Object to receive response headers (will be mutated). */
445
- respHeaders?: ResponseHeaders | null;
446
- /** Custom error message extractor for this request. */
447
- errorExtractor?: ErrorMessageExtractor | null;
477
+ /** Fetch parameters (headers, token, signal, credentials, raw, assert). */
478
+ params?: FetchParams;
479
+ /** Object to receive response headers (will be mutated). */
480
+ respHeaders?: ResponseHeaders | null;
481
+ /** Custom error message extractor for this request. */
482
+ errorExtractor?: ErrorMessageExtractor | null;
448
483
  }
449
484
  ```
450
485
 
@@ -454,14 +489,14 @@ Options for HTTP POST/PUT/PATCH/DELETE requests (new API).
454
489
 
455
490
  ```ts
456
491
  interface DataOptions {
457
- /** Request body data. */
458
- data?: RequestData;
459
- /** Fetch parameters (headers, token, signal, credentials, raw, assert). */
460
- params?: FetchParams;
461
- /** Object to receive response headers (will be mutated). */
462
- respHeaders?: ResponseHeaders | null;
463
- /** Custom error message extractor for this request. */
464
- errorExtractor?: ErrorMessageExtractor | null;
492
+ /** Request body data. */
493
+ data?: RequestData;
494
+ /** Fetch parameters (headers, token, signal, credentials, raw, assert). */
495
+ params?: FetchParams;
496
+ /** Object to receive response headers (will be mutated). */
497
+ respHeaders?: ResponseHeaders | null;
498
+ /** Custom error message extractor for this request. */
499
+ errorExtractor?: ErrorMessageExtractor | null;
465
500
  }
466
501
  ```
467
502
 
@@ -474,12 +509,15 @@ type ResponseHeaders = Record<string, string | number>;
474
509
  ```
475
510
 
476
511
  Special keys added after request:
512
+
477
513
  - `__http_status_code__`: The HTTP status code
478
514
  - `__http_status_text__`: The HTTP status text
479
515
 
480
516
  ### ErrorMessageExtractor
481
517
 
482
- Function to extract error messages from failed HTTP responses. If the extractor throws, the call falls back to the next-priority extractor (per-instance → global → built-in) instead of crashing.
518
+ Function to extract error messages from failed HTTP responses. If the extractor throws,
519
+ the call falls back to the next-priority extractor (per-instance → global → built-in)
520
+ instead of crashing.
483
521
 
484
522
  ```ts
485
523
  type ErrorMessageExtractor = (body: unknown, response: Response) => string;
@@ -489,8 +527,8 @@ type ErrorMessageExtractor = (body: unknown, response: Response) => string;
489
527
 
490
528
  ```ts
491
529
  type RequestInterceptor = (
492
- init: RequestInit,
493
- context: { method: string; url: string }
530
+ init: RequestInit,
531
+ context: { method: string; url: string },
494
532
  ) => RequestInit | void | Promise<RequestInit | void>;
495
533
  ```
496
534
 
@@ -498,8 +536,8 @@ type RequestInterceptor = (
498
536
 
499
537
  ```ts
500
538
  type ResponseInterceptor = (
501
- response: Response,
502
- context: { method: string; url: string }
539
+ response: Response,
540
+ context: { method: string; url: string },
503
541
  ) => Response | void | Promise<Response | void>;
504
542
  ```
505
543
 
@@ -513,38 +551,47 @@ All errors extend `HttpError` base class.
513
551
 
514
552
  ```ts
515
553
  class HttpError extends Error {
516
- status: number; // HTTP status code
517
- statusText: string; // HTTP status text
518
- body: unknown; // Response body (auto-parsed as JSON)
519
- cause: unknown; // Error cause/details
554
+ status: number; // HTTP status code
555
+ statusText: string; // HTTP status text
556
+ body: unknown; // Response body (auto-parsed as JSON)
557
+ cause: unknown; // Error cause/details
520
558
  }
521
559
  ```
522
560
 
523
561
  ### Client Errors (4xx)
524
562
 
525
- | Class | Status | Description |
526
- |-------|--------|-------------|
527
- | `BadRequest` | 400 | Bad Request |
528
- | `Unauthorized` | 401 | Unauthorized |
529
- | `Forbidden` | 403 | Forbidden |
530
- | `NotFound` | 404 | Not Found |
531
- | `MethodNotAllowed` | 405 | Method Not Allowed |
532
- | `RequestTimeout` | 408 | Request Timeout |
533
- | `Conflict` | 409 | Conflict |
534
- | `Gone` | 410 | Gone |
535
- | `LengthRequired` | 411 | Length Required |
536
- | `ImATeapot` | 418 | I'm a Teapot |
537
- | `UnprocessableContent` | 422 | Unprocessable Content |
538
- | `TooManyRequests` | 429 | Too Many Requests |
563
+ | Class | Status | Description |
564
+ | ---------------------- | ------ | --------------------- |
565
+ | `BadRequest` | 400 | Bad Request |
566
+ | `Unauthorized` | 401 | Unauthorized |
567
+ | `Forbidden` | 403 | Forbidden |
568
+ | `NotFound` | 404 | Not Found |
569
+ | `MethodNotAllowed` | 405 | Method Not Allowed |
570
+ | `RequestTimeout` | 408 | Request Timeout |
571
+ | `Conflict` | 409 | Conflict |
572
+ | `Gone` | 410 | Gone |
573
+ | `LengthRequired` | 411 | Length Required |
574
+ | `ImATeapot` | 418 | I'm a Teapot |
575
+ | `UnprocessableContent` | 422 | Unprocessable Content |
576
+ | `TooManyRequests` | 429 | Too Many Requests |
539
577
 
540
578
  ### Server Errors (5xx)
541
579
 
542
- | Class | Status | Description |
543
- |-------|--------|-------------|
544
- | `InternalServerError` | 500 | Internal Server Error |
545
- | `NotImplemented` | 501 | Not Implemented |
546
- | `BadGateway` | 502 | Bad Gateway |
547
- | `ServiceUnavailable` | 503 | Service Unavailable |
580
+ | Class | Status | Description |
581
+ | --------------------- | ------ | --------------------- |
582
+ | `InternalServerError` | 500 | Internal Server Error |
583
+ | `NotImplemented` | 501 | Not Implemented |
584
+ | `BadGateway` | 502 | Bad Gateway |
585
+ | `ServiceUnavailable` | 503 | Service Unavailable |
586
+
587
+ ### Transport Errors
588
+
589
+ | Class | Status | Description |
590
+ | -------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
591
+ | `NetworkError` | 0 | Transport-level failure — DNS failure, refused connection, connect timeout, unreachable host. No HTTP response was received (hence `status` is `0`). Thrown by [`fetchOrThrow`](#fetchorthrow) and the `HttpApi` client; the underlying transport error is attached as `cause`. |
592
+
593
+ `NetworkError` extends `HttpError`, so it is caught by `instanceof HTTP_ERROR.HttpError`
594
+ as well.
548
595
 
549
596
  ### HTTP_ERROR Namespace
550
597
 
@@ -554,14 +601,14 @@ All error classes are available via the `HTTP_ERROR` namespace:
554
601
  import { HTTP_ERROR } from "@marianmeres/http-utils";
555
602
 
556
603
  try {
557
- await api.get("/resource");
604
+ await api.get("/resource");
558
605
  } catch (error) {
559
- if (error instanceof HTTP_ERROR.NotFound) {
560
- console.log("Resource not found");
561
- }
562
- if (error instanceof HTTP_ERROR.HttpError) {
563
- console.log("HTTP error:", error.status);
564
- }
606
+ if (error instanceof HTTP_ERROR.NotFound) {
607
+ console.log("Resource not found");
608
+ }
609
+ if (error instanceof HTTP_ERROR.HttpError) {
610
+ console.log("HTTP error:", error.status);
611
+ }
565
612
  }
566
613
  ```
567
614
 
@@ -576,44 +623,44 @@ Access status codes by category or via direct shortcuts.
576
623
  #### Categories
577
624
 
578
625
  ```ts
579
- HTTP_STATUS.INFO // 1xx Informational
580
- HTTP_STATUS.SUCCESS // 2xx Success
581
- HTTP_STATUS.REDIRECT // 3xx Redirection
582
- HTTP_STATUS.ERROR_CLIENT // 4xx Client Error
583
- HTTP_STATUS.ERROR_SERVER // 5xx Server Error
626
+ HTTP_STATUS.INFO; // 1xx Informational
627
+ HTTP_STATUS.SUCCESS; // 2xx Success
628
+ HTTP_STATUS.REDIRECT; // 3xx Redirection
629
+ HTTP_STATUS.ERROR_CLIENT; // 4xx Client Error
630
+ HTTP_STATUS.ERROR_SERVER; // 5xx Server Error
584
631
  ```
585
632
 
586
633
  #### Category Access
587
634
 
588
635
  ```ts
589
- HTTP_STATUS.SUCCESS.OK.CODE // 200
590
- HTTP_STATUS.SUCCESS.OK.TEXT // "OK"
591
- HTTP_STATUS.ERROR_CLIENT.NOT_FOUND.CODE // 404
592
- HTTP_STATUS.ERROR_CLIENT.NOT_FOUND.TEXT // "Not Found"
636
+ HTTP_STATUS.SUCCESS.OK.CODE; // 200
637
+ HTTP_STATUS.SUCCESS.OK.TEXT; // "OK"
638
+ HTTP_STATUS.ERROR_CLIENT.NOT_FOUND.CODE; // 404
639
+ HTTP_STATUS.ERROR_CLIENT.NOT_FOUND.TEXT; // "Not Found"
593
640
  ```
594
641
 
595
642
  #### Direct Shortcuts
596
643
 
597
644
  ```ts
598
- HTTP_STATUS.OK // 200
599
- HTTP_STATUS.CREATED // 201
600
- HTTP_STATUS.ACCEPTED // 202
601
- HTTP_STATUS.NO_CONTENT // 204
602
- HTTP_STATUS.MOVED_PERMANENTLY // 301
603
- HTTP_STATUS.FOUND // 302
604
- HTTP_STATUS.NOT_MODIFIED // 304
605
- HTTP_STATUS.BAD_REQUEST // 400
606
- HTTP_STATUS.UNAUTHORIZED // 401
607
- HTTP_STATUS.FORBIDDEN // 403
608
- HTTP_STATUS.NOT_FOUND // 404
609
- HTTP_STATUS.METHOD_NOT_ALLOWED // 405
610
- HTTP_STATUS.CONFLICT // 409
611
- HTTP_STATUS.GONE // 410
612
- HTTP_STATUS.UNPROCESSABLE_CONTENT // 422
613
- HTTP_STATUS.TOO_MANY_REQUESTS // 429
614
- HTTP_STATUS.INTERNAL_SERVER_ERROR // 500
615
- HTTP_STATUS.NOT_IMPLEMENTED // 501
616
- HTTP_STATUS.SERVICE_UNAVAILABLE // 503
645
+ HTTP_STATUS.OK; // 200
646
+ HTTP_STATUS.CREATED; // 201
647
+ HTTP_STATUS.ACCEPTED; // 202
648
+ HTTP_STATUS.NO_CONTENT; // 204
649
+ HTTP_STATUS.MOVED_PERMANENTLY; // 301
650
+ HTTP_STATUS.FOUND; // 302
651
+ HTTP_STATUS.NOT_MODIFIED; // 304
652
+ HTTP_STATUS.BAD_REQUEST; // 400
653
+ HTTP_STATUS.UNAUTHORIZED; // 401
654
+ HTTP_STATUS.FORBIDDEN; // 403
655
+ HTTP_STATUS.NOT_FOUND; // 404
656
+ HTTP_STATUS.METHOD_NOT_ALLOWED; // 405
657
+ HTTP_STATUS.CONFLICT; // 409
658
+ HTTP_STATUS.GONE; // 410
659
+ HTTP_STATUS.UNPROCESSABLE_CONTENT; // 422
660
+ HTTP_STATUS.TOO_MANY_REQUESTS; // 429
661
+ HTTP_STATUS.INTERNAL_SERVER_ERROR; // 500
662
+ HTTP_STATUS.NOT_IMPLEMENTED; // 501
663
+ HTTP_STATUS.SERVICE_UNAVAILABLE; // 503
617
664
  ```
618
665
 
619
666
  #### findByCode(code)
@@ -630,6 +677,7 @@ static findByCode(code: number | string): {
630
677
  ```
631
678
 
632
679
  **Example:**
680
+
633
681
  ```ts
634
682
  const info = HTTP_STATUS.findByCode(404);
635
683
  // { CODE: 404, TEXT: "Not Found", _TYPE: "ERROR_CLIENT", _KEY: "NOT_FOUND" }
@@ -639,27 +687,121 @@ const info = HTTP_STATUS.findByCode(404);
639
687
 
640
688
  ## Utilities
641
689
 
690
+ ### fetchOrThrow
691
+
692
+ Wraps the native `fetch` so a transport-level failure surfaces the target host and the
693
+ real reason instead of an opaque `TypeError: fetch failed`. Node/undici buries the actual
694
+ code (`ENOTFOUND`, `ECONNREFUSED`, `UND_ERR_CONNECT_TIMEOUT`, …) on `err.cause`, away from
695
+ the message and stack. On such a failure this throws a [`NetworkError`](#transport-errors)
696
+ whose message includes the URL and reason, and whose `cause` is the underlying transport
697
+ error. Deliberate cancellations (`AbortError`) and timeouts (`TimeoutError`) are re-thrown
698
+ untouched.
699
+
700
+ The `HttpApi` client uses this internally, so all of its requests surface the real reason
701
+ too — you only need `fetchOrThrow` directly when wrapping your own `fetch` calls.
702
+
703
+ ```ts
704
+ function fetchOrThrow(
705
+ input: string | URL | Request,
706
+ init?: RequestInit,
707
+ whatOrOptions?: string | FetchOrThrowOptions,
708
+ ): Promise<Response>;
709
+ ```
710
+
711
+ Like the native `fetch`, this does **not** throw on non-2xx HTTP statuses — only on
712
+ transport-level failures. The 3rd argument is either a label string (e.g.
713
+ `"Token issuer"`) used to prefix the error message, or a `FetchOrThrowOptions` object
714
+ carrying that label plus observer hooks (a bare string is normalized to `{ what }`).
715
+
716
+ **Example:**
717
+
718
+ ```ts
719
+ import { fetchOrThrow, HTTP_ERROR } from "@marianmeres/http-utils";
720
+
721
+ try {
722
+ const res = await fetchOrThrow(
723
+ "https://issuer.example.com/jwks",
724
+ undefined,
725
+ "Token issuer",
726
+ );
727
+ } catch (e) {
728
+ if (e instanceof HTTP_ERROR.NetworkError) {
729
+ console.log(e.message); // "Token issuer unreachable (https://issuer.example.com/jwks): ENOTFOUND"
730
+ console.log(e.cause); // underlying transport error
731
+ }
732
+ }
733
+ ```
734
+
735
+ #### Observer hooks & global defaults
736
+
737
+ ```ts
738
+ interface FetchOrThrowOptions {
739
+ /** Label for the target, used to prefix the error message. */
740
+ what?: string;
741
+ /** Fires synchronously before the request is dispatched. If it throws, the request is NOT sent. */
742
+ onRequest?: (info: { url: string; method?: string; what?: string }) => void;
743
+ /** Fires before a failure is (re-)thrown. A throw here is swallowed. */
744
+ onError?: (info: {
745
+ error: unknown;
746
+ url: string;
747
+ what?: string;
748
+ kind: "abort" | "timeout" | "network";
749
+ }) => void;
750
+ }
751
+
752
+ // Observer hooks only — `what` is per-call by nature.
753
+ type FetchOrThrowGlobalOptions = Pick<FetchOrThrowOptions, "onRequest" | "onError">;
754
+ ```
755
+
756
+ `onRequest`/`onError` are **pure observers**: their return value is ignored and they
757
+ cannot recover or transform the request/error. Reach for them to trace requests — notably
758
+ the _hang_ case, where neither a response nor an error ever arrives. For recovery or
759
+ retries use the `HttpApi` interceptors or your own `catch`.
760
+
761
+ - `onError` fires for **every** terminal failure; `kind`
762
+ (`"abort" | "timeout" | "network"`) lets you filter — e.g. skip deliberate aborts. Only
763
+ a `"network"` failure is wrapped in a `NetworkError`; aborts/timeouts propagate
764
+ untouched (the `error` passed to the hook is always the one that actually throws).
765
+ - A throwing `onRequest` aborts before the request is sent (clear consumer bug, no
766
+ original error to lose). A throwing `onError` is **swallowed**, so a broken hook can
767
+ never mask the real error. This asymmetry is intentional.
768
+
769
+ Set defaults once on `fetchOrThrow.global`; per-call options win (resolution is
770
+ `per-call ?? global`, an override — not a chain). Because `HttpApi` routes through
771
+ `fetchOrThrow`, these globals instrument the client's requests too.
772
+
773
+ ```ts
774
+ // app-wide defaults (also fire for every HttpApi request)
775
+ fetchOrThrow.global.onRequest = ({ method, url }) => console.debug(`→ ${method} ${url}`);
776
+ fetchOrThrow.global.onError = ({ url, kind }) =>
777
+ kind !== "abort" && console.error(`✗ ${url}`);
778
+
779
+ // per-call object form (overrides the global onRequest for this call only)
780
+ await fetchOrThrow(url, init, { what: "Token issuer", onRequest: () => {} });
781
+ ```
782
+
642
783
  ### createHttpError
643
784
 
644
785
  Creates an HTTP error from a status code and optional details.
645
786
 
646
787
  ```ts
647
788
  function createHttpError(
648
- code: number | string,
649
- message?: string | null,
650
- body?: unknown,
651
- cause?: unknown
652
- ): HttpError
789
+ code: number | string,
790
+ message?: string | null,
791
+ body?: unknown,
792
+ cause?: unknown,
793
+ ): HttpError;
653
794
  ```
654
795
 
655
796
  Returns a specific error class for well-known status codes.
656
797
 
657
798
  **Example:**
799
+
658
800
  ```ts
659
801
  const error = createHttpError(404, "User not found", { userId: 123 });
660
802
  console.log(error instanceof NotFound); // true
661
- console.log(error.status); // 404
662
- console.log(error.body); // { userId: 123 }
803
+ console.log(error.status); // 404
804
+ console.log(error.body); // { userId: 123 }
663
805
  ```
664
806
 
665
807
  ### getErrorMessage
@@ -667,10 +809,11 @@ console.log(error.body); // { userId: 123 }
667
809
  Extracts a human-readable error message from various error formats.
668
810
 
669
811
  ```ts
670
- function getErrorMessage(e: unknown, stripErrorPrefix?: boolean): string
812
+ function getErrorMessage(e: unknown, stripErrorPrefix?: boolean): string;
671
813
  ```
672
814
 
673
815
  **Priority order:**
816
+
674
817
  1. `e.cause.message` / `e.cause.code` / `e.cause` (if string)
675
818
  2. `e.body.error.message` / `e.body.message` / `e.body.error` / `e.body` (if string)
676
819
  3. `e.message`
@@ -679,12 +822,13 @@ function getErrorMessage(e: unknown, stripErrorPrefix?: boolean): string
679
822
  6. `"Unknown Error"`
680
823
 
681
824
  **Example:**
825
+
682
826
  ```ts
683
827
  import { getErrorMessage } from "@marianmeres/http-utils";
684
828
 
685
829
  try {
686
- await api.get("/fail");
830
+ await api.get("/fail");
687
831
  } catch (error) {
688
- console.log(getErrorMessage(error)); // "Not Found"
832
+ console.log(getErrorMessage(error)); // "Not Found"
689
833
  }
690
834
  ```