@amqp-contract/worker 0.7.0 → 0.9.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 +33 -4
- package/dist/index.cjs +397 -95
- package/dist/index.d.cts +362 -99
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +362 -99
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +394 -96
- package/dist/index.mjs.map +1 -1
- package/docs/index.md +995 -213
- package/package.json +8 -8
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AmqpClient } from "@amqp-contract/core";
|
|
1
|
+
import { AmqpClient, defaultTelemetryProvider, endSpanError, endSpanSuccess, recordConsumeMetric, startConsumeSpan } from "@amqp-contract/core";
|
|
2
2
|
import { Future, Result } from "@swan-io/boxed";
|
|
3
3
|
import { gunzip, inflate } from "node:zlib";
|
|
4
4
|
import { promisify } from "node:util";
|
|
@@ -37,6 +37,34 @@ var MessageValidationError = class extends WorkerError {
|
|
|
37
37
|
this.name = "MessageValidationError";
|
|
38
38
|
}
|
|
39
39
|
};
|
|
40
|
+
/**
|
|
41
|
+
* Retryable errors - transient failures that may succeed on retry
|
|
42
|
+
* Examples: network timeouts, rate limiting, temporary service unavailability
|
|
43
|
+
*
|
|
44
|
+
* Use this error type when the operation might succeed if retried.
|
|
45
|
+
* The worker will apply exponential backoff and retry the message.
|
|
46
|
+
*/
|
|
47
|
+
var RetryableError = class extends WorkerError {
|
|
48
|
+
constructor(message, cause) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.cause = cause;
|
|
51
|
+
this.name = "RetryableError";
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Non-retryable errors - permanent failures that should not be retried
|
|
56
|
+
* Examples: invalid data, business rule violations, permanent external failures
|
|
57
|
+
*
|
|
58
|
+
* Use this error type when retrying would not help - the message will be
|
|
59
|
+
* immediately sent to the dead letter queue (DLQ) if configured.
|
|
60
|
+
*/
|
|
61
|
+
var NonRetryableError = class extends WorkerError {
|
|
62
|
+
constructor(message, cause) {
|
|
63
|
+
super(message);
|
|
64
|
+
this.cause = cause;
|
|
65
|
+
this.name = "NonRetryableError";
|
|
66
|
+
}
|
|
67
|
+
};
|
|
40
68
|
|
|
41
69
|
//#endregion
|
|
42
70
|
//#region src/decompression.ts
|
|
@@ -104,23 +132,40 @@ async function decompressBuffer(buffer, contentEncoding) {
|
|
|
104
132
|
* ```
|
|
105
133
|
*/
|
|
106
134
|
var TypedAmqpWorker = class TypedAmqpWorker {
|
|
135
|
+
/**
|
|
136
|
+
* Internal handler type - always safe handlers (`Future<Result>`).
|
|
137
|
+
* Unsafe handlers are wrapped into safe handlers by defineUnsafeHandler/defineUnsafeHandlers.
|
|
138
|
+
*/
|
|
107
139
|
actualHandlers;
|
|
108
140
|
consumerOptions;
|
|
109
141
|
batchTimers = /* @__PURE__ */ new Map();
|
|
110
142
|
consumerTags = /* @__PURE__ */ new Set();
|
|
111
|
-
|
|
143
|
+
retryConfig;
|
|
144
|
+
telemetry;
|
|
145
|
+
constructor(contract, amqpClient, handlers, logger, retryOptions, telemetry) {
|
|
112
146
|
this.contract = contract;
|
|
113
147
|
this.amqpClient = amqpClient;
|
|
114
148
|
this.logger = logger;
|
|
149
|
+
this.telemetry = telemetry ?? defaultTelemetryProvider;
|
|
115
150
|
this.actualHandlers = {};
|
|
116
151
|
this.consumerOptions = {};
|
|
117
|
-
|
|
118
|
-
|
|
152
|
+
const handlersRecord = handlers;
|
|
153
|
+
for (const consumerName of Object.keys(handlersRecord)) {
|
|
154
|
+
const handlerEntry = handlersRecord[consumerName];
|
|
155
|
+
const typedConsumerName = consumerName;
|
|
119
156
|
if (Array.isArray(handlerEntry)) {
|
|
120
|
-
this.actualHandlers[
|
|
121
|
-
this.consumerOptions[
|
|
122
|
-
} else this.actualHandlers[
|
|
157
|
+
this.actualHandlers[typedConsumerName] = handlerEntry[0];
|
|
158
|
+
this.consumerOptions[typedConsumerName] = handlerEntry[1];
|
|
159
|
+
} else this.actualHandlers[typedConsumerName] = handlerEntry;
|
|
123
160
|
}
|
|
161
|
+
if (retryOptions === void 0) this.retryConfig = null;
|
|
162
|
+
else this.retryConfig = {
|
|
163
|
+
maxRetries: retryOptions.maxRetries ?? 3,
|
|
164
|
+
initialDelayMs: retryOptions.initialDelayMs ?? 1e3,
|
|
165
|
+
maxDelayMs: retryOptions.maxDelayMs ?? 3e4,
|
|
166
|
+
backoffMultiplier: retryOptions.backoffMultiplier ?? 2,
|
|
167
|
+
jitter: retryOptions.jitter ?? true
|
|
168
|
+
};
|
|
124
169
|
}
|
|
125
170
|
/**
|
|
126
171
|
* Create a type-safe AMQP worker from a contract.
|
|
@@ -138,25 +183,21 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
138
183
|
*
|
|
139
184
|
* @example
|
|
140
185
|
* ```typescript
|
|
141
|
-
* const
|
|
186
|
+
* const worker = await TypedAmqpWorker.create({
|
|
142
187
|
* contract: myContract,
|
|
143
188
|
* handlers: {
|
|
144
189
|
* processOrder: async (msg) => console.log('Order:', msg.orderId)
|
|
145
190
|
* },
|
|
146
191
|
* urls: ['amqp://localhost']
|
|
147
192
|
* }).resultToPromise();
|
|
148
|
-
*
|
|
149
|
-
* if (workerResult.isError()) {
|
|
150
|
-
* console.error('Failed to create worker:', workerResult.error);
|
|
151
|
-
* }
|
|
152
193
|
* ```
|
|
153
194
|
*/
|
|
154
|
-
static create({ contract, handlers, urls, connectionOptions, logger }) {
|
|
195
|
+
static create({ contract, handlers, urls, connectionOptions, logger, retry, telemetry }) {
|
|
155
196
|
const worker = new TypedAmqpWorker(contract, new AmqpClient(contract, {
|
|
156
197
|
urls,
|
|
157
198
|
connectionOptions
|
|
158
|
-
}), handlers, logger);
|
|
159
|
-
return worker.waitForConnectionReady().flatMapOk(() => worker.consumeAll()).mapOk(() => worker);
|
|
199
|
+
}), handlers, logger, retry, telemetry);
|
|
200
|
+
return worker.waitForConnectionReady().flatMapOk(() => worker.setupWaitQueues()).flatMapOk(() => worker.consumeAll()).mapOk(() => worker);
|
|
160
201
|
}
|
|
161
202
|
/**
|
|
162
203
|
* Close the AMQP channel and connection.
|
|
@@ -188,6 +229,42 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
188
229
|
}).flatMapOk(() => Future.fromPromise(this.amqpClient.close())).mapError((error) => new TechnicalError("Failed to close AMQP connection", error)).mapOk(() => void 0);
|
|
189
230
|
}
|
|
190
231
|
/**
|
|
232
|
+
* Set up wait queues for retry mechanism.
|
|
233
|
+
* Creates and binds wait queues for each consumer queue that has DLX configuration.
|
|
234
|
+
*/
|
|
235
|
+
setupWaitQueues() {
|
|
236
|
+
if (this.retryConfig === null) return Future.value(Result.Ok(void 0));
|
|
237
|
+
if (!this.contract.consumers || !this.contract.queues) return Future.value(Result.Ok(void 0));
|
|
238
|
+
const setupTasks = [];
|
|
239
|
+
for (const consumerName of Object.keys(this.contract.consumers)) {
|
|
240
|
+
const consumer = this.contract.consumers[consumerName];
|
|
241
|
+
if (!consumer) continue;
|
|
242
|
+
const queue = consumer.queue;
|
|
243
|
+
const deadLetter = queue.deadLetter;
|
|
244
|
+
if (!deadLetter) continue;
|
|
245
|
+
const queueName = queue.name;
|
|
246
|
+
const waitQueueName = `${queueName}-wait`;
|
|
247
|
+
const dlxName = deadLetter.exchange.name;
|
|
248
|
+
const setupTask = Future.fromPromise(this.amqpClient.channel.addSetup(async (channel) => {
|
|
249
|
+
await channel.assertQueue(waitQueueName, {
|
|
250
|
+
durable: queue.durable ?? false,
|
|
251
|
+
deadLetterExchange: dlxName,
|
|
252
|
+
deadLetterRoutingKey: queueName
|
|
253
|
+
});
|
|
254
|
+
await channel.bindQueue(waitQueueName, dlxName, `${queueName}-wait`);
|
|
255
|
+
this.logger?.info("Wait queue created and bound", {
|
|
256
|
+
consumerName: String(consumerName),
|
|
257
|
+
queueName,
|
|
258
|
+
waitQueueName,
|
|
259
|
+
dlxName
|
|
260
|
+
});
|
|
261
|
+
})).mapError((error) => new TechnicalError(`Failed to setup wait queue for "${String(consumerName)}"`, error));
|
|
262
|
+
setupTasks.push(setupTask);
|
|
263
|
+
}
|
|
264
|
+
if (setupTasks.length === 0) return Future.value(Result.Ok(void 0));
|
|
265
|
+
return Future.all(setupTasks).map(Result.all).mapOk(() => void 0);
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
191
268
|
* Start consuming messages for all consumers
|
|
192
269
|
*/
|
|
193
270
|
consumeAll() {
|
|
@@ -239,10 +316,10 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
239
316
|
}
|
|
240
317
|
/**
|
|
241
318
|
* Parse and validate a message from AMQP
|
|
242
|
-
* @returns Future<Result<validated message, void
|
|
319
|
+
* @returns `Future<Result<validated message, void>>` - Ok with validated message, or Error (already handled with nack)
|
|
243
320
|
*/
|
|
244
321
|
parseAndValidateMessage(msg, consumer, consumerName) {
|
|
245
|
-
const decompressMessage = Future.fromPromise(decompressBuffer(msg.content, msg.properties.contentEncoding)).
|
|
322
|
+
const decompressMessage = Future.fromPromise(decompressBuffer(msg.content, msg.properties.contentEncoding)).tapError((error) => {
|
|
246
323
|
this.logger?.error("Error decompressing message", {
|
|
247
324
|
consumerName: String(consumerName),
|
|
248
325
|
queueName: consumer.queue.name,
|
|
@@ -286,43 +363,65 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
286
363
|
* Consume messages one at a time
|
|
287
364
|
*/
|
|
288
365
|
consumeSingle(consumerName, consumer, handler) {
|
|
289
|
-
|
|
366
|
+
const queueName = consumer.queue.name;
|
|
367
|
+
return Future.fromPromise(this.amqpClient.channel.consume(queueName, async (msg) => {
|
|
290
368
|
if (msg === null) {
|
|
291
369
|
this.logger?.warn("Consumer cancelled by server", {
|
|
292
370
|
consumerName: String(consumerName),
|
|
293
|
-
queueName
|
|
371
|
+
queueName
|
|
294
372
|
});
|
|
295
373
|
return;
|
|
296
374
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
queueName: consumer.queue.name,
|
|
301
|
-
error
|
|
302
|
-
});
|
|
303
|
-
this.amqpClient.channel.nack(msg, false, true);
|
|
304
|
-
})).tapOk(() => {
|
|
375
|
+
const startTime = Date.now();
|
|
376
|
+
const span = startConsumeSpan(this.telemetry, queueName, String(consumerName), { "messaging.rabbitmq.message.delivery_tag": msg.fields.deliveryTag });
|
|
377
|
+
await this.parseAndValidateMessage(msg, consumer, consumerName).flatMapOk((validatedMessage) => handler(validatedMessage).flatMapOk(() => {
|
|
305
378
|
this.logger?.info("Message consumed successfully", {
|
|
306
379
|
consumerName: String(consumerName),
|
|
307
|
-
queueName
|
|
380
|
+
queueName
|
|
308
381
|
});
|
|
309
382
|
this.amqpClient.channel.ack(msg);
|
|
383
|
+
const durationMs = Date.now() - startTime;
|
|
384
|
+
endSpanSuccess(span);
|
|
385
|
+
recordConsumeMetric(this.telemetry, queueName, String(consumerName), true, durationMs);
|
|
386
|
+
return Future.value(Result.Ok(void 0));
|
|
387
|
+
}).flatMapError((handlerError) => {
|
|
388
|
+
this.logger?.error("Error processing message", {
|
|
389
|
+
consumerName: String(consumerName),
|
|
390
|
+
queueName,
|
|
391
|
+
errorType: handlerError.name,
|
|
392
|
+
error: handlerError.message
|
|
393
|
+
});
|
|
394
|
+
const durationMs = Date.now() - startTime;
|
|
395
|
+
endSpanError(span, handlerError);
|
|
396
|
+
recordConsumeMetric(this.telemetry, queueName, String(consumerName), false, durationMs);
|
|
397
|
+
return this.handleError(handlerError, msg, String(consumerName), consumer);
|
|
398
|
+
})).tapError(() => {
|
|
399
|
+
const durationMs = Date.now() - startTime;
|
|
400
|
+
endSpanError(span, /* @__PURE__ */ new Error("Message validation failed"));
|
|
401
|
+
recordConsumeMetric(this.telemetry, queueName, String(consumerName), false, durationMs);
|
|
310
402
|
}).toPromise();
|
|
311
403
|
})).tapOk((reply) => {
|
|
312
404
|
this.consumerTags.add(reply.consumerTag);
|
|
313
405
|
}).mapError((error) => new TechnicalError(`Failed to start consuming for "${String(consumerName)}"`, error)).mapOk(() => void 0);
|
|
314
406
|
}
|
|
315
407
|
/**
|
|
408
|
+
* Handle batch processing error by applying error handling to all messages.
|
|
409
|
+
*/
|
|
410
|
+
handleBatchError(error, currentBatch, consumerName, consumer) {
|
|
411
|
+
return Future.all(currentBatch.map((item) => this.handleError(error, item.amqpMessage, consumerName, consumer))).map(Result.all).mapOk(() => void 0);
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
316
414
|
* Consume messages in batches
|
|
317
415
|
*/
|
|
318
416
|
consumeBatch(consumerName, consumer, options, handler) {
|
|
319
417
|
const batchSize = options.batchSize;
|
|
320
418
|
const batchTimeout = options.batchTimeout ?? 1e3;
|
|
321
419
|
const timerKey = String(consumerName);
|
|
420
|
+
const queueName = consumer.queue.name;
|
|
322
421
|
let batch = [];
|
|
323
422
|
let isProcessing = false;
|
|
324
|
-
const processBatch =
|
|
325
|
-
if (isProcessing || batch.length === 0) return;
|
|
423
|
+
const processBatch = () => {
|
|
424
|
+
if (isProcessing || batch.length === 0) return Future.value(Result.Ok(void 0));
|
|
326
425
|
isProcessing = true;
|
|
327
426
|
const currentBatch = batch;
|
|
328
427
|
batch = [];
|
|
@@ -332,52 +431,57 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
332
431
|
this.batchTimers.delete(timerKey);
|
|
333
432
|
}
|
|
334
433
|
const messages = currentBatch.map((item) => item.message);
|
|
434
|
+
const batchCount = currentBatch.length;
|
|
435
|
+
const startTime = Date.now();
|
|
436
|
+
const span = startConsumeSpan(this.telemetry, queueName, String(consumerName), { "amqp.batch.size": batchCount });
|
|
335
437
|
this.logger?.info("Processing batch", {
|
|
336
438
|
consumerName: String(consumerName),
|
|
337
|
-
queueName
|
|
338
|
-
batchSize:
|
|
439
|
+
queueName,
|
|
440
|
+
batchSize: batchCount
|
|
339
441
|
});
|
|
340
|
-
|
|
341
|
-
await handler(messages);
|
|
442
|
+
return handler(messages).flatMapOk(() => {
|
|
342
443
|
for (const item of currentBatch) this.amqpClient.channel.ack(item.amqpMessage);
|
|
343
444
|
this.logger?.info("Batch processed successfully", {
|
|
344
445
|
consumerName: String(consumerName),
|
|
345
|
-
queueName
|
|
346
|
-
batchSize:
|
|
446
|
+
queueName,
|
|
447
|
+
batchSize: batchCount
|
|
347
448
|
});
|
|
348
|
-
|
|
449
|
+
const durationMs = Date.now() - startTime;
|
|
450
|
+
endSpanSuccess(span);
|
|
451
|
+
recordConsumeMetric(this.telemetry, queueName, String(consumerName), true, durationMs);
|
|
452
|
+
return Future.value(Result.Ok(void 0));
|
|
453
|
+
}).flatMapError((handlerError) => {
|
|
349
454
|
this.logger?.error("Error processing batch", {
|
|
350
455
|
consumerName: String(consumerName),
|
|
351
|
-
queueName
|
|
352
|
-
batchSize:
|
|
353
|
-
|
|
456
|
+
queueName,
|
|
457
|
+
batchSize: batchCount,
|
|
458
|
+
errorType: handlerError.name,
|
|
459
|
+
error: handlerError.message
|
|
354
460
|
});
|
|
355
|
-
|
|
356
|
-
|
|
461
|
+
const durationMs = Date.now() - startTime;
|
|
462
|
+
endSpanError(span, handlerError);
|
|
463
|
+
recordConsumeMetric(this.telemetry, queueName, String(consumerName), false, durationMs);
|
|
464
|
+
return this.handleBatchError(handlerError, currentBatch, String(consumerName), consumer);
|
|
465
|
+
}).tap(() => {
|
|
357
466
|
isProcessing = false;
|
|
358
|
-
}
|
|
467
|
+
});
|
|
359
468
|
};
|
|
360
469
|
const scheduleBatchProcessing = () => {
|
|
361
470
|
if (isProcessing) return;
|
|
362
471
|
const existingTimer = this.batchTimers.get(timerKey);
|
|
363
472
|
if (existingTimer) clearTimeout(existingTimer);
|
|
364
473
|
const timer = setTimeout(() => {
|
|
365
|
-
processBatch().
|
|
366
|
-
this.logger?.error("Unexpected error in batch processing", {
|
|
367
|
-
consumerName: String(consumerName),
|
|
368
|
-
error
|
|
369
|
-
});
|
|
370
|
-
});
|
|
474
|
+
processBatch().toPromise();
|
|
371
475
|
}, batchTimeout);
|
|
372
476
|
this.batchTimers.set(timerKey, timer);
|
|
373
477
|
};
|
|
374
|
-
return Future.fromPromise(this.amqpClient.channel.consume(
|
|
478
|
+
return Future.fromPromise(this.amqpClient.channel.consume(queueName, async (msg) => {
|
|
375
479
|
if (msg === null) {
|
|
376
480
|
this.logger?.warn("Consumer cancelled by server", {
|
|
377
481
|
consumerName: String(consumerName),
|
|
378
|
-
queueName
|
|
482
|
+
queueName
|
|
379
483
|
});
|
|
380
|
-
await processBatch();
|
|
484
|
+
await processBatch().toPromise();
|
|
381
485
|
return;
|
|
382
486
|
}
|
|
383
487
|
const validationResult = await this.parseAndValidateMessage(msg, consumer, consumerName).toPromise();
|
|
@@ -387,91 +491,285 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
387
491
|
amqpMessage: msg
|
|
388
492
|
});
|
|
389
493
|
if (batch.length >= batchSize) {
|
|
390
|
-
await processBatch();
|
|
494
|
+
await processBatch().toPromise();
|
|
391
495
|
if (batch.length > 0 && !this.batchTimers.has(timerKey)) scheduleBatchProcessing();
|
|
392
496
|
} else if (!this.batchTimers.has(timerKey)) scheduleBatchProcessing();
|
|
393
497
|
})).tapOk((reply) => {
|
|
394
498
|
this.consumerTags.add(reply.consumerTag);
|
|
395
499
|
}).mapError((error) => new TechnicalError(`Failed to start consuming for "${String(consumerName)}"`, error)).mapOk(() => void 0);
|
|
396
500
|
}
|
|
501
|
+
/**
|
|
502
|
+
* Handle error in message processing with retry logic.
|
|
503
|
+
*
|
|
504
|
+
* Flow:
|
|
505
|
+
* 1. If NonRetryableError -> send directly to DLQ (no retry)
|
|
506
|
+
* 2. If no retry config -> legacy behavior (immediate requeue)
|
|
507
|
+
* 3. If max retries exceeded -> send to DLQ
|
|
508
|
+
* 4. Otherwise -> publish to wait queue with TTL for retry
|
|
509
|
+
*/
|
|
510
|
+
handleError(error, msg, consumerName, consumer) {
|
|
511
|
+
if (error instanceof NonRetryableError) {
|
|
512
|
+
this.logger?.error("Non-retryable error, sending to DLQ immediately", {
|
|
513
|
+
consumerName,
|
|
514
|
+
errorType: error.name,
|
|
515
|
+
error: error.message
|
|
516
|
+
});
|
|
517
|
+
this.sendToDLQ(msg, consumer);
|
|
518
|
+
return Future.value(Result.Ok(void 0));
|
|
519
|
+
}
|
|
520
|
+
if (this.retryConfig === null) {
|
|
521
|
+
this.logger?.warn("Error in handler (legacy mode: immediate requeue)", {
|
|
522
|
+
consumerName,
|
|
523
|
+
error: error.message
|
|
524
|
+
});
|
|
525
|
+
this.amqpClient.channel.nack(msg, false, true);
|
|
526
|
+
return Future.value(Result.Ok(void 0));
|
|
527
|
+
}
|
|
528
|
+
const retryCount = msg.properties.headers?.["x-retry-count"] ?? 0;
|
|
529
|
+
const config = this.retryConfig;
|
|
530
|
+
if (retryCount >= config.maxRetries) {
|
|
531
|
+
this.logger?.error("Max retries exceeded, sending to DLQ", {
|
|
532
|
+
consumerName,
|
|
533
|
+
retryCount,
|
|
534
|
+
maxRetries: config.maxRetries,
|
|
535
|
+
error: error.message
|
|
536
|
+
});
|
|
537
|
+
this.sendToDLQ(msg, consumer);
|
|
538
|
+
return Future.value(Result.Ok(void 0));
|
|
539
|
+
}
|
|
540
|
+
const delayMs = this.calculateRetryDelay(retryCount);
|
|
541
|
+
this.logger?.warn("Retrying message", {
|
|
542
|
+
consumerName,
|
|
543
|
+
retryCount: retryCount + 1,
|
|
544
|
+
delayMs,
|
|
545
|
+
error: error.message
|
|
546
|
+
});
|
|
547
|
+
return this.publishForRetry(msg, consumer, retryCount + 1, delayMs, error);
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Calculate retry delay with exponential backoff and optional jitter.
|
|
551
|
+
*/
|
|
552
|
+
calculateRetryDelay(retryCount) {
|
|
553
|
+
const { initialDelayMs, maxDelayMs, backoffMultiplier, jitter } = this.retryConfig;
|
|
554
|
+
let delay = Math.min(initialDelayMs * Math.pow(backoffMultiplier, retryCount), maxDelayMs);
|
|
555
|
+
if (jitter) delay = delay * (.5 + Math.random() * .5);
|
|
556
|
+
return Math.floor(delay);
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Parse message content for republishing.
|
|
560
|
+
* Prevents double JSON serialization by converting Buffer to object when possible.
|
|
561
|
+
*/
|
|
562
|
+
parseMessageContentForRetry(msg, queueName) {
|
|
563
|
+
let content = msg.content;
|
|
564
|
+
if (!msg.properties.contentEncoding) try {
|
|
565
|
+
content = JSON.parse(msg.content.toString());
|
|
566
|
+
} catch (err) {
|
|
567
|
+
this.logger?.warn("Failed to parse message for retry, using original buffer", {
|
|
568
|
+
queueName,
|
|
569
|
+
error: err
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
return content;
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Publish message to wait queue for retry after TTL expires.
|
|
576
|
+
*
|
|
577
|
+
* ┌─────────────────────────────────────────────────────────────────┐
|
|
578
|
+
* │ Retry Flow (Native RabbitMQ TTL + DLX Pattern) │
|
|
579
|
+
* ├─────────────────────────────────────────────────────────────────┤
|
|
580
|
+
* │ │
|
|
581
|
+
* │ 1. Handler throws any Error │
|
|
582
|
+
* │ ↓ │
|
|
583
|
+
* │ 2. Worker publishes to DLX with routing key: {queue}-wait │
|
|
584
|
+
* │ ↓ │
|
|
585
|
+
* │ 3. DLX routes to wait queue: {queue}-wait │
|
|
586
|
+
* │ (with expiration: calculated backoff delay) │
|
|
587
|
+
* │ ↓ │
|
|
588
|
+
* │ 4. Message waits in queue until TTL expires │
|
|
589
|
+
* │ ↓ │
|
|
590
|
+
* │ 5. Expired message dead-lettered to DLX │
|
|
591
|
+
* │ (with routing key: {queue}) │
|
|
592
|
+
* │ ↓ │
|
|
593
|
+
* │ 6. DLX routes back to main queue → RETRY │
|
|
594
|
+
* │ ↓ │
|
|
595
|
+
* │ 7. If retries exhausted: nack without requeue → DLQ │
|
|
596
|
+
* │ │
|
|
597
|
+
* └─────────────────────────────────────────────────────────────────┘
|
|
598
|
+
*/
|
|
599
|
+
publishForRetry(msg, consumer, newRetryCount, delayMs, error) {
|
|
600
|
+
const queueName = consumer.queue.name;
|
|
601
|
+
const deadLetter = consumer.queue.deadLetter;
|
|
602
|
+
if (!deadLetter) {
|
|
603
|
+
this.logger?.warn("Cannot retry: queue does not have DLX configured, falling back to nack with requeue", { queueName });
|
|
604
|
+
this.amqpClient.channel.nack(msg, false, true);
|
|
605
|
+
return Future.value(Result.Ok(void 0));
|
|
606
|
+
}
|
|
607
|
+
const dlxName = deadLetter.exchange.name;
|
|
608
|
+
const waitRoutingKey = `${queueName}-wait`;
|
|
609
|
+
this.amqpClient.channel.ack(msg);
|
|
610
|
+
const content = this.parseMessageContentForRetry(msg, queueName);
|
|
611
|
+
return Future.fromPromise(this.amqpClient.channel.publish(dlxName, waitRoutingKey, content, {
|
|
612
|
+
...msg.properties,
|
|
613
|
+
expiration: delayMs.toString(),
|
|
614
|
+
headers: {
|
|
615
|
+
...msg.properties.headers,
|
|
616
|
+
"x-retry-count": newRetryCount,
|
|
617
|
+
"x-last-error": error.message,
|
|
618
|
+
"x-first-failure-timestamp": msg.properties.headers?.["x-first-failure-timestamp"] ?? Date.now()
|
|
619
|
+
}
|
|
620
|
+
})).mapError((error$1) => new TechnicalError("Failed to publish message for retry", error$1)).mapOkToResult((published) => {
|
|
621
|
+
if (!published) {
|
|
622
|
+
this.logger?.error("Failed to publish message for retry (write buffer full)", {
|
|
623
|
+
queueName,
|
|
624
|
+
waitRoutingKey,
|
|
625
|
+
retryCount: newRetryCount
|
|
626
|
+
});
|
|
627
|
+
return Result.Error(new TechnicalError("Failed to publish message for retry (write buffer full)"));
|
|
628
|
+
}
|
|
629
|
+
this.logger?.info("Message published for retry", {
|
|
630
|
+
queueName,
|
|
631
|
+
waitRoutingKey,
|
|
632
|
+
retryCount: newRetryCount,
|
|
633
|
+
delayMs
|
|
634
|
+
});
|
|
635
|
+
return Result.Ok(void 0);
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Send message to dead letter queue.
|
|
640
|
+
* Nacks the message without requeue, relying on DLX configuration.
|
|
641
|
+
*/
|
|
642
|
+
sendToDLQ(msg, consumer) {
|
|
643
|
+
const queueName = consumer.queue.name;
|
|
644
|
+
if (!(consumer.queue.deadLetter !== void 0)) this.logger?.warn("Queue does not have DLX configured - message will be lost on nack", { queueName });
|
|
645
|
+
this.logger?.info("Sending message to DLQ", {
|
|
646
|
+
queueName,
|
|
647
|
+
deliveryTag: msg.fields.deliveryTag
|
|
648
|
+
});
|
|
649
|
+
this.amqpClient.channel.nack(msg, false, false);
|
|
650
|
+
}
|
|
397
651
|
};
|
|
398
652
|
|
|
399
653
|
//#endregion
|
|
400
654
|
//#region src/handlers.ts
|
|
401
|
-
|
|
655
|
+
/**
|
|
656
|
+
* Validate that a consumer exists in the contract
|
|
657
|
+
*/
|
|
658
|
+
function validateConsumerExists(contract, consumerName) {
|
|
402
659
|
const consumers = contract.consumers;
|
|
403
660
|
if (!consumers || !(consumerName in consumers)) {
|
|
404
661
|
const availableConsumers = consumers ? Object.keys(consumers) : [];
|
|
405
662
|
const available = availableConsumers.length > 0 ? availableConsumers.join(", ") : "none";
|
|
406
|
-
throw new Error(`Consumer "${
|
|
663
|
+
throw new Error(`Consumer "${consumerName}" not found in contract. Available consumers: ${available}`);
|
|
407
664
|
}
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Validate that all handlers reference valid consumers
|
|
668
|
+
*/
|
|
669
|
+
function validateHandlers(contract, handlers) {
|
|
670
|
+
const consumers = contract.consumers;
|
|
671
|
+
const availableConsumers = Object.keys(consumers ?? {});
|
|
672
|
+
const availableConsumerNames = availableConsumers.length > 0 ? availableConsumers.join(", ") : "none";
|
|
673
|
+
for (const handlerName of Object.keys(handlers)) if (!consumers || !(handlerName in consumers)) throw new Error(`Consumer "${handlerName}" not found in contract. Available consumers: ${availableConsumerNames}`);
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Wrap a Promise-based handler into a Future-based safe handler.
|
|
677
|
+
* This is used internally by defineUnsafeHandler to convert Promise handlers to Future handlers.
|
|
678
|
+
*/
|
|
679
|
+
function wrapUnsafeHandler(handler) {
|
|
680
|
+
return (input) => {
|
|
681
|
+
return Future.fromPromise(handler(input)).mapOkToResult(() => Result.Ok(void 0)).flatMapError((error) => {
|
|
682
|
+
if (error instanceof NonRetryableError || error instanceof RetryableError) return Future.value(Result.Error(error));
|
|
683
|
+
const retryableError = new RetryableError(error instanceof Error ? error.message : String(error), error);
|
|
684
|
+
return Future.value(Result.Error(retryableError));
|
|
685
|
+
});
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
function defineHandler(contract, consumerName, handler, options) {
|
|
689
|
+
validateConsumerExists(contract, String(consumerName));
|
|
408
690
|
if (options) return [handler, options];
|
|
409
691
|
return handler;
|
|
410
692
|
}
|
|
411
693
|
/**
|
|
412
694
|
* Define multiple type-safe handlers for consumers in a contract.
|
|
413
695
|
*
|
|
414
|
-
* This
|
|
415
|
-
*
|
|
696
|
+
* **Recommended:** This function creates handlers that return `Future<Result<void, HandlerError>>`,
|
|
697
|
+
* providing explicit error handling and better control over retry behavior.
|
|
416
698
|
*
|
|
417
699
|
* @template TContract - The contract definition type
|
|
418
700
|
* @param contract - The contract definition containing the consumers
|
|
419
|
-
* @param handlers - An object with
|
|
701
|
+
* @param handlers - An object with handler functions for each consumer
|
|
420
702
|
* @returns A type-safe handlers object that can be used with TypedAmqpWorker
|
|
421
703
|
*
|
|
422
704
|
* @example
|
|
423
705
|
* ```typescript
|
|
424
|
-
* import { defineHandlers } from '@amqp-contract/worker';
|
|
706
|
+
* import { defineHandlers, RetryableError } from '@amqp-contract/worker';
|
|
707
|
+
* import { Future } from '@swan-io/boxed';
|
|
425
708
|
* import { orderContract } from './contract';
|
|
426
709
|
*
|
|
427
|
-
* // Define all handlers at once
|
|
428
710
|
* const handlers = defineHandlers(orderContract, {
|
|
429
|
-
* processOrder:
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
* shipOrder: async (message) => {
|
|
438
|
-
* await prepareShipment(message);
|
|
439
|
-
* },
|
|
440
|
-
* });
|
|
441
|
-
*
|
|
442
|
-
* // Use the handlers in worker
|
|
443
|
-
* const worker = await TypedAmqpWorker.create({
|
|
444
|
-
* contract: orderContract,
|
|
445
|
-
* handlers,
|
|
446
|
-
* connection: 'amqp://localhost',
|
|
711
|
+
* processOrder: (message) =>
|
|
712
|
+
* Future.fromPromise(processPayment(message))
|
|
713
|
+
* .mapOk(() => undefined)
|
|
714
|
+
* .mapError((error) => new RetryableError('Payment failed', error)),
|
|
715
|
+
* notifyOrder: (message) =>
|
|
716
|
+
* Future.fromPromise(sendNotification(message))
|
|
717
|
+
* .mapOk(() => undefined)
|
|
718
|
+
* .mapError((error) => new RetryableError('Notification failed', error)),
|
|
447
719
|
* });
|
|
448
720
|
* ```
|
|
721
|
+
*/
|
|
722
|
+
function defineHandlers(contract, handlers) {
|
|
723
|
+
validateHandlers(contract, handlers);
|
|
724
|
+
return handlers;
|
|
725
|
+
}
|
|
726
|
+
function defineUnsafeHandler(contract, consumerName, handler, options) {
|
|
727
|
+
validateConsumerExists(contract, String(consumerName));
|
|
728
|
+
const wrappedHandler = wrapUnsafeHandler(handler);
|
|
729
|
+
if (options) return [wrappedHandler, options];
|
|
730
|
+
return wrappedHandler;
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Define multiple unsafe handlers for consumers in a contract.
|
|
734
|
+
*
|
|
735
|
+
* @deprecated Use `defineHandlers` instead for explicit error handling with `Future<Result>`.
|
|
736
|
+
*
|
|
737
|
+
* **Warning:** Unsafe handlers use exception-based error handling.
|
|
738
|
+
* Consider migrating to safe handlers for better error control.
|
|
739
|
+
*
|
|
740
|
+
* **Note:** Internally, this function wraps all Promise-based handlers into Future-based
|
|
741
|
+
* safe handlers for consistent processing in the worker.
|
|
742
|
+
*
|
|
743
|
+
* @template TContract - The contract definition type
|
|
744
|
+
* @param contract - The contract definition containing the consumers
|
|
745
|
+
* @param handlers - An object with async handler functions for each consumer
|
|
746
|
+
* @returns A type-safe handlers object that can be used with TypedAmqpWorker
|
|
449
747
|
*
|
|
450
748
|
* @example
|
|
451
749
|
* ```typescript
|
|
452
|
-
*
|
|
453
|
-
* async function handleProcessOrder(message: WorkerInferConsumerInput<typeof orderContract, 'processOrder'>) {
|
|
454
|
-
* await processOrder(message);
|
|
455
|
-
* }
|
|
750
|
+
* import { defineUnsafeHandlers } from '@amqp-contract/worker';
|
|
456
751
|
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
752
|
+
* // ⚠️ Consider using defineHandlers for better error handling
|
|
753
|
+
* const handlers = defineUnsafeHandlers(orderContract, {
|
|
754
|
+
* processOrder: async (message) => {
|
|
755
|
+
* await processPayment(message);
|
|
756
|
+
* },
|
|
757
|
+
* notifyOrder: async (message) => {
|
|
758
|
+
* await sendNotification(message);
|
|
759
|
+
* },
|
|
464
760
|
* });
|
|
465
761
|
* ```
|
|
466
762
|
*/
|
|
467
|
-
function
|
|
468
|
-
|
|
469
|
-
const
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
-
|
|
763
|
+
function defineUnsafeHandlers(contract, handlers) {
|
|
764
|
+
validateHandlers(contract, handlers);
|
|
765
|
+
const result = {};
|
|
766
|
+
for (const [name, entry] of Object.entries(handlers)) if (Array.isArray(entry)) {
|
|
767
|
+
const [handler, options] = entry;
|
|
768
|
+
result[name] = [wrapUnsafeHandler(handler), options];
|
|
769
|
+
} else result[name] = wrapUnsafeHandler(entry);
|
|
770
|
+
return result;
|
|
473
771
|
}
|
|
474
772
|
|
|
475
773
|
//#endregion
|
|
476
|
-
export { MessageValidationError, TechnicalError, TypedAmqpWorker, defineHandler, defineHandlers };
|
|
774
|
+
export { MessageValidationError, NonRetryableError, RetryableError, TechnicalError, TypedAmqpWorker, defineHandler, defineHandlers, defineUnsafeHandler, defineUnsafeHandlers };
|
|
477
775
|
//# sourceMappingURL=index.mjs.map
|