@amqp-contract/worker 0.20.0 → 0.21.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/README.md +6 -7
- package/dist/index.cjs +161 -134
- package/dist/index.d.cts +62 -53
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +60 -51
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +155 -127
- package/dist/index.mjs.map +1 -1
- package/docs/index.md +75 -64
- package/package.json +29 -29
package/README.md
CHANGED
|
@@ -20,9 +20,8 @@ pnpm add @amqp-contract/worker
|
|
|
20
20
|
|
|
21
21
|
- ✅ **Type-safe message consumption** — Handlers are fully typed based on your contract
|
|
22
22
|
- ✅ **Automatic validation** — Messages are validated before reaching your handlers
|
|
23
|
-
- ✅ **Automatic retry
|
|
23
|
+
- ✅ **Automatic retry mechanism** — Built-in immediate or exponential backoff retry mechanisms
|
|
24
24
|
- ✅ **Prefetch configuration** — Control message flow with per-consumer prefetch settings
|
|
25
|
-
- ✅ **Batch processing** — Process multiple messages at once for better throughput
|
|
26
25
|
- ✅ **Automatic reconnection** — Built-in connection management with failover support
|
|
27
26
|
|
|
28
27
|
## Usage
|
|
@@ -68,16 +67,16 @@ const worker = await TypedAmqpWorker.create({
|
|
|
68
67
|
|
|
69
68
|
### Advanced Features
|
|
70
69
|
|
|
71
|
-
For advanced features like prefetch configuration
|
|
70
|
+
For advanced features like prefetch configuration and **automatic retry**, see the [Worker Usage Guide](https://btravers.github.io/amqp-contract/guide/worker-usage).
|
|
72
71
|
|
|
73
|
-
#### Retry
|
|
72
|
+
#### Retry configuration
|
|
74
73
|
|
|
75
74
|
Retry is configured at the queue level in your contract definition. Add `retry` to your queue definition:
|
|
76
75
|
|
|
77
76
|
```typescript
|
|
78
77
|
import { defineQueue, defineExchange, defineContract } from "@amqp-contract/contract";
|
|
79
78
|
|
|
80
|
-
const dlx = defineExchange("orders-dlx"
|
|
79
|
+
const dlx = defineExchange("orders-dlx");
|
|
81
80
|
|
|
82
81
|
// Configure retry at queue level
|
|
83
82
|
const orderQueue = defineQueue("order-processing", {
|
|
@@ -112,7 +111,7 @@ const worker = await TypedAmqpWorker.create({
|
|
|
112
111
|
});
|
|
113
112
|
```
|
|
114
113
|
|
|
115
|
-
|
|
114
|
+
See the [Error Handling and Retry](https://btravers.github.io/amqp-contract/guide/worker-usage#error-handling-and-retry) section in the guide for complete details.
|
|
116
115
|
|
|
117
116
|
## Defining Handlers Externally
|
|
118
117
|
|
|
@@ -148,7 +147,7 @@ Worker defines error classes:
|
|
|
148
147
|
- `TechnicalError` - Runtime failures (parsing, processing)
|
|
149
148
|
- `MessageValidationError` - Message fails schema validation
|
|
150
149
|
- `RetryableError` - Signals that the error is transient and should be retried
|
|
151
|
-
- `NonRetryableError` - Signals permanent failure, message
|
|
150
|
+
- `NonRetryableError` - Signals permanent failure, message is sent to DLQ (if configured) or dropped
|
|
152
151
|
|
|
153
152
|
## API
|
|
154
153
|
|
package/dist/index.cjs
CHANGED
|
@@ -1,10 +1,41 @@
|
|
|
1
|
-
Object.defineProperty(exports, Symbol.toStringTag, { value:
|
|
2
|
-
let _amqp_contract_core = require("@amqp-contract/core");
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
2
|
let _amqp_contract_contract = require("@amqp-contract/contract");
|
|
3
|
+
let _amqp_contract_core = require("@amqp-contract/core");
|
|
4
4
|
let _swan_io_boxed = require("@swan-io/boxed");
|
|
5
5
|
let node_zlib = require("node:zlib");
|
|
6
6
|
let node_util = require("node:util");
|
|
7
|
-
|
|
7
|
+
//#region src/decompression.ts
|
|
8
|
+
const gunzipAsync = (0, node_util.promisify)(node_zlib.gunzip);
|
|
9
|
+
const inflateAsync = (0, node_util.promisify)(node_zlib.inflate);
|
|
10
|
+
/**
|
|
11
|
+
* Supported content encodings for message decompression.
|
|
12
|
+
*/
|
|
13
|
+
const SUPPORTED_ENCODINGS = ["gzip", "deflate"];
|
|
14
|
+
/**
|
|
15
|
+
* Type guard to check if a string is a supported encoding.
|
|
16
|
+
*/
|
|
17
|
+
function isSupportedEncoding(encoding) {
|
|
18
|
+
return SUPPORTED_ENCODINGS.includes(encoding.toLowerCase());
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Decompress a buffer based on the content-encoding header.
|
|
22
|
+
*
|
|
23
|
+
* @param buffer - The buffer to decompress
|
|
24
|
+
* @param contentEncoding - The content-encoding header value (e.g., 'gzip', 'deflate')
|
|
25
|
+
* @returns A Future with the decompressed buffer or a TechnicalError
|
|
26
|
+
*
|
|
27
|
+
* @internal
|
|
28
|
+
*/
|
|
29
|
+
function decompressBuffer(buffer, contentEncoding) {
|
|
30
|
+
if (!contentEncoding) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(buffer));
|
|
31
|
+
const normalizedEncoding = contentEncoding.toLowerCase();
|
|
32
|
+
if (!isSupportedEncoding(normalizedEncoding)) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError(`Unsupported content-encoding: "${contentEncoding}". Supported encodings are: ${SUPPORTED_ENCODINGS.join(", ")}. Please check your publisher configuration.`)));
|
|
33
|
+
switch (normalizedEncoding) {
|
|
34
|
+
case "gzip": return _swan_io_boxed.Future.fromPromise(gunzipAsync(buffer)).mapError((error) => new _amqp_contract_core.TechnicalError("Failed to decompress gzip", error));
|
|
35
|
+
case "deflate": return _swan_io_boxed.Future.fromPromise(inflateAsync(buffer)).mapError((error) => new _amqp_contract_core.TechnicalError("Failed to decompress deflate", error));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
//#endregion
|
|
8
39
|
//#region src/errors.ts
|
|
9
40
|
/**
|
|
10
41
|
* Retryable errors - transient failures that may succeed on retry
|
|
@@ -165,40 +196,6 @@ function retryable(message, cause) {
|
|
|
165
196
|
function nonRetryable(message, cause) {
|
|
166
197
|
return new NonRetryableError(message, cause);
|
|
167
198
|
}
|
|
168
|
-
|
|
169
|
-
//#endregion
|
|
170
|
-
//#region src/decompression.ts
|
|
171
|
-
const gunzipAsync = (0, node_util.promisify)(node_zlib.gunzip);
|
|
172
|
-
const inflateAsync = (0, node_util.promisify)(node_zlib.inflate);
|
|
173
|
-
/**
|
|
174
|
-
* Supported content encodings for message decompression.
|
|
175
|
-
*/
|
|
176
|
-
const SUPPORTED_ENCODINGS = ["gzip", "deflate"];
|
|
177
|
-
/**
|
|
178
|
-
* Type guard to check if a string is a supported encoding.
|
|
179
|
-
*/
|
|
180
|
-
function isSupportedEncoding(encoding) {
|
|
181
|
-
return SUPPORTED_ENCODINGS.includes(encoding.toLowerCase());
|
|
182
|
-
}
|
|
183
|
-
/**
|
|
184
|
-
* Decompress a buffer based on the content-encoding header.
|
|
185
|
-
*
|
|
186
|
-
* @param buffer - The buffer to decompress
|
|
187
|
-
* @param contentEncoding - The content-encoding header value (e.g., 'gzip', 'deflate')
|
|
188
|
-
* @returns A Future with the decompressed buffer or a TechnicalError
|
|
189
|
-
*
|
|
190
|
-
* @internal
|
|
191
|
-
*/
|
|
192
|
-
function decompressBuffer(buffer, contentEncoding) {
|
|
193
|
-
if (!contentEncoding) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(buffer));
|
|
194
|
-
const normalizedEncoding = contentEncoding.toLowerCase();
|
|
195
|
-
if (!isSupportedEncoding(normalizedEncoding)) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError(`Unsupported content-encoding: "${contentEncoding}". Supported encodings are: ${SUPPORTED_ENCODINGS.join(", ")}. Please check your publisher configuration.`)));
|
|
196
|
-
switch (normalizedEncoding) {
|
|
197
|
-
case "gzip": return _swan_io_boxed.Future.fromPromise(gunzipAsync(buffer)).mapError((error) => new _amqp_contract_core.TechnicalError("Failed to decompress gzip", error));
|
|
198
|
-
case "deflate": return _swan_io_boxed.Future.fromPromise(inflateAsync(buffer)).mapError((error) => new _amqp_contract_core.TechnicalError("Failed to decompress deflate", error));
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
199
|
//#endregion
|
|
203
200
|
//#region src/worker.ts
|
|
204
201
|
/**
|
|
@@ -221,7 +218,7 @@ function isHandlerTuple(entry) {
|
|
|
221
218
|
* import { defineQueue, defineMessage, defineContract, defineConsumer } from '@amqp-contract/contract';
|
|
222
219
|
* import { z } from 'zod';
|
|
223
220
|
*
|
|
224
|
-
* const orderQueue = defineQueue('order-processing'
|
|
221
|
+
* const orderQueue = defineQueue('order-processing');
|
|
225
222
|
* const orderMessage = defineMessage(z.object({
|
|
226
223
|
* orderId: z.string(),
|
|
227
224
|
* amount: z.number()
|
|
@@ -256,9 +253,10 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
256
253
|
consumerOptions;
|
|
257
254
|
consumerTags = /* @__PURE__ */ new Set();
|
|
258
255
|
telemetry;
|
|
259
|
-
constructor(contract, amqpClient, handlers, logger, telemetry) {
|
|
256
|
+
constructor(contract, amqpClient, handlers, defaultConsumerOptions, logger, telemetry) {
|
|
260
257
|
this.contract = contract;
|
|
261
258
|
this.amqpClient = amqpClient;
|
|
259
|
+
this.defaultConsumerOptions = defaultConsumerOptions;
|
|
262
260
|
this.logger = logger;
|
|
263
261
|
this.telemetry = telemetry ?? _amqp_contract_core.defaultTelemetryProvider;
|
|
264
262
|
this.actualHandlers = {};
|
|
@@ -270,8 +268,14 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
270
268
|
if (isHandlerTuple(handlerEntry)) {
|
|
271
269
|
const [handler, options] = handlerEntry;
|
|
272
270
|
this.actualHandlers[typedConsumerName] = handler;
|
|
273
|
-
this.consumerOptions[typedConsumerName] =
|
|
274
|
-
|
|
271
|
+
this.consumerOptions[typedConsumerName] = {
|
|
272
|
+
...this.defaultConsumerOptions,
|
|
273
|
+
...options
|
|
274
|
+
};
|
|
275
|
+
} else {
|
|
276
|
+
this.actualHandlers[typedConsumerName] = handlerEntry;
|
|
277
|
+
this.consumerOptions[typedConsumerName] = this.defaultConsumerOptions;
|
|
278
|
+
}
|
|
275
279
|
}
|
|
276
280
|
}
|
|
277
281
|
/**
|
|
@@ -299,11 +303,11 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
299
303
|
* }).resultToPromise();
|
|
300
304
|
* ```
|
|
301
305
|
*/
|
|
302
|
-
static create({ contract, handlers, urls, connectionOptions, logger, telemetry }) {
|
|
306
|
+
static create({ contract, handlers, urls, connectionOptions, defaultConsumerOptions, logger, telemetry }) {
|
|
303
307
|
const worker = new TypedAmqpWorker(contract, new _amqp_contract_core.AmqpClient(contract, {
|
|
304
308
|
urls,
|
|
305
309
|
connectionOptions
|
|
306
|
-
}), handlers, logger, telemetry);
|
|
310
|
+
}), handlers, defaultConsumerOptions ?? {}, logger, telemetry);
|
|
307
311
|
return worker.waitForConnectionReady().flatMapOk(() => worker.consumeAll()).mapOk(() => worker);
|
|
308
312
|
}
|
|
309
313
|
/**
|
|
@@ -338,7 +342,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
338
342
|
* Defaults are applied in the contract's defineQueue, so we just return the config.
|
|
339
343
|
*/
|
|
340
344
|
getRetryConfigForConsumer(consumer) {
|
|
341
|
-
return consumer.queue.retry;
|
|
345
|
+
return (0, _amqp_contract_contract.extractQueue)(consumer.queue).retry;
|
|
342
346
|
}
|
|
343
347
|
/**
|
|
344
348
|
* Start consuming messages for all consumers.
|
|
@@ -347,13 +351,6 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
347
351
|
consumeAll() {
|
|
348
352
|
const consumers = this.contract.consumers;
|
|
349
353
|
const consumerNames = Object.keys(consumers);
|
|
350
|
-
const maxPrefetch = consumerNames.reduce((max, name) => {
|
|
351
|
-
const prefetch = this.consumerOptions[name]?.prefetch;
|
|
352
|
-
return prefetch ? Math.max(max, prefetch) : max;
|
|
353
|
-
}, 0);
|
|
354
|
-
if (maxPrefetch > 0) this.amqpClient.addSetup(async (channel) => {
|
|
355
|
-
await channel.prefetch(maxPrefetch);
|
|
356
|
-
});
|
|
357
354
|
return _swan_io_boxed.Future.all(consumerNames.map((name) => this.consume(name))).map(_swan_io_boxed.Result.all).mapOk(() => void 0);
|
|
358
355
|
}
|
|
359
356
|
waitForConnectionReady() {
|
|
@@ -392,9 +389,10 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
392
389
|
* @returns Ok with validated message (payload + headers), or Error (message already nacked)
|
|
393
390
|
*/
|
|
394
391
|
parseAndValidateMessage(msg, consumer, consumerName) {
|
|
392
|
+
const queue = (0, _amqp_contract_contract.extractQueue)(consumer.queue);
|
|
395
393
|
const context = {
|
|
396
394
|
consumerName: String(consumerName),
|
|
397
|
-
queueName:
|
|
395
|
+
queueName: queue.name
|
|
398
396
|
};
|
|
399
397
|
const nackAndError = (message, error) => {
|
|
400
398
|
this.logger?.error(message, {
|
|
@@ -427,7 +425,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
427
425
|
* Consume messages one at a time
|
|
428
426
|
*/
|
|
429
427
|
consumeSingle(consumerName, consumer, handler) {
|
|
430
|
-
const queueName = consumer.queue.name;
|
|
428
|
+
const queueName = (0, _amqp_contract_contract.extractQueue)(consumer.queue).name;
|
|
431
429
|
return this.amqpClient.consume(queueName, async (msg) => {
|
|
432
430
|
if (msg === null) {
|
|
433
431
|
this.logger?.warn("Consumer cancelled by server", {
|
|
@@ -464,7 +462,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
464
462
|
(0, _amqp_contract_core.endSpanError)(span, /* @__PURE__ */ new Error("Message validation failed"));
|
|
465
463
|
(0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(consumerName), false, durationMs);
|
|
466
464
|
}).toPromise();
|
|
467
|
-
}).tapOk((consumerTag) => {
|
|
465
|
+
}, this.consumerOptions[consumerName]).tapOk((consumerTag) => {
|
|
468
466
|
this.consumerTags.add(consumerTag);
|
|
469
467
|
}).mapError((error) => new _amqp_contract_core.TechnicalError(`Failed to start consuming for "${String(consumerName)}"`, error)).mapOk(() => void 0);
|
|
470
468
|
}
|
|
@@ -473,17 +471,18 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
473
471
|
*
|
|
474
472
|
* Flow depends on retry mode:
|
|
475
473
|
*
|
|
476
|
-
* **
|
|
474
|
+
* **immediate-requeue mode:**
|
|
477
475
|
* 1. If NonRetryableError -> send directly to DLQ (no retry)
|
|
478
|
-
* 2.
|
|
476
|
+
* 2. If max retries exceeded -> send to DLQ
|
|
477
|
+
* 3. Otherwise -> requeue immediately for retry
|
|
479
478
|
*
|
|
480
479
|
* **ttl-backoff mode:**
|
|
481
480
|
* 1. If NonRetryableError -> send directly to DLQ (no retry)
|
|
482
481
|
* 2. If max retries exceeded -> send to DLQ
|
|
483
482
|
* 3. Otherwise -> publish to wait queue with TTL for retry
|
|
484
483
|
*
|
|
485
|
-
* **
|
|
486
|
-
* 1.
|
|
484
|
+
* **none mode (no retry config):**
|
|
485
|
+
* 1. send directly to DLQ (no retry)
|
|
487
486
|
*/
|
|
488
487
|
handleError(error, msg, consumerName, consumer) {
|
|
489
488
|
if (error instanceof NonRetryableError) {
|
|
@@ -496,52 +495,98 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
496
495
|
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
|
|
497
496
|
}
|
|
498
497
|
const config = this.getRetryConfigForConsumer(consumer);
|
|
499
|
-
if (config.mode === "
|
|
500
|
-
return this.handleErrorTtlBackoff(error, msg, consumerName, consumer, config);
|
|
498
|
+
if (config.mode === "immediate-requeue") return this.handleErrorImmediateRequeue(error, msg, consumerName, consumer, config);
|
|
499
|
+
if (config.mode === "ttl-backoff") return this.handleErrorTtlBackoff(error, msg, consumerName, consumer, config);
|
|
500
|
+
this.logger?.warn("Retry disabled (none mode), sending to DLQ", {
|
|
501
|
+
consumerName,
|
|
502
|
+
error: error.message
|
|
503
|
+
});
|
|
504
|
+
this.sendToDLQ(msg, consumer);
|
|
505
|
+
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
|
|
501
506
|
}
|
|
502
507
|
/**
|
|
503
|
-
* Handle error
|
|
508
|
+
* Handle error by requeuing immediately.
|
|
504
509
|
*
|
|
505
|
-
*
|
|
506
|
-
* -
|
|
507
|
-
*
|
|
510
|
+
* For quorum queues, messages are requeued with `nack(requeue=true)`, and the worker tracks delivery count via the native RabbitMQ `x-delivery-count` header.
|
|
511
|
+
* For classic queues, messages are re-published on the same queue, and the worker tracks delivery count via a custom `x-retry-count` header.
|
|
512
|
+
* When the count exceeds `maxRetries`, the message is automatically dead-lettered (if DLX is configured) or dropped.
|
|
508
513
|
*
|
|
509
514
|
* This is simpler than TTL-based retry but provides immediate retries only.
|
|
510
515
|
*/
|
|
511
|
-
|
|
512
|
-
const queue = consumer.queue;
|
|
516
|
+
handleErrorImmediateRequeue(error, msg, consumerName, consumer, config) {
|
|
517
|
+
const queue = (0, _amqp_contract_contract.extractQueue)(consumer.queue);
|
|
513
518
|
const queueName = queue.name;
|
|
514
|
-
const
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
519
|
+
const retryCount = queue.type === "quorum" ? msg.properties.headers?.["x-delivery-count"] ?? 0 : msg.properties.headers?.["x-retry-count"] ?? 0;
|
|
520
|
+
if (retryCount >= config.maxRetries) {
|
|
521
|
+
this.logger?.error("Max retries exceeded, sending to DLQ (immediate-requeue mode)", {
|
|
522
|
+
consumerName,
|
|
523
|
+
queueName,
|
|
524
|
+
retryCount,
|
|
525
|
+
maxRetries: config.maxRetries,
|
|
526
|
+
error: error.message
|
|
527
|
+
});
|
|
528
|
+
this.sendToDLQ(msg, consumer);
|
|
529
|
+
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
|
|
530
|
+
}
|
|
531
|
+
this.logger?.warn("Retrying message (immediate-requeue mode)", {
|
|
518
532
|
consumerName,
|
|
519
533
|
queueName,
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
willDeadLetterOnNextFailure: deliveryCount === deliveryLimit - 1,
|
|
523
|
-
alreadyExceededLimit: deliveryCount >= deliveryLimit,
|
|
534
|
+
retryCount,
|
|
535
|
+
maxRetries: config.maxRetries,
|
|
524
536
|
error: error.message
|
|
525
537
|
});
|
|
526
|
-
|
|
527
|
-
|
|
538
|
+
if (queue.type === "quorum") {
|
|
539
|
+
this.amqpClient.nack(msg, false, true);
|
|
540
|
+
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
|
|
541
|
+
} else return this.publishForRetry({
|
|
542
|
+
msg,
|
|
543
|
+
exchange: msg.fields.exchange,
|
|
544
|
+
routingKey: msg.fields.routingKey,
|
|
528
545
|
queueName,
|
|
529
|
-
|
|
530
|
-
deliveryLimit,
|
|
531
|
-
attemptsBeforeDeadLetter,
|
|
532
|
-
error: error.message
|
|
546
|
+
error
|
|
533
547
|
});
|
|
534
|
-
this.amqpClient.nack(msg, false, true);
|
|
535
|
-
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
|
|
536
548
|
}
|
|
537
549
|
/**
|
|
538
550
|
* Handle error using TTL + wait queue pattern for exponential backoff.
|
|
551
|
+
*
|
|
552
|
+
* ┌─────────────────────────────────────────────────────────────────┐
|
|
553
|
+
* │ Retry Flow (Native RabbitMQ TTL + Wait queue pattern) │
|
|
554
|
+
* ├─────────────────────────────────────────────────────────────────┤
|
|
555
|
+
* │ │
|
|
556
|
+
* │ 1. Handler throws any Error │
|
|
557
|
+
* │ ↓ │
|
|
558
|
+
* │ 2. Worker publishes to wait exchange |
|
|
559
|
+
* | (with header `x-wait-queue` set to the wait queue name) │
|
|
560
|
+
* │ ↓ │
|
|
561
|
+
* │ 3. Wait exchange routes to wait queue │
|
|
562
|
+
* │ (with expiration: calculated backoff delay) │
|
|
563
|
+
* │ ↓ │
|
|
564
|
+
* │ 4. Message waits in queue until TTL expires │
|
|
565
|
+
* │ ↓ │
|
|
566
|
+
* │ 5. Expired message dead-lettered to retry exchange |
|
|
567
|
+
* | (with header `x-retry-queue` set to the main queue name) │
|
|
568
|
+
* │ ↓ │
|
|
569
|
+
* │ 6. Retry exchange routes back to main queue → RETRY │
|
|
570
|
+
* │ ↓ │
|
|
571
|
+
* │ 7. If retries exhausted: nack without requeue → DLQ │
|
|
572
|
+
* │ │
|
|
573
|
+
* └─────────────────────────────────────────────────────────────────┘
|
|
539
574
|
*/
|
|
540
575
|
handleErrorTtlBackoff(error, msg, consumerName, consumer, config) {
|
|
576
|
+
if (!(0, _amqp_contract_contract.isQueueWithTtlBackoffInfrastructure)(consumer.queue)) {
|
|
577
|
+
this.logger?.error("Queue does not have TTL-backoff infrastructure", {
|
|
578
|
+
consumerName,
|
|
579
|
+
queueName: consumer.queue.name
|
|
580
|
+
});
|
|
581
|
+
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError("Queue does not have TTL-backoff infrastructure")));
|
|
582
|
+
}
|
|
583
|
+
const queueEntry = consumer.queue;
|
|
584
|
+
const queueName = (0, _amqp_contract_contract.extractQueue)(queueEntry).name;
|
|
541
585
|
const retryCount = msg.properties.headers?.["x-retry-count"] ?? 0;
|
|
542
586
|
if (retryCount >= config.maxRetries) {
|
|
543
|
-
this.logger?.error("Max retries exceeded, sending to DLQ", {
|
|
587
|
+
this.logger?.error("Max retries exceeded, sending to DLQ (ttl-backoff mode)", {
|
|
544
588
|
consumerName,
|
|
589
|
+
queueName,
|
|
545
590
|
retryCount,
|
|
546
591
|
maxRetries: config.maxRetries,
|
|
547
592
|
error: error.message
|
|
@@ -552,11 +597,21 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
552
597
|
const delayMs = this.calculateRetryDelay(retryCount, config);
|
|
553
598
|
this.logger?.warn("Retrying message (ttl-backoff mode)", {
|
|
554
599
|
consumerName,
|
|
600
|
+
queueName,
|
|
555
601
|
retryCount: retryCount + 1,
|
|
602
|
+
maxRetries: config.maxRetries,
|
|
556
603
|
delayMs,
|
|
557
604
|
error: error.message
|
|
558
605
|
});
|
|
559
|
-
return this.publishForRetry(
|
|
606
|
+
return this.publishForRetry({
|
|
607
|
+
msg,
|
|
608
|
+
exchange: queueEntry.waitExchange.name,
|
|
609
|
+
routingKey: msg.fields.routingKey,
|
|
610
|
+
waitQueueName: queueEntry.waitQueue.name,
|
|
611
|
+
queueName,
|
|
612
|
+
delayMs,
|
|
613
|
+
error
|
|
614
|
+
});
|
|
560
615
|
}
|
|
561
616
|
/**
|
|
562
617
|
* Calculate retry delay with exponential backoff and optional jitter.
|
|
@@ -584,65 +639,38 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
584
639
|
return content;
|
|
585
640
|
}
|
|
586
641
|
/**
|
|
587
|
-
* Publish message
|
|
588
|
-
*
|
|
589
|
-
* ┌─────────────────────────────────────────────────────────────────┐
|
|
590
|
-
* │ Retry Flow (Native RabbitMQ TTL + DLX Pattern) │
|
|
591
|
-
* ├─────────────────────────────────────────────────────────────────┤
|
|
592
|
-
* │ │
|
|
593
|
-
* │ 1. Handler throws any Error │
|
|
594
|
-
* │ ↓ │
|
|
595
|
-
* │ 2. Worker publishes to DLX with routing key: {queue}-wait │
|
|
596
|
-
* │ ↓ │
|
|
597
|
-
* │ 3. DLX routes to wait queue: {queue}-wait │
|
|
598
|
-
* │ (with expiration: calculated backoff delay) │
|
|
599
|
-
* │ ↓ │
|
|
600
|
-
* │ 4. Message waits in queue until TTL expires │
|
|
601
|
-
* │ ↓ │
|
|
602
|
-
* │ 5. Expired message dead-lettered to DLX │
|
|
603
|
-
* │ (with routing key: {queue}) │
|
|
604
|
-
* │ ↓ │
|
|
605
|
-
* │ 6. DLX routes back to main queue → RETRY │
|
|
606
|
-
* │ ↓ │
|
|
607
|
-
* │ 7. If retries exhausted: nack without requeue → DLQ │
|
|
608
|
-
* │ │
|
|
609
|
-
* └─────────────────────────────────────────────────────────────────┘
|
|
642
|
+
* Publish message with an incremented x-retry-count header and optional TTL.
|
|
610
643
|
*/
|
|
611
|
-
publishForRetry(msg,
|
|
612
|
-
const
|
|
613
|
-
const deadLetter = consumer.queue.deadLetter;
|
|
614
|
-
if (!deadLetter) {
|
|
615
|
-
this.logger?.warn("Cannot retry: queue does not have DLX configured, falling back to nack with requeue", { queueName });
|
|
616
|
-
this.amqpClient.nack(msg, false, true);
|
|
617
|
-
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
|
|
618
|
-
}
|
|
619
|
-
const dlxName = deadLetter.exchange.name;
|
|
620
|
-
const waitRoutingKey = `${queueName}-wait`;
|
|
644
|
+
publishForRetry({ msg, exchange, routingKey, queueName, waitQueueName, delayMs, error }) {
|
|
645
|
+
const newRetryCount = (msg.properties.headers?.["x-retry-count"] ?? 0) + 1;
|
|
621
646
|
this.amqpClient.ack(msg);
|
|
622
647
|
const content = this.parseMessageContentForRetry(msg, queueName);
|
|
623
|
-
return this.amqpClient.publish(
|
|
648
|
+
return this.amqpClient.publish(exchange, routingKey, content, {
|
|
624
649
|
...msg.properties,
|
|
625
|
-
expiration: delayMs.toString(),
|
|
650
|
+
...delayMs !== void 0 ? { expiration: delayMs.toString() } : {},
|
|
626
651
|
headers: {
|
|
627
652
|
...msg.properties.headers,
|
|
628
653
|
"x-retry-count": newRetryCount,
|
|
629
654
|
"x-last-error": error.message,
|
|
630
|
-
"x-first-failure-timestamp": msg.properties.headers?.["x-first-failure-timestamp"] ?? Date.now()
|
|
655
|
+
"x-first-failure-timestamp": msg.properties.headers?.["x-first-failure-timestamp"] ?? Date.now(),
|
|
656
|
+
...waitQueueName !== void 0 ? {
|
|
657
|
+
"x-wait-queue": waitQueueName,
|
|
658
|
+
"x-retry-queue": queueName
|
|
659
|
+
} : {}
|
|
631
660
|
}
|
|
632
661
|
}).mapOkToResult((published) => {
|
|
633
662
|
if (!published) {
|
|
634
663
|
this.logger?.error("Failed to publish message for retry (write buffer full)", {
|
|
635
664
|
queueName,
|
|
636
|
-
|
|
637
|
-
|
|
665
|
+
retryCount: newRetryCount,
|
|
666
|
+
...delayMs !== void 0 ? { delayMs } : {}
|
|
638
667
|
});
|
|
639
668
|
return _swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError("Failed to publish message for retry (write buffer full)"));
|
|
640
669
|
}
|
|
641
670
|
this.logger?.info("Message published for retry", {
|
|
642
671
|
queueName,
|
|
643
|
-
waitRoutingKey,
|
|
644
672
|
retryCount: newRetryCount,
|
|
645
|
-
delayMs
|
|
673
|
+
...delayMs !== void 0 ? { delayMs } : {}
|
|
646
674
|
});
|
|
647
675
|
return _swan_io_boxed.Result.Ok(void 0);
|
|
648
676
|
});
|
|
@@ -652,8 +680,9 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
652
680
|
* Nacks the message without requeue, relying on DLX configuration.
|
|
653
681
|
*/
|
|
654
682
|
sendToDLQ(msg, consumer) {
|
|
655
|
-
const
|
|
656
|
-
|
|
683
|
+
const queue = (0, _amqp_contract_contract.extractQueue)(consumer.queue);
|
|
684
|
+
const queueName = queue.name;
|
|
685
|
+
if (!(queue.deadLetter !== void 0)) this.logger?.warn("Queue does not have DLX configured - message will be lost on nack", { queueName });
|
|
657
686
|
this.logger?.info("Sending message to DLQ", {
|
|
658
687
|
queueName,
|
|
659
688
|
deliveryTag: msg.fields.deliveryTag
|
|
@@ -661,7 +690,6 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
661
690
|
this.amqpClient.nack(msg, false, false);
|
|
662
691
|
}
|
|
663
692
|
};
|
|
664
|
-
|
|
665
693
|
//#endregion
|
|
666
694
|
//#region src/handlers.ts
|
|
667
695
|
/**
|
|
@@ -722,13 +750,12 @@ function defineHandlers(contract, handlers) {
|
|
|
722
750
|
validateHandlers(contract, handlers);
|
|
723
751
|
return handlers;
|
|
724
752
|
}
|
|
725
|
-
|
|
726
753
|
//#endregion
|
|
727
|
-
Object.defineProperty(exports,
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
754
|
+
Object.defineProperty(exports, "MessageValidationError", {
|
|
755
|
+
enumerable: true,
|
|
756
|
+
get: function() {
|
|
757
|
+
return _amqp_contract_core.MessageValidationError;
|
|
758
|
+
}
|
|
732
759
|
});
|
|
733
760
|
exports.NonRetryableError = NonRetryableError;
|
|
734
761
|
exports.RetryableError = RetryableError;
|
|
@@ -739,4 +766,4 @@ exports.isHandlerError = isHandlerError;
|
|
|
739
766
|
exports.isNonRetryableError = isNonRetryableError;
|
|
740
767
|
exports.isRetryableError = isRetryableError;
|
|
741
768
|
exports.nonRetryable = nonRetryable;
|
|
742
|
-
exports.retryable = retryable;
|
|
769
|
+
exports.retryable = retryable;
|