amqplib 2.1.0 → 2.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.
@@ -1,8 +1,25 @@
1
+ # Runs when a GitHub release is published (draft releases do not trigger it).
2
+ #
3
+ # Tests the tagged commit on every supported Node.js version, then stages the
4
+ # package on npm with `npm stage publish` using OIDC trusted publishing, so no
5
+ # npm token is stored in this repository. Nothing goes live until a maintainer
6
+ # reviews the staged version and approves it with 2FA:
7
+ #
8
+ # npm stage list amqplib
9
+ # npm stage approve <stage-id>
10
+ #
11
+ # Prerequisites on npmjs.com: the package's trusted publisher must be set to
12
+ # this repository and this workflow file (publish.yml), with allowed actions
13
+ # restricted to `npm stage publish`.
14
+
1
15
  name: Publish
2
16
 
3
17
  on:
4
18
  release:
5
- types: [created]
19
+ types: [published]
20
+
21
+ permissions:
22
+ contents: read
6
23
 
7
24
  jobs:
8
25
  build:
@@ -19,17 +36,18 @@ jobs:
19
36
  # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
20
37
 
21
38
  steps:
22
- - uses: actions/checkout@v3
39
+ - uses: actions/checkout@v4
23
40
 
24
41
  - name: Use Node.js ${{ matrix.node-version }}
25
- uses: actions/setup-node@v3
42
+ uses: actions/setup-node@v4
26
43
  with:
27
44
  node-version: ${{ matrix.node-version }}
28
45
  cache: "npm"
29
46
 
30
47
  - run: npm ci
31
48
 
32
- - run: |
49
+ - name: Wait for RabbitMQ
50
+ run: |
33
51
  n=0
34
52
  while :
35
53
  do
@@ -43,17 +61,53 @@ jobs:
43
61
 
44
62
  - run: make test
45
63
 
46
- publish:
64
+ stage:
47
65
  needs: build
48
66
  runs-on: ubuntu-latest
67
+ permissions:
68
+ contents: read
69
+ id-token: write # required for OIDC trusted publishing
49
70
  steps:
50
- - uses: actions/checkout@v3
71
+ - uses: actions/checkout@v4
72
+
51
73
  - uses: actions/setup-node@v4
52
74
  with:
53
- node-version: '20.x'
75
+ node-version: 24.x
54
76
  cache: "npm"
55
77
  registry-url: https://registry.npmjs.org/
78
+
79
+ # npm stage publish needs npm 11.15.0 or later
80
+ - run: npm install -g npm@^11.15.0
81
+
56
82
  - run: npm ci
57
- - run: npm publish --dry-run
83
+
84
+ - name: Check release tag matches package.json version
85
+ id: version
86
+ env:
87
+ RELEASE_TAG: ${{ github.event.release.tag_name }}
88
+ run: |
89
+ version="$(node -p "require('./package.json').version")"
90
+ if [[ "$RELEASE_TAG" != "v${version}" ]]; then
91
+ echo "Release tag ${RELEASE_TAG} does not match package.json version v${version}" >&2
92
+ exit 1
93
+ fi
94
+ echo "version=${version}" >> "$GITHUB_OUTPUT"
95
+
96
+ # Pre-release versions (e.g. 2.2.0-rc.0) are staged under the `next`
97
+ # dist-tag; everything else under `latest`. The tag is fixed at staging
98
+ # time and cannot be changed on approval.
99
+ - name: Choose dist-tag
100
+ id: dist-tag
101
+ env:
102
+ VERSION: ${{ steps.version.outputs.version }}
103
+ run: |
104
+ if [[ "$VERSION" == *-* ]]; then
105
+ echo "tag=next" >> "$GITHUB_OUTPUT"
106
+ else
107
+ echo "tag=latest" >> "$GITHUB_OUTPUT"
108
+ fi
109
+
110
+ - name: Stage publish
58
111
  env:
59
- NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }}
112
+ DIST_TAG: ${{ steps.dist-tag.outputs.tag }}
113
+ run: npm stage publish --tag "$DIST_TAG" --provenance
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Change log for amqplib
2
2
 
