@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 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 with exponential backoff** — Built-in retry mechanism using RabbitMQ TTL+DLX pattern
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, batch processing, and **automatic retry with exponential backoff**, see the [Worker Usage Guide](https://btravers.github.io/amqp-contract/guide/worker-usage).
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 with Exponential Backoff
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", "topic", { durable: true });
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
- The retry mechanism uses RabbitMQ's native TTL and Dead Letter Exchange pattern, so it doesn't block the consumer during retry delays. 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.
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 goes to DLQ
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: 'Module' });
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', { durable: true });
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] = options;
274
- } else this.actualHandlers[typedConsumerName] = handlerEntry;
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: consumer.queue.name
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
- * **quorum-native mode:**
474
+ * **immediate-requeue mode:**
477
475
  * 1. If NonRetryableError -> send directly to DLQ (no retry)
478
- * 2. Otherwise -> nack with requeue=true (RabbitMQ handles delivery count)
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
- * **Legacy mode (no retry config):**
486
- * 1. nack with requeue=true (immediate requeue)
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 === "quorum-native") return this.handleErrorQuorumNative(error, msg, consumerName, consumer);
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 using quorum queue's native delivery limit feature.
508
+ * Handle error by requeuing immediately.
504
509
  *
505
- * Simply requeues the message with nack(requeue=true). RabbitMQ automatically:
506
- * - Increments x-delivery-count header
507
- * - Dead-letters the message when count exceeds x-delivery-limit
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
- handleErrorQuorumNative(error, msg, consumerName, consumer) {
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 deliveryCount = msg.properties.headers?.["x-delivery-count"] ?? 0;
515
- const deliveryLimit = queue.type === "quorum" ? queue.deliveryLimit : void 0;
516
- const attemptsBeforeDeadLetter = deliveryLimit !== void 0 ? Math.max(0, deliveryLimit - deliveryCount - 1) : "unknown";
517
- if (deliveryLimit !== void 0 && deliveryCount >= deliveryLimit - 1) this.logger?.warn("Message at final delivery attempt (quorum-native mode)", {
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
- deliveryCount,
521
- deliveryLimit,
522
- willDeadLetterOnNextFailure: deliveryCount === deliveryLimit - 1,
523
- alreadyExceededLimit: deliveryCount >= deliveryLimit,
534
+ retryCount,
535
+ maxRetries: config.maxRetries,
524
536
  error: error.message
525
537
  });
526
- else this.logger?.warn("Retrying message (quorum-native mode)", {
527
- consumerName,
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
- deliveryCount,
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(msg, consumer, retryCount + 1, delayMs, error);
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 to wait queue for retry after TTL expires.
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, consumer, newRetryCount, delayMs, error) {
612
- const queueName = consumer.queue.name;
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(dlxName, waitRoutingKey, content, {
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
- waitRoutingKey,
637
- retryCount: newRetryCount
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 queueName = consumer.queue.name;
656
- if (!(consumer.queue.deadLetter !== void 0)) this.logger?.warn("Queue does not have DLX configured - message will be lost on nack", { queueName });
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, 'MessageValidationError', {
728
- enumerable: true,
729
- get: function () {
730
- return _amqp_contract_core.MessageValidationError;
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;