amqplib 2.0.1 → 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,15 @@
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
+
9
+ ## v2.1.0
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)
11
+ - Fix `setup` being run when `close()` is called while the initial connection attempt is in flight
12
+
3
13
  ## v2.0.1
4
14
  - Remove `buffer-more-ints` dependency; use Node.js built-in BigInt Buffer methods (`readBigInt64BE`, `readBigUInt64BE`, `writeBigInt64BE`, `writeBigUInt64BE`) which have been available since Node.js v10.4
5
15
 
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![Node.js CI](https://github.com/amqp-node/amqplib/workflows/Node.js%20CI/badge.svg)](https://github.com/amqp-node/amqplib/actions?query=workflow%3A%22Node.js+CI%22)
6
6
  [![amqplib](https://snyk.io/advisor/npm-package/amqplib/badge.svg)](https://snyk.io/advisor/npm-package/amqplib)
7
7
 
8
- A library for making AMQP 0-9-1 clients for Node.JS, and an AMQP 0-9-1 client for Node.JS v10+. This library does not implement [AMQP1.0](https://github.com/amqp-node/amqplib/issues/63) or [AMQP0-10](https://github.com/amqp-node/amqplib/issues/94).
8
+ A library for making AMQP 0-9-1 clients for Node.JS, and an AMQP 0-9-1 client for Node.JS v18+. This library does not implement [AMQP1.0](https://github.com/amqp-node/amqplib/issues/63) or [AMQP0-10](https://github.com/amqp-node/amqplib/issues/94).
9
9
 
10
10
  npm install amqplib
11
11
 
@@ -178,6 +178,92 @@ amqplib.connect(
178
178
 
179
179
  Without `recovery` options, behavior is unchanged.
180
180
 
181
+ ### Attaching listeners before the first connection
182
+
183
+ By default `connect` waits for the first successful connection before
184
+ resolving (or invoking the callback), so events emitted during the initial
185
+ attempt such as `connect-failed` and `reconnect-scheduled` cannot be observed.
186
+ Set `waitForConnect: false` to get the connection handle immediately. You can
187
+ then attach listeners, call `waitForConnect()` to await the first connection,
188
+ or call `close()` to cancel the initial attempt. Channel operations wait for a
189
+ connection internally, so `createChannel()` can be called straight away.
190
+
191
+ ```javascript
192
+ const connection = await amqplib.connect('amqp://localhost', {
193
+ recovery: { waitForConnect: false },
194
+ });
195
+
196
+ connection.on('connect-failed', (err) => {
197
+ console.warn('connection attempt failed', err.message);
198
+ });
199
+
200
+ connection.on('reconnect-scheduled', ({ attempt, delay }) => {
201
+ console.log(`retrying (attempt ${attempt}) in ${delay}ms`);
202
+ });
203
+
204
+ await connection.waitForConnect();
205
+ ```
206
+
207
+ The callback API returns the connection handle synchronously, so listeners can
208
+ always be attached before the first attempt. With `waitForConnect: false` the
209
+ callback is invoked immediately with the handle instead of after the first
210
+ connection, and `waitForConnect(callback)` can be used to be notified once
211
+ connected.
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
+
181
267
  ## Error handling in event handlers
182
268
 
183
269
  If a user-supplied event handler throws a synchronous error, the throw will
package/callback_api.d.ts CHANGED
@@ -169,13 +169,37 @@ 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);
183
+ /**
184
+ * When false, the `connect()` callback is invoked immediately with the
185
+ * recovering connection without waiting for the first successful connection.
186
+ * Use `waitForConnect()` to be notified of the first connection. Default: true
187
+ */
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;
176
198
  }
177
199
 
178
200
  export interface RecoveringConnection extends events.EventEmitter {
201
+ /** Invokes the callback once the first connection has been established */
202
+ waitForConnect(callback: (err: Error, connection: RecoveringConnection) => void): void;
179
203
  close(callback?: (err: Error) => void): void;
180
204
  createChannel(callback: (err: Error, channel: Channel) => void): Channel;
181
205
  createChannel(options: ChannelOptions, callback: (err: Error, channel: Channel) => void): Channel;
@@ -205,5 +229,5 @@ export declare function connect(
205
229
  export declare function connect(
206
230
  url: string | Options.Connect,
207
231
  socketOptions: SocketOptions & { recovery: RecoveryOptions | true },
208
- callback: (err: Error, connection: RecoveringConnection) => void,
232
+ callback?: (err: Error, connection: RecoveringConnection) => void,
209
233
  ): RecoveringConnection;
package/index.d.ts CHANGED
@@ -143,13 +143,38 @@ 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);
157
+ /**
158
+ * When false, `connect()` resolves immediately with the recovering connection
159
+ * without waiting for the first successful connection, so listeners can be
160
+ * attached before the initial attempt. Use `waitForConnect()` to await the
161
+ * first connection. Default: true
162
+ */
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;
150
173
  }
151
174
 
152
175
  export interface RecoveringChannelModel extends events.EventEmitter {
176
+ /** Resolves once the first connection has been established */
177
+ waitForConnect(): Promise<this>;
153
178
  close(): Promise<void>;
154
179
  createChannel(options?: ChannelOptions): Promise<Channel>;
155
180
  createConfirmChannel(options?: ChannelOptions): Promise<ConfirmChannel>;
package/lib/recovery.js CHANGED
@@ -7,6 +7,8 @@ const DEFAULT_RECOVERY = {
7
7
  jitter: 0.2,
8
8
  maxRetries: Infinity,
9
9
  setup: null,
10
+ waitForConnect: true,
11
+ calculateDelay: null,
10
12
  };
11
13
 
12
14
  function splitConnectionOptions(connOptions) {
@@ -42,21 +44,32 @@ function toFiniteNumber(value, fallback) {
42
44
  return Number.isFinite(n) ? n : fallback;
43
45
  }
44
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
+
45
54
  function normaliseRecoveryOptions(recovery) {
46
55
  const source = recovery === true ? {} : recovery || {};
47
56
  const initialDelay = Math.max(0, toFiniteNumber(source.initialDelay, DEFAULT_RECOVERY.initialDelay));
48
57
  const maxDelay = Math.max(initialDelay, toFiniteNumber(source.maxDelay, DEFAULT_RECOVERY.maxDelay));
49
58
  const factor = Math.max(1, toFiniteNumber(source.factor, DEFAULT_RECOVERY.factor));
50
59
  const jitter = Math.min(1, Math.max(0, toFiniteNumber(source.jitter, DEFAULT_RECOVERY.jitter)));
51
- const parsedMaxRetries = source.maxRetries == null ? Infinity : toFiniteNumber(source.maxRetries, Infinity);
60
+ const maxRetries = toRetryCount(source.maxRetries, Infinity);
61
+ const initialMaxRetries = toRetryCount(source.initialMaxRetries, maxRetries);
52
62
 
53
63
  return {
54
64
  initialDelay,
55
65
  maxDelay,
56
66
  factor,
57
67
  jitter,
58
- maxRetries: parsedMaxRetries < 0 ? 0 : parsedMaxRetries,
68
+ maxRetries,
69
+ initialMaxRetries,
59
70
  setup: typeof source.setup === 'function' ? source.setup : null,
71
+ waitForConnect: source.waitForConnect !== false,
72
+ calculateDelay: typeof source.calculateDelay === 'function' ? source.calculateDelay : null,
60
73
  };
61
74
  }
62
75
 
@@ -65,13 +78,39 @@ function toError(err, fallbackMessage) {
65
78
  return new Error(err ? String(err) : fallbackMessage);
66
79
  }
67
80
 
68
- function calculateDelay(recovery, attempt) {
69
- 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));
70
91
  const jitterPart = base * recovery.jitter;
71
92
  const offset = jitterPart > 0 ? Math.random() * jitterPart * 2 - jitterPart : 0;
72
93
  return Math.max(0, Math.round(base + offset));
73
94
  }
74
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
+
75
114
  function runSetup(setup, model) {
76
115
  if (!setup) return Promise.resolve();
77
116
 
@@ -116,6 +155,8 @@ class RecoveringCore extends EventEmitter {
116
155
  this._waiters = [];
117
156
  this._connecting = false;
118
157
  this._stopped = false;
158
+ this._failure = null;
159
+ this._connectedOnce = false;
119
160
  this._timer = null;
120
161
  this._attempt = 0;
121
162
 
@@ -124,8 +165,15 @@ class RecoveringCore extends EventEmitter {
124
165
  this._resolveInitial = resolve;
125
166
  this._rejectInitial = reject;
126
167
  });
127
-
128
- this._connect();
168
+ // The initial connection promise is only consumed if the caller opts to
169
+ // wait for it, so swallow rejections here to avoid unhandled rejections
170
+ // when close() is called or retries are exhausted before anyone awaits it.
171
+ this._initialWait.catch(() => {});
172
+
173
+ // Defer the first attempt so callers that receive the model before the
174
+ // initial connection have a chance to attach listeners before any
175
+ // 'connect', 'connect-failed' or 'reconnect-scheduled' events are emitted.
176
+ setImmediate(() => this._connect());
129
177
  }
130
178
 
131
179
  waitForConnect() {
@@ -196,7 +244,7 @@ class RecoveringCore extends EventEmitter {
196
244
 
197
245
  _waitForConnection() {
198
246
  if (this._model) return Promise.resolve(this._model);
199
- if (this._stopped) return Promise.reject(new Error('Connection closed'));
247
+ if (this._stopped) return Promise.reject(this._failure || new Error('Connection closed'));
200
248
 
201
249
  return new Promise((resolve, reject) => {
202
250
  this._waiters.push({ resolve, reject });
@@ -260,7 +308,7 @@ class RecoveringCore extends EventEmitter {
260
308
  let model;
261
309
  try {
262
310
  model = await this._openModel();
263
- await runSetup(this._recovery.setup, model);
311
+ if (!this._stopped) await runSetup(this._recovery.setup, model);
264
312
  if (this._stopped) {
265
313
  await this._closeModelNoThrow(model);
266
314
  return;
@@ -268,6 +316,7 @@ class RecoveringCore extends EventEmitter {
268
316
 
269
317
  this._model = model;
270
318
  this._attempt = 0;
319
+ this._connectedOnce = true;
271
320
  this._bindModel(model);
272
321
  this._resolveInitialConnection();
273
322
  this._resolvePendingWaiters(model);
@@ -287,15 +336,24 @@ class RecoveringCore extends EventEmitter {
287
336
 
288
337
  if (this._timer) return;
289
338
 
290
- if (this._attempt >= this._recovery.maxRetries) {
291
- this._rejectInitialConnection(err);
292
- this._rejectPendingWaiters(err);
293
- 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);
294
342
  return;
295
343
  }
296
344
 
297
345
  const attempt = this._attempt + 1;
298
- 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
+ }
299
357
  this._attempt = attempt;
300
358
 
301
359
  this.emit('reconnect-scheduled', { attempt, delay, error: err });
@@ -305,6 +363,18 @@ class RecoveringCore extends EventEmitter {
305
363
  }, delay);
306
364
  }
307
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
+
308
378
  _closeModelNoThrow(model) {
309
379
  return this._adapters.closeModel(model).catch(() => {});
310
380
  }
@@ -452,12 +522,17 @@ class RecoveringCallbackModel extends EventEmitter {
452
522
 
453
523
  function connectWithRecoveryPromise(openModel, recovery) {
454
524
  const recovering = new RecoveringPromiseModel(openModel, recovery);
525
+ if (!recovering._core._recovery.waitForConnect) return Promise.resolve(recovering);
455
526
  return recovering.waitForConnect();
456
527
  }
457
528
 
458
529
  function connectWithRecoveryCallback(openModel, recovery, cb) {
459
530
  const recovering = new RecoveringCallbackModel(openModel, recovery);
460
- recovering.waitForConnect(cb);
531
+ if (recovering._core._recovery.waitForConnect) {
532
+ recovering.waitForConnect(cb);
533
+ } else if (typeof cb === 'function') {
534
+ process.nextTick(cb, null, recovering);
535
+ }
461
536
  return recovering;
462
537
  }
463
538
 
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.0.1",
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
 
@@ -223,4 +518,282 @@ describe('recovery', () => {
223
518
  assert(failed);
224
519
  assert.equal(2, attempts);
225
520
  });
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
+
624
+ describe('waitForConnect: false', () => {
625
+ function watchUnhandledRejections(t) {
626
+ const rejections = [];
627
+ const onRejection = (err) => rejections.push(err);
628
+ process.on('unhandledRejection', onRejection);
629
+ t.after(() => process.removeListener('unhandledRejection', onRejection));
630
+ return rejections;
631
+ }
632
+
633
+ it('promise connect resolves before the first connection and later emits connect', async () => {
634
+ let opened = 0;
635
+
636
+ function openModel() {
637
+ return Promise.resolve(new FakePromiseModel(++opened));
638
+ }
639
+
640
+ const client = await recovery.connectWithRecoveryPromise(openModel, { waitForConnect: false });
641
+
642
+ assert.equal(0, opened);
643
+
644
+ const model = await new Promise((resolve) => client.once('connect', resolve));
645
+ assert.equal(1, model.id);
646
+ assert.equal(1, opened);
647
+
648
+ await client.close();
649
+ });
650
+
651
+ it('listeners attached after connect observe a failed first attempt', async () => {
652
+ let attempts = 0;
653
+
654
+ function openModel() {
655
+ attempts++;
656
+ return attempts === 1 ? Promise.reject(new Error('broker unavailable')) : Promise.resolve(new FakePromiseModel(attempts));
657
+ }
658
+
659
+ const client = await recovery.connectWithRecoveryPromise(openModel, {
660
+ initialDelay: 1,
661
+ maxDelay: 1,
662
+ jitter: 0,
663
+ waitForConnect: false,
664
+ });
665
+
666
+ const failures = [];
667
+ const scheduled = [];
668
+ client.on('connect-failed', (err) => failures.push(err));
669
+ client.on('reconnect-scheduled', (info) => scheduled.push(info));
670
+
671
+ await client.waitForConnect();
672
+
673
+ assert.equal(1, failures.length);
674
+ assert.equal('broker unavailable', failures[0].message);
675
+ assert.equal(1, scheduled.length);
676
+ assert.equal(1, scheduled[0].attempt);
677
+ assert.strictEqual(failures[0], scheduled[0].error);
678
+ assert.equal(2, attempts);
679
+
680
+ await client.close();
681
+ });
682
+
683
+ it('waitForConnect resolves with the recovering model once connected', async () => {
684
+ function openModel() {
685
+ return Promise.resolve(new FakePromiseModel(1));
686
+ }
687
+
688
+ const client = await recovery.connectWithRecoveryPromise(openModel, { waitForConnect: false });
689
+ const connected = await client.waitForConnect();
690
+
691
+ assert.strictEqual(client, connected);
692
+ const ch = await client.createChannel();
693
+ assert.equal(1, ch.id);
694
+
695
+ await client.close();
696
+ });
697
+
698
+ it('close before the first connection cancels it without an unhandled rejection', async (t) => {
699
+ const rejections = watchUnhandledRejections(t);
700
+ let setupCalls = 0;
701
+ let closeCalls = 0;
702
+ let releaseOpen;
703
+
704
+ function openModel() {
705
+ return new Promise((resolve) => {
706
+ const model = new FakePromiseModel(1);
707
+ model.close = () => {
708
+ closeCalls++;
709
+ return Promise.resolve();
710
+ };
711
+ releaseOpen = () => resolve(model);
712
+ });
713
+ }
714
+
715
+ const client = await recovery.connectWithRecoveryPromise(openModel, {
716
+ waitForConnect: false,
717
+ setup() {
718
+ setupCalls++;
719
+ },
720
+ });
721
+
722
+ let connected = false;
723
+ client.on('connect', () => {
724
+ connected = true;
725
+ });
726
+
727
+ const pendingChannel = client.createChannel();
728
+ await new Promise((resolve) => setImmediate(resolve));
729
+ assert(releaseOpen, 'open should be in flight');
730
+
731
+ await client.close();
732
+
733
+ await assert.rejects(pendingChannel, { message: 'Connection closed' });
734
+ await assert.rejects(client.waitForConnect(), { message: 'Connection closed' });
735
+
736
+ releaseOpen();
737
+ await new Promise((resolve) => setImmediate(resolve));
738
+
739
+ assert.equal(0, setupCalls);
740
+ assert.equal(1, closeCalls);
741
+ assert.equal(false, connected);
742
+ assert.deepEqual([], rejections);
743
+ });
744
+
745
+ it('exhausting retries emits reconnect-failed without an unhandled rejection', async (t) => {
746
+ const rejections = watchUnhandledRejections(t);
747
+ let attempts = 0;
748
+
749
+ function openModel() {
750
+ attempts++;
751
+ return Promise.reject(new Error('connect failed'));
752
+ }
753
+
754
+ const client = await recovery.connectWithRecoveryPromise(openModel, {
755
+ initialDelay: 1,
756
+ maxDelay: 1,
757
+ jitter: 0,
758
+ maxRetries: 1,
759
+ waitForConnect: false,
760
+ });
761
+
762
+ const err = await new Promise((resolve) => client.once('reconnect-failed', resolve));
763
+ assert.equal('connect failed', err.message);
764
+ assert.equal(2, attempts);
765
+
766
+ await assert.rejects(client.waitForConnect(), { message: 'connect failed' });
767
+ await new Promise((resolve) => setImmediate(resolve));
768
+ assert.deepEqual([], rejections);
769
+
770
+ await client.close();
771
+ });
772
+
773
+ it('callback connect invokes the callback before the first connection', (_t, done) => {
774
+ let opened = 0;
775
+
776
+ function openModel() {
777
+ return Promise.resolve(new FakeCallbackModel(++opened));
778
+ }
779
+
780
+ const failures = [];
781
+ const client = recovery.connectWithRecoveryCallback(openModel, { waitForConnect: false }, (err, c) => {
782
+ if (err) return done(err);
783
+ assert.strictEqual(client, c);
784
+ assert.equal(0, opened);
785
+
786
+ c.on('connect-failed', (failure) => failures.push(failure));
787
+ c.waitForConnect((waitErr, connected) => {
788
+ if (waitErr) return done(waitErr);
789
+ assert.strictEqual(client, connected);
790
+ assert.equal(1, opened);
791
+ assert.deepEqual([], failures);
792
+ c.close(done);
793
+ });
794
+ });
795
+
796
+ assert(client);
797
+ });
798
+ });
226
799
  });