@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/AGENTS.md +138 -99
- package/API.md +341 -197
- package/README.md +126 -49
- package/dist/api.d.ts +100 -0
- package/dist/api.js +143 -4
- package/dist/error.d.ts +28 -1
- package/dist/error.js +106 -64
- package/dist/mod.d.ts +2 -2
- package/dist/mod.js +2 -2
- package/dist/status.js +77 -61
- package/package.json +1 -1
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
36
|
-
|
|
37
|
-
| `base`
|
|
38
|
-
| `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
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
120
|
-
|
|
121
|
-
|
|
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 {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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 {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
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");
|
|
225
|
-
api.url("users");
|
|
226
|
-
api.url("https://other.com/path");
|
|
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");
|
|
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
|
|
258
|
-
|
|
259
|
-
| `null` / `undefined`
|
|
260
|
-
| `string`
|
|
261
|
-
| `number` / `boolean`
|
|
262
|
-
| Plain object / array
|
|
263
|
-
| `FormData`
|
|
264
|
-
| `URLSearchParams`
|
|
265
|
-
| `Blob`
|
|
266
|
-
| `ArrayBuffer` / typed arrays | Passed to fetch unchanged
|
|
267
|
-
| `ReadableStream`
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
|
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
|
-
|
|
329
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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,
|
|
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
|
-
|
|
493
|
-
|
|
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
|
-
|
|
502
|
-
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
|
526
|
-
|
|
527
|
-
| `BadRequest`
|
|
528
|
-
| `Unauthorized`
|
|
529
|
-
| `Forbidden`
|
|
530
|
-
| `NotFound`
|
|
531
|
-
| `MethodNotAllowed`
|
|
532
|
-
| `RequestTimeout`
|
|
533
|
-
| `Conflict`
|
|
534
|
-
| `Gone`
|
|
535
|
-
| `LengthRequired`
|
|
536
|
-
| `ImATeapot`
|
|
537
|
-
| `UnprocessableContent` | 422
|
|
538
|
-
| `TooManyRequests`
|
|
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
|
|
543
|
-
|
|
544
|
-
| `InternalServerError` | 500
|
|
545
|
-
| `NotImplemented`
|
|
546
|
-
| `BadGateway`
|
|
547
|
-
| `ServiceUnavailable`
|
|
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
|
-
|
|
604
|
+
await api.get("/resource");
|
|
558
605
|
} catch (error) {
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
|
580
|
-
HTTP_STATUS.SUCCESS
|
|
581
|
-
HTTP_STATUS.REDIRECT
|
|
582
|
-
HTTP_STATUS.ERROR_CLIENT
|
|
583
|
-
HTTP_STATUS.ERROR_SERVER
|
|
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
|
|
590
|
-
HTTP_STATUS.SUCCESS.OK.TEXT
|
|
591
|
-
HTTP_STATUS.ERROR_CLIENT.NOT_FOUND.CODE
|
|
592
|
-
HTTP_STATUS.ERROR_CLIENT.NOT_FOUND.TEXT
|
|
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
|
|
599
|
-
HTTP_STATUS.CREATED
|
|
600
|
-
HTTP_STATUS.ACCEPTED
|
|
601
|
-
HTTP_STATUS.NO_CONTENT
|
|
602
|
-
HTTP_STATUS.MOVED_PERMANENTLY
|
|
603
|
-
HTTP_STATUS.FOUND
|
|
604
|
-
HTTP_STATUS.NOT_MODIFIED
|
|
605
|
-
HTTP_STATUS.BAD_REQUEST
|
|
606
|
-
HTTP_STATUS.UNAUTHORIZED
|
|
607
|
-
HTTP_STATUS.FORBIDDEN
|
|
608
|
-
HTTP_STATUS.NOT_FOUND
|
|
609
|
-
HTTP_STATUS.METHOD_NOT_ALLOWED
|
|
610
|
-
HTTP_STATUS.CONFLICT
|
|
611
|
-
HTTP_STATUS.GONE
|
|
612
|
-
HTTP_STATUS.UNPROCESSABLE_CONTENT // 422
|
|
613
|
-
HTTP_STATUS.TOO_MANY_REQUESTS
|
|
614
|
-
HTTP_STATUS.INTERNAL_SERVER_ERROR // 500
|
|
615
|
-
HTTP_STATUS.NOT_IMPLEMENTED
|
|
616
|
-
HTTP_STATUS.SERVICE_UNAVAILABLE
|
|
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
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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);
|
|
662
|
-
console.log(error.body);
|
|
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
|
-
|
|
830
|
+
await api.get("/fail");
|
|
687
831
|
} catch (error) {
|
|
688
|
-
|
|
832
|
+
console.log(getErrorMessage(error)); // "Not Found"
|
|
689
833
|
}
|
|
690
834
|
```
|