@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/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,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
- constructor(contract, amqpClient, handlers, logger) {
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 ?? _amqp_contract_core.defaultTelemetryProvider;
115
150
  this.actualHandlers = {};
116
151
  this.consumerOptions = {};
117
- for (const consumerName of Object.keys(handlers)) {
118
- const handlerEntry = handlers[consumerName];
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[consumerName] = handlerEntry[0];
121
- this.consumerOptions[consumerName] = handlerEntry[1];
122
- } else this.actualHandlers[consumerName] = handlerEntry;
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 workerResult = await TypedAmqpWorker.create({
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 _amqp_contract_core.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(() => _swan_io_boxed.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 _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
237
+ if (!this.contract.consumers || !this.contract.queues) return _swan_io_boxed.Future.value(_swan_io_boxed.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 = _swan_io_boxed.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 _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
265
+ return _swan_io_boxed.Future.all(setupTasks).map(_swan_io_boxed.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>> - Ok with validated message, or Error (already handled with nack)
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 = _swan_io_boxed.Future.fromPromise(decompressBuffer(msg.content, msg.properties.contentEncoding)).mapError((error) => {
322
+ const decompressMessage = _swan_io_boxed.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
- return _swan_io_boxed.Future.fromPromise(this.amqpClient.channel.consume(consumer.queue.name, async (msg) => {
366
+ const queueName = consumer.queue.name;
367
+ return _swan_io_boxed.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: consumer.queue.name
371
+ queueName
294
372
  });
295
373
  return;
296
374
  }
297
- await this.parseAndValidateMessage(msg, consumer, consumerName).flatMapOk((validatedMessage) => _swan_io_boxed.Future.fromPromise(handler(validatedMessage)).tapError((error) => {
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(() => {
375
+ const startTime = Date.now();
376
+ const span = (0, _amqp_contract_core.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: consumer.queue.name
380
+ queueName
308
381
  });
309
382
  this.amqpClient.channel.ack(msg);
383
+ const durationMs = Date.now() - startTime;
384
+ (0, _amqp_contract_core.endSpanSuccess)(span);
385
+ (0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(consumerName), true, durationMs);
386
+ return _swan_io_boxed.Future.value(_swan_io_boxed.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
+ (0, _amqp_contract_core.endSpanError)(span, handlerError);
396
+ (0, _amqp_contract_core.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
+ (0, _amqp_contract_core.endSpanError)(span, /* @__PURE__ */ new Error("Message validation failed"));
401
+ (0, _amqp_contract_core.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 _swan_io_boxed.Future.all(currentBatch.map((item) => this.handleError(error, item.amqpMessage, consumerName, consumer))).map(_swan_io_boxed.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 = async () => {
325
- if (isProcessing || batch.length === 0) return;
423
+ const processBatch = () => {
424
+ if (isProcessing || batch.length === 0) return _swan_io_boxed.Future.value(_swan_io_boxed.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 = (0, _amqp_contract_core.startConsumeSpan)(this.telemetry, queueName, String(consumerName), { "amqp.batch.size": batchCount });
335
437
  this.logger?.info("Processing batch", {
336
438
  consumerName: String(consumerName),
337
- queueName: consumer.queue.name,
338
- batchSize: currentBatch.length
439
+ queueName,
440
+ batchSize: batchCount
339
441
  });
340
- try {
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: consumer.queue.name,
346
- batchSize: currentBatch.length
446
+ queueName,
447
+ batchSize: batchCount
347
448
  });
348
- } catch (error) {
449
+ const durationMs = Date.now() - startTime;
450
+ (0, _amqp_contract_core.endSpanSuccess)(span);
451
+ (0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(consumerName), true, durationMs);
452
+ return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
453
+ }).flatMapError((handlerError) => {
349
454
  this.logger?.error("Error processing batch", {
350
455
  consumerName: String(consumerName),
351
- queueName: consumer.queue.name,
352
- batchSize: currentBatch.length,
353
- error
456
+ queueName,
457
+ batchSize: batchCount,
458
+ errorType: handlerError.name,
459
+ error: handlerError.message
354
460
  });
355
- for (const item of currentBatch) this.amqpClient.channel.nack(item.amqpMessage, false, true);
356
- } finally {
461
+ const durationMs = Date.now() - startTime;
462
+ (0, _amqp_contract_core.endSpanError)(span, handlerError);
463
+ (0, _amqp_contract_core.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().catch((error) => {
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 _swan_io_boxed.Future.fromPromise(this.amqpClient.channel.consume(consumer.queue.name, async (msg) => {
478
+ return _swan_io_boxed.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: consumer.queue.name
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,94 +491,292 @@ 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 _swan_io_boxed.Future.value(_swan_io_boxed.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 _swan_io_boxed.Future.value(_swan_io_boxed.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 _swan_io_boxed.Future.value(_swan_io_boxed.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 _swan_io_boxed.Future.value(_swan_io_boxed.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 _swan_io_boxed.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 _swan_io_boxed.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 _swan_io_boxed.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
- function defineHandler(contract, consumerName, handler, options) {
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 "${String(consumerName)}" not found in contract. Available consumers: ${available}`);
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 _swan_io_boxed.Future.fromPromise(handler(input)).mapOkToResult(() => _swan_io_boxed.Result.Ok(void 0)).flatMapError((error) => {
682
+ if (error instanceof NonRetryableError || error instanceof RetryableError) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(error));
683
+ const retryableError = new RetryableError(error instanceof Error ? error.message : String(error), error);
684
+ return _swan_io_boxed.Future.value(_swan_io_boxed.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 utility allows you to define all handlers at once outside of the worker creation,
415
- * ensuring type safety and providing better code organization.
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 async handler functions for each consumer
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: async (message) => {
430
- * // message is fully typed based on the contract
431
- * console.log('Processing order:', message.orderId);
432
- * await processPayment(message);
433
- * },
434
- * notifyOrder: async (message) => {
435
- * await sendNotification(message);
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
- * // Separate handler definitions for better organization
453
- * async function handleProcessOrder(message: WorkerInferConsumerInput<typeof orderContract, 'processOrder'>) {
454
- * await processOrder(message);
455
- * }
750
+ * import { defineUnsafeHandlers } from '@amqp-contract/worker';
456
751
  *
457
- * async function handleNotifyOrder(message: WorkerInferConsumerInput<typeof orderContract, 'notifyOrder'>) {
458
- * await sendNotification(message);
459
- * }
460
- *
461
- * const handlers = defineHandlers(orderContract, {
462
- * processOrder: handleProcessOrder,
463
- * notifyOrder: handleNotifyOrder,
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 defineHandlers(contract, handlers) {
468
- const consumers = contract.consumers;
469
- const availableConsumers = Object.keys(consumers ?? {});
470
- const availableConsumerNames = availableConsumers.length > 0 ? availableConsumers.join(", ") : "none";
471
- for (const handlerName of Object.keys(handlers)) if (!consumers || !(handlerName in consumers)) throw new Error(`Consumer "${handlerName}" not found in contract. Available consumers: ${availableConsumerNames}`);
472
- return handlers;
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
774
  exports.MessageValidationError = MessageValidationError;
775
+ exports.NonRetryableError = NonRetryableError;
776
+ exports.RetryableError = RetryableError;
477
777
  exports.TechnicalError = TechnicalError;
478
778
  exports.TypedAmqpWorker = TypedAmqpWorker;
479
779
  exports.defineHandler = defineHandler;
480
- exports.defineHandlers = defineHandlers;
780
+ exports.defineHandlers = defineHandlers;
781
+ exports.defineUnsafeHandler = defineUnsafeHandler;
782
+ exports.defineUnsafeHandlers = defineUnsafeHandlers;