@maschinenlesbar.org/marktstammdatenregister-cli 0.1.0 → 0.2.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.
@@ -30,15 +30,17 @@ export interface EngineOptions {
30
30
  */
31
31
  timeoutMs?: number;
32
32
  /**
33
- * Number of automatic retries for transient (429/503) responses, an integer
34
- * 0..`MAX_RETRIES` (10); defaults to 2. Each waits the response's `Retry-After`
35
- * (up to `MAX_RETRY_AFTER_MS`; a longer one is not retried), or else
36
- * `retryDelayMs * attempt`.
33
+ * Number of automatic retries for transient (429/503) responses and reset
34
+ * connections (`isTransientNetworkError`), an integer
35
+ * 0..`MAX_RETRIES` (10); defaults to 2. Each waits `retryDelayMs * attempt`, or the
36
+ * response's `Retry-After` when that is longer. A `Retry-After` above
37
+ * `MAX_RETRY_AFTER_MS` is not retried: the MastrApiError names the requested wait.
37
38
  */
38
39
  maxRetries?: number;
39
40
  /**
40
- * Base backoff between retries in milliseconds (grows linearly), a non-negative
41
- * integer; used without a Retry-After. Defaults to 200.
41
+ * Base backoff between retries in milliseconds (grows linearly: `retryDelayMs * attempt`),
42
+ * an integer 0..`MAX_RETRY_AFTER_MS` (30 000). Defaults to 200. It is also the floor: a
43
+ * `Retry-After` can make a wait longer, never shorter.
42
44
  */
43
45
  retryDelayMs?: number;
44
46
  /**
@@ -96,13 +98,20 @@ export declare function isBidiControl(code: number): boolean;
96
98
  * Written as a code-point filter so no raw control byte appears in this source.
97
99
  */
98
100
  export declare function sanitizeServerText(text: string): string;
101
+ /**
102
+ * Longest server text (in characters) an error message keeps: a hostile or buggy body
103
+ * must not flood stderr or a CI log with one huge line. `MastrApiError.body` keeps the
104
+ * full text.
105
+ */
106
+ export declare const MAX_DETAIL_LENGTH = 500;
99
107
  /**
100
108
  * Describe a Kendo `Errors` value for an error message: a string as is; otherwise
101
109
  * (a ModelState object such as `{"": {"errors": ["Invalid filter"]}}`, or an array)
102
110
  * every string found in it, sanitised, blanks and repeats dropped, joined "; ".
103
- * Returns `undefined` when nothing readable is left.
111
+ * Returns `undefined` when nothing readable is left. `clean` runs on each raw string
112
+ * first (the engine passes its credential scrubber).
104
113
  */
105
- export declare function describeMastrErrors(errors: unknown): string | undefined;
114
+ export declare function describeMastrErrors(errors: unknown, clean?: (text: string) => string): string | undefined;
106
115
  /**
107
116
  * Check a base URL against every rule of {@link baseUrlProblem} — blank, whitespace
108
117
  * or control characters, not an absolute URL, a scheme other than `http:`/`https:`,
@@ -119,23 +128,63 @@ export declare function validateBaseUrl(raw: string): string;
119
128
  * `userAgent` and every `defaultHeaders` value before any request.
120
129
  */
121
130
  export declare function assertHeaderValue(name: string, value: string): string;
131
+ /**
132
+ * True for a failure caused by a reset or aborted connection, which the engine retries —
133
+ * whichever transport raised it (a Node error, fetch's TypeError with an undici cause), the
134
+ * code anywhere in the `cause` chain. A refused connection, a DNS failure or a timeout is
135
+ * not transient in that sense and is not retried.
136
+ */
137
+ export declare function isTransientNetworkError(err: unknown): boolean;
138
+ /**
139
+ * Throw for a key that is not an option name. A JavaScript caller's typo (`timeout` for
140
+ * `timeoutMs`) was ignored silently and the default applied; TypeScript catches it at
141
+ * compile time, JavaScript does not. `extra` names options a wrapper (the client) adds.
142
+ */
143
+ export declare function assertKnownOptions(options: object, extra?: readonly string[]): void;
144
+ /** Engine options as given, or a MastrValidationError for a non-object (null counts as none). */
145
+ export declare function optionsObject<T extends object>(options: T | null | undefined): T;
122
146
  export declare class RequestEngine {
123
- private readonly baseUrl;
147
+ #private;
124
148
  private readonly transport;
125
149
  private readonly userAgent;
126
- private readonly defaultHeaders;
127
150
  private readonly timeoutMs;
128
151
  private readonly maxRetries;
129
152
  private readonly retryDelayMs;
130
153
  private readonly maxResponseBytes;
131
154
  private readonly sleep;
132
155
  constructor(options?: EngineOptions);
156
+ /**
157
+ * `text` without the base URL's credentials: server text (an error body that echoes the
158
+ * request URL) and transport text (fetch's "Failed to fetch <url>") can carry them. The
159
+ * client runs it on the `Errors` envelopes it turns into errors.
160
+ */
161
+ scrub(text: string): string;
162
+ /**
163
+ * A transport failure as the `cause` of the error the engine raises: the original when its
164
+ * text carries no credentials, otherwise a copy with them scrubbed (message, `code` and the
165
+ * cause chain kept), so logging the error with its causes can't reveal the base URL's
166
+ * password.
167
+ */
168
+ private scrubCause;
133
169
  /** Build a fully-qualified URL from a path and optional query parameters. */
134
170
  buildUrl(path: string, query?: QueryParams): string;
171
+ /**
172
+ * Call the transport under the overall deadline (`timeoutMs`): the request gets an
173
+ * AbortSignal that fires at the deadline, and the call rejects then whether the transport
174
+ * stops or not — a custom transport (fetch, a node:http wrapper) that ignores `timeoutMs`
175
+ * can't hang the caller. A synchronous throw becomes a rejection.
176
+ */
177
+ private callTransport;
135
178
  /**
136
179
  * Perform a GET with Accept negotiation and transient-error retries. Redirects
137
180
  * are NOT followed — the canonical host answers directly, so a 3xx (e.g. a bad
138
181
  * base URL bouncing to a portal page) surfaces as an error.
182
+ *
183
+ * The engine enforces the transport contract itself, so it holds for a custom
184
+ * transport too: `timeoutMs` (an AbortSignal deadline), `maxResponseBytes` (checked on
185
+ * the body it gets back), any byte-array body, `Headers`/`Map`/any-case headers. Whatever
186
+ * a transport throws becomes a `MastrNetworkError`, and so does a malformed response; a
187
+ * reset connection (`isTransientNetworkError`) is retried like a 503.
139
188
  */
140
189
  request(path: string, query?: QueryParams, accept?: string): Promise<RawResponse>;
141
190
  /**
@@ -144,5 +193,6 @@ export declare class RequestEngine {
144
193
  * `MastrParseError`, never a silent `null`.
145
194
  */
146
195
  getJson<T>(path: string, query?: QueryParams): Promise<T>;
196
+ /** The MastrApiError for a non-2xx answer; `note` is appended to the detail. */
147
197
  private toApiError;
148
198
  }
@@ -2,9 +2,10 @@
2
2
  // a Transport, applies retry/backoff for transient statuses (429, 503), and decodes
3
3
  // JSON responses. MaStR's public search backend is an unauthenticated GET API whose
4
4
  // parameters travel in the query string.
5
- import { MAX_TIMEOUT_MS, nodeHttpTransport } from "./http.js";
5
+ import { TextDecoder } from "node:util";
6
+ import { MAX_TIMEOUT_MS, nodeHttpTransport, sizeLimitMessage, } from "./http.js";
6
7
  import { buildQueryString } from "./query.js";
7
- import { MastrApiError, MastrParseError } from "./errors.js";
8
+ import { MastrApiError, MastrError, MastrNetworkError, MastrParseError, MastrValidationError, credentialsIn, redactCredentials, redactUrl, } from "./errors.js";
8
9
  import { assertValid, baseUrlProblem, headerNameProblem, headerValueProblem, intRangeProblem, } from "./validate.js";
9
10
  export const DEFAULT_BASE_URL = "https://www.marktstammdatenregister.de/MaStR";
10
11
  const DEFAULT_USER_AGENT = "marktstammdatenregister-cli";
@@ -90,17 +91,28 @@ export function sanitizeServerText(text) {
90
91
  }
91
92
  return out.replace(/\s+/g, " ").trim();
92
93
  }
