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