@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/README.md
CHANGED
|
@@ -4,14 +4,18 @@
|
|
|
4
4
|
[](https://jsr.io/@marianmeres/http-utils)
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
6
|
|
|
7
|
-
Opinionated, lightweight HTTP client wrapper for `fetch` with type-safe errors and
|
|
7
|
+
Opinionated, lightweight HTTP client wrapper for `fetch` with type-safe errors and
|
|
8
|
+
convenient defaults.
|
|
8
9
|
|
|
9
10
|
## Features
|
|
10
11
|
|
|
11
12
|
- ðŊ **Type-safe HTTP errors** - Well-known status codes map to specific error classes
|
|
12
13
|
- ð§ **Convenient defaults** - Auto JSON parsing, Bearer tokens, base URLs
|
|
13
14
|
- ðŠķ **Lightweight** - Zero dependencies, thin wrapper over native `fetch`
|
|
14
|
-
- ðĻ **Flexible error handling** - Three-tier error message extraction (local â factory â
|
|
15
|
+
- ðĻ **Flexible error handling** - Three-tier error message extraction (local â factory â
|
|
16
|
+
global)
|
|
17
|
+
- ð°ïļ **No swallowed transport errors** - DNS/connection failures surface the host and real
|
|
18
|
+
reason instead of an opaque "fetch failed"
|
|
15
19
|
- ðĶ **Deno & Node.js** - Works in both runtimes
|
|
16
20
|
- ðĶū **Generic return types** - Optional type parameters for typed responses
|
|
17
21
|
|
|
@@ -26,51 +30,60 @@ npm install @marianmeres/http-utils
|
|
|
26
30
|
```
|
|
27
31
|
|
|
28
32
|
```ts
|
|
29
|
-
import { createHttpApi,
|
|
33
|
+
import { createHttpApi, HTTP_ERROR, opts } from "@marianmeres/http-utils";
|
|
30
34
|
```
|
|
31
35
|
|
|
32
36
|
## Quick Start
|
|
33
37
|
|
|
34
38
|
```ts
|
|
35
|
-
import { createHttpApi,
|
|
39
|
+
import { createHttpApi, HTTP_ERROR, NotFound, opts } from "@marianmeres/http-utils";
|
|
36
40
|
|
|
37
41
|
// Create an API client with base URL
|
|
38
42
|
const api = createHttpApi("https://api.example.com", {
|
|
39
|
-
|
|
43
|
+
headers: { "Authorization": "Bearer your-token" },
|
|
40
44
|
});
|
|
41
45
|
|
|
42
46
|
// GET request (options API with opts() wrapper)
|
|
43
|
-
const users = await api.get(
|
|
44
|
-
|
|
45
|
-
|
|
47
|
+
const users = await api.get(
|
|
48
|
+
"/users",
|
|
49
|
+
opts({
|
|
50
|
+
params: { headers: { "X-Custom": "value" } },
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
46
53
|
|
|
47
54
|
// POST request (options API with opts() wrapper)
|
|
48
|
-
const newUser = await api.post(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
55
|
+
const newUser = await api.post(
|
|
56
|
+
"/users",
|
|
57
|
+
opts({
|
|
58
|
+
data: { name: "John Doe" },
|
|
59
|
+
params: { headers: { "X-Custom": "value" } },
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
52
62
|
|
|
53
63
|
// Legacy API (default behavior without opts())
|
|
54
64
|
const legacyUsers = await api.get("/users", { headers: { "X-Custom": "value" } });
|
|
55
65
|
const legacyUser = await api.post("/users", { name: "John Doe" });
|
|
56
66
|
|
|
57
67
|
// With type parameters for typed responses
|
|
58
|
-
interface User {
|
|
68
|
+
interface User {
|
|
69
|
+
id: number;
|
|
70
|
+
name: string;
|
|
71
|
+
}
|
|
59
72
|
const user = await api.get<User>("/users/1");
|
|
60
73
|
const created = await api.post<User>("/users", opts({ data: { name: "Jane" } }));
|
|
61
74
|
|
|
62
75
|
// Error handling
|
|
63
76
|
try {
|
|
64
|
-
|
|
77
|
+
await api.get("/not-found");
|
|
65
78
|
} catch (error) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
79
|
+
if (error instanceof NotFound) {
|
|
80
|
+
console.log("Resource not found");
|
|
81
|
+
}
|
|
82
|
+
// or use the namespace
|
|
83
|
+
if (error instanceof HTTP_ERROR.NotFound) {
|
|
84
|
+
console.log(error.status); // 404
|
|
85
|
+
console.log(error.body); // Response body
|
|
86
|
+
}
|
|
74
87
|
}
|
|
75
88
|
```
|
|
76
89
|
|
|
@@ -82,7 +95,7 @@ Creates an HTTP API client.
|
|
|
82
95
|
|
|
83
96
|
```ts
|
|
84
97
|
const api = createHttpApi("https://api.example.com", {
|
|
85
|
-
|
|
98
|
+
headers: { "Authorization": "Bearer token" },
|
|
86
99
|
});
|
|
87
100
|
```
|
|
88
101
|
|
|
@@ -90,16 +103,22 @@ const api = createHttpApi("https://api.example.com", {
|
|
|
90
103
|
|
|
91
104
|
```ts
|
|
92
105
|
// GET (options API with opts() wrapper)
|
|
93
|
-
const data = await api.get(
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
106
|
+
const data = await api.get(
|
|
107
|
+
"/users",
|
|
108
|
+
opts({
|
|
109
|
+
params: { headers: { "X-Custom": "value" } },
|
|
110
|
+
respHeaders: {},
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
97
113
|
|
|
98
114
|
// POST/PUT/PATCH/DELETE (options API with opts() wrapper)
|
|
99
|
-
await api.post(
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
115
|
+
await api.post(
|
|
116
|
+
"/users",
|
|
117
|
+
opts({
|
|
118
|
+
data: { name: "John" },
|
|
119
|
+
params: { token: "bearer-token" },
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
103
122
|
|
|
104
123
|
// Legacy API (default behavior without opts())
|
|
105
124
|
const data = await api.get("/users", { headers: { "X-Custom": "value" } });
|
|
@@ -108,14 +127,15 @@ await api.post("/users", { name: "John" });
|
|
|
108
127
|
|
|
109
128
|
### The `opts()` Helper
|
|
110
129
|
|
|
111
|
-
The `opts()` function explicitly marks an options object for the options-based API.
|
|
130
|
+
The `opts()` function explicitly marks an options object for the options-based API.
|
|
131
|
+
Without it, arguments are treated as legacy positional parameters.
|
|
112
132
|
|
|
113
133
|
```ts
|
|
114
134
|
// Without opts() - legacy behavior: object is sent as request body
|
|
115
|
-
await api.post("/users", { data: { name: "John" } });
|
|
135
|
+
await api.post("/users", { data: { name: "John" } }); // Sends: { data: { name: "John" } }
|
|
116
136
|
|
|
117
137
|
// With opts() - options API: data is extracted and sent as body
|
|
118
|
-
await api.post("/users", opts({ data: { name: "John" } }));
|
|
138
|
+
await api.post("/users", opts({ data: { name: "John" } })); // Sends: { name: "John" }
|
|
119
139
|
```
|
|
120
140
|
|
|
121
141
|
This makes the API unambiguous and prevents accidental misinterpretation of request data.
|
|
@@ -126,27 +146,37 @@ This makes the API unambiguous and prevents accidental misinterpretation of requ
|
|
|
126
146
|
import { HTTP_ERROR, NotFound } from "@marianmeres/http-utils";
|
|
127
147
|
|
|
128
148
|
try {
|
|
129
|
-
|
|
149
|
+
await api.get("/resource");
|
|
130
150
|
} catch (error) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
151
|
+
if (error instanceof NotFound) {
|
|
152
|
+
console.log("Not found:", error.body);
|
|
153
|
+
}
|
|
154
|
+
// Transport-level failures (DNS, refused connection, unreachable host) throw
|
|
155
|
+
// a NetworkError (status 0) instead of an opaque "fetch failed":
|
|
156
|
+
if (error instanceof HTTP_ERROR.NetworkError) {
|
|
157
|
+
console.log(error.message); // e.g. "GET unreachable (https://...): ECONNREFUSED"
|
|
158
|
+
console.log(error.cause); // underlying transport error
|
|
159
|
+
}
|
|
160
|
+
// All errors have: status, statusText, body, cause
|
|
135
161
|
}
|
|
136
162
|
```
|
|
137
163
|
|
|
138
164
|
### Key Features
|
|
139
165
|
|
|
140
|
-
- **Auto JSON**: Response bodies are automatically parsed as JSON; empty bodies (204/205)
|
|
141
|
-
|
|
166
|
+
- **Auto JSON**: Response bodies are automatically parsed as JSON; empty bodies (204/205)
|
|
167
|
+
return `null`
|
|
168
|
+
- **Smart body handling**: Plain objects â JSON; `FormData` / `URLSearchParams` / `Blob` /
|
|
169
|
+
typed arrays / `ReadableStream` pass through; strings sent as-is
|
|
142
170
|
- **Query params**: Pass `query: { page: 1, tag: ["a", "b"] }` for URL search params
|
|
143
171
|
- **Timeouts**: Pass `timeout: 5000` for automatic request cancellation
|
|
144
172
|
- **Bearer tokens**: Use `token` param to auto-add `Authorization: Bearer` header
|
|
145
173
|
- **Response headers**: Pass `respHeaders: {}` to capture response headers
|
|
146
|
-
- **Raw response**: Use `raw: true` to get the raw Response object (caller must consume
|
|
174
|
+
- **Raw response**: Use `raw: true` to get the raw Response object (caller must consume
|
|
175
|
+
the body)
|
|
147
176
|
- **Non-throwing**: Use `assert: false` to prevent throwing on errors
|
|
148
177
|
- **AbortController**: Pass `signal` for request cancellation (composes with `timeout`)
|
|
149
|
-
- **Interceptors**: `api.onRequest(...)` / `api.onResponse(...)` for tracing, auth
|
|
178
|
+
- **Interceptors**: `api.onRequest(...)` / `api.onResponse(...)` for tracing, auth
|
|
179
|
+
refresh, etc.
|
|
150
180
|
- **Typed responses**: Use generics for type-safe responses: `api.get<User>("/users/1")`
|
|
151
181
|
|
|
152
182
|
### Query, Timeout, Interceptors
|
|
@@ -160,18 +190,65 @@ await api.get("/slow", { timeout: 5000 });
|
|
|
160
190
|
|
|
161
191
|
// Interceptors
|
|
162
192
|
api.onRequest((init, { method, url }) => {
|
|
163
|
-
|
|
193
|
+
console.log(method, url);
|
|
164
194
|
}).onResponse(async (resp) => {
|
|
165
|
-
|
|
195
|
+
if (resp.status === 401) await refreshToken();
|
|
166
196
|
});
|
|
167
197
|
```
|
|
168
198
|
|
|
169
199
|
## Full API Reference
|
|
170
200
|
|
|
171
|
-
For complete API documentation including all error classes, HTTP status codes, types, and
|
|
201
|
+
For complete API documentation including all error classes, HTTP status codes, types, and
|
|
202
|
+
utilities, see **[API.md](API.md)**.
|
|
172
203
|
|
|
173
204
|
## Utilities
|
|
174
205
|
|
|
206
|
+
### `fetchOrThrow(input, init?, whatOrOptions?)`
|
|
207
|
+
|
|
208
|
+
Wraps the native `fetch` so a transport-level failure surfaces the target host and the
|
|
209
|
+
real reason instead of an opaque `TypeError: fetch failed`. On failure it throws a
|
|
210
|
+
`NetworkError` (in the `HTTP_ERROR` namespace) whose message includes the URL and reason,
|
|
211
|
+
and whose `cause` is the underlying transport error. Deliberate
|
|
212
|
+
`AbortError`/`TimeoutError` are re-thrown untouched. The `HttpApi` client uses this
|
|
213
|
+
internally â reach for it directly when wrapping your own `fetch` calls.
|
|
214
|
+
|
|
215
|
+
The 3rd argument is either a label string or a `FetchOrThrowOptions` object carrying that
|
|
216
|
+
label plus optional `onRequest`/`onError` observer hooks.
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
import { fetchOrThrow, HTTP_ERROR } from "@marianmeres/http-utils";
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const res = await fetchOrThrow(
|
|
223
|
+
"https://issuer.example.com/jwks",
|
|
224
|
+
undefined,
|
|
225
|
+
"Token issuer",
|
|
226
|
+
);
|
|
227
|
+
} catch (e) {
|
|
228
|
+
if (e instanceof HTTP_ERROR.NetworkError) {
|
|
229
|
+
console.log(e.message); // "Token issuer unreachable (https://issuer.example.com/jwks): ENOTFOUND"
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
**Tracing requests.** The `onRequest`/`onError` hooks are pure observers (they can't
|
|
235
|
+
recover or transform anything) â handy for logging, and the only way to catch a _hang_
|
|
236
|
+
where neither a response nor an error ever arrives. Set defaults once on
|
|
237
|
+
`fetchOrThrow.global` (overridable per call; resolution is `per-call ?? global`). Because
|
|
238
|
+
`HttpApi` routes through `fetchOrThrow`, the global hooks instrument it too.
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
// app-wide defaults (also fire for every HttpApi request)
|
|
242
|
+
fetchOrThrow.global.onRequest = ({ method, url }) => console.debug(`â ${method} ${url}`);
|
|
243
|
+
fetchOrThrow.global.onError = ({ url, kind }) =>
|
|
244
|
+
kind !== "abort" && console.error(`â ${url}`); // kind: "abort" | "timeout" | "network"
|
|
245
|
+
|
|
246
|
+
// per-call override (here: silence the global tracer for one call)
|
|
247
|
+
await fetchOrThrow(url, init, { what: "Token issuer", onRequest: () => {} });
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
See [API.md](./API.md#fetchorthrow) for the full options reference.
|
|
251
|
+
|
|
175
252
|
### `getErrorMessage(error)`
|
|
176
253
|
|
|
177
254
|
Extracts human-readable messages from any error format:
|
|
@@ -180,9 +257,9 @@ Extracts human-readable messages from any error format:
|
|
|
180
257
|
import { getErrorMessage } from "@marianmeres/http-utils";
|
|
181
258
|
|
|
182
259
|
try {
|
|
183
|
-
|
|
260
|
+
await api.get("/fail");
|
|
184
261
|
} catch (error) {
|
|
185
|
-
|
|
262
|
+
console.log(getErrorMessage(error)); // "Not Found"
|
|
186
263
|
}
|
|
187
264
|
```
|
|
188
265
|
|
|
@@ -195,4 +272,4 @@ import { createHttpError } from "@marianmeres/http-utils";
|
|
|
195
272
|
|
|
196
273
|
const error = createHttpError(404, "User not found", { userId: 123 });
|
|
197
274
|
throw error; // instanceof NotFound
|
|
198
|
-
```
|
|
275
|
+
```
|
package/dist/api.d.ts
CHANGED
|
@@ -117,6 +117,106 @@ export interface DataOptions {
|
|
|
117
117
|
* ```
|
|
118
118
|
*/
|
|
119
119
|
export declare function opts<T extends GetOptions | DataOptions>(options: T): T;
|
|
120
|
+
/**
|
|
121
|
+
* Options for {@link fetchOrThrow}. Pass as the 3rd argument in place of a bare
|
|
122
|
+
* `what` string (a string is normalized to `{ what }`).
|
|
123
|
+
*
|
|
124
|
+
* `onRequest`/`onError` are **pure observers** â their return value is ignored,
|
|
125
|
+
* they cannot recover or transform the request/error. For recovery or retries
|
|
126
|
+
* use the {@link HttpApi} interceptors or your own `catch`.
|
|
127
|
+
*/
|
|
128
|
+
export interface FetchOrThrowOptions {
|
|
129
|
+
/** Human-readable label for the target (e.g. "Token issuer"), used in error messages. */
|
|
130
|
+
what?: string;
|
|
131
|
+
/**
|
|
132
|
+
* Called synchronously just before the request is dispatched. No-op by
|
|
133
|
+
* default. Useful for request tracing â including the hang case where
|
|
134
|
+
* neither a response nor an error ever arrives. If this throws, the request
|
|
135
|
+
* is NOT sent (a throwing tracer is a clear consumer bug, and there is no
|
|
136
|
+
* original error to preserve â unlike {@link FetchOrThrowOptions.onError}).
|
|
137
|
+
*/
|
|
138
|
+
onRequest?: (info: {
|
|
139
|
+
url: string;
|
|
140
|
+
method?: string;
|
|
141
|
+
what?: string;
|
|
142
|
+
}) => void;
|
|
143
|
+
/**
|
|
144
|
+
* Called just before a transport-level failure is (re-)thrown. Pure observer:
|
|
145
|
+
* its return value is ignored and the original error always propagates. A
|
|
146
|
+
* throw here is swallowed so a broken hook can never mask the real error.
|
|
147
|
+
* `kind` classifies the failure so callers can, e.g., skip deliberate aborts.
|
|
148
|
+
*/
|
|
149
|
+
onError?: (info: {
|
|
150
|
+
error: unknown;
|
|
151
|
+
url: string;
|
|
152
|
+
what?: string;
|
|
153
|
+
kind: "abort" | "timeout" | "network";
|
|
154
|
+
}) => void;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Global defaults for {@link fetchOrThrow}, exposed as `fetchOrThrow.global` and
|
|
158
|
+
* overridable per call (resolution is `per-call ?? global`). Observer hooks only
|
|
159
|
+
* â `what` is per-call by nature, so it is intentionally excluded.
|
|
160
|
+
*/
|
|
161
|
+
export type FetchOrThrowGlobalOptions = Pick<FetchOrThrowOptions, "onRequest" | "onError">;
|
|
162
|
+
/**
|
|
163
|
+
* Wraps the native `fetch` so a transport-level failure surfaces the target host
|
|
164
|
+
* and the real reason instead of an opaque "fetch failed".
|
|
165
|
+
*
|
|
166
|
+
* Node/undici collapses DNS failures, refused connections and connect timeouts
|
|
167
|
+
* into a `TypeError: fetch failed` whose actual code (`ENOTFOUND`,
|
|
168
|
+
* `ECONNREFUSED`, `UND_ERR_CONNECT_TIMEOUT`, ...) lives on `err.cause` and is
|
|
169
|
+
* absent from the message and stack. On such a failure this throws a
|
|
170
|
+
* `NetworkError` (see {@link HTTP_ERROR}) whose message includes the URL and the
|
|
171
|
+
* resolved reason, and whose `cause` is the underlying transport error (so both
|
|
172
|
+
* `error.message` and `getErrorMessage(error)` report the real reason).
|
|
173
|
+
*
|
|
174
|
+
* Deliberate cancellations (`AbortError`) and timeouts (`TimeoutError`) are
|
|
175
|
+
* re-thrown untouched â they already carry clear semantics and must not be
|
|
176
|
+
* masked as "host unreachable".
|
|
177
|
+
*
|
|
178
|
+
* Optional observer hooks (`onRequest`/`onError`, see {@link FetchOrThrowOptions})
|
|
179
|
+
* can trace the request without wrapping the call site in `try/catch`; the most
|
|
180
|
+
* useful case is detecting a hang, where neither a response nor an error ever
|
|
181
|
+
* arrives. Defaults can be set once on `fetchOrThrow.global` and overridden per
|
|
182
|
+
* call (resolution is `per-call ?? global`). Because {@link HttpApi} routes every
|
|
183
|
+
* request through this function, the global hooks instrument it too.
|
|
184
|
+
*
|
|
185
|
+
* @param input - The `fetch` resource (URL string, `URL`, or `Request`).
|
|
186
|
+
* @param init - The `fetch` `RequestInit` options.
|
|
187
|
+
* @param what - Either a label describing the target (e.g. "Token issuer"), used
|
|
188
|
+
* to prefix the error message, or a {@link FetchOrThrowOptions} object carrying
|
|
189
|
+
* that label plus the `onRequest`/`onError` observer hooks.
|
|
190
|
+
*
|
|
191
|
+
* @returns The `Response`. This does NOT throw on non-2xx HTTP statuses â only
|
|
192
|
+
* on transport-level failures, exactly like the native `fetch`.
|
|
193
|
+
*
|
|
194
|
+
* @throws A `NetworkError` on a transport-level failure (DNS, refused
|
|
195
|
+
* connection, unreachable host, ...).
|
|
196
|
+
*
|
|
197
|
+
* @example
|
|
198
|
+
* ```ts
|
|
199
|
+
* import { fetchOrThrow, HTTP_ERROR } from "@marianmeres/http-utils";
|
|
200
|
+
*
|
|
201
|
+
* // Configure tracing once, app-wide (also instruments the HttpApi client):
|
|
202
|
+
* fetchOrThrow.global.onRequest = ({ method, url }) => console.debug(`â ${method} ${url}`);
|
|
203
|
+
* fetchOrThrow.global.onError = ({ url, kind }) =>
|
|
204
|
+
* kind !== "abort" && console.error(`â ${url}`);
|
|
205
|
+
*
|
|
206
|
+
* try {
|
|
207
|
+
* const res = await fetchOrThrow("https://issuer.example.com/jwks", undefined, "Token issuer");
|
|
208
|
+
* } catch (e) {
|
|
209
|
+
* if (e instanceof HTTP_ERROR.NetworkError) {
|
|
210
|
+
* // e.message â "Token issuer unreachable (https://issuer.example.com/jwks): ENOTFOUND"
|
|
211
|
+
* // e.cause â underlying transport error
|
|
212
|
+
* }
|
|
213
|
+
* }
|
|
214
|
+
* ```
|
|
215
|
+
*/
|
|
216
|
+
export declare function fetchOrThrow(input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1], what?: string | FetchOrThrowOptions): Promise<Response>;
|
|
217
|
+
export declare namespace fetchOrThrow {
|
|
218
|
+
var global: FetchOrThrowGlobalOptions;
|
|
219
|
+
}
|
|
120
220
|
/**
|
|
121
221
|
* HTTP API client with convenient defaults and error handling.
|
|
122
222
|
*/
|
package/dist/api.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* HTTP API client factory and related types.
|
|
5
5
|
* Provides a convenient wrapper over the native `fetch` API with sensible defaults.
|
|
6
6
|
*/
|
|
7
|
-
import { createHttpError } from "./error.js";
|
|
7
|
+
import { createHttpError, getErrorMessage, NetworkError } from "./error.js";
|
|
8
8
|
/**
|
|
9
9
|
* Deep merges two objects. Later properties overwrite earlier properties.
|
|
10
10
|
* Arrays are overwritten, not concatenated (conventional behavior).
|
|
@@ -101,8 +101,9 @@ function composeSignal(userSignal, timeoutMs) {
|
|
|
101
101
|
userSignal.addEventListener("abort", () => abort(userSignal.reason));
|
|
102
102
|
if (timeoutSignal.aborted)
|
|
103
103
|
abort(timeoutSignal.reason);
|
|
104
|
-
else
|
|
104
|
+
else {
|
|
105
105
|
timeoutSignal.addEventListener("abort", () => abort(timeoutSignal.reason));
|
|
106
|
+
}
|
|
106
107
|
return ctrl.signal;
|
|
107
108
|
}
|
|
108
109
|
/** Symbol marker for explicit options API detection. */
|
|
@@ -209,6 +210,142 @@ function buildRequest(params) {
|
|
|
209
210
|
url = appendQuery(url, query);
|
|
210
211
|
return { url, init };
|
|
211
212
|
}
|
|
213
|
+
/** Best-effort human-readable description of a `fetch` target for error messages. */
|
|
214
|
+
function _describeFetchTarget(input) {
|
|
215
|
+
if (typeof input === "string")
|
|
216
|
+
return input;
|
|
217
|
+
if (input instanceof URL)
|
|
218
|
+
return input.href;
|
|
219
|
+
return input?.url ?? String(input);
|
|
220
|
+
}
|
|
221
|
+
// `Symbol.for` + `globalThis` so multiple bundled copies of this package still
|
|
222
|
+
// share one config object (same approach as `@marianmeres/clog`'s global state).
|
|
223
|
+
const _FOT_GLOBAL_KEY = Symbol.for("@marianmeres/http-utils/fetchOrThrow");
|
|
224
|
+
const _FOT_GLOBAL =
|
|
225
|
+
// deno-lint-ignore no-explicit-any
|
|
226
|
+
(globalThis[_FOT_GLOBAL_KEY] ??= {
|
|
227
|
+
onRequest: undefined,
|
|
228
|
+
onError: undefined,
|
|
229
|
+
});
|
|
230
|
+
/**
|
|
231
|
+
* Invoke an `onError` observer defensively: `url` is only computed when a hook
|
|
232
|
+
* is present, and a throwing hook is swallowed so it can never replace the real
|
|
233
|
+
* error that is about to propagate.
|
|
234
|
+
*/
|
|
235
|
+
function _notifyFetchError(hook, error, describe, input, what, kind) {
|
|
236
|
+
if (!hook)
|
|
237
|
+
return;
|
|
238
|
+
try {
|
|
239
|
+
hook({ error, url: describe(input), what, kind });
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
/* observer hooks must not alter control flow */
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Wraps the native `fetch` so a transport-level failure surfaces the target host
|
|
247
|
+
* and the real reason instead of an opaque "fetch failed".
|
|
248
|
+
*
|
|
249
|
+
* Node/undici collapses DNS failures, refused connections and connect timeouts
|
|
250
|
+
* into a `TypeError: fetch failed` whose actual code (`ENOTFOUND`,
|
|
251
|
+
* `ECONNREFUSED`, `UND_ERR_CONNECT_TIMEOUT`, ...) lives on `err.cause` and is
|
|
252
|
+
* absent from the message and stack. On such a failure this throws a
|
|
253
|
+
* `NetworkError` (see {@link HTTP_ERROR}) whose message includes the URL and the
|
|
254
|
+
* resolved reason, and whose `cause` is the underlying transport error (so both
|
|
255
|
+
* `error.message` and `getErrorMessage(error)` report the real reason).
|
|
256
|
+
*
|
|
257
|
+
* Deliberate cancellations (`AbortError`) and timeouts (`TimeoutError`) are
|
|
258
|
+
* re-thrown untouched â they already carry clear semantics and must not be
|
|
259
|
+
* masked as "host unreachable".
|
|
260
|
+
*
|
|
261
|
+
* Optional observer hooks (`onRequest`/`onError`, see {@link FetchOrThrowOptions})
|
|
262
|
+
* can trace the request without wrapping the call site in `try/catch`; the most
|
|
263
|
+
* useful case is detecting a hang, where neither a response nor an error ever
|
|
264
|
+
* arrives. Defaults can be set once on `fetchOrThrow.global` and overridden per
|
|
265
|
+
* call (resolution is `per-call ?? global`). Because {@link HttpApi} routes every
|
|
266
|
+
* request through this function, the global hooks instrument it too.
|
|
267
|
+
*
|
|
268
|
+
* @param input - The `fetch` resource (URL string, `URL`, or `Request`).
|
|
269
|
+
* @param init - The `fetch` `RequestInit` options.
|
|
270
|
+
* @param what - Either a label describing the target (e.g. "Token issuer"), used
|
|
271
|
+
* to prefix the error message, or a {@link FetchOrThrowOptions} object carrying
|
|
272
|
+
* that label plus the `onRequest`/`onError` observer hooks.
|
|
273
|
+
*
|
|
274
|
+
* @returns The `Response`. This does NOT throw on non-2xx HTTP statuses â only
|
|
275
|
+
* on transport-level failures, exactly like the native `fetch`.
|
|
276
|
+
*
|
|
277
|
+
* @throws A `NetworkError` on a transport-level failure (DNS, refused
|
|
278
|
+
* connection, unreachable host, ...).
|
|
279
|
+
*
|
|
280
|
+
* @example
|
|
281
|
+
* ```ts
|
|
282
|
+
* import { fetchOrThrow, HTTP_ERROR } from "@marianmeres/http-utils";
|
|
283
|
+
*
|
|
284
|
+
* // Configure tracing once, app-wide (also instruments the HttpApi client):
|
|
285
|
+
* fetchOrThrow.global.onRequest = ({ method, url }) => console.debug(`â ${method} ${url}`);
|
|
286
|
+
* fetchOrThrow.global.onError = ({ url, kind }) =>
|
|
287
|
+
* kind !== "abort" && console.error(`â ${url}`);
|
|
288
|
+
*
|
|
289
|
+
* try {
|
|
290
|
+
* const res = await fetchOrThrow("https://issuer.example.com/jwks", undefined, "Token issuer");
|
|
291
|
+
* } catch (e) {
|
|
292
|
+
* if (e instanceof HTTP_ERROR.NetworkError) {
|
|
293
|
+
* // e.message â "Token issuer unreachable (https://issuer.example.com/jwks): ENOTFOUND"
|
|
294
|
+
* // e.cause â underlying transport error
|
|
295
|
+
* }
|
|
296
|
+
* }
|
|
297
|
+
* ```
|
|
298
|
+
*/
|
|
299
|
+
export async function fetchOrThrow(input, init, what) {
|
|
300
|
+
const opts = typeof what === "string" ? { what } : (what ?? {});
|
|
301
|
+
const label = opts.what;
|
|
302
|
+
// Per-call wins over the global default (override, not chain).
|
|
303
|
+
const onRequest = opts.onRequest ?? _FOT_GLOBAL.onRequest;
|
|
304
|
+
const onError = opts.onError ?? _FOT_GLOBAL.onError;
|
|
305
|
+
if (onRequest) {
|
|
306
|
+
const method = init?.method ??
|
|
307
|
+
(input instanceof Request ? input.method : undefined);
|
|
308
|
+
onRequest({ url: _describeFetchTarget(input), method, what: label });
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
return await fetch(input, init);
|
|
312
|
+
}
|
|
313
|
+
catch (err) {
|
|
314
|
+
const name = err?.name;
|
|
315
|
+
const kind = name === "AbortError"
|
|
316
|
+
? "abort"
|
|
317
|
+
: name === "TimeoutError"
|
|
318
|
+
? "timeout"
|
|
319
|
+
: "network";
|
|
320
|
+
// Preserve deliberate cancellations and timeouts as-is.
|
|
321
|
+
if (kind !== "network") {
|
|
322
|
+
_notifyFetchError(onError, err, _describeFetchTarget, input, label, kind);
|
|
323
|
+
throw err;
|
|
324
|
+
}
|
|
325
|
+
// undici/Node bury the real reason on `err.cause`; prefer it so both
|
|
326
|
+
// `.message` and `getErrorMessage(networkError)` surface ENOTFOUND/etc.
|
|
327
|
+
// rather than re-surfacing the opaque outer "fetch failed".
|
|
328
|
+
const underlying = err?.cause ?? err;
|
|
329
|
+
const reason = getErrorMessage(underlying);
|
|
330
|
+
const url = _describeFetchTarget(input);
|
|
331
|
+
const message = label
|
|
332
|
+
? `${label} unreachable (${url}): ${reason}`
|
|
333
|
+
: `Network request to ${url} failed: ${reason}`;
|
|
334
|
+
const networkError = new NetworkError(message, { cause: underlying });
|
|
335
|
+
_notifyFetchError(onError, networkError, () => url, input, label, kind);
|
|
336
|
+
throw networkError;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Global defaults for {@link fetchOrThrow}'s observer hooks, overridable per call.
|
|
341
|
+
* Mirrors `createHttpApi.defaultErrorMessageExtractor` and `createClog.global`.
|
|
342
|
+
*
|
|
343
|
+
* @example
|
|
344
|
+
* ```ts
|
|
345
|
+
* fetchOrThrow.global.onRequest = ({ method, url }) => console.debug(`â ${method} ${url}`);
|
|
346
|
+
* ```
|
|
347
|
+
*/
|
|
348
|
+
fetchOrThrow.global = _FOT_GLOBAL;
|
|
212
349
|
const _fetch = async (params, respHeaders = null, errorMessageExtractor = null, requestInterceptor = null, responseInterceptor = null, _dumpParams = false) => {
|
|
213
350
|
if (_dumpParams)
|
|
214
351
|
return params;
|
|
@@ -221,7 +358,7 @@ const _fetch = async (params, respHeaders = null, errorMessageExtractor = null,
|
|
|
221
358
|
if (patched)
|
|
222
359
|
init = patched;
|
|
223
360
|
}
|
|
224
|
-
let r = await
|
|
361
|
+
let r = await fetchOrThrow(url, init, params.method);
|
|
225
362
|
if (responseInterceptor) {
|
|
226
363
|
const patched = await responseInterceptor(r, {
|
|
227
364
|
method: params.method,
|
|
@@ -232,7 +369,9 @@ const _fetch = async (params, respHeaders = null, errorMessageExtractor = null,
|
|
|
232
369
|
try {
|
|
233
370
|
await r.body?.cancel();
|
|
234
371
|
}
|
|
235
|
-
catch (_e) {
|
|
372
|
+
catch (_e) {
|
|
373
|
+
/* ignore */
|
|
374
|
+
}
|
|
236
375
|
r = patched;
|
|
237
376
|
}
|
|
238
377
|
}
|
package/dist/error.d.ts
CHANGED
|
@@ -124,7 +124,33 @@ declare class ServiceUnavailable extends HttpError {
|
|
|
124
124
|
status: number;
|
|
125
125
|
statusText: string;
|
|
126
126
|
}
|
|
127
|
-
|
|
127
|
+
/**
|
|
128
|
+
* Transport-level failure: DNS resolution failure, refused connection, connect
|
|
129
|
+
* timeout, unreachable host, etc. No HTTP response was received, so `status` is
|
|
130
|
+
* `0` (the de-facto web convention for network-level failures, mirroring
|
|
131
|
+
* `XMLHttpRequest.status`). Thrown by `fetchOrThrow` (and, by extension, by the
|
|
132
|
+
* `HttpApi` client) with the underlying transport error attached as `cause`, so
|
|
133
|
+
* the real reason (`ENOTFOUND`, `ECONNREFUSED`, ...) is never swallowed behind
|
|
134
|
+
* an opaque "fetch failed".
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* ```ts
|
|
138
|
+
* try {
|
|
139
|
+
* await api.get("/resource");
|
|
140
|
+
* } catch (error) {
|
|
141
|
+
* if (error instanceof HTTP_ERROR.NetworkError) {
|
|
142
|
+
* console.log(error.message); // e.g. "GET unreachable (https://...): ECONNREFUSED"
|
|
143
|
+
* console.log(error.cause); // the underlying transport error
|
|
144
|
+
* }
|
|
145
|
+
* }
|
|
146
|
+
* ```
|
|
147
|
+
*/
|
|
148
|
+
declare class NetworkError extends HttpError {
|
|
149
|
+
name: string;
|
|
150
|
+
status: number;
|
|
151
|
+
statusText: string;
|
|
152
|
+
}
|
|
153
|
+
export { BadGateway, BadRequest, Conflict, Forbidden, Gone, HttpError, ImATeapot, InternalServerError, LengthRequired, MethodNotAllowed, NetworkError, NotFound, NotImplemented, RequestTimeout, ServiceUnavailable, TooManyRequests, Unauthorized, UnprocessableContent, };
|
|
128
154
|
/**
|
|
129
155
|
* Namespace containing all HTTP error classes for convenient access.
|
|
130
156
|
*
|
|
@@ -162,6 +188,7 @@ export declare const HTTP_ERROR: {
|
|
|
162
188
|
NotImplemented: typeof NotImplemented;
|
|
163
189
|
BadGateway: typeof BadGateway;
|
|
164
190
|
ServiceUnavailable: typeof ServiceUnavailable;
|
|
191
|
+
NetworkError: typeof NetworkError;
|
|
165
192
|
};
|
|
166
193
|
/**
|
|
167
194
|
* Creates an HTTP error from a status code and optional details.
|