94
+ /**
95
+ * Longest server text (in characters) an error message keeps: a hostile or buggy body
96
+ * must not flood stderr or a CI log with one huge line. `MastrApiError.body` keeps the
97
+ * full text.
98
+ */
99
+ export const MAX_DETAIL_LENGTH = 500;
100
+ /** `text` cut at MAX_DETAIL_LENGTH characters, ending in "…" when cut. */
101
+ function cutDetail(text) {
102
+ return text.length > MAX_DETAIL_LENGTH ? `${text.slice(0, MAX_DETAIL_LENGTH)}…` : text;
103
+ }
93
104
  /**
94
105
  * Describe a Kendo `Errors` value for an error message: a string as is; otherwise
95
106
  * (a ModelState object such as `{"": {"errors": ["Invalid filter"]}}`, or an array)
96
107
  * every string found in it, sanitised, blanks and repeats dropped, joined "; ".
97
- * Returns `undefined` when nothing readable is left.
108
+ * Returns `undefined` when nothing readable is left. `clean` runs on each raw string
109
+ * first (the engine passes its credential scrubber).
98
110
  */
99
- export function describeMastrErrors(errors) {
111
+ export function describeMastrErrors(errors, clean = (text) => text) {
100
112
  const found = [];
101
113
  const walk = (value, depth) => {
102
114
  if (typeof value === "string") {
103
- const text = sanitizeServerText(value);
115
+ const text = sanitizeServerText(clean(value));
104
116
  if (text !== "" && !found.includes(text))
105
117
  found.push(text);
106
118
  }
@@ -110,7 +122,7 @@ export function describeMastrErrors(errors) {
110
122
  }
111
123
  };
112
124
  walk(errors, 0);
113
- return found.length > 0 ? found.join("; ") : undefined;
125
+ return found.length > 0 ? cutDetail(found.join("; ")) : undefined;
114
126
  }
115
127
  /**
116
128
  * Check a base URL against every rule of {@link baseUrlProblem} — blank, whitespace
@@ -134,6 +146,9 @@ export function assertHeaderValue(name, value) {
134
146
  }
135
147
  /** Check every `defaultHeaders` name (a token) and value; returns a copy. */
136
148
  function checkedHeaders(headers) {
149
+ if (typeof headers !== "object" || headers === null || Array.isArray(headers)) {
150
+ throw new MastrValidationError(`Invalid defaultHeaders: expected an object of header names and values, got ${describeType(headers)}.`);
151
+ }
137
152
  const out = {};
138
153
  for (const [name, value] of Object.entries(headers)) {
139
154
  assertValid("defaultHeaders name", name, headerNameProblem);
@@ -141,53 +156,271 @@ function checkedHeaders(headers) {
141
156
  }
142
157
  return out;
143
158
  }
159
+ /** Why `value` is not a usable HttpResponse, or undefined when it is. */
160
+ function responseProblem(value) {
161
+ if (typeof value !== "object" || value === null)
162
+ return "not an object";
163
+ const r = value;
164
+ if (typeof r.status !== "number" || !Number.isInteger(r.status) || r.status < 100 || r.status > 599) {
165
+ return "status is not an HTTP status code";
166
+ }
167
+ if (typeof r.headers !== "object" || r.headers === null || Array.isArray(r.headers))
168
+ return "headers is not an object";
169
+ if (bodyBytes(r.body) === undefined)
170
+ return "body is not a Buffer, Uint8Array, other ArrayBuffer view or ArrayBuffer";
171
+ return undefined;
172
+ }
173
+ /**
174
+ * The response body as a Buffer (a view, no copy): a Buffer, any ArrayBuffer view (a
175
+ * Uint8Array from fetch, a DataView) or an ArrayBuffer/SharedArrayBuffer — checked by internal
176
+ * slot, not `instanceof`, so a value from another realm (a vm context, a Jest test) counts.
177
+ * Undefined for anything else.
178
+ */
179
+ function bodyBytes(value) {
180
+ if (Buffer.isBuffer(value))
181
+ return value;
182
+ if (ArrayBuffer.isView(value))
183
+ return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
184
+ const tag = Object.prototype.toString.call(value);
185
+ if (tag === "[object ArrayBuffer]" || tag === "[object SharedArrayBuffer]")
186
+ return Buffer.from(value);
187
+ return undefined;
188
+ }
189
+ /**
190
+ * The response headers as a plain record with lower-case names. A transport built on
191
+ * `fetch` returns its `Headers` object, which has no plain properties (the engine then saw
192
+ * no Retry-After and no Content-Type at all); such an object, or a `Map` (anything with
193
+ * `get` and `forEach`), is copied into a record. A custom transport may also not
194
+ * lower-case the names ("Retry-After").
195
+ */
196
+ function plainHeaders(headers) {
197
+ const h = headers;
198
+ const record = {};
199
+ if (typeof h.get === "function" && typeof h.forEach === "function") {
200
+ h.forEach.call(headers, (value, name) => {
201
+ record[String(name).toLowerCase()] = value;
202
+ });
203
+ return record;
204
+ }
205
+ for (const [name, value] of Object.entries(headers)) {
206
+ record[name.toLowerCase()] = value;
207
+ }
208
+ return record;
209
+ }
210
+ /**
211
+ * Error codes of a connection that broke off mid-request: Node's (`socket hang up` is
212
+ * ECONNRESET) and undici's (`fetch failed` with cause UND_ERR_SOCKET, "other side closed").
213
+ */
214
+ const TRANSIENT_NETWORK_CODES = new Set(["ECONNRESET", "EPIPE", "ECONNABORTED", "UND_ERR_SOCKET"]);
215
+ /** True when `err` or an error in its `cause` chain has a transient connection code. */
216
+ function hasTransientCode(err, depth = 0) {
217
+ if (typeof err !== "object" || err === null || depth > 4)
218
+ return false;
219
+ const code = err.code;
220
+ if (typeof code === "string" && TRANSIENT_NETWORK_CODES.has(code))
221
+ return true;
222
+ return hasTransientCode(err.cause, depth + 1);
223
+ }
224
+ /**
225
+ * True for a failure caused by a reset or aborted connection, which the engine retries —
226
+ * whichever transport raised it (a Node error, fetch's TypeError with an undici cause), the
227
+ * code anywhere in the `cause` chain. A refused connection, a DNS failure or a timeout is
228
+ * not transient in that sense and is not retried.
229
+ */
230
+ export function isTransientNetworkError(err) {
231
+ return hasTransientCode(err);
232
+ }
233
+ /** A value's type for a validation message: "null", "an array", "a string", … */
234
+ function describeType(value) {
235
+ if (value === null)
236
+ return "null";
237
+ if (Array.isArray(value))
238
+ return "an array";
239
+ return `a ${typeof value}`;
240
+ }
241
+ /** Every EngineOptions key. */
242
+ const OPTION_NAMES = [
243
+ "baseUrl",
244
+ "transport",
245
+ "userAgent",
246
+ "defaultHeaders",
247
+ "timeoutMs",
248
+ "maxRetries",
249
+ "retryDelayMs",
250
+ "maxResponseBytes",
251
+ "sleep",
252
+ ];
253
+ /**
254
+ * Throw for a key that is not an option name. A JavaScript caller's typo (`timeout` for
255
+ * `timeoutMs`) was ignored silently and the default applied; TypeScript catches it at
256
+ * compile time, JavaScript does not. `extra` names options a wrapper (the client) adds.
257
+ */
258
+ export function assertKnownOptions(options, extra = []) {
259
+ const names = [...OPTION_NAMES, ...extra];
260
+ for (const [key, value] of Object.entries(options)) {
261
+ // An unset key (`proxy: undefined` from a spread config) changes nothing: skip it.
262
+ if (value === undefined || names.includes(key))
263
+ continue;
264
+ const lower = key.toLowerCase();
265
+ const hint = names.find((name) => name.toLowerCase().includes(lower) || lower.includes(name.toLowerCase()));
266
+ throw new MastrValidationError(`Unknown option ${JSON.stringify(key)}` +
267
+ (hint === undefined ? `; the options are ${names.join(", ")}.` : ` (did you mean ${hint}?).`));
268
+ }
269
+ }
270
+ /**
271
+ * Read a function option: `undefined` gives the default; anything else that is not a
272
+ * function throws. A string `transport` used to fail at the first request, and a bad
273
+ * `sleep` as a raw TypeError on the first retry.
274
+ */
275
+ function functionOption(name, value, fallback) {
276
+ if (value === undefined)
277
+ return fallback;
278
+ if (typeof value !== "function") {
279
+ throw new MastrValidationError(`Invalid ${name}: expected a function, got ${describeType(value)}.`);
280
+ }
281
+ return value;
282
+ }
283
+ /** Engine options as given, or a MastrValidationError for a non-object (null counts as none). */
284
+ export function optionsObject(options) {
285
+ if (options === undefined || options === null)
286
+ return {};
287
+ if (typeof options !== "object" || Array.isArray(options)) {
288
+ throw new MastrValidationError(`Invalid options: expected an object, got ${describeType(options)}.`);
289
+ }
290
+ return options;
291
+ }
144
292
  const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
145
293
  export class RequestEngine {
146
- baseUrl;
294
+ // Real private fields (not TypeScript's `private`): util.inspect, console.log and
295
+ // JSON.stringify of a client never show them, so a password in the base URL (or a
296
+ // credential in a default header) can't be logged by accident.
297
+ #baseUrl;
298
+ /** The base URL's userinfo, raw and percent-decoded, for scrubbing server and transport text. */
299
+ #credentials;
147
300
  transport;
148
301
  userAgent;
149
- defaultHeaders;
302
+ #defaultHeaders;
150
303
  timeoutMs;
151
304
  maxRetries;
152
305
  retryDelayMs;
153
306
  maxResponseBytes;
154
307
  sleep;
155
308
  constructor(options = {}) {
309
+ // A JavaScript caller may pass null for "no options"; anything else must be an object
310
+ // with known keys.
311
+ options = optionsObject(options);
312
+ assertKnownOptions(options);
156
313
  // The raw value is checked before the trailing-slash strip, so "https://h/ "
157
314
  // cannot slip past it; only an omitted baseUrl selects the default.
158
- this.baseUrl = validateBaseUrl(options.baseUrl === undefined ? DEFAULT_BASE_URL : options.baseUrl);
159
- this.transport = options.transport ?? nodeHttpTransport;
315
+ const baseUrl = options.baseUrl === undefined ? DEFAULT_BASE_URL : options.baseUrl;
316
+ this.#baseUrl = validateBaseUrl(baseUrl);
317
+ this.#credentials = credentialsIn(baseUrl).flatMap((raw) => {
318
+ try {
319
+ return [raw, decodeURIComponent(raw)];
320
+ }
321
+ catch {
322
+ return [raw];
323
+ }
324
+ });
325
+ this.transport = functionOption("transport", options.transport, nodeHttpTransport);
160
326
  // Header values are checked up front: a blank one would be sent as is, and a
161
327
  // CR/LF or a character above U+00FF would reach a custom transport raw or make
162
328
  // Node's HTTP layer throw an untyped ERR_INVALID_CHAR. Only an omitted
163
329
  // userAgent selects the default.
164
330
  this.userAgent =
165
331
  options.userAgent === undefined ? DEFAULT_USER_AGENT : assertHeaderValue("userAgent", options.userAgent);
166
- this.defaultHeaders = checkedHeaders(options.defaultHeaders ?? {});
332
+ this.#defaultHeaders = checkedHeaders(options.defaultHeaders ?? {});
167
333
  // Range-check the numeric options: a negative, NaN or fractional value would
168
334
  // otherwise silently disable the timeout or the size cap, and an unbounded
169
335
  // maxRetries would keep retrying against the production register.
170
336
  this.timeoutMs = intOption("timeoutMs", options.timeoutMs, MAX_TIMEOUT_MS, 30_000);
171
337
  this.maxRetries = intOption("maxRetries", options.maxRetries, MAX_RETRIES, 2);
172
- this.retryDelayMs = intOption("retryDelayMs", options.retryDelayMs, Number.MAX_SAFE_INTEGER, 200);
338
+ // Bounded like a Retry-After wait: a larger value would stall the CLI, and one above
339
+ // 2^31 - 1 ms would overflow Node's timer and retry after 1 ms.
340
+ this.retryDelayMs = intOption("retryDelayMs", options.retryDelayMs, MAX_RETRY_AFTER_MS, 200);
173
341
  this.maxResponseBytes = intOption("maxResponseBytes", options.maxResponseBytes, Number.MAX_SAFE_INTEGER, DEFAULT_MAX_RESPONSE_BYTES);
174
- this.sleep = options.sleep ?? realSleep;
342
+ this.sleep = functionOption("sleep", options.sleep, realSleep);
343
+ }
344
+ /**
345
+ * `text` without the base URL's credentials: server text (an error body that echoes the
346
+ * request URL) and transport text (fetch's "Failed to fetch <url>") can carry them. The
347
+ * client runs it on the `Errors` envelopes it turns into errors.
348
+ */
349
+ scrub(text) {
350
+ return this.#credentials.length === 0 ? text : redactCredentials(text, this.#credentials);
351
+ }
352
+ /**
353
+ * A transport failure as the `cause` of the error the engine raises: the original when its
354
+ * text carries no credentials, otherwise a copy with them scrubbed (message, `code` and the
355
+ * cause chain kept), so logging the error with its causes can't reveal the base URL's
356
+ * password.
357
+ */
358
+ scrubCause(cause, depth = 0) {
359
+ if (this.#credentials.length === 0 || depth > 5)
360
+ return cause;
361
+ if (typeof cause === "string")
362
+ return this.scrub(cause);
363
+ if (!(cause instanceof Error))
364
+ return cause;
365
+ const inner = this.scrubCause(cause.cause, depth + 1);
366
+ const message = this.scrub(cause.message);
367
+ if (message === cause.message && inner === cause.cause && !this.scrub(cause.stack ?? "").includes("***@")) {
368
+ return cause;
369
+ }
370
+ const copy = new Error(message, inner === undefined ? undefined : { cause: inner });
371
+ copy.name = cause.name;
372
+ const code = cause.code;
373
+ if (code !== undefined)
374
+ Object.assign(copy, { code });
375
+ return copy;
175
376
  }
176
377
  /** Build a fully-qualified URL from a path and optional query parameters. */
177
378
  buildUrl(path, query) {
178
379
  const normalizedPath = path.startsWith("/") ? path : `/${path}`;
179
380
  const qs = query ? buildQueryString(query) : "";
180
- return `${this.baseUrl}${normalizedPath}${qs ? `?${qs}` : ""}`;
381
+ return `${this.#baseUrl}${normalizedPath}${qs ? `?${qs}` : ""}`;
382
+ }
383
+ /**
384
+ * Call the transport under the overall deadline (`timeoutMs`): the request gets an
385
+ * AbortSignal that fires at the deadline, and the call rejects then whether the transport
386
+ * stops or not — a custom transport (fetch, a node:http wrapper) that ignores `timeoutMs`
387
+ * can't hang the caller. A synchronous throw becomes a rejection.
388
+ */
389
+ async callTransport(request) {
390
+ const call = (signal) => Promise.resolve().then(() => this.transport(signal === undefined ? request : { ...request, signal }));
391
+ if (this.timeoutMs === 0)
392
+ return call();
393
+ const controller = new AbortController();
394
+ let timer;
395
+ const deadline = new Promise((_, reject) => {
396
+ timer = setTimeout(() => {
397
+ const err = new MastrNetworkError(`Request timed out after ${this.timeoutMs}ms`);
398
+ controller.abort(err);
399
+ reject(err);
400
+ }, this.timeoutMs);
401
+ });
402
+ try {
403
+ return await Promise.race([call(controller.signal), deadline]);
404
+ }
405
+ finally {
406
+ clearTimeout(timer);
407
+ }
181
408
  }
182
409
  /**
183
410
  * Perform a GET with Accept negotiation and transient-error retries. Redirects
184
411
  * are NOT followed — the canonical host answers directly, so a 3xx (e.g. a bad
185
412
  * base URL bouncing to a portal page) surfaces as an error.
413
+ *
414
+ * The engine enforces the transport contract itself, so it holds for a custom
415
+ * transport too: `timeoutMs` (an AbortSignal deadline), `maxResponseBytes` (checked on
416
+ * the body it gets back), any byte-array body, `Headers`/`Map`/any-case headers. Whatever
417
+ * a transport throws becomes a `MastrNetworkError`, and so does a malformed response; a
418
+ * reset connection (`isTransientNetworkError`) is retried like a 503.
186
419
  */
187
420
  async request(path, query, accept = "application/json") {
188
421
  const url = this.buildUrl(path, query);
189
422
  const headers = {
190
- ...this.defaultHeaders,
423
+ ...this.#defaultHeaders,
191
424
  Accept: accept,
192
425
  "User-Agent": this.userAgent,
193
426
  // The MaStR search backend is a Kendo/DataTables endpoint that expects an
@@ -196,30 +429,80 @@ export class RequestEngine {
196
429
  };
197
430
  let attempt = 0;
198
431
  for (;;) {
199
- const response = await this.transport({
200
- method: "GET",
201
- url,
202
- headers,
203
- timeoutMs: this.timeoutMs,
204
- ...(this.maxResponseBytes > 0 ? { maxResponseBytes: this.maxResponseBytes } : {}),
205
- });
432
+ let response;
433
+ try {
434
+ response = await this.callTransport({
435
+ method: "GET",
436
+ url,
437
+ headers,
438
+ timeoutMs: this.timeoutMs,
439
+ ...(this.maxResponseBytes > 0 ? { maxResponseBytes: this.maxResponseBytes } : {}),
440
+ });
441
+ }
442
+ catch (cause) {
443
+ // A connection the server (or a gateway) reset is the network-level twin of a 503:
444
+ // retry it, whichever transport reported it. Timeouts are not retried.
445
+ if (hasTransientCode(cause) && attempt < this.maxRetries) {
446
+ attempt += 1;
447
+ await this.sleep(this.retryDelayMs * attempt);
448
+ continue;
449
+ }
450
+ // The default transport rejects with MastrNetworkError only; an injected one may
451
+ // throw anything (fetch's TypeError, a string, null). Keep the library's contract:
452
+ // every failure is a MastrError.
453
+ if (cause instanceof MastrNetworkError) {
454
+ // Its text may echo the request URL; re-raise it scrubbed when it does.
455
+ const message = this.scrub(cause.message);
456
+ const inner = this.scrubCause(cause.cause);
457
+ if (message === cause.message && inner === cause.cause)
458
+ throw cause;
459
+ throw new MastrNetworkError(message, inner === undefined ? undefined : { cause: inner });
460
+ }
461
+ if (cause instanceof MastrError)
462
+ throw cause;
463
+ const reason = cause instanceof Error ? cause.message : String(cause);
464
+ throw new MastrNetworkError(`GET ${redactUrl(url)} failed: ${sanitizeServerText(this.scrub(reason))}`, {
465
+ cause: this.scrubCause(cause),
466
+ });
467
+ }
468
+ // An injected transport may resolve with anything; a malformed HttpResponse would
469
+ // otherwise surface as a raw TypeError, or a missing status as a success.
470
+ const invalid = responseProblem(response);
471
+ if (invalid !== undefined) {
472
+ throw new MastrNetworkError(`GET ${redactUrl(url)} failed: the transport returned an invalid response (${invalid}).`);
473
+ }
206
474
  const status = response.status;
475
+ const responseHeaders = plainHeaders(response.headers);
476
+ const body = bodyBytes(response.body);
477
+ // The size cap holds whatever the transport did: the default one aborts early, a
478
+ // custom one may have read everything.
479
+ if (this.maxResponseBytes > 0 && body.byteLength > this.maxResponseBytes) {
480
+ throw new MastrNetworkError(sizeLimitMessage(this.maxResponseBytes));
481
+ }
207
482
  const retryable = status === 429 || status === 503;
483
+ // A Retry-After beyond MAX_RETRY_AFTER_MS is not retried: retrying early would land
484
+ // inside the window the server asked us to wait out, and a hostile value must not stall
485
+ // the CLI. The error then names the requested wait, so a script knows when to try again.
486
+ const retryAfter = retryable ? parseRetryAfter(responseHeaders["retry-after"]) : undefined;
208
487
  if (retryable && attempt < this.maxRetries) {
209
- // Honour Retry-After; without a usable one, back off linearly. A Retry-After
210
- // beyond MAX_RETRY_AFTER_MS is not retried: the error below surfaces at once.
211
- const retryAfter = parseRetryAfter(response.headers["retry-after"]);
212
488
  if (retryAfter === undefined || retryAfter <= MAX_RETRY_AFTER_MS) {
213
489
  attempt += 1;
214
- await this.sleep(retryAfter ?? this.retryDelayMs * attempt);
490
+ // The linear backoff is the floor: a Retry-After can make a wait longer, never
491
+ // shorter. `Retry-After: 0` or a date in the past turned the retries into a
492
+ // zero-delay burst against a register that had just answered 429/503.
493
+ const backoff = this.retryDelayMs * attempt;
494
+ await this.sleep(retryAfter === undefined ? backoff : Math.max(retryAfter, backoff));
215
495
  continue;
216
496
  }
497
+ throw this.toApiError(url, status, body, `the server asked to wait ${Math.ceil(retryAfter / 1000)} s (Retry-After) before trying again, ` +
498
+ `longer than the ${MAX_RETRY_AFTER_MS / 1000} s the client waits, so it was not retried; ` +
499
+ "retrying sooner won't help");
217
500
  }
218
- const contentType = String(response.headers["content-type"] ?? "");
501
+ const contentType = String(responseHeaders["content-type"] ?? "");
219
502
  if (status < 200 || status >= 300) {
220
- throw this.toApiError(url, status, response.body);
503
+ throw this.toApiError(url, status, body);
221
504
  }
222
- return { data: response.body, contentType, status };
505
+ return { data: body, contentType, status };
223
506
  }
224
507
  }
225
508
  /**
@@ -229,7 +512,7 @@ export class RequestEngine {
229
512
  */
230
513
  async getJson(path, query) {
231
514
  const res = await this.request(path, query);
232
- const text = res.data.toString("utf8");
515
+ const text = decodeBody(res.data, res.contentType, path);
233
516
  if (res.status === 204 || text.trim().length === 0) {
234
517
  throw new MastrParseError(`Empty response body from ${path}`);
235
518
  }
@@ -240,8 +523,9 @@ export class RequestEngine {
240
523
  throw new MastrParseError(`Failed to parse JSON response from ${path}`, { cause });
241
524
  }
242
525
  }
243
- toApiError(url, status, body) {
244
- const text = body.toString("utf8");
526
+ /** The MastrApiError for a non-2xx answer; `note` is appended to the detail. */
527
+ toApiError(url, status, body, note) {
528
+ const text = this.scrub(body.toString("utf8"));
245
529
  let detail;
246
530
  try {
247
531
  const parsed = JSON.parse(text);
@@ -264,7 +548,26 @@ export class RequestEngine {
264
548
  // collapse above does not remove ESC, so strip control characters before it can
265
549
  // reach stderr and inject terminal escape sequences.
266
550
  if (detail !== undefined)
267
- detail = sanitizeServerText(detail);
551
+ detail = cutDetail(sanitizeServerText(detail));
552
+ if (note !== undefined)
553
+ detail = detail === undefined || detail === "" ? note : `${detail}; ${note}`;
268
554
  return new MastrApiError({ status, url, method: "GET", body: text, detail });
269
555
  }
270
556
  }
557
+ /**
558
+ * Decode a response body by the charset its Content-Type names (UTF-8 when it names none):
559
+ * a Latin-1 body from a re-encoding proxy or mirror became U+FFFD with `toString("utf8")`.
560
+ * TextDecoder also drops a leading byte order mark, which JSON.parse would reject. An
561
+ * unknown charset label is a MastrParseError.
562
+ */
563
+ function decodeBody(body, contentType, path) {
564
+ const charset = /;\s*charset\s*=\s*"?([^";\s]+)"?/i.exec(contentType)?.[1] ?? "utf-8";
565
+ let decoder;
566
+ try {
567
+ decoder = new TextDecoder(charset);
568
+ }
569
+ catch {
570
+ throw new MastrParseError(`Unsupported response charset "${sanitizeServerText(charset)}" from ${path}.`);
571
+ }
572
+ return decoder.decode(body);
573
+ }
@@ -4,6 +4,21 @@
4
4
  * A URL without userinfo, or one that does not parse, is returned unchanged.
5
5
  */
6
6
  export declare function redactUrl(url: string): string;
7
+ /**
8
+ * The userinfo a URL-like value carries, exactly as written — `["alice:pa#ss"]` for
9
+ * `https://alice:pa#ss@host` — or `[]` when it carries none. It works on values that don't
10
+ * parse as a URL too, and on values with a prefix (`--base-url=https://u:p@h`): the userinfo
11
+ * is everything between `://` and the last `@` before the host. A value without a scheme
12
+ * counts when it reads `user:password@host`. Used to redact those exact strings from text
13
+ * that echoes the value (usage errors, help), whatever characters the password contains.
14
+ */
15
+ export declare function credentialsIn(value: string): string[];
16
+ /**
17
+ * `text` with every occurrence of each credential (as `credentialsIn` returns them) that is
18
+ * followed by `@` replaced by `***`. Matching the exact strings, not a pattern, covers
19
+ * passwords with spaces, quotes, `#`, `?` or `/` that no URL pattern can delimit.
20
+ */
21
+ export declare function redactCredentials(text: string, credentials: readonly string[]): string;
7
22
  /** Base class for every error originating from this client. */
8
23
  export declare class MastrError extends Error {
9
24
  constructor(message: string, options?: {