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.
- package/.github/workflows/publish.yml +63 -9
- package/CHANGELOG.md +10 -0
- package/README.md +87 -1
- package/callback_api.d.ts +26 -2
- package/index.d.ts +26 -1
- package/lib/recovery.js +89 -14
- package/package.json +1 -1
- package/test/recovery.test.js +579 -6
|
@@ -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: [
|
|
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@
|
|
39
|
+
- uses: actions/checkout@v4
|
|
23
40
|
|
|
24
41
|
- name: Use Node.js ${{ matrix.node-version }}
|
|
25
|
-
uses: actions/setup-node@
|
|
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
|
-
-
|
|
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
|
-
|
|
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@
|
|
71
|
+
- uses: actions/checkout@v4
|
|
72
|
+
|
|
51
73
|
- uses: actions/setup-node@v4
|
|
52
74
|
with:
|
|
53
|
-
node-version:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
[](https://github.com/amqp-node/amqplib/actions?query=workflow%3A%22Node.js+CI%22)
|
|
6
6
|
[](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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
this.
|
|
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
|
-
|
|
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
|
|
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
package/test/recovery.test.js
CHANGED
|
@@ -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
|
});
|