@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.
@@ -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 { MAX_TIMEOUT_MS, nodeHttpTransport } from "./http.js";
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 PegelError(`Invalid option ${name}: expected an integer from 0 to ${max}, got ${String(value)}.`);
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: retrying early would only land inside the window the server asked us to wait
38
- * out, and a hostile value must not stall the CLI.
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
- baseUrl;
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.baseUrl = validateBaseUrl(options.baseUrl ?? DEFAULT_BASE_URL);
134
- this.transport = options.transport ?? nodeHttpTransport;
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.extraHeaders = options.headers ?? {};
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 ?? realSleep;
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 PegelError for a path with a "." or ".." segment. The resource methods
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 PegelError(`Invalid path segment "${dotSegment}" in ${normalizedPath}: "." and ".." cannot be used as an id.`);
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.baseUrl}${normalizedPath}${qs ? `?${qs}` : ""}`;
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.extraHeaders,
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
- const response = await this.transport({
184
- method,
185
- url,
186
- headers,
187
- timeoutMs: this.timeoutMs,
188
- ...(this.maxResponseBytes > 0 ? { maxResponseBytes: this.maxResponseBytes } : {}),
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
- // Honour Retry-After; without a usable one, back off linearly. A Retry-After
194
- // beyond MAX_RETRY_AFTER_MS is not retried: the error below surfaces at once.
195
- const retryAfter = parseRetryAfter(response.headers["retry-after"]);
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
- await this.sleep(retryAfter ?? this.retryDelayMs * attempt);
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 = response.headers["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, response.body, location, redirects || undefined);
507
+ throw this.toApiError(method, url, status, body, location, redirects || undefined);
209
508
  }
210
509
  if (next !== undefined) {
211
- // Security: never carry credential-bearing headers across an origin
212
- // boundary. The CLI sends none today, but this guards a future
213
- // Authorization/Cookie/X-Api-Key header from leaking to an
214
- // attacker-controlled redirect target. Comparing full origin (scheme +
215
- // host + port) also strips on a same-host https->http downgrade.
216
- if (next.origin !== new URL(url).origin) {
217
- stripSensitiveHeaders(headers);
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(response.headers["content-type"] ?? "");
534
+ const contentType = String(headerValue(responseHeaders["content-type"]) ?? "");
226
535
  if (status < 200 || status >= 300) {
227
- throw this.toApiError(method, url, status, response.body, location);
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: response.body, contentType, status };
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.toString("utf8");
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 = sanitizeServerText(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
- /** Resolve a Location header against the current URL; undefined if missing or malformed. */
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
- return new URL(location, base);
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 = sanitizeServerText(resolved ? redactUrl(resolved.href) : location).trim();
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 URL without userinfo, or one that does not parse, is returned unchanged.
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;