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.
@@ -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
- > **Parts 1 and 2 are implemented.** Part 3 — the `Retry` / `Park` verdict API — is decided but
10
- > not yet scheduled; see the end. Text below is written as a proposal because that is what it
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** — **decided, not yet scheduled.** Parts 1 and 2 are the emergency
1115
- and do not depend on it; a consumer of comq gets retry-then-park by upgrading and writing no
1116
- code. But the parking queue takes its noun from a verdict class that would not otherwise
1117
- exist, and without verdicts a message a contract has already refused costs every attempt —
1118
- two minutes at the event default — and then lands in the parking queue beside the
1119
- messages that genuinely failed. "Decided, not yet scheduled" rather than "open", because
1120
- people read this deciding whether to build on it.
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
@@ -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
@@ -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 was not retried to exhaustion')
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "ComQ",
5
5
  "author": {
6
6
  "name": "Artem Gurtovoi",
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
- // one rung per retry: a message that has climbed the ladder has nowhere left to wait
494
- if (attempt > this.#delays.length) await this.#park(queue, message, exception)
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
@@ -1,6 +1,10 @@
1
1
  'use strict'
2
2
 
3
3
  const { connect, assert } = require('./connect')
4
+ const { Retry, Park } = require('./verdicts')
4
5
 
5
6
  exports.connect = connect
6
7
  exports.assert = assert
8
+
9
+ exports.Retry = Retry
10
+ exports.Park = Park
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(payload)
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
@@ -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)
@@ -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 {}