@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 +26 -13
- package/dist/index.d.mts +13 -4
- package/dist/index.mjs +60 -30
- package/package.json +8 -8
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
|
|
32
|
-
|
|
|
33
|
-
| `baseUrl`
|
|
34
|
-
| `token`
|
|
35
|
-
| `tokenScheme`
|
|
36
|
-
| `apiKeyHeader`
|
|
37
|
-
| `extraHeaders`
|
|
38
|
-
| `defaultQuery`
|
|
39
|
-
| `vendor`
|
|
40
|
-
| `fetch`
|
|
41
|
-
| `logger`
|
|
42
|
-
| `errors`
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
171
|
-
errors.captureException(apiErr);
|
|
172
|
-
throw apiErr;
|
|
202
|
+
break;
|
|
173
203
|
}
|
|
174
204
|
if (!res.ok) {
|
|
175
|
-
const body = await res
|
|
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.
|
|
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/
|
|
40
|
-
"@theholocron/
|
|
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"
|