@theholocron/http-client 1.57.1-alpha.1 → 1.57.1-alpha.2

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
@@ -28,21 +28,34 @@ const data = await client.request<{ id: string }>("/widgets/42");
28
28
 
29
29
  **Config options:**
30
30
 
31
- | Option | Type | Default | Description |
32
- | -------------- | ------------------------ | ------------------ | ------------------------------------------------- |
33
- | `baseUrl` | `string` | — | Base URL, trailing slashes trimmed automatically |
34
- | `token` | `string` | — | Auth token |
35
- | `tokenScheme` | `"bearer" \| "apikey"` | `"bearer"` | How the token is sent |
36
- | `apiKeyHeader` | `string` | `"x-api-key"` | Header name when `tokenScheme` is `"apikey"` |
37
- | `extraHeaders` | `Record<string, string>` | — | Static headers merged into every request |
38
- | `defaultQuery` | `Record<string, string>` | — | Query params appended to every request URL |
39
- | `vendor` | `string` | — | Vendor label for error messages |
40
- | `fetch` | `typeof fetch` | `globalThis.fetch` | Override fetch for testing |
41
- | `logger` | `Logger` | no-op | Structured request/response diagnostics (`debug`) |
42
- | `errors` | `ErrorSink` | no-op | Reports transport failures + unexpected 5xx |
31
+ | Option | Type | Default | Description |
32
+ | --------------- | ------------------------ | ------------------ | ------------------------------------------------------------ |
33
+ | `baseUrl` | `string` | — | Base URL, trailing slashes trimmed automatically |
34
+ | `token` | `string` | — | Auth token |
35
+ | `tokenScheme` | `"bearer" \| "apikey"` | `"bearer"` | How the token is sent |
36
+ | `apiKeyHeader` | `string` | `"x-api-key"` | Header name when `tokenScheme` is `"apikey"` |
37
+ | `extraHeaders` | `Record<string, string>` | — | Static headers merged into every request |
38
+ | `defaultQuery` | `Record<string, string>` | — | Query params appended to every request URL |
39
+ | `vendor` | `string` | — | Vendor label for error messages |
40
+ | `fetch` | `typeof fetch` | `globalThis.fetch` | Override fetch for testing |
41
+ | `logger` | `Logger` | no-op | Structured request/response diagnostics (`debug`) |
42
+ | `errors` | `ErrorSink` | no-op | Reports transport failures + unexpected 5xx |
43
+ | `retries` | `number` | `2` | Times a request is sent again (see Retries); `0` is off |
44
+ | `retryDelayMs` | `number` | `300` | Wait before the first retry; each later one waits 3× as long |
45
+ | `retryStatuses` | `readonly number[]` | none | HTTP statuses a **read** is sent again on (see Retries) |
46
+
47
+ **Retries.** A request is sent again, up to `retries` times, after a transport failure that cannot have applied it twice (the connection never opened, or a read was dropped). By default an HTTP error status is never retried. List statuses in `retryStatuses` (for example `[500, 502, 503, 504]`) to ask for a read again when the server answers one of them. Only `GET` and `HEAD` are repeated on a status: a write may already have been applied, so it fails at once. A status that is not listed (a 4xx, a 429) fails at once too; `Retry-After` is not honoured.
48
+
49
+ When a request still fails after being sent again, the `ProviderApiError` keeps the last `status` and `details` and its message says how many sends it took, with the vendor's request id when the response carried one (`cf-ray`, else `x-request-id`):
50
+
51
+ ```text
52
+ Cloudflare GET /zones/abc/dns_records → 500 (after 3 attempts; cf-ray: 8a1b2c3d4e5f-LHR)
53
+ ```
54
+
55
+ Every retry is reported to [`onRetry`](#onretrylistener); for a status the event's `code` is `HTTP 500`.
43
56
 
44
57
  **Observability seam.** `logger` and `errors` accept the `Logger` / `ErrorSink`
45
- interfaces from [`@theholocron/observability/core`](https://github.com/theholocron/observability)
58
+ interfaces from [`@theholocron/observability/core`](https://github.com/theholocron/holocron/tree/alpha/observability)
46
59
  — a seam, not a runtime: the library never calls `Sentry.init` or reads
47
60
  credentials itself, and both default to a silent no-op when omitted.
48
61
 
package/dist/index.d.mts CHANGED
@@ -91,12 +91,18 @@ interface RestClientConfig {
91
91
  errors?: ErrorSink;
92
92
  /**
93
93
  * How many times to send a request again after a transport failure that is safe to repeat (see `worthRetrying`):
94
- * the connection never opened, or a read was dropped. HTTP error statuses are never retried. Defaults to 2; 0
95
- * turns it off.
94
+ * the connection never opened, or a read was dropped. An HTTP error status is retried only when it is listed in
95
+ * `retryStatuses`. Defaults to 2; 0 turns it off.
96
96
  */
97
97
  retries?: number;
98
98
  /** Milliseconds before the first retry; each later one waits three times as long. Defaults to 300. */
99
99
  retryDelayMs?: number;
100
+ /**
101
+ * HTTP statuses (for example `[500, 502, 503, 504]`) that are worth asking for again, using the same `retries`
102
+ * and `retryDelayMs`. Only a read (GET or HEAD) is repeated; a write is never sent again on a status, because the
103
+ * server may have applied it. Defaults to none, so a client that does not ask behaves as before.
104
+ */
105
+ retryStatuses?: readonly number[];
100
106
  }
101
107
  interface RequestOptions {
102
108
  method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
@@ -111,13 +117,16 @@ interface RestClient {
111
117
  request<T>(path: string, opts?: RequestOptions): Promise<T>;
112
118
  }
113
119
  export declare function createRestClient(config: RestClientConfig): RestClient;
114
- /** A request about to be sent again after a transport failure. */
120
+ /** A request about to be sent again after a transport failure, or an HTTP status the client was told to retry. */
115
121
  interface RetryEvent {
116
122
  /** The client's `vendor` label, e.g. `GitHub`. */
117
123
  vendor?: string;
118
124
  method: string;
119
125
  path: string;
120
- /** What went wrong (`ECONNRESET`, `UND_ERR_CONNECT_TIMEOUT`); a failure with no known cause is never retried. */
126
+ /**
127
+ * What went wrong (`ECONNRESET`, `UND_ERR_CONNECT_TIMEOUT`, or `HTTP 503` for a retried status); a failure with no
128
+ * known cause is never retried.
129
+ */
121
130
  code: string;
122
131
  /** The number of the send that is about to happen: 2 for the first retry. */
123
132
  attempt: number;
package/dist/index.mjs CHANGED
@@ -95,6 +95,10 @@ const DROPPED = /* @__PURE__ */ new Set([
95
95
  "UND_ERR_SOCKET",
96
96
  "ETIMEDOUT"
97
97
  ]);
98
+ /** Whether a request only reads, so sending it again changes nothing. */
99
+ function isRead(method) {
100
+ return method === "GET" || method === "HEAD";
101
+ }
98
102
  /**
99
103
  * Whether a failed request is worth sending again: always when it never left, and for a read when the connection
100
104
  * dropped (a repeat read changes nothing). A write that may have arrived is never repeated.
@@ -102,9 +106,23 @@ const DROPPED = /* @__PURE__ */ new Set([
102
106
  function worthRetrying(code, method) {
103
107
  if (!code) return false;
104
108
  if (NEVER_SENT.has(code)) return true;
105
- return DROPPED.has(code) && (method === "GET" || method === "HEAD");
109
+ return DROPPED.has(code) && isRead(method);
106
110
  }
107
111
  // src/rest-client.ts
112
+ /** The text of a response, or nothing when it cannot be read: an error body is a courtesy, not a reason to fail. */
113
+ const readBody = (res) => res.text().catch(() => "");
114
+ /** Response headers that name the request on the vendor's side, the first one present is quoted in an error. */
115
+ const REQUEST_ID_HEADERS = ["cf-ray", "x-request-id"];
116
+ /**
117
+ * What to add to the message of an error that came after the request was sent again: how many sends it took, and the
118
+ * vendor's id for the last one, so a failure that kept happening can be told apart from a misconfiguration and be
119
+ * reported to the vendor. Nothing for a request that was only sent once.
120
+ */
121
+ function retriedNote(res, attempt) {
122
+ if (attempt === 0) return "";
123
+ const header = REQUEST_ID_HEADERS.find((name) => res.headers.has(name));
124
+ return ` (after ${attempt + 1} attempts${header ? `; ${header}: ${res.headers.get(header)}` : ""})`;
125
+ }
108
126
  function createRestClient(config) {
109
127
  const fetchImpl = config.fetch ?? globalThis.fetch;
110
128
  const vendor = config.vendor ?? "";
@@ -112,6 +130,7 @@ function createRestClient(config) {
112
130
  const errors = config.errors ?? new NoopErrorSink();
113
131
  const maxRetries = config.retries ?? 2;
114
132
  const retryDelay = config.retryDelayMs ?? 300;
133
+ const retryStatuses = new Set(config.retryStatuses ?? []);
115
134
  let baseUrl = config.baseUrl;
116
135
  while (baseUrl.endsWith("/")) baseUrl = baseUrl.slice(0, -1);
117
136
  const staticHeaders = { accept: "application/json" };
@@ -140,40 +159,51 @@ function createRestClient(config) {
140
159
  method: init.method,
141
160
  path
142
161
  }, "request");
162
+ const pause = async (attempt, code) => {
163
+ logger.debug({
164
+ vendor,
165
+ method: init.method,
166
+ path,
167
+ code,
168
+ attempt: attempt + 1
169
+ }, "retrying");
170
+ const delayMs = retryDelay * 3 ** attempt;
171
+ notifyRetry({
172
+ ...vendor ? { vendor } : {},
173
+ method: init.method,
174
+ path,
175
+ code,
176
+ attempt: attempt + 2,
177
+ of: maxRetries + 1,
178
+ delayMs
179
+ });
180
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
181
+ };
143
182
  let res;
144
- for (let attempt = 0;; attempt++) try {
145
- res = await fetchImpl(url.toString(), init);
146
- break;
147
- } catch (err) {
148
- const code = transportCode(err);
149
- if (attempt < maxRetries && worthRetrying(code, init.method)) {
150
- logger.debug({
151
- vendor,
152
- method: init.method,
153
- path,
154
- code,
155
- attempt: attempt + 1
156
- }, "retrying");
157
- const delayMs = retryDelay * 3 ** attempt;
158
- notifyRetry({
159
- ...vendor ? { vendor } : {},
160
- method: init.method,
161
- path,
162
- code,
163
- attempt: attempt + 2,
164
- of: maxRetries + 1,
165
- delayMs
166
- });
167
- await new Promise((resolve) => setTimeout(resolve, delayMs));
183
+ let attempt = 0;
184
+ for (;; attempt++) {
185
+ try {
186
+ res = await fetchImpl(url.toString(), init);
187
+ } catch (err) {
188
+ const code = transportCode(err);
189
+ if (attempt < maxRetries && worthRetrying(code, init.method)) {
190
+ await pause(attempt, code);
191
+ continue;
192
+ }
193
+ const apiErr = new ProviderApiError(`${tag} failed: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}${code ? ` (${code})` : ""}`, 0, void 0);
194
+ errors.captureException(apiErr);
195
+ throw apiErr;
196
+ }
197
+ if (!res.ok && attempt < maxRetries && retryStatuses.has(res.status) && isRead(init.method)) {
198
+ await readBody(res);
199
+ await pause(attempt, `HTTP ${res.status}`);
168
200
  continue;
169
201
  }
170
- const apiErr = new ProviderApiError(`${tag} failed: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}${code ? ` (${code})` : ""}`, 0, void 0);
171
- errors.captureException(apiErr);
172
- throw apiErr;
202
+ break;
173
203
  }
174
204
  if (!res.ok) {
175
- const body = await res.text().catch(() => "");
176
- const apiErr = new ProviderApiError(`${tag} → ${res.status}`, res.status, body);
205
+ const body = await readBody(res);
206
+ const apiErr = new ProviderApiError(`${tag} → ${res.status}${retriedNote(res, attempt)}`, res.status, body);
177
207
  if (res.status >= 500) errors.captureException(apiErr);
178
208
  throw apiErr;
179
209
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theholocron/http-client",
3
- "version": "1.57.1-alpha.1",
3
+ "version": "1.57.1-alpha.2",
4
4
  "description": "Shared HTTP primitives for theholocron — REST client factory, auth resolver, and error types",
5
5
  "keywords": [
6
6
  "typescript",
@@ -36,14 +36,10 @@
36
36
  "dist"
37
37
  ],
38
38
  "dependencies": {
39
- "@theholocron/env-utils": "^1.16.0",
40
- "@theholocron/observability": "^0.6.0"
39
+ "@theholocron/observability": "^0.6.1-alpha.1",
40
+ "@theholocron/env-utils": "^1.16.1-alpha.1"
41
41
  },
42
42
  "devDependencies": {
43
- "@theholocron/eslint-config": "^8.8.2",
44
- "@theholocron/tsconfig": "^8.8.2",
45
- "@theholocron/tsdown-config": "^8.8.2",
46
- "@theholocron/vitest-config": "^8.8.2",
47
43
  "@types/node": "^26",
48
44
  "@vitest/coverage-v8": "^5.0.5",
49
45
  "@vitest/eslint-plugin": "^1.6.27",
@@ -52,7 +48,11 @@
52
48
  "globals": "^17.11.0",
53
49
  "tsdown": "^0.23.3",
54
50
  "typescript": "^5.9.3",
55
- "vitest": "^5.0.5"
51
+ "vitest": "^5.0.5",
52
+ "@theholocron/eslint-config": "^8.9.0-alpha.3",
53
+ "@theholocron/tsconfig": "^8.9.0-alpha.3",
54
+ "@theholocron/tsdown-config": "^8.9.0-alpha.3",
55
+ "@theholocron/vitest-config": "^8.9.0-alpha.3"
56
56
  },
57
57
  "peerDependencies": {
58
58
  "vitest": ">=4.0.0"