@amqp-contract/worker 0.6.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 +39 -10
- package/dist/index.cjs +432 -102
- package/dist/index.d.cts +354 -98
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +354 -98
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +428 -102
- package/dist/index.mjs.map +1 -1
- package/docs/index.md +994 -213
- package/package.json +19 -12
package/dist/index.cjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
let _amqp_contract_core = require("@amqp-contract/core");
|
|
2
2
|
let _swan_io_boxed = require("@swan-io/boxed");
|
|
3
|
+
let node_zlib = require("node:zlib");
|
|
4
|
+
let node_util = require("node:util");
|
|
3
5
|
|
|
4
6
|
//#region src/errors.ts
|
|
5
7
|
/**
|
|
@@ -35,6 +37,57 @@ var MessageValidationError = class extends WorkerError {
|
|
|
35
37
|
this.name = "MessageValidationError";
|
|
36
38
|
}
|
|
37
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
|
+
};
|
|
68
|
+
|
|
69
|
+
//#endregion
|
|
70
|
+
//#region src/decompression.ts
|
|
71
|
+
const gunzipAsync = (0, node_util.promisify)(node_zlib.gunzip);
|
|
72
|
+
const inflateAsync = (0, node_util.promisify)(node_zlib.inflate);
|
|
73
|
+
/**
|
|
74
|
+
* Decompress a buffer based on the content-encoding header.
|
|
75
|
+
*
|
|
76
|
+
* @param buffer - The buffer to decompress
|
|
77
|
+
* @param contentEncoding - The content-encoding header value (e.g., 'gzip', 'deflate')
|
|
78
|
+
* @returns A promise that resolves to the decompressed buffer
|
|
79
|
+
* @throws Error if decompression fails or if the encoding is unsupported
|
|
80
|
+
*
|
|
81
|
+
* @internal
|
|
82
|
+
*/
|
|
83
|
+
async function decompressBuffer(buffer, contentEncoding) {
|
|
84
|
+
if (!contentEncoding) return buffer;
|
|
85
|
+
switch (contentEncoding.toLowerCase()) {
|
|
86
|
+
case "gzip": return gunzipAsync(buffer);
|
|
87
|
+
case "deflate": return inflateAsync(buffer);
|
|
88
|
+
default: throw new Error(`Unsupported content-encoding: ${contentEncoding}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
38
91
|
|
|
39
92
|
//#endregion
|
|
40
93
|
//#region src/worker.ts
|
|
@@ -79,22 +132,38 @@ var MessageValidationError = class extends WorkerError {
|
|
|
79
132
|
* ```
|
|
80
133
|
*/
|
|
81
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
|
+
*/
|
|
82
139
|
actualHandlers;
|
|
83
140
|
consumerOptions;
|
|
84
141
|
batchTimers = /* @__PURE__ */ new Map();
|
|
85
|
-
|
|
142
|
+
consumerTags = /* @__PURE__ */ new Set();
|
|
143
|
+
retryConfig;
|
|
144
|
+
constructor(contract, amqpClient, handlers, logger, retryOptions) {
|
|
86
145
|
this.contract = contract;
|
|
87
146
|
this.amqpClient = amqpClient;
|
|
88
147
|
this.logger = logger;
|
|
89
148
|
this.actualHandlers = {};
|
|
90
149
|
this.consumerOptions = {};
|
|
91
|
-
|
|
92
|
-
|
|
150
|
+
const handlersRecord = handlers;
|
|
151
|
+
for (const consumerName of Object.keys(handlersRecord)) {
|
|
152
|
+
const handlerEntry = handlersRecord[consumerName];
|
|
153
|
+
const typedConsumerName = consumerName;
|
|
93
154
|
if (Array.isArray(handlerEntry)) {
|
|
94
|
-
this.actualHandlers[
|
|
95
|
-
this.consumerOptions[
|
|
96
|
-
} else this.actualHandlers[
|
|
155
|
+
this.actualHandlers[typedConsumerName] = handlerEntry[0];
|
|
156
|
+
this.consumerOptions[typedConsumerName] = handlerEntry[1];
|
|
157
|
+
} else this.actualHandlers[typedConsumerName] = handlerEntry;
|
|
97
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
|
+
};
|
|
98
167
|
}
|
|
99
168
|
/**
|
|
100
169
|
* Create a type-safe AMQP worker from a contract.
|
|
@@ -112,25 +181,21 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
112
181
|
*
|
|
113
182
|
* @example
|
|
114
183
|
* ```typescript
|
|
115
|
-
* const
|
|
184
|
+
* const worker = await TypedAmqpWorker.create({
|
|
116
185
|
* contract: myContract,
|
|
117
186
|
* handlers: {
|
|
118
187
|
* processOrder: async (msg) => console.log('Order:', msg.orderId)
|
|
119
188
|
* },
|
|
120
189
|
* urls: ['amqp://localhost']
|
|
121
190
|
* }).resultToPromise();
|
|
122
|
-
*
|
|
123
|
-
* if (workerResult.isError()) {
|
|
124
|
-
* console.error('Failed to create worker:', workerResult.error);
|
|
125
|
-
* }
|
|
126
191
|
* ```
|
|
127
192
|
*/
|
|
128
|
-
static create({ contract, handlers, urls, connectionOptions, logger }) {
|
|
193
|
+
static create({ contract, handlers, urls, connectionOptions, logger, retry }) {
|
|
129
194
|
const worker = new TypedAmqpWorker(contract, new _amqp_contract_core.AmqpClient(contract, {
|
|
130
195
|
urls,
|
|
131
196
|
connectionOptions
|
|
132
|
-
}), handlers, logger);
|
|
133
|
-
return worker.waitForConnectionReady().flatMapOk(() => worker.consumeAll()).mapOk(() => worker);
|
|
197
|
+
}), handlers, logger, retry);
|
|
198
|
+
return worker.waitForConnectionReady().flatMapOk(() => worker.setupWaitQueues()).flatMapOk(() => worker.consumeAll()).mapOk(() => worker);
|
|
134
199
|
}
|
|
135
200
|
/**
|
|
136
201
|
* Close the AMQP channel and connection.
|
|
@@ -151,7 +216,51 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
151
216
|
close() {
|
|
152
217
|
for (const timer of this.batchTimers.values()) clearTimeout(timer);
|
|
153
218
|
this.batchTimers.clear();
|
|
154
|
-
return _swan_io_boxed.Future.
|
|
219
|
+
return _swan_io_boxed.Future.all(Array.from(this.consumerTags).map((consumerTag) => _swan_io_boxed.Future.fromPromise(this.amqpClient.channel.cancel(consumerTag)).mapErrorToResult((error) => {
|
|
220
|
+
this.logger?.warn("Failed to cancel consumer during close", {
|
|
221
|
+
consumerTag,
|
|
222
|
+
error
|
|
223
|
+
});
|
|
224
|
+
return _swan_io_boxed.Result.Ok(void 0);
|
|
225
|
+
}))).map(_swan_io_boxed.Result.all).tapOk(() => {
|
|
226
|
+
this.consumerTags.clear();
|
|
227
|
+
}).flatMapOk(() => _swan_io_boxed.Future.fromPromise(this.amqpClient.close())).mapError((error) => new TechnicalError("Failed to close AMQP connection", error)).mapOk(() => void 0);
|
|
228
|
+
}
|
|
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);
|
|
155
264
|
}
|
|
156
265
|
/**
|
|
157
266
|
* Start consuming messages for all consumers
|
|
@@ -205,33 +314,48 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
205
314
|
}
|
|
206
315
|
/**
|
|
207
316
|
* Parse and validate a message from AMQP
|
|
208
|
-
* @returns Future<Result<validated message, void
|
|
317
|
+
* @returns `Future<Result<validated message, void>>` - Ok with validated message, or Error (already handled with nack)
|
|
209
318
|
*/
|
|
210
319
|
parseAndValidateMessage(msg, consumer, consumerName) {
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
this.logger?.error("Error parsing message", {
|
|
320
|
+
const decompressMessage = _swan_io_boxed.Future.fromPromise(decompressBuffer(msg.content, msg.properties.contentEncoding)).tapError((error) => {
|
|
321
|
+
this.logger?.error("Error decompressing message", {
|
|
214
322
|
consumerName: String(consumerName),
|
|
215
323
|
queueName: consumer.queue.name,
|
|
216
|
-
|
|
324
|
+
contentEncoding: msg.properties.contentEncoding,
|
|
325
|
+
error
|
|
217
326
|
});
|
|
218
327
|
this.amqpClient.channel.nack(msg, false, false);
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const error = new MessageValidationError(String(consumerName), validationResult.issues);
|
|
225
|
-
this.logger?.error("Message validation failed", {
|
|
328
|
+
});
|
|
329
|
+
const parseMessage = (buffer) => {
|
|
330
|
+
const parseResult = _swan_io_boxed.Result.fromExecution(() => JSON.parse(buffer.toString()));
|
|
331
|
+
if (parseResult.isError()) {
|
|
332
|
+
this.logger?.error("Error parsing message", {
|
|
226
333
|
consumerName: String(consumerName),
|
|
227
334
|
queueName: consumer.queue.name,
|
|
228
|
-
error
|
|
335
|
+
error: parseResult.error
|
|
229
336
|
});
|
|
230
337
|
this.amqpClient.channel.nack(msg, false, false);
|
|
231
|
-
return _swan_io_boxed.Result.Error(void 0);
|
|
338
|
+
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(void 0));
|
|
232
339
|
}
|
|
233
|
-
return _swan_io_boxed.Result.Ok(
|
|
234
|
-
}
|
|
340
|
+
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(parseResult.value));
|
|
341
|
+
};
|
|
342
|
+
const validateMessage = (parsedMessage) => {
|
|
343
|
+
const rawValidation = consumer.message.payload["~standard"].validate(parsedMessage);
|
|
344
|
+
return _swan_io_boxed.Future.fromPromise(rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation)).mapOkToResult((validationResult) => {
|
|
345
|
+
if (validationResult.issues) {
|
|
346
|
+
const error = new MessageValidationError(String(consumerName), validationResult.issues);
|
|
347
|
+
this.logger?.error("Message validation failed", {
|
|
348
|
+
consumerName: String(consumerName),
|
|
349
|
+
queueName: consumer.queue.name,
|
|
350
|
+
error
|
|
351
|
+
});
|
|
352
|
+
this.amqpClient.channel.nack(msg, false, false);
|
|
353
|
+
return _swan_io_boxed.Result.Error(void 0);
|
|
354
|
+
}
|
|
355
|
+
return _swan_io_boxed.Result.Ok(validationResult.value);
|
|
356
|
+
});
|
|
357
|
+
};
|
|
358
|
+
return decompressMessage.flatMapOk(parseMessage).flatMapOk(validateMessage);
|
|
235
359
|
}
|
|
236
360
|
/**
|
|
237
361
|
* Consume messages one at a time
|
|
@@ -245,21 +369,31 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
245
369
|
});
|
|
246
370
|
return;
|
|
247
371
|
}
|
|
248
|
-
await this.parseAndValidateMessage(msg, consumer, consumerName).flatMapOk((validatedMessage) =>
|
|
249
|
-
this.logger?.error("Error processing message", {
|
|
250
|
-
consumerName: String(consumerName),
|
|
251
|
-
queueName: consumer.queue.name,
|
|
252
|
-
error
|
|
253
|
-
});
|
|
254
|
-
this.amqpClient.channel.nack(msg, false, true);
|
|
255
|
-
})).tapOk(() => {
|
|
372
|
+
await this.parseAndValidateMessage(msg, consumer, consumerName).flatMapOk((validatedMessage) => handler(validatedMessage).flatMapOk(() => {
|
|
256
373
|
this.logger?.info("Message consumed successfully", {
|
|
257
374
|
consumerName: String(consumerName),
|
|
258
375
|
queueName: consumer.queue.name
|
|
259
376
|
});
|
|
260
377
|
this.amqpClient.channel.ack(msg);
|
|
261
|
-
|
|
262
|
-
|
|
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();
|
|
388
|
+
})).tapOk((reply) => {
|
|
389
|
+
this.consumerTags.add(reply.consumerTag);
|
|
390
|
+
}).mapError((error) => new TechnicalError(`Failed to start consuming for "${String(consumerName)}"`, error)).mapOk(() => void 0);
|
|
391
|
+
}
|
|
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);
|
|
263
397
|
}
|
|
264
398
|
/**
|
|
265
399
|
* Consume messages in batches
|
|
@@ -270,8 +404,8 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
270
404
|
const timerKey = String(consumerName);
|
|
271
405
|
let batch = [];
|
|
272
406
|
let isProcessing = false;
|
|
273
|
-
const processBatch =
|
|
274
|
-
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));
|
|
275
409
|
isProcessing = true;
|
|
276
410
|
const currentBatch = batch;
|
|
277
411
|
batch = [];
|
|
@@ -286,37 +420,33 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
286
420
|
queueName: consumer.queue.name,
|
|
287
421
|
batchSize: currentBatch.length
|
|
288
422
|
});
|
|
289
|
-
|
|
290
|
-
await handler(messages);
|
|
423
|
+
return handler(messages).flatMapOk(() => {
|
|
291
424
|
for (const item of currentBatch) this.amqpClient.channel.ack(item.amqpMessage);
|
|
292
425
|
this.logger?.info("Batch processed successfully", {
|
|
293
426
|
consumerName: String(consumerName),
|
|
294
427
|
queueName: consumer.queue.name,
|
|
295
428
|
batchSize: currentBatch.length
|
|
296
429
|
});
|
|
297
|
-
|
|
430
|
+
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
|
|
431
|
+
}).flatMapError((handlerError) => {
|
|
298
432
|
this.logger?.error("Error processing batch", {
|
|
299
433
|
consumerName: String(consumerName),
|
|
300
434
|
queueName: consumer.queue.name,
|
|
301
435
|
batchSize: currentBatch.length,
|
|
302
|
-
|
|
436
|
+
errorType: handlerError.name,
|
|
437
|
+
error: handlerError.message
|
|
303
438
|
});
|
|
304
|
-
|
|
305
|
-
}
|
|
439
|
+
return this.handleBatchError(handlerError, currentBatch, String(consumerName), consumer);
|
|
440
|
+
}).tap(() => {
|
|
306
441
|
isProcessing = false;
|
|
307
|
-
}
|
|
442
|
+
});
|
|
308
443
|
};
|
|
309
444
|
const scheduleBatchProcessing = () => {
|
|
310
445
|
if (isProcessing) return;
|
|
311
446
|
const existingTimer = this.batchTimers.get(timerKey);
|
|
312
447
|
if (existingTimer) clearTimeout(existingTimer);
|
|
313
448
|
const timer = setTimeout(() => {
|
|
314
|
-
processBatch().
|
|
315
|
-
this.logger?.error("Unexpected error in batch processing", {
|
|
316
|
-
consumerName: String(consumerName),
|
|
317
|
-
error
|
|
318
|
-
});
|
|
319
|
-
});
|
|
449
|
+
processBatch().toPromise();
|
|
320
450
|
}, batchTimeout);
|
|
321
451
|
this.batchTimers.set(timerKey, timer);
|
|
322
452
|
};
|
|
@@ -326,7 +456,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
326
456
|
consumerName: String(consumerName),
|
|
327
457
|
queueName: consumer.queue.name
|
|
328
458
|
});
|
|
329
|
-
await processBatch();
|
|
459
|
+
await processBatch().toPromise();
|
|
330
460
|
return;
|
|
331
461
|
}
|
|
332
462
|
const validationResult = await this.parseAndValidateMessage(msg, consumer, consumerName).toPromise();
|
|
@@ -336,92 +466,292 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
336
466
|
amqpMessage: msg
|
|
337
467
|
});
|
|
338
468
|
if (batch.length >= batchSize) {
|
|
339
|
-
await processBatch();
|
|
469
|
+
await processBatch().toPromise();
|
|
340
470
|
if (batch.length > 0 && !this.batchTimers.has(timerKey)) scheduleBatchProcessing();
|
|
341
471
|
} else if (!this.batchTimers.has(timerKey)) scheduleBatchProcessing();
|
|
342
|
-
})).
|
|
472
|
+
})).tapOk((reply) => {
|
|
473
|
+
this.consumerTags.add(reply.consumerTag);
|
|
474
|
+
}).mapError((error) => new TechnicalError(`Failed to start consuming for "${String(consumerName)}"`, error)).mapOk(() => void 0);
|
|
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);
|
|
343
625
|
}
|
|
344
626
|
};
|
|
345
627
|
|
|
346
628
|
//#endregion
|
|
347
629
|
//#region src/handlers.ts
|
|
348
|
-
|
|
630
|
+
/**
|
|
631
|
+
* Validate that a consumer exists in the contract
|
|
632
|
+
*/
|
|
633
|
+
function validateConsumerExists(contract, consumerName) {
|
|
349
634
|
const consumers = contract.consumers;
|
|
350
635
|
if (!consumers || !(consumerName in consumers)) {
|
|
351
636
|
const availableConsumers = consumers ? Object.keys(consumers) : [];
|
|
352
637
|
const available = availableConsumers.length > 0 ? availableConsumers.join(", ") : "none";
|
|
353
|
-
throw new Error(`Consumer "${
|
|
638
|
+
throw new Error(`Consumer "${consumerName}" not found in contract. Available consumers: ${available}`);
|
|
354
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));
|
|
355
665
|
if (options) return [handler, options];
|
|
356
666
|
return handler;
|
|
357
667
|
}
|
|
358
668
|
/**
|
|
359
669
|
* Define multiple type-safe handlers for consumers in a contract.
|
|
360
670
|
*
|
|
361
|
-
* This
|
|
362
|
-
*
|
|
671
|
+
* **Recommended:** This function creates handlers that return `Future<Result<void, HandlerError>>`,
|
|
672
|
+
* providing explicit error handling and better control over retry behavior.
|
|
363
673
|
*
|
|
364
674
|
* @template TContract - The contract definition type
|
|
365
675
|
* @param contract - The contract definition containing the consumers
|
|
366
|
-
* @param handlers - An object with
|
|
676
|
+
* @param handlers - An object with handler functions for each consumer
|
|
367
677
|
* @returns A type-safe handlers object that can be used with TypedAmqpWorker
|
|
368
678
|
*
|
|
369
679
|
* @example
|
|
370
680
|
* ```typescript
|
|
371
|
-
* import { defineHandlers } from '@amqp-contract/worker';
|
|
681
|
+
* import { defineHandlers, RetryableError } from '@amqp-contract/worker';
|
|
682
|
+
* import { Future } from '@swan-io/boxed';
|
|
372
683
|
* import { orderContract } from './contract';
|
|
373
684
|
*
|
|
374
|
-
* // Define all handlers at once
|
|
375
685
|
* const handlers = defineHandlers(orderContract, {
|
|
376
|
-
* processOrder:
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
* shipOrder: async (message) => {
|
|
385
|
-
* await prepareShipment(message);
|
|
386
|
-
* },
|
|
387
|
-
* });
|
|
388
|
-
*
|
|
389
|
-
* // Use the handlers in worker
|
|
390
|
-
* const worker = await TypedAmqpWorker.create({
|
|
391
|
-
* contract: orderContract,
|
|
392
|
-
* handlers,
|
|
393
|
-
* 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)),
|
|
394
694
|
* });
|
|
395
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
|
|
396
722
|
*
|
|
397
723
|
* @example
|
|
398
724
|
* ```typescript
|
|
399
|
-
*
|
|
400
|
-
* async function handleProcessOrder(message: WorkerInferConsumerInput<typeof orderContract, 'processOrder'>) {
|
|
401
|
-
* await processOrder(message);
|
|
402
|
-
* }
|
|
403
|
-
*
|
|
404
|
-
* async function handleNotifyOrder(message: WorkerInferConsumerInput<typeof orderContract, 'notifyOrder'>) {
|
|
405
|
-
* await sendNotification(message);
|
|
406
|
-
* }
|
|
725
|
+
* import { defineUnsafeHandlers } from '@amqp-contract/worker';
|
|
407
726
|
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
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
|
+
* },
|
|
411
735
|
* });
|
|
412
736
|
* ```
|
|
413
737
|
*/
|
|
414
|
-
function
|
|
415
|
-
|
|
416
|
-
const
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
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;
|
|
420
746
|
}
|
|
421
747
|
|
|
422
748
|
//#endregion
|
|
423
749
|
exports.MessageValidationError = MessageValidationError;
|
|
750
|
+
exports.NonRetryableError = NonRetryableError;
|
|
751
|
+
exports.RetryableError = RetryableError;
|
|
424
752
|
exports.TechnicalError = TechnicalError;
|
|
425
753
|
exports.TypedAmqpWorker = TypedAmqpWorker;
|
|
426
754
|
exports.defineHandler = defineHandler;
|
|
427
|
-
exports.defineHandlers = defineHandlers;
|
|
755
|
+
exports.defineHandlers = defineHandlers;
|
|
756
|
+
exports.defineUnsafeHandler = defineUnsafeHandler;
|
|
757
|
+
exports.defineUnsafeHandlers = defineUnsafeHandlers;
|