@maschinenlesbar.org/pegel-online-cli 0.2.0 → 0.3.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 +29 -8
- package/dist/src/cli/commands/stations.js +29 -4
- package/dist/src/cli/commands/timeseries.js +3 -3
- package/dist/src/cli/io.d.ts +2 -1
- package/dist/src/cli/io.js +7 -2
- package/dist/src/cli/program.js +6 -6
- package/dist/src/cli/run.d.ts +12 -0
- package/dist/src/cli/run.js +29 -1
- package/dist/src/cli/shared.d.ts +8 -0
- package/dist/src/cli/shared.js +16 -0
- package/dist/src/client/client.d.ts +40 -0
- package/dist/src/client/client.js +138 -31
- package/dist/src/client/engine.d.ts +42 -12
- package/dist/src/client/engine.js +385 -47
- package/dist/src/client/errors.d.ts +31 -1
- package/dist/src/client/errors.js +67 -3
- package/dist/src/client/http.d.ts +26 -0
- package/dist/src/client/http.js +16 -1
- package/dist/src/client/index.d.ts +5 -4
- package/dist/src/client/index.js +4 -4
- package/dist/src/client/types.d.ts +54 -7
- package/dist/src/client/validate.d.ts +23 -2
- package/dist/src/client/validate.js +49 -2
- package/package.json +3 -3
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// The request engine: turns logical (method, path, query) calls into HTTP
|
|
2
2
|
// requests via a Transport, applies retry/backoff for transient statuses
|
|
3
3
|
// (429, 503), and decodes responses.
|
|
4
|
-
import {
|
|
4
|
+
import { TextDecoder } from "node:util";
|
|
5
|
+
import { MAX_TIMEOUT_MS, nodeHttpTransport, sizeLimitMessage, } from "./http.js";
|
|
5
6
|
import { buildQueryString } from "./query.js";
|
|
6
|
-
import { PegelApiError, PegelError, PegelParseError, redactUrl } from "./errors.js";
|
|
7
|
-
import { assertValid, baseUrlProblem, headerValueProblem } from "./validate.js";
|
|
7
|
+
import { PegelApiError, PegelError, PegelNetworkError, PegelParseError, PegelValidationError, credentialsIn, redactCredentials, redactUrl, } from "./errors.js";
|
|
8
|
+
import { assertValid, baseUrlProblem, headerValueProblem, knownKeysProblem } from "./validate.js";
|
|
8
9
|
export const DEFAULT_BASE_URL = "https://www.pegelonline.wsv.de";
|
|
9
10
|
const DEFAULT_USER_AGENT = "pegel-online-cli";
|
|
10
11
|
const DEFAULT_MAX_RESPONSE_BYTES = 100 * 1024 * 1024;
|
|
@@ -21,10 +22,66 @@ function intOption(name, value, fallback, max) {
|
|
|
21
22
|
if (value === undefined)
|
|
22
23
|
return fallback;
|
|
23
24
|
if (!Number.isSafeInteger(value) || value < 0 || value > max) {
|
|
24
|
-
throw new
|
|
25
|
+
throw new PegelValidationError(`Invalid option ${name}: expected an integer from 0 to ${max}, got ${typeof value === "number" ? String(value) : typeof value}.`);
|
|
25
26
|
}
|
|
26
27
|
return value;
|
|
27
28
|
}
|
|
29
|
+
/** Every EngineOptions key; any other is rejected (a JavaScript `timeout` for `timeoutMs`). */
|
|
30
|
+
const OPTION_NAMES = [
|
|
31
|
+
"baseUrl",
|
|
32
|
+
"transport",
|
|
33
|
+
"userAgent",
|
|
34
|
+
"headers",
|
|
35
|
+
"timeoutMs",
|
|
36
|
+
"maxRetries",
|
|
37
|
+
"retryDelayMs",
|
|
38
|
+
"maxRedirects",
|
|
39
|
+
"maxResponseBytes",
|
|
40
|
+
"sleep",
|
|
41
|
+
];
|
|
42
|
+
/**
|
|
43
|
+
* Read a function option: `undefined` gives the default; anything else that is not a
|
|
44
|
+
* function throws. A string `transport` used to fail at the first request as a raw
|
|
45
|
+
* TypeError, and a bad `sleep` on the first retry.
|
|
46
|
+
*/
|
|
47
|
+
function functionOption(name, value, fallback) {
|
|
48
|
+
if (value === undefined)
|
|
49
|
+
return fallback;
|
|
50
|
+
if (typeof value !== "function") {
|
|
51
|
+
throw new PegelValidationError(`Invalid option ${name}: expected a function, got ${typeof value}.`);
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Read the `headers` option: a plain object of header values, each one an HTTP header
|
|
57
|
+
* can carry (headerValueProblem). An array or a non-string value used to reach Node as
|
|
58
|
+
* an opaque TypeError at request time.
|
|
59
|
+
*/
|
|
60
|
+
function headersOption(value) {
|
|
61
|
+
if (value === undefined)
|
|
62
|
+
return {};
|
|
63
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
64
|
+
throw new PegelValidationError(`Invalid option headers: expected an object of header values, got ${Array.isArray(value) ? "an array" : typeof value}.`);
|
|
65
|
+
}
|
|
66
|
+
for (const [name, header] of Object.entries(value)) {
|
|
67
|
+
const reason = headerValueProblem(header);
|
|
68
|
+
if (reason !== undefined)
|
|
69
|
+
throw new PegelValidationError(`Invalid option headers: ${JSON.stringify(name)}: ${reason}`);
|
|
70
|
+
}
|
|
71
|
+
return { ...value };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Longest server text (in characters) kept for an error message: an error `detail`, a
|
|
75
|
+
* transport's error text or a redirect target. A longer one is cut and ends in "…", so a
|
|
76
|
+
* hostile or buggy body cannot flood stderr or a CI log with one huge line.
|
|
77
|
+
* `PegelApiError.body` keeps the full text.
|
|
78
|
+
*/
|
|
79
|
+
const MAX_DETAIL_LENGTH = 500;
|
|
80
|
+
/** sanitizeServerText, then cut at MAX_DETAIL_LENGTH characters. */
|
|
81
|
+
function cleanDetail(text) {
|
|
82
|
+
const clean = sanitizeServerText(text);
|
|
83
|
+
return clean.length > MAX_DETAIL_LENGTH ? `${clean.slice(0, MAX_DETAIL_LENGTH)}…` : clean;
|
|
84
|
+
}
|
|
28
85
|
/**
|
|
29
86
|
* The redirect statuses the engine follows. 300 (a choice for the user), 304 (a
|
|
30
87
|
* cache answer to a conditional request this client never sends) and 305/306
|
|
@@ -34,8 +91,10 @@ const FOLLOWED_REDIRECTS = new Set([301, 302, 303, 307, 308]);
|
|
|
34
91
|
/**
|
|
35
92
|
* Longest `Retry-After` the engine waits out before retrying a 429/503. When the
|
|
36
93
|
* server asks for longer, the engine does not retry at all and surfaces the error at
|
|
37
|
-
* once
|
|
38
|
-
* out, and a hostile value must
|
|
94
|
+
* once, naming the requested wait (`PegelApiError.retryAfterMs`): retrying early would
|
|
95
|
+
* only land inside the window the server asked us to wait out, and a hostile value must
|
|
96
|
+
* not stall the CLI. A shorter `Retry-After` never makes a wait shorter than the normal
|
|
97
|
+
* backoff.
|
|
39
98
|
*/
|
|
40
99
|
export const MAX_RETRY_AFTER_MS = 30_000;
|
|
41
100
|
/** An IMF-fixdate (RFC 9110 §5.6.7), the one HTTP-date form senders must generate. */
|
|
@@ -61,6 +120,88 @@ export function parseRetryAfter(header, now = Date.now()) {
|
|
|
61
120
|
const when = Date.parse(value);
|
|
62
121
|
return Number.isNaN(when) ? undefined : Math.max(0, when - now);
|
|
63
122
|
}
|
|
123
|
+
/** Why `value` is not a usable HttpResponse, or undefined when it is. */
|
|
124
|
+
function responseProblem(value) {
|
|
125
|
+
if (typeof value !== "object" || value === null)
|
|
126
|
+
return "not an object";
|
|
127
|
+
const r = value;
|
|
128
|
+
if (typeof r.status !== "number" || !Number.isInteger(r.status) || r.status < 100 || r.status > 599) {
|
|
129
|
+
return "status is not an HTTP status code";
|
|
130
|
+
}
|
|
131
|
+
if (typeof r.headers !== "object" || r.headers === null || Array.isArray(r.headers))
|
|
132
|
+
return "headers is not an object";
|
|
133
|
+
if (bodyBytes(r.body) === undefined)
|
|
134
|
+
return "body is not a Buffer, Uint8Array, other ArrayBuffer view or ArrayBuffer";
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The response body as a Buffer (a view, no copy): a Buffer, any ArrayBuffer view (a
|
|
139
|
+
* Uint8Array from fetch, a DataView) or an ArrayBuffer/SharedArrayBuffer — checked by
|
|
140
|
+
* internal slot, not `instanceof`, so a value from another realm (a vm context, a Jest
|
|
141
|
+
* test) counts. Undefined for anything else. (`Uint8Array#toString` ignores an encoding
|
|
142
|
+
* argument and yields "123,34,…", which is how a fetch body used to fail to parse.)
|
|
143
|
+
*/
|
|
144
|
+
function bodyBytes(value) {
|
|
145
|
+
if (Buffer.isBuffer(value))
|
|
146
|
+
return value;
|
|
147
|
+
if (ArrayBuffer.isView(value))
|
|
148
|
+
return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
|
149
|
+
const tag = Object.prototype.toString.call(value);
|
|
150
|
+
if (tag === "[object ArrayBuffer]" || tag === "[object SharedArrayBuffer]")
|
|
151
|
+
return Buffer.from(value);
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* The response headers as a plain record with lower-case names. A transport built on
|
|
156
|
+
* `fetch` naturally returns its `Headers` object, which has no plain properties, and a
|
|
157
|
+
* custom one may write `Retry-After` or `Location` capitalised: the engine then saw no
|
|
158
|
+
* Retry-After (and retried at once) and no Location (and failed the redirect). Such an
|
|
159
|
+
* object (anything with `get` and `forEach`, a `Map` included) is copied; a plain record
|
|
160
|
+
* gets its names lower-cased.
|
|
161
|
+
*/
|
|
162
|
+
function plainHeaders(headers) {
|
|
163
|
+
const h = headers;
|
|
164
|
+
if (typeof h.get === "function" && typeof h.forEach === "function") {
|
|
165
|
+
const record = {};
|
|
166
|
+
h.forEach.call(headers, (value, name) => {
|
|
167
|
+
// Headers#forEach gives (value, name), and so does Map#forEach.
|
|
168
|
+
record[String(name).toLowerCase()] = String(value);
|
|
169
|
+
});
|
|
170
|
+
return record;
|
|
171
|
+
}
|
|
172
|
+
const record = {};
|
|
173
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
174
|
+
record[name.toLowerCase()] = value;
|
|
175
|
+
}
|
|
176
|
+
return record;
|
|
177
|
+
}
|
|
178
|
+
/** One header value as a string (the first of a repeated one), or undefined. */
|
|
179
|
+
function headerValue(value) {
|
|
180
|
+
return Array.isArray(value) ? value[0] : value;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Error codes of a connection that broke off mid-request: Node's (`socket hang up` is
|
|
184
|
+
* ECONNRESET) and undici's (`fetch failed` with cause UND_ERR_SOCKET, "other side closed").
|
|
185
|
+
*/
|
|
186
|
+
const TRANSIENT_NETWORK_CODES = new Set(["ECONNRESET", "EPIPE", "ECONNABORTED", "UND_ERR_SOCKET"]);
|
|
187
|
+
/** True when `err` or an error in its `cause` chain has a transient connection code. */
|
|
188
|
+
function hasTransientCode(err, depth = 0) {
|
|
189
|
+
if (typeof err !== "object" || err === null || depth > 4)
|
|
190
|
+
return false;
|
|
191
|
+
const code = err.code;
|
|
192
|
+
if (typeof code === "string" && TRANSIENT_NETWORK_CODES.has(code))
|
|
193
|
+
return true;
|
|
194
|
+
return hasTransientCode(err.cause, depth + 1);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* True for a PegelNetworkError caused by a reset or aborted connection, which the
|
|
198
|
+
* engine retries — whichever transport raised it (a Node error, fetch's TypeError with
|
|
199
|
+
* an undici cause). A refused connection, a DNS failure or a timeout is not transient
|
|
200
|
+
* in that sense and is not retried.
|
|
201
|
+
*/
|
|
202
|
+
export function isTransientNetworkError(err) {
|
|
203
|
+
return err instanceof PegelNetworkError && hasTransientCode(err.cause);
|
|
204
|
+
}
|
|
64
205
|
/**
|
|
65
206
|
* Credential-bearing headers that must never be carried across an origin boundary
|
|
66
207
|
* on a redirect. Stored lower-cased and compared case-insensitively so a header
|
|
@@ -79,10 +220,34 @@ const CREDENTIAL_HEADERS = new Set([
|
|
|
79
220
|
* guarantee correct regardless of the casing the caller used.
|
|
80
221
|
*/
|
|
81
222
|
function stripSensitiveHeaders(headers) {
|
|
223
|
+
let stripped = false;
|
|
82
224
|
for (const key of Object.keys(headers)) {
|
|
83
|
-
if (CREDENTIAL_HEADERS.has(key.toLowerCase()))
|
|
225
|
+
if (CREDENTIAL_HEADERS.has(key.toLowerCase())) {
|
|
84
226
|
delete headers[key];
|
|
227
|
+
stripped = true;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return stripped;
|
|
231
|
+
}
|
|
232
|
+
/** True when `a` and `b` parse and share scheme, host and port; false otherwise. */
|
|
233
|
+
function sameOrigin(a, b) {
|
|
234
|
+
try {
|
|
235
|
+
return new URL(a).origin === new URL(b).origin;
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Why a 401/403 after a redirect to another origin may not be the credentials' fault:
|
|
243
|
+
* the engine did not send them there. An http->https upgrade on the same host gets its
|
|
244
|
+
* own advice.
|
|
245
|
+
*/
|
|
246
|
+
function credentialsDroppedHint(from, to) {
|
|
247
|
+
if (from.protocol === "http:" && to.protocol === "https:" && from.hostname === to.hostname) {
|
|
248
|
+
return "the server redirected http to https, so the credentials were not sent there; use an https base URL";
|
|
85
249
|
}
|
|
250
|
+
return `the server redirected to another origin (${to.origin}), so the credentials were not sent there; use that origin as the base URL if it should get them`;
|
|
86
251
|
}
|
|
87
252
|
/**
|
|
88
253
|
* Strip control characters (all C0/C1 controls except tab and newline, plus DEL)
|
|
@@ -117,10 +282,16 @@ export function validateBaseUrl(raw) {
|
|
|
117
282
|
}
|
|
118
283
|
const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
119
284
|
export class RequestEngine {
|
|
120
|
-
|
|
285
|
+
// Real private fields (not TypeScript's `private`): util.inspect, console.log and
|
|
286
|
+
// JSON.stringify of a client never show them, so a password in the base URL, or an
|
|
287
|
+
// Authorization header a caller added, can't be logged by accident. Messages show the
|
|
288
|
+
// base URL through redactUrl.
|
|
289
|
+
#baseUrl;
|
|
290
|
+
/** The base URL's userinfo, raw and percent-decoded, for scrubbing server and transport text. */
|
|
291
|
+
#credentials;
|
|
292
|
+
#extraHeaders;
|
|
121
293
|
transport;
|
|
122
294
|
userAgent;
|
|
123
|
-
extraHeaders;
|
|
124
295
|
timeoutMs;
|
|
125
296
|
maxRetries;
|
|
126
297
|
retryDelayMs;
|
|
@@ -128,10 +299,22 @@ export class RequestEngine {
|
|
|
128
299
|
maxResponseBytes;
|
|
129
300
|
sleep;
|
|
130
301
|
constructor(options = {}) {
|
|
302
|
+
// A JavaScript caller may pass null for "no options"; treat it like undefined.
|
|
303
|
+
options = options ?? {};
|
|
304
|
+
// A typo (`timeout` for `timeoutMs`) used to be ignored and the default applied.
|
|
305
|
+
assertValid("options", options, knownKeysProblem(OPTION_NAMES));
|
|
131
306
|
// The raw value, before the slash strip: the engine glues it into every URL, so
|
|
132
307
|
// "https://h/ " must not lose its slash first and slip past the check.
|
|
133
|
-
this
|
|
134
|
-
this
|
|
308
|
+
this.#baseUrl = validateBaseUrl(options.baseUrl ?? DEFAULT_BASE_URL);
|
|
309
|
+
this.#credentials = credentialsIn(this.#baseUrl).flatMap((raw) => {
|
|
310
|
+
try {
|
|
311
|
+
return [raw, decodeURIComponent(raw)];
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
return [raw];
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
this.transport = functionOption("transport", options.transport, nodeHttpTransport);
|
|
135
318
|
// Only `undefined` selects the default. An explicit value must be one an HTTP
|
|
136
319
|
// header can carry (headerValueProblem): not blank, which would replace the
|
|
137
320
|
// default with an empty header, and no control character (CR/LF in particular,
|
|
@@ -141,18 +324,18 @@ export class RequestEngine {
|
|
|
141
324
|
options.userAgent === undefined
|
|
142
325
|
? DEFAULT_USER_AGENT
|
|
143
326
|
: assertValid("User-Agent", options.userAgent, headerValueProblem);
|
|
144
|
-
this
|
|
327
|
+
this.#extraHeaders = headersOption(options.headers);
|
|
145
328
|
this.timeoutMs = intOption("timeoutMs", options.timeoutMs, 30_000, MAX_TIMEOUT_MS);
|
|
146
329
|
this.maxRetries = intOption("maxRetries", options.maxRetries, 2, MAX_RETRIES);
|
|
147
330
|
this.retryDelayMs = intOption("retryDelayMs", options.retryDelayMs, 200, MAX_RETRY_AFTER_MS);
|
|
148
331
|
this.maxRedirects = intOption("maxRedirects", options.maxRedirects, 5, MAX_REDIRECTS);
|
|
149
332
|
this.maxResponseBytes = intOption("maxResponseBytes", options.maxResponseBytes, DEFAULT_MAX_RESPONSE_BYTES, Number.MAX_SAFE_INTEGER);
|
|
150
|
-
this.sleep = options.sleep
|
|
333
|
+
this.sleep = functionOption("sleep", options.sleep, realSleep);
|
|
151
334
|
}
|
|
152
335
|
/**
|
|
153
336
|
* Build a fully-qualified URL from a path and optional query parameters.
|
|
154
337
|
*
|
|
155
|
-
* Throws a
|
|
338
|
+
* Throws a PegelValidationError for a path with a "." or ".." segment. The resource methods
|
|
156
339
|
* put ids into the path with `encodeURIComponent`, which leaves those two
|
|
157
340
|
* unchanged, and URL parsing then resolves them: `currentMeasurement("BONN", "..")`
|
|
158
341
|
* would request `/stations/currentmeasurement.json` (a station of that name).
|
|
@@ -163,58 +346,184 @@ export class RequestEngine {
|
|
|
163
346
|
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
|
164
347
|
const dotSegment = normalizedPath.split("/").find((s) => s === "." || s === "..");
|
|
165
348
|
if (dotSegment !== undefined) {
|
|
166
|
-
throw new
|
|
349
|
+
throw new PegelValidationError(`Invalid path segment "${dotSegment}" in ${normalizedPath}: "." and ".." cannot be used as an id.`);
|
|
167
350
|
}
|
|
168
351
|
const qs = query ? buildQueryString(query) : "";
|
|
169
|
-
return `${this
|
|
352
|
+
return `${this.#baseUrl}${normalizedPath}${qs ? `?${qs}` : ""}`;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* `text` without the base URL's credentials: server text (an error body that echoes
|
|
356
|
+
* the request URL) and transport text (fetch's "Failed to fetch <url>") can carry them.
|
|
357
|
+
*/
|
|
358
|
+
scrub(text) {
|
|
359
|
+
return this.#credentials.length === 0 ? text : redactCredentials(text, this.#credentials);
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* A transport failure as the `cause` of the error the engine raises: the original
|
|
363
|
+
* when its text carries no credentials, otherwise a copy with them scrubbed (message,
|
|
364
|
+
* `code` and the cause chain kept), so logging the error with its causes can't reveal
|
|
365
|
+
* the base URL's password.
|
|
366
|
+
*/
|
|
367
|
+
scrubCause(cause, depth = 0) {
|
|
368
|
+
if (this.#credentials.length === 0 || depth > 5)
|
|
369
|
+
return cause;
|
|
370
|
+
if (typeof cause === "string")
|
|
371
|
+
return this.scrub(cause);
|
|
372
|
+
if (!(cause instanceof Error))
|
|
373
|
+
return cause;
|
|
374
|
+
const inner = this.scrubCause(cause.cause, depth + 1);
|
|
375
|
+
const message = this.scrub(cause.message);
|
|
376
|
+
if (message === cause.message && inner === cause.cause && !this.scrub(cause.stack ?? "").includes("***@"))
|
|
377
|
+
return cause;
|
|
378
|
+
const copy = new Error(message, inner === undefined ? undefined : { cause: inner });
|
|
379
|
+
copy.name = cause.name;
|
|
380
|
+
const code = cause.code;
|
|
381
|
+
if (code !== undefined)
|
|
382
|
+
Object.assign(copy, { code });
|
|
383
|
+
return copy;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Call the transport under the overall deadline (`timeoutMs`): the request gets an
|
|
387
|
+
* AbortSignal that fires at the deadline, and the call rejects then whether the
|
|
388
|
+
* transport stops or not — a custom transport (fetch, a node:http wrapper) that
|
|
389
|
+
* ignores `timeoutMs` can't hang the caller. A synchronous throw becomes a rejection.
|
|
390
|
+
*/
|
|
391
|
+
async callTransport(request) {
|
|
392
|
+
const call = (signal) => Promise.resolve().then(() => this.transport(signal === undefined ? request : { ...request, signal }));
|
|
393
|
+
if (this.timeoutMs === 0)
|
|
394
|
+
return call();
|
|
395
|
+
const controller = new AbortController();
|
|
396
|
+
let timer;
|
|
397
|
+
const deadline = new Promise((_, reject) => {
|
|
398
|
+
timer = setTimeout(() => {
|
|
399
|
+
const err = new PegelNetworkError(`Request timed out after ${this.timeoutMs}ms`);
|
|
400
|
+
controller.abort(err);
|
|
401
|
+
reject(err);
|
|
402
|
+
}, this.timeoutMs);
|
|
403
|
+
timer.unref?.();
|
|
404
|
+
});
|
|
405
|
+
try {
|
|
406
|
+
return await Promise.race([call(controller.signal), deadline]);
|
|
407
|
+
}
|
|
408
|
+
finally {
|
|
409
|
+
clearTimeout(timer);
|
|
410
|
+
}
|
|
170
411
|
}
|
|
171
412
|
/** Perform a request with Accept negotiation and transient-error retries. */
|
|
172
413
|
async request(method, path, options = { accept: "application/json" }) {
|
|
173
414
|
let url = this.buildUrl(path, options.query);
|
|
174
415
|
const headers = {
|
|
175
|
-
...this
|
|
416
|
+
...this.#extraHeaders,
|
|
176
417
|
Accept: options.accept,
|
|
177
418
|
"User-Agent": this.userAgent,
|
|
178
419
|
};
|
|
420
|
+
// Only an idempotent request is sent again after a reset: request() is public, and
|
|
421
|
+
// a POST re-sent after a broken connection may be applied twice. The client itself
|
|
422
|
+
// sends GETs only.
|
|
423
|
+
const idempotent = /^(GET|HEAD)$/i.test(method);
|
|
179
424
|
let attempt = 0;
|
|
180
425
|
let redirects = 0;
|
|
426
|
+
/** Where a redirect to another origin dropped the credentials, for the 401/403 hint. */
|
|
427
|
+
let droppedCredentialsAt;
|
|
181
428
|
// attempts = initial try + maxRetries (redirects are counted separately)
|
|
182
429
|
for (;;) {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
430
|
+
let response;
|
|
431
|
+
try {
|
|
432
|
+
response = await this.callTransport({
|
|
433
|
+
method,
|
|
434
|
+
url,
|
|
435
|
+
headers,
|
|
436
|
+
redirect: "manual",
|
|
437
|
+
timeoutMs: this.timeoutMs,
|
|
438
|
+
...(this.maxResponseBytes > 0 ? { maxResponseBytes: this.maxResponseBytes } : {}),
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
catch (cause) {
|
|
442
|
+
// A connection the server (or a gateway) reset is the network-level twin of a
|
|
443
|
+
// 503: retry the GET, whichever transport reported it. Timeouts are not retried
|
|
444
|
+
// — a slow upstream should not be asked again at once.
|
|
445
|
+
if (idempotent && hasTransientCode(cause) && attempt < this.maxRetries) {
|
|
446
|
+
attempt += 1;
|
|
447
|
+
await this.sleep(this.retryDelayMs * attempt);
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
// The default transport rejects with PegelNetworkError only; an injected one may
|
|
451
|
+
// throw anything (a string, a TypeError, null). Keep the error contract for both:
|
|
452
|
+
// every failure is a PegelError. The message names the request — Node's text
|
|
453
|
+
// ("socket hang up") says nothing about which host or gauge failed — and the
|
|
454
|
+
// original is the `cause`. Any other PegelError passes through.
|
|
455
|
+
if (cause instanceof PegelError && !(cause instanceof PegelNetworkError))
|
|
456
|
+
throw cause;
|
|
457
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
458
|
+
const retried = attempt > 0 ? ` (after ${attempt} ${attempt === 1 ? "retry" : "retries"})` : "";
|
|
459
|
+
throw new PegelNetworkError(`${method} ${redactUrl(url)} failed: ${cleanDetail(this.scrub(reason))}${retried}`, { cause: this.scrubCause(cause) });
|
|
460
|
+
}
|
|
461
|
+
// An injected transport may resolve with anything; a malformed HttpResponse would
|
|
462
|
+
// otherwise surface below as a raw TypeError, outside the PegelError contract.
|
|
463
|
+
const invalid = responseProblem(response);
|
|
464
|
+
if (invalid !== undefined) {
|
|
465
|
+
throw new PegelNetworkError(`${method} ${redactUrl(url)} failed: the transport returned an invalid response (${invalid}).`);
|
|
466
|
+
}
|
|
467
|
+
// Transports must not follow redirects (HttpRequest.redirect is "manual"); fetch does
|
|
468
|
+
// by default. One that reports a final URL on another origin has carried the
|
|
469
|
+
// request — and maybe a credential header fetch doesn't strip — somewhere the
|
|
470
|
+
// engine never vetted, so its answer is not trusted.
|
|
471
|
+
const finalUrl = response.url;
|
|
472
|
+
if (typeof finalUrl === "string" && finalUrl !== "" && !sameOrigin(finalUrl, url)) {
|
|
473
|
+
throw new PegelNetworkError(`${method} ${redactUrl(url)} failed: the transport followed a redirect to another origin ` +
|
|
474
|
+
`(${cleanDetail(redactUrl(finalUrl))}); a transport must not follow redirects (HttpRequest.redirect is "manual").`);
|
|
475
|
+
}
|
|
190
476
|
const status = response.status;
|
|
477
|
+
const responseHeaders = plainHeaders(response.headers);
|
|
478
|
+
const body = bodyBytes(response.body);
|
|
479
|
+
// The size cap holds whatever the transport did: the default one aborts early, a
|
|
480
|
+
// custom one may have read everything.
|
|
481
|
+
if (this.maxResponseBytes > 0 && body.byteLength > this.maxResponseBytes) {
|
|
482
|
+
throw new PegelNetworkError(`${method} ${redactUrl(url)} failed: ${sizeLimitMessage(this.maxResponseBytes)}`);
|
|
483
|
+
}
|
|
191
484
|
const retryable = status === 429 || status === 503;
|
|
485
|
+
// A Retry-After beyond MAX_RETRY_AFTER_MS is not retried: the error below surfaces
|
|
486
|
+
// at once and names the wait the server asked for.
|
|
487
|
+
let refusedWaitMs;
|
|
192
488
|
if (retryable && attempt < this.maxRetries) {
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
|
|
489
|
+
// Back off linearly (retryDelayMs × attempt). A Retry-After can make the wait
|
|
490
|
+
// longer, never shorter: `Retry-After: 0` or a date in the past used to turn the
|
|
491
|
+
// retries into a zero-delay burst against a server that had just asked for less.
|
|
492
|
+
const retryAfter = parseRetryAfter(responseHeaders["retry-after"]);
|
|
196
493
|
if (retryAfter === undefined || retryAfter <= MAX_RETRY_AFTER_MS) {
|
|
197
494
|
attempt += 1;
|
|
198
|
-
|
|
495
|
+
const backoff = this.retryDelayMs * attempt;
|
|
496
|
+
await this.sleep(retryAfter === undefined ? backoff : Math.max(retryAfter, backoff));
|
|
199
497
|
continue;
|
|
200
498
|
}
|
|
499
|
+
refusedWaitMs = retryAfter;
|
|
201
500
|
}
|
|
202
501
|
// Follow redirects, resolving the Location relative to the current URL.
|
|
203
|
-
const location =
|
|
502
|
+
const location = headerValue(responseHeaders["location"]);
|
|
204
503
|
const next = FOLLOWED_REDIRECTS.has(status) ? resolveLocation(location, url) : undefined;
|
|
205
504
|
if (next !== undefined && redirects >= this.maxRedirects) {
|
|
206
505
|
// A loop (or a long chain): say how far it got rather than a bare 3xx.
|
|
207
506
|
// (With maxRedirects 0 nothing was followed; the plain text says enough.)
|
|
208
|
-
throw this.toApiError(method, url, status,
|
|
507
|
+
throw this.toApiError(method, url, status, body, location, redirects || undefined);
|
|
209
508
|
}
|
|
210
509
|
if (next !== undefined) {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
510
|
+
const current = new URL(url);
|
|
511
|
+
if (next.origin !== current.origin) {
|
|
512
|
+
// Security: never carry credentials across an origin boundary — neither
|
|
513
|
+
// credential-bearing headers (a caller's Authorization/Cookie/X-Api-Key) nor
|
|
514
|
+
// the base URL's userinfo, which an absolute Location to another host doesn't
|
|
515
|
+
// carry. Comparing full origin (scheme + host + port) also covers a same-host
|
|
516
|
+
// https->http downgrade and an http->https upgrade.
|
|
517
|
+
if (stripSensitiveHeaders(headers) || current.username !== "" || current.password !== "") {
|
|
518
|
+
droppedCredentialsAt = { from: current, to: next };
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
else if (next.username === "" && next.password === "") {
|
|
522
|
+
// Same origin: keep the base URL's userinfo. A relative Location inherits it
|
|
523
|
+
// when resolved; an absolute one (`Location: https://same-host/…`) used to
|
|
524
|
+
// drop it and turn a mirror login into a 401.
|
|
525
|
+
next.username = current.username;
|
|
526
|
+
next.password = current.password;
|
|
218
527
|
}
|
|
219
528
|
url = next.toString();
|
|
220
529
|
redirects += 1;
|
|
@@ -222,17 +531,20 @@ export class RequestEngine {
|
|
|
222
531
|
}
|
|
223
532
|
// Any other 3xx — not a followed status, or no usable Location — falls
|
|
224
533
|
// through and surfaces as a PegelApiError naming the target.
|
|
225
|
-
const contentType = String(
|
|
534
|
+
const contentType = String(headerValue(responseHeaders["content-type"]) ?? "");
|
|
226
535
|
if (status < 200 || status >= 300) {
|
|
227
|
-
|
|
536
|
+
const hint = (status === 401 || status === 403) && droppedCredentialsAt !== undefined
|
|
537
|
+
? credentialsDroppedHint(droppedCredentialsAt.from, droppedCredentialsAt.to)
|
|
538
|
+
: undefined;
|
|
539
|
+
throw this.toApiError(method, url, status, body, location, undefined, refusedWaitMs, hint);
|
|
228
540
|
}
|
|
229
|
-
return { data:
|
|
541
|
+
return { data: body, contentType, status };
|
|
230
542
|
}
|
|
231
543
|
}
|
|
232
544
|
/** Perform a GET expecting JSON and parse it into `T`. */
|
|
233
545
|
async getJson(path, query) {
|
|
234
546
|
const res = await this.request("GET", path, { query, accept: "application/json" });
|
|
235
|
-
const text = res.data.
|
|
547
|
+
const text = decodeBody(res.data, res.contentType, path);
|
|
236
548
|
try {
|
|
237
549
|
return JSON.parse(text);
|
|
238
550
|
}
|
|
@@ -240,8 +552,8 @@ export class RequestEngine {
|
|
|
240
552
|
throw new PegelParseError(`Failed to parse JSON response from ${path}`, { cause });
|
|
241
553
|
}
|
|
242
554
|
}
|
|
243
|
-
toApiError(method, url, status, body, locationHeader, redirectsFollowed) {
|
|
244
|
-
const text = body.toString("utf8");
|
|
555
|
+
toApiError(method, url, status, body, locationHeader, redirectsFollowed, retryAfterMs, hint) {
|
|
556
|
+
const text = this.scrub(body.toString("utf8"));
|
|
245
557
|
let detail;
|
|
246
558
|
try {
|
|
247
559
|
const parsed = JSON.parse(text);
|
|
@@ -256,7 +568,7 @@ export class RequestEngine {
|
|
|
256
568
|
// `detail` came from the response body; strip control characters so a hostile
|
|
257
569
|
// endpoint cannot inject terminal escape sequences via the stderr error message.
|
|
258
570
|
if (detail !== undefined)
|
|
259
|
-
detail =
|
|
571
|
+
detail = cleanDetail(detail);
|
|
260
572
|
// Name the target of a redirect that was not followed.
|
|
261
573
|
const location = status >= 300 && status < 400 && locationHeader ? redirectTarget(url, locationHeader) : undefined;
|
|
262
574
|
return new PegelApiError({
|
|
@@ -267,15 +579,41 @@ export class RequestEngine {
|
|
|
267
579
|
detail,
|
|
268
580
|
...(location !== undefined ? { location } : {}),
|
|
269
581
|
...(redirectsFollowed !== undefined ? { redirectsFollowed } : {}),
|
|
582
|
+
...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
|
|
583
|
+
...(hint !== undefined ? { hint } : {}),
|
|
270
584
|
});
|
|
271
585
|
}
|
|
272
586
|
}
|
|
273
|
-
/**
|
|
587
|
+
/**
|
|
588
|
+
* Decode a response body by the charset its Content-Type names (UTF-8 when it names
|
|
589
|
+
* none). A proxy or mirror that answers in ISO-8859-1 used to come out as "K\uFFFDLN"
|
|
590
|
+
* with exit 0. TextDecoder also drops a leading byte order mark, which
|
|
591
|
+
* Buffer#toString keeps and JSON.parse then rejects. An unknown charset label is a
|
|
592
|
+
* PegelParseError.
|
|
593
|
+
*/
|
|
594
|
+
function decodeBody(body, contentType, path) {
|
|
595
|
+
const charset = /;\s*charset\s*=\s*"?([^";\s]+)"?/i.exec(contentType)?.[1] ?? "utf-8";
|
|
596
|
+
let decoder;
|
|
597
|
+
try {
|
|
598
|
+
decoder = new TextDecoder(charset);
|
|
599
|
+
}
|
|
600
|
+
catch {
|
|
601
|
+
throw new PegelParseError(`Unsupported response charset "${sanitizeServerText(charset)}" from ${path}.`);
|
|
602
|
+
}
|
|
603
|
+
return decoder.decode(body);
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Resolve a Location header against the current URL; undefined if missing, malformed
|
|
607
|
+
* or not http(s). A `file:`, `data:` or `javascript:` target is refused here, before
|
|
608
|
+
* any transport sees it (the default transport would refuse it too; a custom one may
|
|
609
|
+
* not), and surfaces as a PegelApiError naming the target.
|
|
610
|
+
*/
|
|
274
611
|
function resolveLocation(location, base) {
|
|
275
612
|
if (location === undefined || location === "")
|
|
276
613
|
return undefined;
|
|
277
614
|
try {
|
|
278
|
-
|
|
615
|
+
const next = new URL(location, base);
|
|
616
|
+
return next.protocol === "http:" || next.protocol === "https:" ? next : undefined;
|
|
279
617
|
}
|
|
280
618
|
catch {
|
|
281
619
|
return undefined;
|
|
@@ -288,6 +626,6 @@ function resolveLocation(location, base) {
|
|
|
288
626
|
*/
|
|
289
627
|
function redirectTarget(requestUrl, location) {
|
|
290
628
|
const resolved = resolveLocation(location, requestUrl);
|
|
291
|
-
const clean =
|
|
629
|
+
const clean = cleanDetail(resolved ? redactUrl(resolved.href) : location).trim();
|
|
292
630
|
return clean === "" ? undefined : clean;
|
|
293
631
|
}
|
|
@@ -7,9 +7,29 @@ export declare class PegelError extends Error {
|
|
|
7
7
|
/**
|
|
8
8
|
* Replace the userinfo of a URL (`https://user:secret@host/...`) with `***`, so a
|
|
9
9
|
* credential in a base URL never reaches an error message, a log or CI output.
|
|
10
|
-
* A
|
|
10
|
+
* A value that does not parse as a URL (a port typo, an unencoded "#" in the
|
|
11
|
+
* password) or has no scheme (`user:pw@host`) is cut by text instead
|
|
12
|
+
* (`credentialsIn` + `redactCredentials`); one without userinfo is returned unchanged.
|
|
11
13
|
*/
|
|
12
14
|
export declare function redactUrl(url: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* The userinfo a URL-like value carries, exactly as written — `["alice:pa#ss"]` for
|
|
17
|
+
* `https://alice:pa#ss@host` — or `[]` when it carries none. It works on values that
|
|
18
|
+
* don't parse as a URL too, and on values with a prefix (`--base-url=https://u:p@h`):
|
|
19
|
+
* the userinfo is everything between `://` and the last `@` before the host. A value
|
|
20
|
+
* without a scheme counts when it reads `user:password@host`. Used to redact those
|
|
21
|
+
* exact strings from text that echoes the value (usage errors, help), whatever
|
|
22
|
+
* characters the password contains.
|
|
23
|
+
*/
|
|
24
|
+
export declare function credentialsIn(value: string): string[];
|
|
25
|
+
/**
|
|
26
|
+
* `text` with every occurrence of each credential (as `credentialsIn` returns them)
|
|
27
|
+
* that is followed by `@` replaced by `***`. Matching the exact strings, not a
|
|
28
|
+
* pattern, covers passwords with spaces, quotes, `#`, `?` or `/` that no URL pattern
|
|
29
|
+
* can delimit. The percent-encoded form (`alice%3Apw%40`, as a URL typed where a
|
|
30
|
+
* station id belongs ends up in a request path) is replaced too.
|
|
31
|
+
*/
|
|
32
|
+
export declare function redactCredentials(text: string, credentials: readonly string[]): string;
|
|
13
33
|
/**
|
|
14
34
|
* The API responded with a non-2xx status code. `detail` holds a human-readable
|
|
15
35
|
* message extracted from the response body when one is present.
|
|
@@ -26,6 +46,12 @@ export declare class PegelApiError extends PegelError {
|
|
|
26
46
|
* redacted. The message names it.
|
|
27
47
|
*/
|
|
28
48
|
readonly location: string | undefined;
|
|
49
|
+
/**
|
|
50
|
+
* For a 429/503 that was not retried because its `Retry-After` asked for longer than
|
|
51
|
+
* the client waits (`MAX_RETRY_AFTER_MS`, 30 s): the wait the server asked for, in
|
|
52
|
+
* milliseconds. Retrying before then won't help.
|
|
53
|
+
*/
|
|
54
|
+
readonly retryAfterMs: number | undefined;
|
|
29
55
|
constructor(args: {
|
|
30
56
|
status: number;
|
|
31
57
|
url: string;
|
|
@@ -35,6 +61,10 @@ export declare class PegelApiError extends PegelError {
|
|
|
35
61
|
location?: string;
|
|
36
62
|
/** Redirects already followed when the limit stopped this one (> 0 only). */
|
|
37
63
|
redirectsFollowed?: number;
|
|
64
|
+
/** A Retry-After longer than the client waits (not retried). */
|
|
65
|
+
retryAfterMs?: number;
|
|
66
|
+
/** Advice appended to the message (e.g. that a redirect dropped the credentials). */
|
|
67
|
+
hint?: string;
|
|
38
68
|
});
|
|
39
69
|
/** True for statuses the API documents as transient and retry-able. */
|
|
40
70
|
get isRetryable(): boolean;
|