comq 0.15.3 → 0.15.5

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/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ ## [0.15.5](https://github.com/toa-io/comq/compare/v0.15.4...v0.15.5) (2026-08-25)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * recover from a lost connection reliably ([59b1fab](https://github.com/toa-io/comq/commit/59b1fab61d4f27ab6eb608693da6493b5b1f4ae5))
7
+ * recover from a lost connection reliably ([#247](https://github.com/toa-io/comq/issues/247)) ([e4701cf](https://github.com/toa-io/comq/commit/e4701cfa384d5451c15d0a6bf53c82ce1da91ac5))
8
+
9
+ ## [0.15.4](https://github.com/toa-io/comq/compare/v0.15.3...v0.15.4) (2026-08-16)
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * remove request() timeout ([#246](https://github.com/toa-io/comq/issues/246)) ([d432388](https://github.com/toa-io/comq/commit/d4323889bcad867a870d0ed351b92098f6aad551))
15
+
1
16
  ## [0.15.3](https://github.com/toa-io/comq/compare/v0.15.2...v0.15.3) (2026-08-04)
2
17
 
3
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.15.3",
3
+ "version": "0.15.5",
4
4
  "description": "ComQ",
5
5
  "author": {
6
6
  "name": "Artem Gurtovoi",
package/readme.md CHANGED
@@ -103,11 +103,10 @@ await io.reply('add_numbers', ({ a, b }) => (a + b))
103
103
 
104
104
  ## Request
105
105
 
106
- `async IO.request(queue: string, payload: any, encoding?: string, timeout?: number): any`
106
+ `async IO.request(queue: string, payload: any, encoding?: string): any`
107
107
 
108
108
  Send encoded Request message with `replyTo` and `correlationId` properties set and
109
- return decoded Reply content. Optional `timeout` (ms) rejects with `ETIMEDOUT` if no
110
- Reply arrives in time; encoding may be omitted when only `timeout` is passed.
109
+ return decoded Reply content. The promise stays pending until the Reply arrives.
111
110
 
112
111
  On the initial call, queues for Requests and Replies are asserted.
113
112
 
@@ -115,7 +114,6 @@ On the initial call, queues for Requests and Replies are asserted.
115
114
 
116
115
  ```javascript
117
116
  const sum = await io.request('add_numbers', { a: 1, b: 2 })
118
- const sumOrTimeout = await io.request('add_numbers', { a: 1, b: 2 }, 5000)
119
117
  ```
120
118
 
121
119
  ## Consumption
@@ -332,8 +330,7 @@ Outgoing messages are sent to a single connection chosen at random from the shar
332
330
  underlying connection or experience channel [back pressure](#flow-control) on a corresponding channel are removed from
333
331
  the pool until the issue is resolved. Pending messages meeting these conditions are immediately routed among the
334
332
  remaining shards in the pool. If no shards are available, `send` / `publish` / `request` wait until a shard's
335
- connection is re-established (unbounded). For RPC, pass a [`request` timeout](#request) so callers fail with
336
- `ETIMEDOUT` instead of hanging while the pool is empty or a Reply never arrives.
333
+ connection is re-established.
337
334
 
338
335
  Incoming messages are consumed from all shards.
339
336
 
@@ -493,10 +490,14 @@ Subscribe to one of the diagnostic events:
493
490
  - `close`: connection is closed.
494
491
  Optional [`error`](https://amqp-node.github.io/amqplib/channel_api.html#model_events) is passed
495
492
  as an argument.
493
+ - `error`: an attempt to restore the connection has failed. The exception is passed as an argument.
494
+ Attempts continue, so this event is the only way to observe an ongoing outage.
496
495
  - `flow`: back pressure is applied to a channel. [Channel type](./types/topology.d.ts) is passed as
497
496
  an argument.
498
497
  - `drain`: back pressure is removed from a channel. Channel type is passed.
499
498
  - `remove`: channel is removed from the [pool](#sharded-connection).
499
+ - `lost`: a shard has lost its connection, hence the requests awaiting their replies on it are
500
+ re-sent. Channel type is passed.
500
501
  - `recover`: channel's topology is recovered. Channel type is passed.
501
502
  - `discard`: message is [discarded](#messages) as it repeatedly caused
502
503
  exceptions. Channel type,
@@ -518,6 +519,10 @@ established, there is no way to capture the initial `open` event.
518
519
 
519
520
  ```javascript
520
521
  io.diagnose('flow', (type) => console.log(`Back pressure was applied to the ${type} channel`))
522
+
523
+ io.diagnose('open', () => console.log('AMQP connection established'))
524
+ io.diagnose('close', (error) => console.log('AMQP connection closed', error?.message))
525
+ io.diagnose('error', (error) => console.log('AMQP connection failed', error.message))
521
526
  ```
522
527
 
523
528
  # Gratitude
@@ -1,5 +1,6 @@
1
1
  'use strict'
2
2
 
3
+ const { setTimeout: delay } = require('node:timers/promises')
3
4
  const amqp = require('amqplib')
4
5
  const { Promex } = require('promex')
5
6
  const { retry } = require('reretry')
@@ -31,9 +32,18 @@ class Connection {
31
32
  /** @type {boolean} */
32
33
  #running = false
33
34
 
35
+ /** @type {boolean} */
36
+ #closed = false
37
+
34
38
  /** @type {NodeJS.Timeout | null} */
35
39
  #heartbeatTimer = null
36
40
 
41
+ /** @type {import('node:net').Socket | null} */
42
+ #heartbeatSocket = null
43
+
44
+ /** @type {(() => void) | null} */
45
+ #heartbeatReset = null
46
+
37
47
  #diagnostics = emitter.create()
38
48
 
39
49
  /**
@@ -51,6 +61,8 @@ class Connection {
51
61
  }
52
62
 
53
63
  async open () {
64
+ this.#closed = false
65
+
54
66
  if (this.#opening !== null) return this.#opening
55
67
 
56
68
  this.#opening = retry(this.#open).finally(() => { this.#opening = null })
@@ -61,9 +73,12 @@ class Connection {
61
73
  }
62
74
 
63
75
  async close () {
64
- if (this.#connection === undefined) await this.#recovery
76
+ this.#closed = true
65
77
 
66
- await this.#connection.close()
78
+ // a connection that is about to be established must not be left open, yet an
79
+ // attempt that is being retried must not hold up the shutdown
80
+ if (this.#opening !== null) await Promise.race([this.#opening.catch(noop), expiration()])
81
+ if (this.#connection !== undefined) await this.#shutdown(this.#connection)
67
82
  }
68
83
 
69
84
  createChannel = failsafe(this, this.#recover,
@@ -88,16 +103,25 @@ class Connection {
88
103
  }
89
104
 
90
105
  #open = async (retry) => {
106
+ if (this.#closed) return
107
+
91
108
  /** @type {comq.amqp.Connection} */
92
109
  let connection
93
110
 
94
111
  try {
95
- connection = await amqp.connect(this.#url)
112
+ connection = await amqp.connect(this.#url, { timeout: CONNECT_MS })
96
113
  } catch (exception) {
97
- if (this.#transient(exception)) return retry
98
- else throw exception
114
+ if (this.#closed) return
115
+ if (!this.#transient(exception)) throw exception
116
+
117
+ // attempts are made until one succeeds, so an outage is observable only here
118
+ this.#diagnostics.emit('error', exception)
119
+
120
+ return retry
99
121
  }
100
122
 
123
+ if (this.#closed) return await this.#shutdown(connection)
124
+
101
125
  // This prevents the process from crashing; 'close' will be emitted next.
102
126
  // https://amqp-node.github.io/amqplib/channel_api.html#model_events
103
127
  connection.on('error', noop)
@@ -110,17 +134,16 @@ class Connection {
110
134
  try {
111
135
  for (const channel of this.#channels) await channel.recover(connection)
112
136
  } catch (exception) {
113
- this.#disarmWatchdog()
114
137
  this.#diagnostics.emit('error', exception)
115
- connection.removeAllListeners()
116
-
117
- await connection.close().catch(noop)
118
-
119
- if (this.#connection === connection) this.#connection = undefined
138
+ this.#drop(connection)
120
139
 
121
140
  return retry
122
141
  }
123
142
 
143
+ // the connection may have been lost while the topology was being recovered,
144
+ // in which case 'close' has left the reconnection to this very attempt
145
+ if (this.#connection !== connection) return retry
146
+
124
147
  this.#recovery.resolve()
125
148
  this.#recovery = new Promex()
126
149
  }
@@ -130,19 +153,45 @@ class Connection {
130
153
  * @param {Error} [error]
131
154
  */
132
155
  #close = (connection, error) => {
133
- this.#disarmWatchdog()
134
-
135
156
  if (this.#connection !== connection) return
136
157
 
158
+ this.#disarmWatchdog()
137
159
  this.#diagnostics.emit('close', error)
138
160
  connection.removeAllListeners()
139
161
  this.#connection = undefined
140
162
 
141
- if (error !== undefined) {
163
+ if (error !== undefined && !this.#closed) {
142
164
  this.open().catch((exception) => this.#diagnostics.emit('error', exception))
143
165
  }
144
166
  }
145
167
 
168
+ /**
169
+ * An AMQP connection is only closed once the broker has replied with Close-Ok,
170
+ * which never happens on a connection that has already been lost.
171
+ *
172
+ * @param {comq.amqp.Connection} connection
173
+ */
174
+ async #shutdown (connection) {
175
+ const closing = connection.close().catch(noop)
176
+
177
+ await Promise.race([closing, expiration()])
178
+
179
+ this.#drop(connection)
180
+ }
181
+
182
+ /**
183
+ * @param {comq.amqp.Connection} connection
184
+ */
185
+ #drop (connection) {
186
+ if (this.#connection === connection) {
187
+ this.#disarmWatchdog()
188
+ this.#connection = undefined
189
+ }
190
+
191
+ connection.removeAllListeners()
192
+ connection.connection?.stream?.destroy()
193
+ }
194
+
146
195
  #recover () {
147
196
  return this.#recovery
148
197
  }
@@ -162,6 +211,11 @@ class Connection {
162
211
  this.#heartbeatTimer.unref()
163
212
  }
164
213
 
214
+ this.#disarmWatchdog()
215
+
216
+ this.#heartbeatSocket = socket
217
+ this.#heartbeatReset = reset
218
+
165
219
  reset()
166
220
  socket.on('data', reset)
167
221
  }
@@ -169,6 +223,13 @@ class Connection {
169
223
  #disarmWatchdog () {
170
224
  clearTimeout(this.#heartbeatTimer)
171
225
  this.#heartbeatTimer = null
226
+
227
+ // otherwise a byte arriving on a replaced socket rearms the watchdog of a
228
+ // connection that is already gone, leaving the current one unguarded
229
+ this.#heartbeatSocket?.off('data', this.#heartbeatReset)
230
+
231
+ this.#heartbeatSocket = null
232
+ this.#heartbeatReset = null
172
233
  }
173
234
 
174
235
  #transient (exception) {
@@ -183,6 +244,12 @@ class Connection {
183
244
  /** @type {number} */
184
245
  const WATCHDOG_MS = 60_000
185
246
 
247
+ /** @type {number} */
248
+ const CONNECT_MS = 30_000
249
+
250
+ /** @type {number} */
251
+ const SHUTDOWN_MS = 5_000
252
+
186
253
  const TRANSIENT_CODES = new Set([
187
254
  'ECONNREFUSED',
188
255
  'EAI_AGAIN',
@@ -195,9 +262,19 @@ const TRANSIENT_CODES = new Set([
195
262
 
196
263
  const TRANSIENT_MESSAGES = new Set([
197
264
  'Socket closed abruptly during opening handshake',
198
- 'Client network socket disconnected before secure TLS connection was established'
265
+ 'Client network socket disconnected before secure TLS connection was established',
266
+ 'connect ETIMEDOUT' // amqplib reports the `timeout` option without a code
199
267
  ])
200
268
 
269
+ /**
270
+ * @return {Promise<void>}
271
+ */
272
+ function expiration () {
273
+ const timeoutMs = global.COMQ_TESTING_SHUTDOWN_TIMEOUT ?? SHUTDOWN_MS
274
+
275
+ return delay(timeoutMs, undefined, { ref: false })
276
+ }
277
+
201
278
  function noop () {}
202
279
 
203
280
  exports.Connection = Connection
package/source/io.js CHANGED
@@ -3,7 +3,7 @@
3
3
  const stream = require('node:stream')
4
4
  const { EventEmitter } = require('node:events')
5
5
  const { randomBytes } = require('node:crypto')
6
- const { setTimeout: delay } = require('node:timers/promises')
6
+ const { setTimeout } = require('node:timers/promises')
7
7
  const { Promex } = require('promex')
8
8
  const { memo, failsafe, lazy, track } = require('./attributes')
9
9
 
@@ -52,6 +52,9 @@ class IO {
52
52
  constructor (connection) {
53
53
  this.#connection = connection
54
54
 
55
+ // EventEmitter throws on 'error' with no listeners
56
+ this.#diagnostics.on('error', noop)
57
+
55
58
  for (const event of events.connection) {
56
59
  this.#connection.diagnose(event, (...args) => this.#diagnostics.emit(event, ...args))
57
60
  }
@@ -75,45 +78,24 @@ class IO {
75
78
  /**
76
79
  * @param {string} queue
77
80
  * @param {any | Readable} payload
78
- * @param {comq.Encoding | number} [encoding]
79
- * @param {number} [timeout]
81
+ * @param {comq.Encoding} [encoding]
80
82
  * @returns {Promise<any | Readable>}
81
83
  */
82
- async (queue, payload, encoding, timeout) => {
83
- if (typeof encoding === 'number') {
84
- timeout = encoding
85
- encoding = undefined
86
- }
87
-
84
+ async (queue, payload, encoding) => {
88
85
  if (payload instanceof stream.Readable) {
89
86
  return pipeline(
90
87
  payload,
91
- (payload) => this.request(queue, payload, encoding, timeout),
88
+ (payload) => this.request(queue, payload, encoding),
92
89
  this.#requests
93
90
  )
94
91
  }
95
92
 
96
- const request = this.#createRequest(queue, payload, /** @type {comq.Encoding} */ encoding)
93
+ const request = this.#createRequest(queue, payload, encoding)
97
94
  const reply = this.#createReply(request)
98
95
 
99
- const done = this.#requests.send(queue, request.buffer, request.properties)
100
- .then(() => reply)
96
+ await this.#requests.send(queue, request.buffer, request.properties)
101
97
 
102
- if (typeof timeout !== 'number') return done
103
-
104
- try {
105
- return await Promise.race([
106
- done,
107
- delay(timeout).then(() => Promise.reject(timeoutError(timeout)))
108
- ])
109
- } catch (exception) {
110
- if (exception.code === 'ETIMEDOUT') {
111
- request.emitter.removeAllListeners(request.properties.correlationId)
112
- reply.reject(exception)
113
- }
114
-
115
- throw exception
116
- }
98
+ return reply
117
99
  }))
118
100
 
119
101
  consume = lazy(this, this.#createEventChannel,
@@ -428,7 +410,7 @@ class IO {
428
410
  Even if these messages are lost, the reply stream will be closed anyway,
429
411
  either due to missing heartbeat or the deletion of the stream queue.
430
412
  */
431
- await delay(50)
413
+ await setTimeout(50)
432
414
  }
433
415
 
434
416
  /**
@@ -495,19 +477,6 @@ const DEFAULT = 'application/json'
495
477
 
496
478
  const RETRANSMISSION = /** @type {Error} */ Symbol('retransmission')
497
479
 
498
- /**
499
- * @param {number} timeout
500
- * @returns {Error}
501
- */
502
- function timeoutError (timeout) {
503
- const error = /** @type {Error & { code: string }} */ new Error(
504
- `Request timed out after ${timeout}ms`)
505
-
506
- error.code = 'ETIMEDOUT'
507
-
508
- return error
509
- }
510
-
511
480
  function noop () {}
512
481
 
513
482
  exports.IO = IO
@@ -19,6 +19,9 @@ class Connection {
19
19
  constructor (connections) {
20
20
  this.#connections = connections
21
21
 
22
+ // EventEmitter throws on 'error' with no listeners
23
+ this.#diagnostics.on('error', noop)
24
+
22
25
  connections.map(this.#pipe)
23
26
  }
24
27
 
@@ -72,4 +75,6 @@ class Connection {
72
75
  }
73
76
  }
74
77
 
78
+ function noop () {}
79
+
75
80
  exports.Connection = Connection
@@ -39,9 +39,12 @@ class Connection extends EventEmitter {
39
39
  close = jest.fn(async () => undefined)
40
40
  }
41
41
 
42
+ const connect = async () => new Connection()
43
+
42
44
  /** @type {jest.MockedObject<import('amqplib')>} */
43
45
  const amqplib = {
44
- connect: jest.fn(async () => new Connection())
46
+ connect: jest.fn(connect)
45
47
  }
46
48
 
47
49
  exports.amqplib = amqplib
50
+ exports.connect = connect
@@ -5,7 +5,7 @@
5
5
  const { generate } = require('randomstring')
6
6
 
7
7
  const { timeout, promex, random } = require('@toa.io/generic')
8
- const { amqplib } = require('./amqplib.mock')
8
+ const { amqplib, connect } = require('./amqplib.mock')
9
9
  const { channel: create } = require('./connection.mock')
10
10
  const mock = { amqplib, channel: { create } }
11
11
 
@@ -26,6 +26,7 @@ const url = generate()
26
26
 
27
27
  beforeEach(() => {
28
28
  jest.clearAllMocks()
29
+ amqplib.connect.mockImplementation(connect)
29
30
 
30
31
  connection = new Connection(url)
31
32
  })
@@ -36,12 +37,13 @@ describe('initial connection', () => {
36
37
  it('should connect', async () => {
37
38
  await connection.open()
38
39
 
39
- expect(amqplib.connect).toHaveBeenCalledWith(url)
40
+ expect(amqplib.connect).toHaveBeenCalledWith(url, { timeout: expect.any(Number) })
40
41
  })
41
42
 
42
43
  it.each(/** @type {[string, Partial<Error>][]} */[
43
44
  ['Socket closed', { message: 'Socket closed abruptly during opening handshake' }],
44
45
  ['TLS disconnect', { message: 'Client network socket disconnected before secure TLS connection was established' }],
46
+ ['connect timeout', { message: 'connect ETIMEDOUT' }],
45
47
  ['ECONNREFUSED', { code: 'ECONNREFUSED' }],
46
48
  ['EAI_AGAIN', { code: 'EAI_AGAIN' }],
47
49
  ['ENOTFOUND', { code: 'ENOTFOUND' }],
@@ -157,6 +159,58 @@ describe('reconnection', () => {
157
159
  expect(unhandled).not.toHaveBeenCalled()
158
160
  })
159
161
 
162
+ it('should reconnect when connection is lost while channels recover', async () => {
163
+ const channel = await connection.createChannel('request')
164
+
165
+ // the connection is lost right after the topology has been recovered on it
166
+ channel.recover.mockImplementationOnce(async (connection) => {
167
+ connection.emit('close', new Error('lost again'))
168
+ })
169
+
170
+ conn.emit('close', new Error('lost'))
171
+
172
+ await expect(connection.createChannel('event')).resolves.toBeDefined()
173
+
174
+ expect(amqplib.connect).toHaveBeenCalledTimes(3)
175
+ }, 10000)
176
+
177
+ it('should not wait for a connection that failed to recover to close', async () => {
178
+ const channel = await connection.createChannel('request')
179
+
180
+ channel.recover
181
+ .mockRejectedValueOnce(new Error('Channel closed'))
182
+ .mockResolvedValue(undefined)
183
+
184
+ // Close-Ok never arrives on a connection that has already been lost
185
+ conn.close.mockImplementation(() => new Promise(() => undefined))
186
+ conn.emit('close', new Error('lost'))
187
+
188
+ await timeout(0)
189
+
190
+ const failed = await amqplib.connect.mock.results[1].value
191
+
192
+ failed.close.mockImplementation(() => new Promise(() => undefined))
193
+
194
+ await expect(connection.createChannel('event')).resolves.toBeDefined()
195
+
196
+ expect(failed.connection.stream.destroy).toHaveBeenCalled()
197
+ }, 10000)
198
+
199
+ it('should emit error on a failed connection attempt', async () => {
200
+ const errors = []
201
+ const exception = { code: 'ECONNREFUSED' }
202
+
203
+ connection.diagnose('error', (error) => errors.push(error))
204
+ amqplib.connect.mockImplementationOnce(async () => { throw exception })
205
+
206
+ conn.emit('close', new Error('lost'))
207
+
208
+ // the attempt is retried, hence the channel is created on the next connection
209
+ await connection.createChannel('event')
210
+
211
+ expect(errors).toContain(exception)
212
+ }, 10000)
213
+
160
214
  it('should ignore close from a stale connection', async () => {
161
215
  const stale = conn
162
216
  const closeHandler = stale.on.mock.calls.find(([event]) => event === 'close')[1]
@@ -289,6 +343,28 @@ describe('watchdog', () => {
289
343
  expect(conn.connection.stream.destroy).toHaveBeenCalled()
290
344
  })
291
345
 
346
+ it('should not let a replaced socket disarm the watchdog', async () => {
347
+ jest.useFakeTimers()
348
+
349
+ await connection.open()
350
+
351
+ const stale = await amqplib.connect.mock.results[0].value
352
+
353
+ stale.emit('close', new Error('lost'))
354
+
355
+ await jest.advanceTimersByTimeAsync(1)
356
+
357
+ const live = await amqplib.connect.mock.results[1].value
358
+
359
+ await jest.advanceTimersByTimeAsync(30_000)
360
+
361
+ stale.connection.stream.emit('data', Buffer.alloc(0))
362
+
363
+ await jest.advanceTimersByTimeAsync(30_000)
364
+
365
+ expect(live.connection.stream.destroy).toHaveBeenCalled()
366
+ })
367
+
292
368
  it('should reset watchdog on socket data', async () => {
293
369
  jest.useFakeTimers()
294
370
 
@@ -311,6 +387,14 @@ describe('watchdog', () => {
311
387
  })
312
388
 
313
389
  describe('close', () => {
390
+ beforeEach(() => {
391
+ global.COMQ_TESTING_SHUTDOWN_TIMEOUT = 10
392
+ })
393
+
394
+ afterEach(() => {
395
+ delete global.COMQ_TESTING_SHUTDOWN_TIMEOUT
396
+ })
397
+
314
398
  it('should close connection', async () => {
315
399
  await connection.open()
316
400
  await connection.close()
@@ -331,6 +415,49 @@ describe('close', () => {
331
415
 
332
416
  expect(conn.close).toHaveBeenCalled()
333
417
  })
418
+
419
+ it('should not wait for Close-Ok that never arrives', async () => {
420
+ await connection.open()
421
+
422
+ /** @type {jest.MockedObject<comq.amqp.Connection>} */
423
+ const conn = await amqplib.connect.mock.results[0].value
424
+
425
+ conn.close.mockImplementation(() => new Promise(() => undefined))
426
+
427
+ await connection.close()
428
+
429
+ expect(conn.connection.stream.destroy).toHaveBeenCalled()
430
+ })
431
+
432
+ it('should open again after close', async () => {
433
+ await connection.open()
434
+ await connection.close()
435
+ await connection.open()
436
+
437
+ await expect(connection.createChannel('request')).resolves.toBeDefined()
438
+
439
+ expect(amqplib.connect).toHaveBeenCalledTimes(2)
440
+ })
441
+
442
+ it('should stop reconnecting', async () => {
443
+ await connection.open()
444
+
445
+ const conn = await amqplib.connect.mock.results[0].value
446
+ const attempts = []
447
+
448
+ connection.diagnose('error', (exception) => attempts.push(exception))
449
+ amqplib.connect.mockImplementation(async () => { throw { code: 'ECONNREFUSED' } }) // eslint-disable-line
450
+
451
+ conn.emit('close', new Error('lost'))
452
+
453
+ await connection.close()
454
+
455
+ const failed = attempts.length
456
+
457
+ await timeout(1500)
458
+
459
+ expect(attempts.length).toStrictEqual(failed)
460
+ }, 10000)
334
461
  })
335
462
 
336
463
  describe('diagnostics', () => {
@@ -54,6 +54,13 @@ describe.each(['request', 'reply', 'event'])('%s channel events',
54
54
  })
55
55
  })
56
56
 
57
+ it('should not throw on error without listeners', async () => {
58
+ const call = connection.diagnose.mock.calls.find(([event]) => event === 'error')
59
+ const emit = call[1]
60
+
61
+ expect(() => emit(new Error(generate()))).not.toThrow()
62
+ })
63
+
57
64
  it.each(['open', 'close'])('should re-emit %s from connection',
58
65
  /**
59
66
  * @param {comq.diagnostics.Event} event
package/types/io.d.ts CHANGED
@@ -27,11 +27,9 @@ declare namespace comq {
27
27
  interface IO extends _diagnostics.Diagnosable {
28
28
  reply(queue: string, produce: Producer): Promise<void>
29
29
 
30
- request<Reply = any, Request = any>(queue: string, payload: Request, encoding?: _encoding.Encoding, timeout?: number): Promise<Reply> | Promise<Readable>
30
+ request<Reply = any, Request = any>(queue: string, payload: Request, encoding?: _encoding.Encoding): Promise<Reply> | Promise<Readable>
31
31
 
32
- request<Reply = any, Request = any>(queue: string, payload: Request, timeout: number): Promise<Reply> | Promise<Readable>
33
-
34
- request(queue: string, stream: Readable, encoding?: _encoding.Encoding, timeout?: number): Promise<Readable>
32
+ request(queue: string, stream: Readable, encoding?: _encoding.Encoding): Promise<Readable>
35
33
 
36
34
  consume<T = any>(exchange: string, group: string, consumer: Consumer<T>): Promise<void>
37
35
 
@@ -61,7 +59,7 @@ declare namespace comq {
61
59
 
62
60
  diagnose(event: 'open', listener: (index?: number) => void): void
63
61
 
64
- diagnose(event: 'close', listener: (index?: number) => void): void
62
+ diagnose(event: 'close', listener: (error?: Error, index?: number) => void): void
65
63
 
66
64
  diagnose(event: 'error', listener: (error: Error, index?: number) => void): void
67
65
 
@@ -71,6 +69,8 @@ declare namespace comq {
71
69
 
72
70
  diagnose(event: 'remove', listener: (index?: number) => void): void
73
71
 
72
+ diagnose(event: 'lost', listener: (channel: _topology.type, index?: number) => void): void
73
+
74
74
  diagnose(event: 'recover', listener: (channel: _topology.type, index?: number) => void): void
75
75
 
76
76
  diagnose(event: 'discard', listener: (channel: _topology.type, message: any, index?: number) => void): void
@@ -1,61 +0,0 @@
1
- 'use strict'
2
-
3
- const { generate } = require('randomstring')
4
-
5
- const mock = require('./connection.mock')
6
- const { IO } = require('../source/io')
7
-
8
- /** @type {comq.IO} */
9
- let io
10
-
11
- /** @type {jest.MockedObject<comq.Connection>} */
12
- let connection
13
-
14
- const queue = generate()
15
- const payload = { [generate()]: generate() }
16
-
17
- beforeEach(() => {
18
- jest.clearAllMocks()
19
-
20
- connection = mock.connection()
21
- io = new IO(connection)
22
- })
23
-
24
- it('should reject when request times out', async () => {
25
- await expect(io.request(queue, payload, 50)).rejects.toMatchObject({
26
- message: expect.stringContaining('timed out'),
27
- code: 'ETIMEDOUT'
28
- })
29
- })
30
-
31
- it('should reject when request times out with encoding', async () => {
32
- await expect(io.request(queue, payload, 'application/json', 50)).rejects.toMatchObject({
33
- code: 'ETIMEDOUT'
34
- })
35
- })
36
-
37
- it('should time out while send is still pending', async () => {
38
- connection.createChannel.mockImplementation(async () => hangingChannel())
39
-
40
- await expect(io.request(queue, payload, 50)).rejects.toMatchObject({
41
- code: 'ETIMEDOUT'
42
- })
43
- })
44
-
45
- /**
46
- * @returns {jest.MockedObject<comq.Channel>}
47
- */
48
- function hangingChannel () {
49
- return /** @type {jest.MockedObject<comq.Channel>} */ ({
50
- sharded: false,
51
- consume: jest.fn(async () => undefined),
52
- send: jest.fn(() => new Promise(() => {})),
53
- deliver: jest.fn(async () => undefined),
54
- fire: jest.fn(async () => undefined),
55
- subscribe: jest.fn(async () => undefined),
56
- publish: jest.fn(async () => undefined),
57
- diagnose: jest.fn(),
58
- seal: jest.fn(async () => undefined),
59
- recover: jest.fn(async () => undefined)
60
- })
61
- }