node-vault-client 3.1.0 → 3.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.
@@ -35,6 +35,17 @@ function humanizeMs(ms) {
35
35
  * @returns {number|null}
36
36
  * @private
37
37
  */
38
+ /**
39
+ * True for a token Vault marked renewable whose lookup gave nothing to schedule a renewal from
40
+ * (see {@link AuthToken.fromResponse}). It is not renewable, but the renewal timer still reports
41
+ * it, as it did while such a lookup resolved to a NaN deadline.
42
+ * @param {AuthToken} authToken
43
+ * @returns {boolean}
44
+ */
45
+ function renewableWithoutExpiry(authToken) {
46
+ return authToken.__renewableWithoutExpiry === true;
47
+ }
48
+
38
49
  function tokenExpiryMilliseconds(authToken) {
39
50
  if (typeof authToken.getExpiresAtMilliseconds === 'function') {
40
51
  const expiry = authToken.getExpiresAtMilliseconds();
@@ -138,6 +149,32 @@ class VaultBaseAuth {
138
149
  this.__pendingLogin = null;
139
150
  this.__refreshTimeout = null;
140
151
  this.__renewalEpoch = 0;
152
+ /**
153
+ * Bumped by every {@link VaultBaseAuth#cancelTokenRefresh} call (`VaultClient#close()`
154
+ * makes one) and by nothing else. A login compares it before and after, so a login that
155
+ * was in flight when the client was closed does not arm a refresh timer.
156
+ * @type {number}
157
+ */
158
+ this.__cancelCount = 0;
159
+ /**
160
+ * The in-flight {@link VaultBaseAuth#checkRejectedToken} lookup, shared by every
161
+ * request that was rejected with the same token.
162
+ * @type {{id: string, promise: Promise<boolean>}|null}
163
+ */
164
+ this.__rejectionCheck = null;
165
+ /**
166
+ * Whether {@link VaultBaseAuth#revokeSelfOnClose} revokes the held token on the Vault
167
+ * server (`auth/token/revoke-self`). Off by default; set `auth.revokeOnClose` (see
168
+ * {@link VaultBaseAuth#configureRenewal}).
169
+ * @type {boolean}
170
+ */
171
+ this.__revokeOnClose = false;
172
+ /**
173
+ * The id of the token whose revocation on close has already been started, so a
174
+ * repeated close() does not revoke it twice.
175
+ * @type {String|null}
176
+ */
177
+ this.__revokedOnCloseId = null;
141
178
  }
142
179
 
143
180
  /**
@@ -149,12 +186,15 @@ class VaultBaseAuth {
149
186
  * @param {boolean} [authConfig.renewal=true]
150
187
  * @param {number} [authConfig.renewalFraction=0.5]
151
188
  * @param {number} [authConfig.renewalIncrement]
189
+ * @param {boolean} [authConfig.revokeOnClose=false] - revoke the held token
190
+ * (`auth/token/revoke-self`) when the client is closed
152
191
  * @returns {void}
153
192
  */
154
193
  configureRenewal(authConfig) {
155
194
  this.__renewalEnabled = VaultBaseAuth.__validateRenewal(authConfig);
156
195
  this.__renewalFraction = VaultBaseAuth.__validateFraction(authConfig);
157
196
  this.__renewalIncrement = VaultBaseAuth.__validateIncrement(authConfig);
197
+ this.__revokeOnClose = VaultBaseAuth.__validateRevokeOnClose(authConfig);
158
198
  }
159
199
 
160
200
  /**
@@ -180,6 +220,29 @@ class VaultBaseAuth {
180
220
  return config.renewal;
181
221
  }
182
222
 
223
+ /**
224
+ * Strict for the same reason as "renewal": node-config's `custom-environment-variables`
225
+ * yields strings, so a loose check would read `VAULT_REVOKE_ON_CLOSE=true` as `'true'`
226
+ * and silently leave revocation off -- the exact behaviour the flag exists to request.
227
+ *
228
+ * @param {Object} [authConfig]
229
+ * @returns {boolean}
230
+ * @private
231
+ */
232
+ static __validateRevokeOnClose(config) {
233
+ if (!config || config.revokeOnClose === undefined) {
234
+ return false;
235
+ }
236
+
237
+ if (typeof config.revokeOnClose !== 'boolean') {
238
+ throw new errors.InvalidArgumentsError(
239
+ `"revokeOnClose" should be a boolean, got ${String(config.revokeOnClose)}`
240
+ );
241
+ }
242
+
243
+ return config.revokeOnClose;
244
+ }
245
+
183
246
  /**
184
247
  * @param {Object} [authConfig]
185
248
  * @returns {number}
@@ -252,12 +315,20 @@ class VaultBaseAuth {
252
315
 
253
316
  this._log.info('getting auth token (mount=%s)', this._mount);
254
317
 
255
- const pendingLogin = this._authenticate().then(authToken => {
318
+ // Read before _authenticate() runs: a close() from then on, even from inside it, means
319
+ // this login arms no refresh timer.
320
+ const cancelCountAtStart = this.__cancelCount;
321
+ // A backend that throws instead of rejecting, or returns something that is not a
322
+ // promise, must not throw out of every operation. The executor still calls
323
+ // _authenticate() synchronously, as before.
324
+ const authentication = new Promise((resolve) => resolve(this._authenticate()));
325
+
326
+ const pendingLogin = authentication.then(authToken => {
256
327
  this.__pendingLogin = null;
257
328
  this.__authToken = authToken;
258
329
  this.__renewalEpoch++;
259
330
 
260
- if (authToken.isRenewable()) {
331
+ if (authToken.isRenewable() || renewableWithoutExpiry(authToken)) {
261
332
  // __setupTokenRefreshTimer logs once it has actually armed a timer, and warns
262
333
  // when it cannot; this covers the remaining case, the one diagnostic an operator
263
334
  // reads when a `renewal: false` process still hangs.
@@ -267,7 +338,9 @@ class VaultBaseAuth {
267
338
  authToken.getAccessor()
268
339
  );
269
340
  }
270
- this.__setupTokenRefreshTimer(authToken);
341
+ // close() ran while this login was in flight: every check and warning runs as
342
+ // before, but no timer is armed to hold the event loop.
343
+ this.__setupTokenRefreshTimer(authToken, this.__cancelCount !== cancelCountAtStart);
271
344
  }
272
345
 
273
346
  return authToken;
@@ -293,6 +366,7 @@ class VaultBaseAuth {
293
366
  * @returns {void}
294
367
  */
295
368
  cancelTokenRefresh() {
369
+ this.__markClosed();
296
370
  this.__renewalEpoch++;
297
371
  if (this.__refreshTimeout !== null) {
298
372
  lt.clearTimeout(this.__refreshTimeout);
@@ -300,6 +374,162 @@ class VaultBaseAuth {
300
374
  }
301
375
  }
302
376
 
377
+ /**
378
+ * Record a close(), so a login in flight arms no refresh timer. Called by
379
+ * {@link VaultBaseAuth#cancelTokenRefresh} and directly by `VaultClient#close()`, so a
380
+ * subclass that overrides cancelTokenRefresh() without calling super keeps the behaviour.
381
+ * @returns {void}
382
+ * @private
383
+ */
384
+ __markClosed() {
385
+ this.__cancelCount++;
386
+ }
387
+
388
+ /**
389
+ * Revoke the held token on the Vault server (`POST auth/token/revoke-self`), when
390
+ * `auth.revokeOnClose` is enabled. Called by `VaultClient#close()`.
391
+ *
392
+ * The token to revoke is the one held now, or the one an in-flight login is about to
393
+ * produce: that login still resolves after a close(), and its token would otherwise
394
+ * outlive the client by its whole TTL. A login that fails issued no token. A token that
395
+ * was never obtained, and one whose revocation an earlier close() already started, cost
396
+ * nothing to leave alone.
397
+ *
398
+ * @returns {Promise<void>|null} the revocation, so a caller may await it, or `null` when
399
+ * there is nothing to do: the option is off, or no token was ever obtained. The
400
+ * returned promise never rejects: `VaultClient.clear()` fires it without awaiting,
401
+ * and a failed revocation is logged (`revokeSelf on close failed`) with the token
402
+ * left live on the Vault server instead.
403
+ */
404
+ revokeSelfOnClose() {
405
+ if (!this.__revokeOnClose) {
406
+ return null;
407
+ }
408
+ if (this.__pendingLogin === null && this.__authToken === null) {
409
+ // No token was ever obtained: nothing was issued, so there is nothing to revoke.
410
+ return null;
411
+ }
412
+
413
+ // The token to revoke: the one held now, or the one the in-flight login resolves
414
+ // with. The login's own bookkeeping (the token cache, timer decisions) has run by
415
+ // the time this `then` sees it.
416
+ const held = this.__pendingLogin !== null
417
+ ? this.__pendingLogin.then((authToken) => authToken, () => null)
418
+ : Promise.resolve(this.__authToken);
419
+
420
+ return held.then((authToken) => {
421
+ // An expired token is already dead server-side: revoke-self would answer 403 and log
422
+ // a failure claiming the token was left live.
423
+ if (authToken === null || authToken.isExpired() || authToken.getId() === this.__revokedOnCloseId) {
424
+ return;
425
+ }
426
+ const id = authToken.getId();
427
+ this.__revokedOnCloseId = id;
428
+ try {
429
+ this._log.info('revoking the auth token on close (accessor=%s)', authToken.getAccessor());
430
+ } catch {
431
+ // a throwing logger must not cancel the revocation
432
+ }
433
+ return this.__apiClient
434
+ .makeRequest('POST', '/auth/token/revoke-self', null, { 'X-Vault-Token': authToken.getId() })
435
+ .then(() => {
436
+ // Gone server-side: do not hand it to a call that keeps using the client.
437
+ if (this.__authToken === authToken) {
438
+ this.__authToken = null;
439
+ }
440
+ }, (err) => {
441
+ // Still live: let a later close() try again.
442
+ if (this.__revokedOnCloseId === id) {
443
+ this.__revokedOnCloseId = null;
444
+ }
445
+ try {
446
+ this._log.error(
447
+ 'revokeSelf on close failed (accessor=%s): %s',
448
+ authToken.getAccessor(), safeMessage(err)
449
+ );
450
+ } catch {
451
+ // a throwing logger changes nothing: the failure is already lost to the caller
452
+ }
453
+ });
454
+ });
455
+ }
456
+
457
+ /**
458
+ * Called by VaultClient after Vault answered 403 to a request made with `authToken`.
459
+ * Vault answers 403 both to a token it no longer accepts (revoked, or expired before the
460
+ * local clock says so) and to a policy that denies the request. `lookup-self`, which every
461
+ * login here already makes, tells the two apart, so a policy denial costs no login. A dead
462
+ * token is dropped, and its renewal timer cancelled, so the next getAuthToken() logs in.
463
+ *
464
+ * @param {AuthToken} authToken - the token the rejected request carried
465
+ * @returns {Promise<boolean>} true when the request may succeed if sent once more with
466
+ * the token getAuthToken() now gives: the token was dead and has been dropped, or it
467
+ * had been replaced already
468
+ */
469
+ checkRejectedToken(authToken) {
470
+ if (!this._reauthenticationAllowed()) {
471
+ return Promise.resolve(false);
472
+ }
473
+ const id = authToken.getId();
474
+ if (id === this.__revokedOnCloseId) {
475
+ // close() revoked this token on purpose (auth.revokeOnClose): a request still in
476
+ // flight that it rejects must not log in again after close() and leave a new,
477
+ // unrevoked token behind.
478
+ return Promise.resolve(false);
479
+ }
480
+ if (this.__authToken === null || this.__authToken.getId() !== id) {
481
+ return Promise.resolve(true);
482
+ }
483
+ if (this.__rejectionCheck !== null && this.__rejectionCheck.id === id) {
484
+ return this.__rejectionCheck.promise;
485
+ }
486
+
487
+ const check = {
488
+ id,
489
+ promise: this.__apiClient.makeRequest('GET', '/auth/token/lookup-self', null, {'X-Vault-Token': id})
490
+ .then(() => false, err => {
491
+ if (err instanceof errors.VaultHttpError && err.statusCode === 403) {
492
+ return true;
493
+ }
494
+ // Neither answer: the 403 is returned as it is, so say why no login follows.
495
+ try {
496
+ this._log.debug(
497
+ 'token rejection check inconclusive (accessor=%s): %s; returning the 403',
498
+ authToken.getAccessor(), safeMessage(err)
499
+ );
500
+ } catch {
501
+ // a throwing logger changes nothing here
502
+ }
503
+ return false;
504
+ })
505
+ .then(dead => {
506
+ if (this.__rejectionCheck === check) {
507
+ this.__rejectionCheck = null;
508
+ }
509
+ if (dead && this.__authToken !== null && this.__authToken.getId() === id) {
510
+ this.__authToken = null;
511
+ // As before, through the public method, so an override still hears of it.
512
+ // Dropping a dead token is not a close(): the next login's token must be
513
+ // renewed, so the close count is put back.
514
+ const cancelCount = this.__cancelCount;
515
+ this.cancelTokenRefresh();
516
+ this.__cancelCount = cancelCount;
517
+ try {
518
+ this._log.warn(
519
+ 'Vault no longer accepts the token (accessor=%s); logging in again',
520
+ authToken.getAccessor()
521
+ );
522
+ } catch {
523
+ // a throwing logger must not keep the dead token in use
524
+ }
525
+ }
526
+ return dead;
527
+ }),
528
+ };
529
+ this.__rejectionCheck = check;
530
+ return check.promise;
531
+ }
532
+
303
533
  /**
304
534
  * @protected
305
535
  * @returns {Promise<AuthToken>}
@@ -321,9 +551,11 @@ class VaultBaseAuth {
321
551
 
322
552
  /**
323
553
  * @param {AuthToken} authToken
554
+ * @param {boolean} [closedDuringLogin=false] - close() ran while the login that brought
555
+ * `authToken` was in flight: check and warn as usual, but arm no timer
324
556
  * @private
325
557
  */
326
- __setupTokenRefreshTimer(authToken) {
558
+ __setupTokenRefreshTimer(authToken, closedDuringLogin = false) {
327
559
  if (this.__refreshTimeout !== null) {
328
560
  lt.clearTimeout(this.__refreshTimeout);
329
561
  this.__refreshTimeout = null;
@@ -333,11 +565,13 @@ class VaultBaseAuth {
333
565
  return;
334
566
  }
335
567
  if (!authToken.isRenewable() || authToken.isExpired()) {
336
- warnRenewalStopped(
337
- this,
338
- authToken,
339
- authToken.isExpired() ? 'token already expired' : 'token is not renewable'
340
- );
568
+ let reason = 'token is not renewable';
569
+ if (authToken.isExpired()) {
570
+ reason = 'token already expired';
571
+ } else if (renewableWithoutExpiry(authToken)) {
572
+ reason = 'token has no usable expiry';
573
+ }
574
+ warnRenewalStopped(this, authToken, reason);
341
575
  return;
342
576
  }
343
577
 
@@ -362,6 +596,19 @@ class VaultBaseAuth {
362
596
  return;
363
597
  }
364
598
 
599
+ if (closedDuringLogin) {
600
+ // The token is cached and returned as after any close(); nothing holds the loop.
601
+ try {
602
+ this._log.debug(
603
+ 'client closed during login; not arming a refresh timer (accessor=%s)',
604
+ authToken.getAccessor()
605
+ );
606
+ } catch {
607
+ // a throwing logger must not fail a login that succeeded
608
+ }
609
+ return;
610
+ }
611
+
365
612
  // A renewal that is still in flight when the token is replaced or cancelled must not
366
613
  // act on what it finds when it lands: it would overwrite the newer token, or clear
367
614
  // the newer token's timer and then bail on its own expired one, leaving a live token
@@ -4,6 +4,23 @@ const VaultBaseAuth = require('./VaultBaseAuth');
4
4
  const aws4 = require('aws4');
5
5
  const { fromNodeProviderChain } = require('@aws-sdk/credential-providers');
6
6
  const errors = require('../errors');
7
+ const warnOnce = require('../warnOnce');
8
+
9
+ /**
10
+ * The Vault server part of the warning key: origin and path of `api.url`, so spellings such as a
11
+ * trailing slash or an upper-case host name the same server. An unparsable value is used as is.
12
+ * @param {*} url
13
+ * @returns {string}
14
+ * @private
15
+ */
16
+ function serverKey(url) {
17
+ try {
18
+ const parsed = new URL(url);
19
+ return parsed.origin + parsed.pathname.replace(/\/+$/, '');
20
+ } catch {
21
+ return String(url);
22
+ }
23
+ }
7
24
 
8
25
  /**
9
26
  * Implementation of AWS Auth Backend :: IAM Authentication Method
@@ -51,6 +68,8 @@ class VaultIAMAuth extends VaultBaseAuth {
51
68
  * @property {String} [namespace] - Optional. Vault namespace. Applied as the X-Vault-Namespace
52
69
  * header to every request by {@link VaultApiClient}; see {@link VaultClient#constructor}.
53
70
  * @property {String} [iam_server_id_header_value] - Optional. Header's value X-Vault-AWS-IAM-Server-ID.
71
+ * Without it the signed request names no Vault server, and the first login logs a warning,
72
+ * once per Vault server, namespace and mount in a process.
54
73
  * @property {String} [region] - Optional. AWS region used to sign the STS GetCallerIdentity
55
74
  * request. When set, the request is signed against the regional STS endpoint
56
75
  * (`sts.<region>.amazonaws.com`) and the SigV4 credential scope is bound to that region.
@@ -80,10 +99,45 @@ class VaultIAMAuth extends VaultBaseAuth {
80
99
  this.__credentialChain = credentials ? () => Promise.resolve(credentials) : fromNodeProviderChain();
81
100
  }
82
101
 
102
+ /**
103
+ * Without `X-Vault-AWS-IAM-Server-ID` the signed STS request names no Vault server, so a
104
+ * server that receives it (or anyone who captures it) can replay it to another Vault server
105
+ * that trusts the same AWS principal until the signature expires. Warn once per Vault server,
106
+ * namespace and mount for each logger (once per process on the default logger) when the
107
+ * header will not be sent or holds only whitespace. Called on login, not in the constructor,
108
+ * so a client whose construction fails later (in VaultClient) logs nothing. Never throws: a
109
+ * failure here must not fail the login.
110
+ * @returns {void}
111
+ * @private
112
+ */
113
+ __warnOnMissingServerId() {
114
+ try {
115
+ const value = this.__iam_server_id_header_value;
116
+ // A value of only whitespace is sent, but names no server either.
117
+ if (typeof value === 'string' ? value.trim() !== '' : value) {
118
+ return;
119
+ }
120
+ const api = this.__apiClient || {};
121
+ const key = [serverKey(api.__config && api.__config.url), String(api.__namespace), this._mount]
122
+ .join('\u0000');
123
+ warnOnce(
124
+ this._log,
125
+ key,
126
+ 'IAM login on mount "%s" is not configured with "iam_server_id_header_value", so the signed '
127
+ + 'STS request names no Vault server and could be replayed to another one that trusts the same '
128
+ + 'AWS principal. Set it to the same value as iam_server_id_header_value on auth/%s/config/client',
129
+ this._mount, this._mount
130
+ );
131
+ } catch {
132
+ // an odd api object changes nothing here
133
+ }
134
+ }
135
+
83
136
  /**
84
137
  * @inheritDoc
85
138
  */
86
139
  _authenticate() {
140
+ this.__warnOnMissingServerId();
87
141
  this._log.info(
88
142
  'making authentication request: role=%s',
89
143
  this.__role
@@ -33,20 +33,27 @@ class VaultKubernetesAuth extends VaultBaseAuth {
33
33
  this.__role, this.__tokenPath
34
34
  );
35
35
 
36
- const k8sJwtToken = fs.readFileSync(this.__tokenPath).toString();
37
- this._log.debug(
38
- 'read K8s service-account JWT from "%s" (%d bytes)',
39
- this.__tokenPath, k8sJwtToken.length
40
- );
41
-
42
- return this.__apiClient.makeRequest('POST', `/auth/${this._mount}/login`, {
43
- role: this.__role,
44
- jwt: k8sJwtToken,
45
- }).then((res) => {
46
- this._log.debug('received Vault client token from Kubernetes login');
47
-
48
- return this._getTokenEntity(res.auth.client_token);
49
- });
36
+ // Inside the chain, as in VaultJwtAuth and VaultIAMAuth: a missing or unreadable token
37
+ // file rejects the login instead of throwing. getAuthToken() turns any backend throw
38
+ // into a rejection too; this keeps the backend's own _authenticate() contract async.
39
+ return Promise.resolve()
40
+ .then(() => {
41
+ const k8sJwtToken = fs.readFileSync(this.__tokenPath).toString();
42
+ this._log.debug(
43
+ 'read K8s service-account JWT from "%s" (%d bytes)',
44
+ this.__tokenPath, k8sJwtToken.length
45
+ );
46
+
47
+ return this.__apiClient.makeRequest('POST', `/auth/${this._mount}/login`, {
48
+ role: this.__role,
49
+ jwt: k8sJwtToken,
50
+ });
51
+ })
52
+ .then((res) => {
53
+ this._log.debug('received Vault client token from Kubernetes login');
54
+
55
+ return this._getTokenEntity(res.auth.client_token);
56
+ });
50
57
  }
51
58
  }
52
59
 
package/src/errors.d.ts CHANGED
@@ -12,9 +12,16 @@ export declare class AuthTokenExpiredError extends VaultError {}
12
12
  export declare class UnsupportedOperationError extends VaultError {}
13
13
 
14
14
  export declare class VaultHttpError extends VaultError {
15
- constructor(statusCode: number, text: string, body?: unknown);
15
+ constructor(statusCode: number, text: string, body?: unknown, headers?: { retryAfter?: string | null });
16
16
  statusCode: number;
17
17
  error: unknown;
18
+ /** Raw `Retry-After` response header; absent when the response did not send one or it was empty. */
19
+ retryAfter?: string;
20
+ /**
21
+ * `Retry-After` in milliseconds (delay-seconds or HTTP-date), capped at 2147483647;
22
+ * absent when the header was not sent or cannot be parsed.
23
+ */
24
+ retryAfterMs?: number;
18
25
  }
19
26
 
20
27
  export declare class RequestTimeoutError extends VaultError {
package/src/errors.js CHANGED
@@ -19,6 +19,29 @@ class InvalidAWSCredentialsError extends InvalidArgumentsError {}
19
19
  class AuthTokenExpiredError extends VaultError {}
20
20
  class UnsupportedOperationError extends VaultError {}
21
21
 
22
+ // The longest delay setTimeout() honours; a longer one fires after 1 ms (same limit as api.timeout).
23
+ const MAX_DELAY_MS = 2147483647;
24
+
25
+ /**
26
+ * Parses a `Retry-After` header value (RFC 9110 §10.2.3): delay-seconds or an HTTP-date.
27
+ *
28
+ * @param {string} value - the raw header value, trimmed and not empty
29
+ * @param {number} now - current time in milliseconds
30
+ * @returns {number|undefined} milliseconds to wait, at most MAX_DELAY_MS; `undefined` for a
31
+ * value in neither format (e.g. `-5`, or `30, 120` from two headers)
32
+ */
33
+ function parseRetryAfter(value, now) {
34
+ if (/^\d+$/.test(value)) {
35
+ return Math.min(Number(value) * 1000, MAX_DELAY_MS);
36
+ }
37
+ // An HTTP-date always names the day or the month; Date.parse() alone also accepts e.g. "-5".
38
+ const date = /[a-z]/i.test(value) ? Date.parse(value) : NaN;
39
+ if (Number.isNaN(date)) {
40
+ return undefined;
41
+ }
42
+ return Math.min(Math.max(0, date - now), MAX_DELAY_MS);
43
+ }
44
+
22
45
  /**
23
46
  * A non-2xx HTTP response from the Vault server.
24
47
  *
@@ -33,11 +56,26 @@ class VaultHttpError extends VaultError {
33
56
  * @param {string} text - raw response body text, used in the message.
34
57
  * @param {*} [body] - parsed JSON body when the response was valid JSON, the raw text
35
58
  * otherwise, or `undefined` for an empty body. Exposed as `error.error`.
59
+ * @param {Object} [headers] - response headers the error exposes:
60
+ * @param {string|null} [headers.retryAfter] - the raw `Retry-After` header, `null` or
61
+ * `undefined` when it was not sent. Vault's rate-limit quotas answer 429 with it, in
62
+ * seconds; proxies in front of Vault send it with 429 and 503. Exposed as
63
+ * `error.retryAfter` (raw) and `error.retryAfterMs` (parsed); both are absent when the
64
+ * header was not sent or is empty, and `retryAfterMs` also when it cannot be parsed.
36
65
  */
37
- constructor(statusCode, text, body) {
66
+ constructor(statusCode, text, body, { retryAfter } = {}) {
38
67
  super(`${statusCode} - ${text}`);
39
68
  this.statusCode = statusCode;
40
69
  this.error = body;
70
+
71
+ const value = typeof retryAfter === 'string' ? retryAfter.trim() : '';
72
+ if (value !== '') {
73
+ this.retryAfter = value;
74
+ const ms = parseRetryAfter(value, Date.now());
75
+ if (ms !== undefined) {
76
+ this.retryAfterMs = ms;
77
+ }
78
+ }
41
79
  }
42
80
  }
43
81
 
@@ -0,0 +1,44 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Warnings already logged, kept per `logger.warn` function: the default console logger of every
5
+ * client shares `console.warn`, so the process logs a warning once however many clients it
6
+ * creates, while a client with a logger of its own still gets it, and the no-op logger of
7
+ * `logger: false` uses up nobody else's. Each set holds one short string per distinct warning.
8
+ * @type {WeakMap<Function, Set<string>>}
9
+ */
10
+ const logged = new WeakMap();
11
+
12
+ /**
13
+ * Log `message` with `args` through `logger.warn`, unless that same `logger.warn` has already
14
+ * logged `message` for `key`. It is recorded only once it has been logged, so a throwing logger
15
+ * uses nothing up, and this never throws.
16
+ * @param {Object} logger
17
+ * @param {string} key - what the warning is about, such as a host or a mount
18
+ * @param {string} message - the format string
19
+ * @param {...*} args
20
+ * @returns {boolean} true when the warning is logged now or was logged before; false when the
21
+ * logger threw, so the caller can try again later
22
+ * @private
23
+ */
24
+ function warnOnce(logger, key, message, ...args) {
25
+ try {
26
+ const warn = logger.warn;
27
+ const id = `${message}\u0000${key}`;
28
+ let seen = logged.get(warn);
29
+ if (seen && seen.has(id)) {
30
+ return true;
31
+ }
32
+ logger.warn(message, ...args);
33
+ if (!seen) {
34
+ seen = new Set();
35
+ logged.set(warn, seen);
36
+ }
37
+ seen.add(id);
38
+ return true;
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ module.exports = warnOnce;