@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/README.md CHANGED
@@ -4,14 +4,18 @@
4
4
  [![JSR version](https://jsr.io/badges/@marianmeres/http-utils)](https://jsr.io/@marianmeres/http-utils)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
6
 
7
- Opinionated, lightweight HTTP client wrapper for `fetch` with type-safe errors and convenient defaults.
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 → global)
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, opts, HTTP_ERROR } from "@marianmeres/http-utils";
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, opts, HTTP_ERROR, NotFound } from "@marianmeres/http-utils";
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
- headers: { "Authorization": "Bearer your-token" }
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("/users", opts({
44
- params: { headers: { "X-Custom": "value" } }
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("/users", opts({
49
- data: { name: "John Doe" },
50
- params: { headers: { "X-Custom": "value" } }
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 { id: number; name: string; }
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
- await api.get("/not-found");
77
+ await api.get("/not-found");
65
78
  } catch (error) {
66
- if (error instanceof NotFound) {
67
- console.log("Resource not found");
68
- }
69
- // or use the namespace
70
- if (error instanceof HTTP_ERROR.NotFound) {
71
- console.log(error.status); // 404
72
- console.log(error.body); // Response body
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
- headers: { "Authorization": "Bearer token" }
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("/users", opts({
94
- params: { headers: { "X-Custom": "value" } },
95
- respHeaders: {}
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("/users", opts({
100
- data: { name: "John" },
101
- params: { token: "bearer-token" }
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. Without it, arguments are treated as legacy positional parameters.
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" } }); // Sends: { 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" } })); // Sends: { 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
- await api.get("/resource");
149
+ await api.get("/resource");
130
150
  } catch (error) {
131
- if (error instanceof NotFound) {
132
- console.log("Not found:", error.body);
133
- }
134
- // All errors have: status, statusText, body, cause
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) return `null`
141
- - **Smart body handling**: Plain objects → JSON; `FormData` / `URLSearchParams` / `Blob` / typed arrays / `ReadableStream` pass through; strings sent as-is
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 the body)
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 refresh, etc.
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
- console.log(method, url);
193
+ console.log(method, url);
164
194
  }).onResponse(async (resp) => {
165
- if (resp.status === 401) await refreshToken();
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 utilities, see **[API.md](API.md)**.
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
- await api.get("/fail");
260
+ await api.get("/fail");
184
261
  } catch (error) {
185
- console.log(getErrorMessage(error)); // "Not Found"
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 fetch(url, init);
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) { /* ignore */ }
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
- export { HttpError, BadRequest, Unauthorized, Forbidden, NotFound, MethodNotAllowed, RequestTimeout, Conflict, Gone, LengthRequired, ImATeapot, UnprocessableContent, TooManyRequests, InternalServerError, NotImplemented, BadGateway, ServiceUnavailable, };
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.