3
+ ## v2.2.0
4
+ - Add `calculateDelay` recovery option for supplying a custom reconnect delay strategy, e.g. full jitter, decorrelated jitter or a fixed schedule. When set, `maxDelay` is not applied to its return value; when it throws or returns an invalid value, recovery gives up as if `maxRetries` were exhausted (fixes #855, thanks [@GiHoon1123](https://github.com/GiHoon1123))
5
+ - The built-in reconnect delay no longer exceeds `maxDelay`. Previously the cap was applied before jitter, so delays could overshoot it by up to the jitter fraction. The exponential base is now capped at `maxDelay / (1 + jitter)` so the full jitter range fits under the cap; with default settings the steady-state delay is now spread over 20-30s rather than 24-36s
6
+ - Channel operations attempted after recovery has given up (`reconnect-failed`) now reject with the failure instead of waiting forever
7
+ - Add `initialMaxRetries` recovery option to bound retries before the first successful connection separately from `maxRetries`, so startup can fail fast while steady-state recovery retries indefinitely (fixes #856)
8
+
3
9
  ## v2.1.0
4
10
  - Add `waitForConnect` recovery option. When `false`, `connect` returns the recovering connection immediately instead of waiting for the first successful connection, so listeners for `connect`, `connect-failed` and `reconnect-scheduled` can be attached before the initial attempt, and `close()` can cancel it. Expose `waitForConnect()` on recovering connections to await the first connection (fixes #858)
5
11
  - Fix `setup` being run when `close()` is called while the initial connection attempt is in flight
package/README.md CHANGED
@@ -210,6 +210,60 @@ callback is invoked immediately with the handle instead of after the first
210
210
  connection, and `waitForConnect(callback)` can be used to be notified once
211
211
  connected.
212
212
 
213
+ ### Custom delay strategy
214
+
215
+ By default, reconnect delays follow an exponential backoff with jitter,
216
+ controlled by `initialDelay`, `maxDelay`, `factor` and `jitter`. To use a
217
+ different strategy entirely (full jitter, decorrelated jitter, a fixed step
218
+ schedule, etc.), provide a `calculateDelay` function instead:
219
+
220
+ ```javascript
221
+ const connection = await amqplib.connect('amqp://localhost', {
222
+ recovery: {
223
+ maxRetries: Infinity,
224
+ // Called with the reconnect attempt number, starting at 1.
225
+ // Must return the delay in milliseconds.
226
+ calculateDelay(attempt) {
227
+ return Math.min(30000, 100 * 2 ** (attempt - 1));
228
+ },
229
+ },
230
+ });
231
+ ```
232
+
233
+ `maxDelay` only bounds the built-in strategy. Once you provide `calculateDelay`,
234
+ amqplib does not cap its return value - you're responsible for enforcing your
235
+ own maximum, as the example above does with `Math.min`.
236
+
237
+ When `calculateDelay` is absent, the built-in strategy is used. If it throws,
238
+ or returns something other than a finite, non-negative number, amqplib does
239
+ not fall back to the built-in strategy. Instead recovery gives up as if
240
+ `maxRetries` had been exhausted: the initial `connect` rejects (or the
241
+ callback receives the error), pending channel operations reject, and
242
+ `reconnect-failed` is emitted with the error. A broken `calculateDelay` is a
243
+ bug in caller-supplied code, so it is surfaced rather than papered over.
244
+
245
+ ### Separate retry budget for the initial connection
246
+
247
+ By default `maxRetries` bounds every phase of recovery, including the attempts
248
+ made before the first connection succeeds. To give the very first connection
249
+ its own budget, set `initialMaxRetries`; once connected, `maxRetries` applies.
250
+ A common production posture is to fail fast at startup, so a misconfigured or
251
+ unreachable broker fails the deployment, while never giving up on a service
252
+ that has already connected:
253
+
254
+ ```javascript
255
+ const connection = await amqplib.connect('amqp://localhost', {
256
+ recovery: {
257
+ initialMaxRetries: 5, // give up (and reject connect) after 5 failed retries at startup
258
+ maxRetries: Infinity, // but keep reconnecting forever once connected
259
+ },
260
+ });
261
+ ```
262
+
263
+ When the initial budget is exhausted `connect` rejects (or the callback receives
264
+ the error) and `reconnect-failed` is emitted. `initialMaxRetries` defaults to
265
+ `maxRetries`, so behaviour is unchanged unless it is set.
266
+
213
267
  ## Error handling in event handlers
214
268
 
215
269
  If a user-supplied event handler throws a synchronous error, the throw will
package/callback_api.d.ts CHANGED
@@ -169,8 +169,15 @@ export interface RecoveryOptions {
169
169
  factor?: number;
170
170
  /** Jitter factor (0–1) applied to delay to avoid thundering herd. Default: 0.2 */
171
171
  jitter?: number;
172
- /** Maximum number of reconnect attempts. Default: Infinity */
172
+ /** Maximum number of reconnect attempts. Also bounds the initial connection unless `initialMaxRetries` is set. Default: Infinity */
173
173
  maxRetries?: number;
174
+ /**
175
+ * Maximum number of retries before the first successful connection. Once
176
+ * connected, `maxRetries` applies instead. Lets startup fail fast against an
177
+ * unreachable broker while steady-state recovery keeps retrying.
178
+ * Default: the value of `maxRetries`
179
+ */
180
+ initialMaxRetries?: number;
174
181
  /** Optional setup function called after each successful connection */
175
182
  setup?: ((model: Connection) => Promise<void>) | ((model: Connection, done: (err?: Error) => void) => void);
176
183
  /**
@@ -179,6 +186,15 @@ export interface RecoveryOptions {
179
186
  * Use `waitForConnect()` to be notified of the first connection. Default: true
180
187
  */
181
188
  waitForConnect?: boolean;
189
+ /**
190
+ * Optional custom delay strategy. Called with the reconnect attempt number
191
+ * (starting at 1) and must return the delay in milliseconds. Falls back to
192
+ * the built-in exponential-backoff-with-jitter strategy when absent. If it
193
+ * throws, or returns something other than a finite, non-negative number,
194
+ * recovery gives up as if `maxRetries` were exhausted: the initial connection
195
+ * fails with the error and `reconnect-failed` is emitted.
196
+ */
197
+ calculateDelay?: (attempt: number) => number;
182
198
  }
183
199
 
184
200
  export interface RecoveringConnection extends events.EventEmitter {
package/index.d.ts CHANGED
@@ -143,8 +143,15 @@ export interface RecoveryOptions {
143
143
  factor?: number;
144
144
  /** Jitter factor (0–1) applied to delay to avoid thundering herd. Default: 0.2 */
145
145
  jitter?: number;
146
- /** Maximum number of reconnect attempts. Default: Infinity */
146
+ /** Maximum number of reconnect attempts. Also bounds the initial connection unless `initialMaxRetries` is set. Default: Infinity */
147
147
  maxRetries?: number;
148
+ /**
149
+ * Maximum number of retries before the first successful connection. Once
150
+ * connected, `maxRetries` applies instead. Lets startup fail fast against an
151
+ * unreachable broker while steady-state recovery keeps retrying.
152
+ * Default: the value of `maxRetries`
153
+ */
154
+ initialMaxRetries?: number;
148
155
  /** Optional setup function called after each successful connection */
149
156
  setup?: ((model: ChannelModel) => Promise<void>) | ((model: ChannelModel, done: (err?: Error) => void) => void);
150
157
  /**
@@ -154,6 +161,15 @@ export interface RecoveryOptions {
154
161
  * first connection. Default: true
155
162
  */
156
163
  waitForConnect?: boolean;
164
+ /**
165
+ * Optional custom delay strategy. Called with the reconnect attempt number
166
+ * (starting at 1) and must return the delay in milliseconds. Falls back to
167
+ * the built-in exponential-backoff-with-jitter strategy when absent. If it
168
+ * throws, or returns something other than a finite, non-negative number,
169
+ * recovery gives up as if `maxRetries` were exhausted: the initial connection
170
+ * fails with the error and `reconnect-failed` is emitted.
171
+ */
172
+ calculateDelay?: (attempt: number) => number;
157
173
  }
158
174
 
159
175
  export interface RecoveringChannelModel extends events.EventEmitter {
package/lib/recovery.js CHANGED
@@ -8,6 +8,7 @@ const DEFAULT_RECOVERY = {
8
8
  maxRetries: Infinity,
9
9
  setup: null,
10
10
  waitForConnect: true,
11
+ calculateDelay: null,
11
12
  };
12
13
 
13
14
  function splitConnectionOptions(connOptions) {
@@ -43,22 +44,32 @@ function toFiniteNumber(value, fallback) {
43
44
  return Number.isFinite(n) ? n : fallback;
44
45
  }
45
46
 
47
+ function toRetryCount(value, fallback) {
48
+ if (value == null) return fallback;
49
+ if (value === Infinity) return Infinity;
50
+ const n = toFiniteNumber(value, fallback);
51
+ return n < 0 ? 0 : n;
52
+ }
53
+
46
54
  function normaliseRecoveryOptions(recovery) {
47
55
  const source = recovery === true ? {} : recovery || {};
48
56
  const initialDelay = Math.max(0, toFiniteNumber(source.initialDelay, DEFAULT_RECOVERY.initialDelay));
49
57
  const maxDelay = Math.max(initialDelay, toFiniteNumber(source.maxDelay, DEFAULT_RECOVERY.maxDelay));
50
58
  const factor = Math.max(1, toFiniteNumber(source.factor, DEFAULT_RECOVERY.factor));
51
59
  const jitter = Math.min(1, Math.max(0, toFiniteNumber(source.jitter, DEFAULT_RECOVERY.jitter)));
52
- const parsedMaxRetries = source.maxRetries == null ? Infinity : toFiniteNumber(source.maxRetries, Infinity);
60
+ const maxRetries = toRetryCount(source.maxRetries, Infinity);
61
+ const initialMaxRetries = toRetryCount(source.initialMaxRetries, maxRetries);
53
62
 
54
63
  return {
55
64
  initialDelay,
56
65
  maxDelay,
57
66
  factor,
58
67
  jitter,
59
- maxRetries: parsedMaxRetries < 0 ? 0 : parsedMaxRetries,
68
+ maxRetries,
69
+ initialMaxRetries,
60
70
  setup: typeof source.setup === 'function' ? source.setup : null,
61
71
  waitForConnect: source.waitForConnect !== false,
72
+ calculateDelay: typeof source.calculateDelay === 'function' ? source.calculateDelay : null,
62
73
  };
63
74
  }
64
75
 
@@ -67,13 +78,39 @@ function toError(err, fallbackMessage) {
67
78
  return new Error(err ? String(err) : fallbackMessage);
68
79
  }
69
80
 
70
- function calculateDelay(recovery, attempt) {
71
- const base = Math.min(recovery.maxDelay, recovery.initialDelay * recovery.factor ** (attempt - 1));
81
+ function calculateBuiltinDelay(recovery, attempt) {
82
+ // Capping the base at maxDelay and then clamping base+offset to maxDelay
83
+ // would put a point mass on maxDelay itself: once base saturates, every
84
+ // positive jitter draw (half of them) collapses onto the same value,
85
+ // defeating the point of jitter. Capping the base at
86
+ // maxDelay / (1 + jitter) instead means the maximum possible offset
87
+ // lands exactly on maxDelay, so the result never exceeds it without
88
+ // needing a final clamp - the distribution stays smooth right up to the cap.
89
+ const cappedBase = recovery.maxDelay / (1 + recovery.jitter);
90
+ const base = Math.min(cappedBase, recovery.initialDelay * recovery.factor ** (attempt - 1));
72
91
  const jitterPart = base * recovery.jitter;
73
92
  const offset = jitterPart > 0 ? Math.random() * jitterPart * 2 - jitterPart : 0;
74
93
  return Math.max(0, Math.round(base + offset));
75
94
  }
76
95
 
96
+ // A custom `calculateDelay` that throws, or returns something other than a
97
+ // finite, non-negative number, is a bug in caller-supplied code. Rather than
98
+ // silently falling back to the built-in strategy, the error is treated as a
99
+ // terminal recovery failure by the caller (see _scheduleReconnect), so it
100
+ // surfaces immediately instead of manifesting as unexplained delay behavior.
101
+ function calculateDelay(recovery, attempt) {
102
+ if (!recovery.calculateDelay) {
103
+ return calculateBuiltinDelay(recovery, attempt);
104
+ }
105
+
106
+ const custom = recovery.calculateDelay(attempt);
107
+ if (typeof custom === 'number' && Number.isFinite(custom) && custom >= 0) {
108
+ return Math.round(custom);
109
+ }
110
+
111
+ throw new Error(`calculateDelay must return a finite, non-negative number of milliseconds (got ${String(custom)})`);
112
+ }
113
+
77
114
  function runSetup(setup, model) {
78
115
  if (!setup) return Promise.resolve();
79
116
 
@@ -118,6 +155,8 @@ class RecoveringCore extends EventEmitter {
118
155
  this._waiters = [];
119
156
  this._connecting = false;
120
157
  this._stopped = false;
158
+ this._failure = null;
159
+ this._connectedOnce = false;
121
160
  this._timer = null;
122
161
  this._attempt = 0;
123
162
 
@@ -205,7 +244,7 @@ class RecoveringCore extends EventEmitter {
205
244
 
206
245
  _waitForConnection() {
207
246
  if (this._model) return Promise.resolve(this._model);
208
- if (this._stopped) return Promise.reject(new Error('Connection closed'));
247
+ if (this._stopped) return Promise.reject(this._failure || new Error('Connection closed'));
209
248
 
210
249
  return new Promise((resolve, reject) => {
211
250
  this._waiters.push({ resolve, reject });
@@ -277,6 +316,7 @@ class RecoveringCore extends EventEmitter {
277
316
 
278
317
  this._model = model;
279
318
  this._attempt = 0;
319
+ this._connectedOnce = true;
280
320
  this._bindModel(model);
281
321
  this._resolveInitialConnection();
282
322
  this._resolvePendingWaiters(model);
@@ -296,15 +336,24 @@ class RecoveringCore extends EventEmitter {
296
336
 
297
337
  if (this._timer) return;
298
338
 
299
- if (this._attempt >= this._recovery.maxRetries) {
300
- this._rejectInitialConnection(err);
301
- this._rejectPendingWaiters(err);
302
- this.emit('reconnect-failed', err);
339
+ const maxRetries = this._connectedOnce ? this._recovery.maxRetries : this._recovery.initialMaxRetries;
340
+ if (this._attempt >= maxRetries) {
341
+ this._abandonRecovery(err);
303
342
  return;
304
343
  }
305
344
 
306
345
  const attempt = this._attempt + 1;
307
- const delay = calculateDelay(this._recovery, attempt);
346
+ let delay;
347
+ try {
348
+ delay = calculateDelay(this._recovery, attempt);
349
+ } catch (delayErr) {
350
+ // Both callers of _scheduleReconnect run from timers or socket events
351
+ // where a throw would either be an unhandled rejection or an uncaught
352
+ // exception. Give up recovery cleanly instead so the failure reaches the
353
+ // initial connect promise, pending channel waiters and 'reconnect-failed'.
354
+ this._abandonRecovery(toError(delayErr, 'calculateDelay failed'));
355
+ return;
356
+ }
308
357
  this._attempt = attempt;
309
358
 
310
359
  this.emit('reconnect-scheduled', { attempt, delay, error: err });
@@ -314,6 +363,18 @@ class RecoveringCore extends EventEmitter {
314
363
  }, delay);
315
364
  }
316
365
 
366
+ _abandonRecovery(err) {
367
+ // Recovery is over and cannot be restarted, so treat the connection as
368
+ // stopped. Remember why, so channel operations attempted afterwards
369
+ // reject with the underlying failure instead of waiting forever for a
370
+ // connection that will never arrive.
371
+ this._stopped = true;
372
+ this._failure = err;
373
+ this._rejectInitialConnection(err);
374
+ this._rejectPendingWaiters(err);
375
+ this.emit('reconnect-failed', err);
376
+ }
377
+
317
378
  _closeModelNoThrow(model) {
318
379
  return this._adapters.closeModel(model).catch(() => {});
319
380
  }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "amqplib",
3
3
  "homepage": "http://amqp-node.github.io/amqplib/",
4
4
  "main": "./channel_api.js",
5
- "version": "2.1.0",
5
+ "version": "2.2.0",
6
6
  "types": "./index.d.ts",
7
7
  "exports": {
8
8
  ".": {
@@ -1,5 +1,3 @@
1
-
2
-
3
1
  const { describe, it } = require('node:test');
4
2
  const assert = require('node:assert');
5
3
  const EventEmitter = require('node:events');
@@ -16,11 +14,11 @@ class FakePromiseModel extends EventEmitter {
16
14
  }
17
15
 
18
16
  createChannel(options) {
19
- return Promise.resolve({kind: 'channel', id: this.id, options});
17
+ return Promise.resolve({ kind: 'channel', id: this.id, options });
20
18
  }
21
19
 
22
20
  createConfirmChannel(options) {
23
- return Promise.resolve({kind: 'confirm', id: this.id, options});
21
+ return Promise.resolve({ kind: 'confirm', id: this.id, options });
24
22
  }
25
23
 
26
24
  updateSecret(_newSecret, _reason) {
@@ -43,7 +41,7 @@ class FakeCallbackModel extends EventEmitter {
43
41
  cb = options;
44
42
  options = undefined;
45
43
  }
46
- cb && cb(null, {kind: 'channel', id: this.id, options});
44
+ cb && cb(null, { kind: 'channel', id: this.id, options });
47
45
  }
48
46
 
49
47
  createConfirmChannel(options, cb) {
@@ -51,7 +49,7 @@ class FakeCallbackModel extends EventEmitter {
51
49
  cb = options;
52
50
  options = undefined;
53
51
  }
54
- cb && cb(null, {kind: 'confirm', id: this.id, options});
52
+ cb && cb(null, { kind: 'confirm', id: this.id, options });
55
53
  }
56
54
 
57
55
  updateSecret(_newSecret, _reason, cb) {
@@ -199,6 +197,303 @@ describe('recovery', () => {
199
197
  await client.close();
200
198
  });
201
199
 
200
+ it('built-in delay never exceeds maxDelay even at full jitter', async () => {
201
+ const models = [];
202
+ let opened = 0;
203
+
204
+ function openModel() {
205
+ const model = new FakePromiseModel(++opened);
206
+ models.push(model);
207
+ return Promise.resolve(model);
208
+ }
209
+
210
+ const client = await recovery.connectWithRecoveryPromise(openModel, {
211
+ initialDelay: 5,
212
+ maxDelay: 5,
213
+ factor: 1,
214
+ jitter: 1,
215
+ maxRetries: Infinity,
216
+ });
217
+
218
+ const originalRandom = Math.random;
219
+ let delay;
220
+
221
+ try {
222
+ // Force the maximum positive jitter. The base is capped at
223
+ // maxDelay / (1 + jitter) = 2.5, so base + offset tops out at
224
+ // exactly maxDelay (5) instead of overshooting it.
225
+ Math.random = () => 1;
226
+
227
+ delay = await new Promise((resolve) => {
228
+ client.once('reconnect-scheduled', (info) => resolve(info.delay));
229
+ models[models.length - 1].emit('close', new Error('socket closed'));
230
+ });
231
+ } finally {
232
+ Math.random = originalRandom;
233
+ await client.close();
234
+ }
235
+
236
+ assert.equal(delay, 5);
237
+ });
238
+
239
+ it('does not collapse onto maxDelay for every positive jitter draw once the base saturates', async () => {
240
+ const models = [];
241
+ let opened = 0;
242
+
243
+ function openModel() {
244
+ const model = new FakePromiseModel(++opened);
245
+ models.push(model);
246
+ return Promise.resolve(model);
247
+ }
248
+
249
+ const client = await recovery.connectWithRecoveryPromise(openModel, {
250
+ initialDelay: 100,
251
+ maxDelay: 100,
252
+ factor: 1,
253
+ jitter: 0.2,
254
+ maxRetries: Infinity,
255
+ });
256
+
257
+ const originalRandom = Math.random;
258
+ let delay;
259
+
260
+ try {
261
+ // Math.random() just above the midpoint gives a small positive
262
+ // offset. Capping base+offset at maxDelay (the old behavior) would
263
+ // collapse this to exactly 100 instead of the slightly lower value
264
+ // this offset actually produces.
265
+ Math.random = () => 0.6;
266
+
267
+ delay = await new Promise((resolve) => {
268
+ client.once('reconnect-scheduled', (info) => resolve(info.delay));
269
+ models[models.length - 1].emit('close', new Error('socket closed'));
270
+ });
271
+ } finally {
272
+ Math.random = originalRandom;
273
+ await client.close();
274
+ }
275
+
276
+ // base = 100 / 1.2 = 83.33, jitterPart = 16.67,
277
+ // offset = 0.6 * 16.67 * 2 - 16.67 = 3.33, result = round(83.33 + 3.33) = 87
278
+ assert.equal(delay, 87);
279
+ assert.notEqual(delay, 100);
280
+ });
281
+
282
+ it('uses a custom calculateDelay strategy when provided', async () => {
283
+ const models = [];
284
+ let opened = 0;
285
+ const attemptsSeen = [];
286
+
287
+ function openModel() {
288
+ const model = new FakePromiseModel(++opened);
289
+ models.push(model);
290
+ return Promise.resolve(model);
291
+ }
292
+
293
+ const client = await recovery.connectWithRecoveryPromise(openModel, {
294
+ initialDelay: 1,
295
+ maxDelay: 1,
296
+ jitter: 0,
297
+ maxRetries: 3,
298
+ calculateDelay(attempt) {
299
+ attemptsSeen.push(attempt);
300
+ return attempt * 1000;
301
+ },
302
+ });
303
+
304
+ await new Promise((resolve) => {
305
+ client.once('reconnect-scheduled', ({ attempt, delay }) => {
306
+ assert.equal(1, attempt);
307
+ assert.equal(1000, delay);
308
+ resolve();
309
+ });
310
+
311
+ models[0].emit('close', new Error('socket closed'));
312
+ });
313
+
314
+ assert.deepEqual([1], attemptsSeen);
315
+
316
+ await client.close();
317
+ });
318
+
319
+ it('gives up recovery with reconnect-failed when calculateDelay throws', async () => {
320
+ const models = [];
321
+ let opened = 0;
322
+
323
+ function openModel() {
324
+ const model = new FakePromiseModel(++opened);
325
+ models.push(model);
326
+ return Promise.resolve(model);
327
+ }
328
+
329
+ const client = await recovery.connectWithRecoveryPromise(openModel, {
330
+ initialDelay: 5,
331
+ maxDelay: 5,
332
+ jitter: 0,
333
+ maxRetries: 3,
334
+ calculateDelay() {
335
+ throw new Error('calculateDelay is broken');
336
+ },
337
+ });
338
+
339
+ const err = await new Promise((resolve) => {
340
+ client.once('reconnect-failed', resolve);
341
+ models[0].emit('close', new Error('socket closed'));
342
+ });
343
+ assert.match(err.message, /calculateDelay is broken/);
344
+ await assert.rejects(client.createChannel(), /calculateDelay is broken/);
345
+
346
+ await client.close();
347
+ });
348
+
349
+ it('reports a validation error instead of a JSON error for a circular-reference return value', async () => {
350
+ const models = [];
351
+ let opened = 0;
352
+
353
+ function openModel() {
354
+ const model = new FakePromiseModel(++opened);
355
+ models.push(model);
356
+ return Promise.resolve(model);
357
+ }
358
+
359
+ const client = await recovery.connectWithRecoveryPromise(openModel, {
360
+ initialDelay: 5,
361
+ maxDelay: 5,
362
+ jitter: 0,
363
+ maxRetries: 3,
364
+ calculateDelay() {
365
+ const circular = {};
366
+ circular.self = circular;
367
+ return circular;
368
+ },
369
+ });
370
+
371
+ // Must be our own validation error, not a JSON.stringify TypeError from
372
+ // trying to serialize the circular value into the message.
373
+ const err = await new Promise((resolve) => {
374
+ client.once('reconnect-failed', resolve);
375
+ models[0].emit('close', new Error('socket closed'));
376
+ });
377
+ assert.match(err.message, /calculateDelay must return a finite, non-negative number/);
378
+
379
+ await client.close();
380
+ });
381
+
382
+ it('gives up recovery when calculateDelay returns an invalid value', async () => {
383
+ const models = [];
384
+ let opened = 0;
385
+
386
+ function openModel() {
387
+ const model = new FakePromiseModel(++opened);
388
+ models.push(model);
389
+ return Promise.resolve(model);
390
+ }
391
+
392
+ const client = await recovery.connectWithRecoveryPromise(openModel, {
393
+ initialDelay: 5,
394
+ maxDelay: 5,
395
+ jitter: 0,
396
+ maxRetries: 3,
397
+ calculateDelay() {
398
+ return -1;
399
+ },
400
+ });
401
+
402
+ const err = await new Promise((resolve) => {
403
+ client.once('reconnect-failed', resolve);
404
+ models[0].emit('close', new Error('socket closed'));
405
+ });
406
+ assert.match(err.message, /calculateDelay must return a finite, non-negative number/);
407
+
408
+ await client.close();
409
+ });
410
+
411
+ it('does not coerce non-number calculateDelay return values', async () => {
412
+ const models = [];
413
+ let opened = 0;
414
+
415
+ function openModel() {
416
+ const model = new FakePromiseModel(++opened);
417
+ models.push(model);
418
+ return Promise.resolve(model);
419
+ }
420
+
421
+ const client = await recovery.connectWithRecoveryPromise(openModel, {
422
+ initialDelay: 5,
423
+ maxDelay: 5,
424
+ jitter: 0,
425
+ maxRetries: 3,
426
+ calculateDelay() {
427
+ return '1000'; // a string, not a number - must not be accepted as-is
428
+ },
429
+ });
430
+
431
+ const err = await new Promise((resolve) => {
432
+ client.once('reconnect-failed', resolve);
433
+ models[0].emit('close', new Error('socket closed'));
434
+ });
435
+ assert.match(err.message, /calculateDelay must return a finite, non-negative number/);
436
+
437
+ await client.close();
438
+ });
439
+
440
+ it('rejects the initial connection when calculateDelay throws after a failed connect', async (t) => {
441
+ const rejections = [];
442
+ const onRejection = (err) => rejections.push(err);
443
+ process.on('unhandledRejection', onRejection);
444
+ t.after(() => process.removeListener('unhandledRejection', onRejection));
445
+
446
+ let attempts = 0;
447
+
448
+ function openModel() {
449
+ attempts++;
450
+ return Promise.reject(new Error('connect failed'));
451
+ }
452
+
453
+ await assert.rejects(
454
+ recovery.connectWithRecoveryPromise(openModel, {
455
+ maxRetries: Infinity,
456
+ calculateDelay() {
457
+ throw new Error('calculateDelay is broken');
458
+ },
459
+ }),
460
+ /calculateDelay is broken/,
461
+ );
462
+
463
+ await new Promise((resolve) => setImmediate(resolve));
464
+ assert.equal(1, attempts);
465
+ assert.deepEqual([], rejections);
466
+ });
467
+
468
+ it('channel operations reject with the failure once recovery has given up', async () => {
469
+ let attempts = 0;
470
+
471
+ function openModel() {
472
+ attempts++;
473
+ return Promise.reject(new Error('connect failed'));
474
+ }
475
+
476
+ const client = await recovery.connectWithRecoveryPromise(openModel, {
477
+ initialDelay: 1,
478
+ maxDelay: 1,
479
+ jitter: 0,
480
+ maxRetries: 1,
481
+ waitForConnect: false,
482
+ });
483
+
484
+ const err = await new Promise((resolve) => client.once('reconnect-failed', resolve));
485
+ assert.equal('connect failed', err.message);
486
+ assert.equal(2, attempts);
487
+
488
+ await assert.rejects(client.createChannel(), { message: 'connect failed' });
489
+ await assert.rejects(client.createConfirmChannel(), { message: 'connect failed' });
490
+ await assert.rejects(client.updateSecret(Buffer.from('x'), 'rotate'), { message: 'connect failed' });
491
+
492
+ // Nothing further is attempted and close() is a no-op.
493
+ await client.close();
494
+ assert.equal(2, attempts);
495
+ });
496
+
202
497
  it('promise recovery fails after max retries', async () => {
203
498
  let attempts = 0;
204
499
 
@@ -224,6 +519,108 @@ describe('recovery', () => {
224
519
  assert.equal(2, attempts);
225
520
  });
226
521
 
522
+ describe('initialMaxRetries', () => {
523
+ it('bounds the initial connection separately from maxRetries', async () => {
524
+ let attempts = 0;
525
+
526
+ function openModel() {
527
+ attempts++;
528
+ return Promise.reject(new Error('connect failed'));
529
+ }
530
+
531
+ await assert.rejects(
532
+ recovery.connectWithRecoveryPromise(openModel, {
533
+ initialDelay: 1,
534
+ maxDelay: 1,
535
+ jitter: 0,
536
+ initialMaxRetries: 2,
537
+ maxRetries: Infinity,
538
+ }),
539
+ { message: 'connect failed' },
540
+ );
541
+
542
+ assert.equal(3, attempts);
543
+ });
544
+
545
+ it('stops applying once a connection has been established', async () => {
546
+ const models = [];
547
+ let attempts = 0;
548
+
549
+ function openModel() {
550
+ attempts++;
551
+ // Succeed at first, fail twice after the disconnect, then succeed again.
552
+ if (attempts === 2 || attempts === 3) return Promise.reject(new Error('connect failed'));
553
+ const model = new FakePromiseModel(attempts);
554
+ models.push(model);
555
+ return Promise.resolve(model);
556
+ }
557
+
558
+ const client = await recovery.connectWithRecoveryPromise(openModel, {
559
+ initialDelay: 1,
560
+ maxDelay: 1,
561
+ jitter: 0,
562
+ initialMaxRetries: 0,
563
+ maxRetries: 3,
564
+ });
565
+
566
+ const failures = [];
567
+ client.on('reconnect-failed', (err) => failures.push(err));
568
+
569
+ const reconnected = new Promise((resolve) => client.once('connect', resolve));
570
+ models[0].emit('close', new Error('socket closed'));
571
+ await reconnected;
572
+
573
+ assert.equal(4, attempts);
574
+ assert.deepEqual([], failures);
575
+
576
+ await client.close();
577
+ });
578
+
579
+ it('accepts Infinity explicitly even when maxRetries is finite', async () => {
580
+ let attempts = 0;
581
+
582
+ function openModel() {
583
+ attempts++;
584
+ if (attempts < 4) return Promise.reject(new Error('connect failed'));
585
+ return Promise.resolve(new FakePromiseModel(attempts));
586
+ }
587
+
588
+ const client = await recovery.connectWithRecoveryPromise(openModel, {
589
+ initialDelay: 1,
590
+ maxDelay: 1,
591
+ jitter: 0,
592
+ initialMaxRetries: Infinity,
593
+ maxRetries: 1,
594
+ });
595
+
596
+ assert.equal(4, attempts);
597
+ await client.close();
598
+ });
599
+
600
+ it('rejects the callback connect once the initial budget is exhausted', (_t, done) => {
601
+ let attempts = 0;
602
+
603
+ function openModel() {
604
+ attempts++;
605
+ return Promise.reject(new Error('connect failed'));
606
+ }
607
+
608
+ recovery.connectWithRecoveryCallback(
609
+ openModel,
610
+ { initialDelay: 1, maxDelay: 1, jitter: 0, initialMaxRetries: 1, maxRetries: Infinity },
611
+ (err) => {
612
+ try {
613
+ assert.equal('connect failed', err.message);
614
+ assert.equal(2, attempts);
615
+ done();
616
+ } catch (e) {
617
+ done(e);
618
+ }
619
+ },
620
+ );
621
+ });
622
+ });
623
+
227
624
  describe('waitForConnect: false', () => {
228
625
  function watchUnhandledRejections(t) {
229
626
  const rejections = [];
@@ -240,7 +637,7 @@ describe('recovery', () => {
240
637
  return Promise.resolve(new FakePromiseModel(++opened));
241
638
  }
242
639
 
243
- const client = await recovery.connectWithRecoveryPromise(openModel, {waitForConnect: false});
640
+ const client = await recovery.connectWithRecoveryPromise(openModel, { waitForConnect: false });
244
641
 
245
642
  assert.equal(0, opened);
246
643
 
@@ -288,7 +685,7 @@ describe('recovery', () => {
288
685
  return Promise.resolve(new FakePromiseModel(1));
289
686
  }
290
687
 
291
- const client = await recovery.connectWithRecoveryPromise(openModel, {waitForConnect: false});
688
+ const client = await recovery.connectWithRecoveryPromise(openModel, { waitForConnect: false });
292
689
  const connected = await client.waitForConnect();
293
690
 
294
691
  assert.strictEqual(client, connected);
@@ -333,8 +730,8 @@ describe('recovery', () => {
333
730
 
334
731
  await client.close();
335
732
 
336
- await assert.rejects(pendingChannel, {message: 'Connection closed'});
337
- await assert.rejects(client.waitForConnect(), {message: 'Connection closed'});
733
+ await assert.rejects(pendingChannel, { message: 'Connection closed' });
734
+ await assert.rejects(client.waitForConnect(), { message: 'Connection closed' });
338
735
 
339
736
  releaseOpen();
340
737
  await new Promise((resolve) => setImmediate(resolve));
@@ -366,7 +763,7 @@ describe('recovery', () => {
366
763
  assert.equal('connect failed', err.message);
367
764
  assert.equal(2, attempts);
368
765
 
369
- await assert.rejects(client.waitForConnect(), {message: 'connect failed'});
766
+ await assert.rejects(client.waitForConnect(), { message: 'connect failed' });
370
767
  await new Promise((resolve) => setImmediate(resolve));
371
768
  assert.deepEqual([], rejections);
372
769
 
@@ -381,7 +778,7 @@ describe('recovery', () => {
381
778
  }
382
779
 
383
780
  const failures = [];
384
- const client = recovery.connectWithRecoveryCallback(openModel, {waitForConnect: false}, (err, c) => {
781
+ const client = recovery.connectWithRecoveryCallback(openModel, { waitForConnect: false }, (err, c) => {
385
782
  if (err) return done(err);
386
783
  assert.strictEqual(client, c);
387
784
  assert.equal(0, opened);
@@ -1,7 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(gh pr *)"
5
- ]
6
- }
7
- }