comq 0.18.0 → 0.19.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/discussions/poison-message-handling.md +14 -10
- package/docs/headers.md +1 -0
- package/features/events.poison.feature +11 -0
- package/features/steps/events.js +15 -0
- package/features/steps/poison.js +3 -2
- package/package.json +1 -1
- package/readme.md +35 -0
- package/source/channel.js +10 -2
- package/source/index.js +4 -0
- package/source/io.js +27 -1
- package/source/verdicts.js +54 -0
- package/test/channel.test.js +104 -0
- package/test/io.reply.test.js +66 -0
- package/test/verdicts.test.js +57 -0
- package/types/index.d.ts +17 -0
|
@@ -6,9 +6,8 @@
|
|
|
6
6
|
> became shared and delay-named, the delay became configurable, and message provenance moved to
|
|
7
7
|
> where it survives a retry.
|
|
8
8
|
>
|
|
9
|
-
> **
|
|
10
|
-
>
|
|
11
|
-
> was; it describes what the code now does, except where Part 3 says otherwise.
|
|
9
|
+
> **All three parts are implemented.** Text below is written as a proposal because that is what
|
|
10
|
+
> it was; it describes what the code now does.
|
|
12
11
|
|
|
13
12
|
When a consumer callback rejects, comq is supposed to retry the message a few times and then
|
|
14
13
|
give up on it. What it actually does is kill the process, and take every other consumer in that
|
|
@@ -1111,10 +1110,15 @@ Nothing blocking. The three questions this document opened are resolved:
|
|
|
1111
1110
|
- **The noun** — `Park` / `comq.parked.<queue>`. See Part 1.
|
|
1112
1111
|
- **`Park` on an `io.reply` producer** — rejected as a category error, treated at runtime as a
|
|
1113
1112
|
bare rejection plus a diagnostic. See Part 3.
|
|
1114
|
-
- **Whether Part 3 is built** —
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1113
|
+
- **Whether Part 3 is built** — built. Parts 1 and 2 shipped first as the emergency, and a
|
|
1114
|
+
consumer of comq got retry-then-park by upgrading and writing no code; the verdicts followed
|
|
1115
|
+
as an additive minor.
|
|
1116
|
+
|
|
1117
|
+
One thing the design did not settle, found while building it: it placed the `Park`-from-a-Producer
|
|
1118
|
+
rejection in `#failed`, which cannot implement it — `Channel` takes a topology but not its type
|
|
1119
|
+
([channel.js:64](../source/channel.js#L64)), so it cannot tell the request channel from the event
|
|
1120
|
+
one. It lives in `#getRequestConsumer` ([io.js:297](../source/io.js#L297)) instead, which knows by
|
|
1121
|
+
construction that it is wrapping a `Producer`. And rather than emit a diagnostic nobody may be
|
|
1122
|
+
subscribed to, it rethrows with an explanation, which reaches the parked message as its
|
|
1123
|
+
`x-comq-reason` and both existing diagnostics as their exception — louder, and no new event name
|
|
1124
|
+
to document.
|
package/docs/headers.md
CHANGED
|
@@ -28,6 +28,7 @@ the message properties.
|
|
|
28
28
|
| `x-comq-key` | a retried or parked message | The routing key it was originally published with. |
|
|
29
29
|
| `x-comq-queue` | a parked message | The queue it was consumed from. |
|
|
30
30
|
| `x-comq-reason` | a parked message | The `message` of the exception that ended its attempts. |
|
|
31
|
+
| `x-comq-cause` | a parked message | The `message` of that exception's `cause`, when it has one — a verdict is usually thrown with the failure that prompted it. |
|
|
31
32
|
| `x-comq-at` | a parked message | When it was parked, as `Date.now()`. |
|
|
32
33
|
|
|
33
34
|
`x-comq-exchange` and `x-comq-key` are recorded on the first failure rather than read at parking
|
|
@@ -40,3 +40,14 @@ Feature: Poison events
|
|
|
40
40
|
When an event is emitted to the `poison_open` exchange
|
|
41
41
|
And after 1000ms
|
|
42
42
|
Then `bystander` has received 1 event
|
|
43
|
+
|
|
44
|
+
Scenario: A consumer refusing a message parks it at once
|
|
45
|
+
|
|
46
|
+
A `Park` says the consumer will never process this message, so climbing the
|
|
47
|
+
ladder would only delay the inevitable.
|
|
48
|
+
|
|
49
|
+
Given that events from the `poison_refused` exchange are refused as poison
|
|
50
|
+
When an event is emitted to the `poison_refused` exchange
|
|
51
|
+
Then the event is attempted 1 time
|
|
52
|
+
And the message is parked
|
|
53
|
+
And the parked message is kept, and says it came from the `poison_refused` exchange
|
package/features/steps/events.js
CHANGED
|
@@ -38,6 +38,21 @@ Given('(that )events are exclusively consumed from the {token} exchange',
|
|
|
38
38
|
this.consumptionPromise = consume.call(this, undefined, exchange)
|
|
39
39
|
})
|
|
40
40
|
|
|
41
|
+
Given('(that )events from the {token} exchange are refused as poison',
|
|
42
|
+
/**
|
|
43
|
+
* @param {string} exchange
|
|
44
|
+
* @this {comq.features.Context}
|
|
45
|
+
*/
|
|
46
|
+
async function (exchange) {
|
|
47
|
+
const { Park } = require('../../')
|
|
48
|
+
|
|
49
|
+
await this.io.consume(exchange, 'exceptions', (payload, properties) => {
|
|
50
|
+
this.attempts.push(properties.headers?.['x-comq-attempt'] ?? 1)
|
|
51
|
+
|
|
52
|
+
throw new Park('Expected refusal')
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
41
56
|
Given('(that )events from the {token} exchange are causing exceptions',
|
|
42
57
|
/**
|
|
43
58
|
* @param {string} exchange
|
package/features/steps/poison.js
CHANGED
|
@@ -57,9 +57,10 @@ Then('the parked message is kept, and says it came from the {token} exchange',
|
|
|
57
57
|
assert.equal(headers['x-comq-exchange'], exchange,
|
|
58
58
|
`The parked message says it came from '${headers['x-comq-exchange']}'`)
|
|
59
59
|
|
|
60
|
+
// absent on a message parked from its first delivery, exactly as a consumer reads it;
|
|
60
61
|
// derived rather than stated, so it cannot drift from the configured ladder
|
|
61
|
-
assert.equal(headers['x-comq-attempt'], this.attempts.length,
|
|
62
|
-
'The parked message
|
|
62
|
+
assert.equal(headers['x-comq-attempt'] ?? 1, this.attempts.length,
|
|
63
|
+
'The parked message did not have as many attempts as the consumer saw')
|
|
63
64
|
})
|
|
64
65
|
|
|
65
66
|
Then('{token} has received {int} event(s)',
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -515,6 +515,41 @@ One retry queue and one exchange are declared per distinct wait and shared by ev
|
|
|
515
515
|
uses them, so their number grows with the length of the ladder rather than with the number of
|
|
516
516
|
queues.
|
|
517
517
|
|
|
518
|
+
#### Saying how it failed
|
|
519
|
+
|
|
520
|
+
A consumer can classify its failure, which decides whether the message is worth another
|
|
521
|
+
attempt at all:
|
|
522
|
+
|
|
523
|
+
```javascript
|
|
524
|
+
const { Retry, Park } = require('comq')
|
|
525
|
+
|
|
526
|
+
await io.consume('orders', 'billing', async (order) => {
|
|
527
|
+
if (typeof order.total !== 'number') throw new Park('no total on order')
|
|
528
|
+
|
|
529
|
+
try {
|
|
530
|
+
await billing.charge(order)
|
|
531
|
+
} catch (e) {
|
|
532
|
+
throw new Retry('billing unavailable', { cause: e })
|
|
533
|
+
}
|
|
534
|
+
})
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
- `Park` says this consumer will never process this message, however many times it is handed
|
|
538
|
+
over. It is [parked](#parked-messages) at once, without climbing the ladder.
|
|
539
|
+
- `Retry` says whatever was missing may be back shortly. This is what an unclassified rejection
|
|
540
|
+
already means, so throwing it changes nothing but the reader's certainty.
|
|
541
|
+
|
|
542
|
+
**A bare rejection means `Retry`** — a failure nobody classified is a failure nobody chose — so
|
|
543
|
+
consumers written before this behave exactly as they did.
|
|
544
|
+
|
|
545
|
+
Both are `Error`s and both take a `cause`, which is recorded on the parked message alongside the
|
|
546
|
+
verdict's own message.
|
|
547
|
+
|
|
548
|
+
> **`Park` is not applicable to a `Producer` given to [`IO.reply`](#reply).** A Request
|
|
549
|
+
> has a caller awaiting a reply, so what happens to a failed one is not a policy choice. A `Park`
|
|
550
|
+
> thrown there is treated as an ordinary failure — retried, then parked on the count — and the
|
|
551
|
+
> parked message says so as its reason.
|
|
552
|
+
|
|
518
553
|
#### Parked messages
|
|
519
554
|
|
|
520
555
|
A message that has run out of attempts is published to `comq.parked.<queue>` and acknowledged
|
package/source/channel.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { Promex } = require('promex')
|
|
4
4
|
const { failsafe, lazy, recall } = require('./attributes')
|
|
5
|
+
const { verdictOf, PARK } = require('./verdicts')
|
|
5
6
|
const emitter = require('./emitter')
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -490,8 +491,11 @@ class Channel {
|
|
|
490
491
|
// the header is which attempt this delivery is, and the first does not carry one
|
|
491
492
|
const attempt = message.properties.headers?.[ATTEMPT_HEADER] ?? 1
|
|
492
493
|
|
|
493
|
-
//
|
|
494
|
-
|
|
494
|
+
// a consumer that says the message will never work is taken at its word; otherwise
|
|
495
|
+
// one rung per retry, and a message that has climbed the ladder has nowhere left to wait
|
|
496
|
+
const parking = verdictOf(exception) === PARK || attempt > this.#delays.length
|
|
497
|
+
|
|
498
|
+
if (parking) await this.#park(queue, message, exception)
|
|
495
499
|
else await this.#retry(queue, message, attempt, exception)
|
|
496
500
|
} catch {
|
|
497
501
|
// the message could not be moved: give the delivery back rather than lose it
|
|
@@ -527,6 +531,9 @@ class Channel {
|
|
|
527
531
|
const properties = this.#carry(message, {
|
|
528
532
|
[PARKED_QUEUE_HEADER]: queue,
|
|
529
533
|
[PARKED_REASON_HEADER]: exception?.message,
|
|
534
|
+
// a verdict is usually thrown with the exception that prompted it, and that one
|
|
535
|
+
// says what actually went wrong
|
|
536
|
+
[PARKED_CAUSE_HEADER]: exception?.cause?.message,
|
|
530
537
|
[PARKED_AT_HEADER]: Date.now()
|
|
531
538
|
})
|
|
532
539
|
|
|
@@ -659,6 +666,7 @@ const ORIGIN_EXCHANGE_HEADER = 'x-comq-exchange'
|
|
|
659
666
|
const ORIGIN_KEY_HEADER = 'x-comq-key'
|
|
660
667
|
const PARKED_QUEUE_HEADER = 'x-comq-queue'
|
|
661
668
|
const PARKED_REASON_HEADER = 'x-comq-reason'
|
|
669
|
+
const PARKED_CAUSE_HEADER = 'x-comq-cause'
|
|
662
670
|
const PARKED_AT_HEADER = 'x-comq-at'
|
|
663
671
|
|
|
664
672
|
function noop () {}
|
package/source/index.js
CHANGED
package/source/io.js
CHANGED
|
@@ -4,6 +4,7 @@ const stream = require('node:stream')
|
|
|
4
4
|
const { setTimeout } = require('node:timers/promises')
|
|
5
5
|
const { Promex } = require('promex')
|
|
6
6
|
const { memo, failsafe, lazy, track } = require('./attributes')
|
|
7
|
+
const { verdictOf, PARK } = require('./verdicts')
|
|
7
8
|
|
|
8
9
|
const { decode } = require('./decode')
|
|
9
10
|
const { encode } = require('./encode')
|
|
@@ -302,7 +303,7 @@ class IO {
|
|
|
302
303
|
*/
|
|
303
304
|
async (request) => {
|
|
304
305
|
const payload = decode(request)
|
|
305
|
-
const reply = await producer
|
|
306
|
+
const reply = await produce(producer, payload)
|
|
306
307
|
|
|
307
308
|
if (request.properties.replyTo === undefined) return
|
|
308
309
|
|
|
@@ -549,4 +550,29 @@ const DEFAULT = 'application/json'
|
|
|
549
550
|
|
|
550
551
|
const RETRANSMISSION = /** @type {Error} */ Symbol('retransmission')
|
|
551
552
|
|
|
553
|
+
/**
|
|
554
|
+
* A verdict answers what should happen to a message now that it has failed and nobody
|
|
555
|
+
* is waiting for it. A Producer has a caller waiting, and what it is owed is a reply,
|
|
556
|
+
* so `Park` is a category error there rather than a policy choice — comq's own type
|
|
557
|
+
* vocabulary already draws the line between a Consumer and a Producer.
|
|
558
|
+
*
|
|
559
|
+
* It cannot be caught at wiring time, since nothing there knows what a producer will
|
|
560
|
+
* throw. Caught here instead and re-thrown as an ordinary rejection, which retries and
|
|
561
|
+
* eventually parks, carrying an explanation the parked message keeps as its reason.
|
|
562
|
+
*
|
|
563
|
+
* @param {comq.Producer} producer
|
|
564
|
+
* @param {any} payload
|
|
565
|
+
*/
|
|
566
|
+
async function produce (producer, payload) {
|
|
567
|
+
try {
|
|
568
|
+
return await producer(payload)
|
|
569
|
+
} catch (exception) {
|
|
570
|
+
if (verdictOf(exception) !== PARK) throw exception
|
|
571
|
+
|
|
572
|
+
throw new Error('Park is not applicable to a reply producer: a Request has a caller ' +
|
|
573
|
+
'awaiting a reply, so it is retried and parked on the count like any other failure',
|
|
574
|
+
{ cause: exception })
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
552
578
|
exports.IO = IO
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How a consumer says what should happen to a message it could not handle.
|
|
5
|
+
*
|
|
6
|
+
* The brand is a registered symbol rather than the class identity: comq is a library,
|
|
7
|
+
* and a dependency that pins it while the application resolves its own tree makes two
|
|
8
|
+
* copies of it the ordinary outcome. `instanceof` is silently `false` across those,
|
|
9
|
+
* which would read a `Park` as an unclassified rejection and retry it to exhaustion
|
|
10
|
+
* before parking it anyway. The global symbol registry is shared; a class is not.
|
|
11
|
+
*/
|
|
12
|
+
const VERDICT = Symbol.for('comq.verdict')
|
|
13
|
+
|
|
14
|
+
const RETRY = 'retry'
|
|
15
|
+
const PARK = 'park'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Whatever the consumer needed was briefly not there. This is what an unclassified
|
|
19
|
+
* rejection already means, so throwing it changes nothing but the reader's certainty.
|
|
20
|
+
*/
|
|
21
|
+
class Retry extends Error {
|
|
22
|
+
[VERDICT] = RETRY
|
|
23
|
+
|
|
24
|
+
name = 'Retry'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* This consumer will never process this message, however many times it is handed over.
|
|
29
|
+
* The message is kept rather than retried.
|
|
30
|
+
*/
|
|
31
|
+
class Park extends Error {
|
|
32
|
+
[VERDICT] = PARK
|
|
33
|
+
|
|
34
|
+
name = 'Park'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A rejection nobody classified is a rejection nobody chose: it means Retry.
|
|
39
|
+
*
|
|
40
|
+
* @param {any} exception
|
|
41
|
+
* @returns {'retry' | 'park'}
|
|
42
|
+
*/
|
|
43
|
+
const verdictOf = (exception) => {
|
|
44
|
+
const verdict = exception?.[VERDICT]
|
|
45
|
+
|
|
46
|
+
return verdict === PARK || verdict === RETRY ? verdict : RETRY
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
exports.Retry = Retry
|
|
50
|
+
exports.Park = Park
|
|
51
|
+
exports.verdictOf = verdictOf
|
|
52
|
+
exports.VERDICT = VERDICT
|
|
53
|
+
exports.RETRY = RETRY
|
|
54
|
+
exports.PARK = PARK
|
package/test/channel.test.js
CHANGED
|
@@ -10,6 +10,7 @@ const backpressure = require('./backpressure')
|
|
|
10
10
|
const { amqplib } = require('./amqplib.mock')
|
|
11
11
|
const fixtures = require('./channel.fixtures')
|
|
12
12
|
const { create } = require('../source/channel')
|
|
13
|
+
const { Retry, Park } = require('../source/verdicts')
|
|
13
14
|
|
|
14
15
|
it('should be', async () => {
|
|
15
16
|
expect(create).toBeDefined()
|
|
@@ -1584,6 +1585,109 @@ describe('failed messages', () => {
|
|
|
1584
1585
|
})
|
|
1585
1586
|
})
|
|
1586
1587
|
|
|
1588
|
+
describe('verdicts', () => {
|
|
1589
|
+
it('should park on the first delivery when the consumer says to', async () => {
|
|
1590
|
+
await channel.consume(queue, consumer)
|
|
1591
|
+
|
|
1592
|
+
consumer.mockImplementationOnce(async () => { throw new Park(generate()) })
|
|
1593
|
+
|
|
1594
|
+
await deliver(delivery({}))
|
|
1595
|
+
|
|
1596
|
+
const [exchange, key] = publications()[0]
|
|
1597
|
+
|
|
1598
|
+
expect(exchange).toStrictEqual('')
|
|
1599
|
+
expect(key).toStrictEqual('comq.parked.' + queue)
|
|
1600
|
+
expect(publications()).toHaveLength(1)
|
|
1601
|
+
})
|
|
1602
|
+
|
|
1603
|
+
it('should not retry a parked message even with the ladder untouched', async () => {
|
|
1604
|
+
await channel.consume(queue, consumer)
|
|
1605
|
+
|
|
1606
|
+
consumer.mockImplementationOnce(async () => { throw new Park(generate()) })
|
|
1607
|
+
|
|
1608
|
+
await deliver(delivery({}))
|
|
1609
|
+
|
|
1610
|
+
const [, , , options] = publications()[0]
|
|
1611
|
+
|
|
1612
|
+
// parked on the first delivery, so it never climbed a rung
|
|
1613
|
+
expect(options.headers['x-comq-attempt']).toBeUndefined()
|
|
1614
|
+
})
|
|
1615
|
+
|
|
1616
|
+
it('should treat Retry exactly as a bare rejection', async () => {
|
|
1617
|
+
await channel.consume(queue, consumer)
|
|
1618
|
+
|
|
1619
|
+
consumer.mockImplementationOnce(async () => { throw new Retry(generate()) })
|
|
1620
|
+
|
|
1621
|
+
await deliver(delivery({}))
|
|
1622
|
+
|
|
1623
|
+
const [exchange] = publications()[0]
|
|
1624
|
+
|
|
1625
|
+
expect(exchange).toStrictEqual('comq.retry.' + DELAY)
|
|
1626
|
+
})
|
|
1627
|
+
|
|
1628
|
+
it('should record the verdict message as the reason', async () => {
|
|
1629
|
+
await channel.consume(queue, consumer)
|
|
1630
|
+
|
|
1631
|
+
const reason = generate()
|
|
1632
|
+
|
|
1633
|
+
consumer.mockImplementationOnce(async () => { throw new Park(reason) })
|
|
1634
|
+
|
|
1635
|
+
await deliver(delivery({}))
|
|
1636
|
+
|
|
1637
|
+
const [, , , options] = publications()[0]
|
|
1638
|
+
|
|
1639
|
+
expect(options.headers['x-comq-reason']).toStrictEqual(reason)
|
|
1640
|
+
})
|
|
1641
|
+
|
|
1642
|
+
it('should record the cause alongside the reason', async () => {
|
|
1643
|
+
await channel.consume(queue, consumer)
|
|
1644
|
+
|
|
1645
|
+
const reason = generate()
|
|
1646
|
+
const cause = new Error(generate())
|
|
1647
|
+
|
|
1648
|
+
consumer.mockImplementationOnce(async () => { throw new Park(reason, { cause }) })
|
|
1649
|
+
|
|
1650
|
+
await deliver(delivery({}))
|
|
1651
|
+
|
|
1652
|
+
const [, , , options] = publications()[0]
|
|
1653
|
+
|
|
1654
|
+
expect(options.headers['x-comq-reason']).toStrictEqual(reason)
|
|
1655
|
+
expect(options.headers['x-comq-cause']).toStrictEqual(cause.message)
|
|
1656
|
+
})
|
|
1657
|
+
|
|
1658
|
+
it('should emit `discard` for a parked verdict', async () => {
|
|
1659
|
+
const listener = jest.fn()
|
|
1660
|
+
|
|
1661
|
+
channel.diagnose('discard', listener)
|
|
1662
|
+
|
|
1663
|
+
await channel.consume(queue, consumer)
|
|
1664
|
+
|
|
1665
|
+
const park = new Park(generate())
|
|
1666
|
+
|
|
1667
|
+
consumer.mockImplementationOnce(async () => { throw park })
|
|
1668
|
+
|
|
1669
|
+
const message = delivery({})
|
|
1670
|
+
|
|
1671
|
+
await deliver(message)
|
|
1672
|
+
|
|
1673
|
+
expect(listener).toHaveBeenCalledWith(message, park)
|
|
1674
|
+
})
|
|
1675
|
+
|
|
1676
|
+
it('should honour a Park thrown by another copy of comq', async () => {
|
|
1677
|
+
await channel.consume(queue, consumer)
|
|
1678
|
+
|
|
1679
|
+
const foreign = new Error(generate())
|
|
1680
|
+
|
|
1681
|
+
foreign[Symbol.for('comq.verdict')] = 'park'
|
|
1682
|
+
|
|
1683
|
+
consumer.mockImplementationOnce(async () => { throw foreign })
|
|
1684
|
+
|
|
1685
|
+
await deliver(delivery({}))
|
|
1686
|
+
|
|
1687
|
+
expect(publications()[0][1]).toStrictEqual('comq.parked.' + queue)
|
|
1688
|
+
})
|
|
1689
|
+
})
|
|
1690
|
+
|
|
1587
1691
|
describe('when the message cannot be moved', () => {
|
|
1588
1692
|
it('should give the delivery back', async () => {
|
|
1589
1693
|
await channel.consume(queue, consumer)
|
package/test/io.reply.test.js
CHANGED
|
@@ -10,6 +10,7 @@ const mock = require('./connection.mock')
|
|
|
10
10
|
const { encodings } = require('./encodings')
|
|
11
11
|
|
|
12
12
|
const { IO } = require('../source/io')
|
|
13
|
+
const { Retry, Park } = require('../source/verdicts')
|
|
13
14
|
|
|
14
15
|
/** @type {comq.IO} */
|
|
15
16
|
let io
|
|
@@ -240,3 +241,68 @@ const findChannel = (type) => {
|
|
|
240
241
|
|
|
241
242
|
return connection.createChannel.mock.results[index].value
|
|
242
243
|
}
|
|
244
|
+
|
|
245
|
+
describe('verdicts', () => {
|
|
246
|
+
/** @type {jest.MockedObject<comq.Channel>} */
|
|
247
|
+
let requests
|
|
248
|
+
|
|
249
|
+
const message = () => /** @type {comq.amqp.Message} */ ({
|
|
250
|
+
content: randomBytes(10),
|
|
251
|
+
properties: { replyTo: generate(), contentType: 'text/plain' }
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
const findChannel = async (type) => {
|
|
255
|
+
const index = connection.createChannel.mock.calls.findIndex(([argument]) => argument === type)
|
|
256
|
+
|
|
257
|
+
return await connection.createChannel.mock.results[index].value
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
beforeEach(async () => {
|
|
261
|
+
await io.reply(queue, produce)
|
|
262
|
+
|
|
263
|
+
requests = await findChannel('request')
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('should refuse Park from a producer', async () => {
|
|
267
|
+
// a Request has a caller awaiting a reply, so a verdict is a category error there
|
|
268
|
+
const park = new Park(generate())
|
|
269
|
+
|
|
270
|
+
produce.mockImplementationOnce(async () => { throw park })
|
|
271
|
+
|
|
272
|
+
const consumer = requests.consume.mock.calls[0][1]
|
|
273
|
+
|
|
274
|
+
await expect(consumer(message())).rejects.toThrow(/not applicable to a reply producer/)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('should keep the Park as the cause of what it throws instead', async () => {
|
|
278
|
+
const park = new Park(generate())
|
|
279
|
+
|
|
280
|
+
produce.mockImplementationOnce(async () => { throw park })
|
|
281
|
+
|
|
282
|
+
const consumer = requests.consume.mock.calls[0][1]
|
|
283
|
+
const exception = await consumer(message()).catch((error) => error)
|
|
284
|
+
|
|
285
|
+
// the explanation reaches the parked message as its reason, and this as its cause
|
|
286
|
+
expect(exception.cause).toStrictEqual(park)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('should pass an ordinary rejection through untouched', async () => {
|
|
290
|
+
const failure = new Error(generate())
|
|
291
|
+
|
|
292
|
+
produce.mockImplementationOnce(async () => { throw failure })
|
|
293
|
+
|
|
294
|
+
const consumer = requests.consume.mock.calls[0][1]
|
|
295
|
+
|
|
296
|
+
await expect(consumer(message())).rejects.toStrictEqual(failure)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('should pass Retry through untouched', async () => {
|
|
300
|
+
const retry = new Retry(generate())
|
|
301
|
+
|
|
302
|
+
produce.mockImplementationOnce(async () => { throw retry })
|
|
303
|
+
|
|
304
|
+
const consumer = requests.consume.mock.calls[0][1]
|
|
305
|
+
|
|
306
|
+
await expect(consumer(message())).rejects.toStrictEqual(retry)
|
|
307
|
+
})
|
|
308
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { generate } = require('randomstring')
|
|
4
|
+
const { Retry, Park, verdictOf, RETRY, PARK, VERDICT } = require('../source/verdicts')
|
|
5
|
+
|
|
6
|
+
it('should be', async () => {
|
|
7
|
+
expect(Retry).toBeDefined()
|
|
8
|
+
expect(Park).toBeDefined()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
describe('verdictOf', () => {
|
|
12
|
+
it.each([
|
|
13
|
+
['undefined', undefined],
|
|
14
|
+
['null', null],
|
|
15
|
+
['a string', 'nope'],
|
|
16
|
+
['a plain Error', new Error(generate())],
|
|
17
|
+
['an object branded with something else', { [Symbol.for('other.verdict')]: 'park' }],
|
|
18
|
+
['an object whose brand is not a verdict', { [VERDICT]: generate() }]
|
|
19
|
+
])('should read %s as a retry', async (_, exception) => {
|
|
20
|
+
expect(verdictOf(exception)).toStrictEqual(RETRY)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('should read Retry as a retry', async () => {
|
|
24
|
+
expect(verdictOf(new Retry(generate()))).toStrictEqual(RETRY)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('should read Park as a park', async () => {
|
|
28
|
+
expect(verdictOf(new Park(generate()))).toStrictEqual(PARK)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('should read a Park thrown by another copy of comq', async () => {
|
|
32
|
+
// a dependency pinning comq while the application resolves its own tree makes two
|
|
33
|
+
// copies the ordinary outcome, and `instanceof` is silently false across them
|
|
34
|
+
const foreign = { [Symbol.for('comq.verdict')]: 'park' }
|
|
35
|
+
|
|
36
|
+
expect(verdictOf(foreign)).toStrictEqual(PARK)
|
|
37
|
+
expect(foreign instanceof Park).toStrictEqual(false)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('the classes', () => {
|
|
42
|
+
it.each([['Retry', Retry], ['Park', Park]])('should make %s an Error', async (name, Verdict) => {
|
|
43
|
+
const message = generate()
|
|
44
|
+
const verdict = new Verdict(message)
|
|
45
|
+
|
|
46
|
+
expect(verdict).toBeInstanceOf(Error)
|
|
47
|
+
expect(verdict.message).toStrictEqual(message)
|
|
48
|
+
expect(verdict.name).toStrictEqual(name)
|
|
49
|
+
expect(typeof verdict.stack).toStrictEqual('string')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it.each([['Retry', Retry], ['Park', Park]])('should let %s carry a cause', async (_, Verdict) => {
|
|
53
|
+
const cause = new Error(generate())
|
|
54
|
+
|
|
55
|
+
expect(new Verdict(generate(), { cause }).cause).toStrictEqual(cause)
|
|
56
|
+
})
|
|
57
|
+
})
|
package/types/index.d.ts
CHANGED
|
@@ -4,3 +4,20 @@ export type { IO, Consumer, Producer } from './io'
|
|
|
4
4
|
|
|
5
5
|
export const connect: Connect
|
|
6
6
|
export const assert: Connect
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Thrown by a consumer to say that whatever it needed was briefly not there. This is
|
|
10
|
+
* what an unclassified rejection already means, so throwing it changes nothing but the
|
|
11
|
+
* reader's certainty.
|
|
12
|
+
*/
|
|
13
|
+
export class Retry extends Error {}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Thrown by a consumer to say that it will never process this message, however many
|
|
17
|
+
* times it is handed over. The message is kept rather than retried.
|
|
18
|
+
*
|
|
19
|
+
* Not applicable to a `Producer` given to `IO.reply`: a Request has a caller awaiting a
|
|
20
|
+
* reply, so a verdict is a category error there and the message is retried and parked on
|
|
21
|
+
* the count like any other failure.
|
|
22
|
+
*/
|
|
23
|
+
export class Park extends Error {}
|