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