@amqp-contract/worker 0.9.0 → 0.11.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.mjs CHANGED
@@ -92,6 +92,23 @@ async function decompressBuffer(buffer, contentEncoding) {
92
92
  //#endregion
93
93
  //#region src/worker.ts
94
94
  /**
95
+ * Type guard to check if a handler entry is a tuple format [handler, options].
96
+ */
97
+ function isHandlerTuple(entry) {
98
+ return Array.isArray(entry) && entry.length === 2;
99
+ }
100
+ /**
101
+ * Type guard to check if a value is a Standard Schema v1 compliant schema.
102
+ */
103
+ function isStandardSchema(value) {
104
+ if (typeof value !== "object" || value === null) return false;
105
+ if (!("~standard" in value)) return false;
106
+ const standard = value["~standard"];
107
+ if (typeof standard !== "object" || standard === null) return false;
108
+ if (!("validate" in standard)) return false;
109
+ return typeof standard.validate === "function";
110
+ }
111
+ /**
95
112
  * Type-safe AMQP worker for consuming messages from RabbitMQ.
96
113
  *
97
114
  * This class provides automatic message validation, connection management,
@@ -133,16 +150,13 @@ async function decompressBuffer(buffer, contentEncoding) {
133
150
  */
134
151
  var TypedAmqpWorker = class TypedAmqpWorker {
135
152
  /**
136
- * Internal handler type - always safe handlers (`Future<Result>`).
137
- * Unsafe handlers are wrapped into safe handlers by defineUnsafeHandler/defineUnsafeHandlers.
153
+ * Internal handler storage - handlers returning `Future<Result>`.
138
154
  */
139
155
  actualHandlers;
140
156
  consumerOptions;
141
- batchTimers = /* @__PURE__ */ new Map();
142
157
  consumerTags = /* @__PURE__ */ new Set();
143
- retryConfig;
144
158
  telemetry;
145
- constructor(contract, amqpClient, handlers, logger, retryOptions, telemetry) {
159
+ constructor(contract, amqpClient, handlers, logger, telemetry) {
146
160
  this.contract = contract;
147
161
  this.amqpClient = amqpClient;
148
162
  this.logger = logger;
@@ -153,19 +167,12 @@ var TypedAmqpWorker = class TypedAmqpWorker {
153
167
  for (const consumerName of Object.keys(handlersRecord)) {
154
168
  const handlerEntry = handlersRecord[consumerName];
155
169
  const typedConsumerName = consumerName;
156
- if (Array.isArray(handlerEntry)) {
157
- this.actualHandlers[typedConsumerName] = handlerEntry[0];
158
- this.consumerOptions[typedConsumerName] = handlerEntry[1];
170
+ if (isHandlerTuple(handlerEntry)) {
171
+ const [handler, options] = handlerEntry;
172
+ this.actualHandlers[typedConsumerName] = handler;
173
+ this.consumerOptions[typedConsumerName] = options;
159
174
  } else this.actualHandlers[typedConsumerName] = handlerEntry;
160
175
  }
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
- };
169
176
  }
170
177
  /**
171
178
  * Create a type-safe AMQP worker from a contract.
@@ -186,18 +193,18 @@ var TypedAmqpWorker = class TypedAmqpWorker {
186
193
  * const worker = await TypedAmqpWorker.create({
187
194
  * contract: myContract,
188
195
  * handlers: {
189
- * processOrder: async (msg) => console.log('Order:', msg.orderId)
196
+ * processOrder: async ({ payload }) => console.log('Order:', payload.orderId)
190
197
  * },
191
198
  * urls: ['amqp://localhost']
192
199
  * }).resultToPromise();
193
200
  * ```
194
201
  */
195
- static create({ contract, handlers, urls, connectionOptions, logger, retry, telemetry }) {
202
+ static create({ contract, handlers, urls, connectionOptions, logger, telemetry }) {
196
203
  const worker = new TypedAmqpWorker(contract, new AmqpClient(contract, {
197
204
  urls,
198
205
  connectionOptions
199
- }), handlers, logger, retry, telemetry);
200
- return worker.waitForConnectionReady().flatMapOk(() => worker.setupWaitQueues()).flatMapOk(() => worker.consumeAll()).mapOk(() => worker);
206
+ }), handlers, logger, telemetry);
207
+ return worker.waitForConnectionReady().flatMapOk(() => worker.validateRetryConfiguration()).flatMapOk(() => worker.consumeAll()).mapOk(() => worker);
201
208
  }
202
209
  /**
203
210
  * Close the AMQP channel and connection.
@@ -216,8 +223,6 @@ var TypedAmqpWorker = class TypedAmqpWorker {
216
223
  * ```
217
224
  */
218
225
  close() {
219
- for (const timer of this.batchTimers.values()) clearTimeout(timer);
220
- this.batchTimers.clear();
221
226
  return Future.all(Array.from(this.consumerTags).map((consumerTag) => Future.fromPromise(this.amqpClient.channel.cancel(consumerTag)).mapErrorToResult((error) => {
222
227
  this.logger?.warn("Failed to cancel consumer during close", {
223
228
  consumerTag,
@@ -229,40 +234,82 @@ var TypedAmqpWorker = class TypedAmqpWorker {
229
234
  }).flatMapOk(() => Future.fromPromise(this.amqpClient.close())).mapError((error) => new TechnicalError("Failed to close AMQP connection", error)).mapOk(() => void 0);
230
235
  }
231
236
  /**
232
- * Set up wait queues for retry mechanism.
233
- * Creates and binds wait queues for each consumer queue that has DLX configuration.
237
+ * Validate retry configuration for all consumers.
238
+ *
239
+ * For quorum-native mode, validates that the queue is properly configured.
240
+ * For TTL-backoff mode, wait queues are created by setupAmqpTopology in the core package.
234
241
  */
235
- setupWaitQueues() {
236
- if (this.retryConfig === null) return Future.value(Result.Ok(void 0));
237
- if (!this.contract.consumers || !this.contract.queues) return Future.value(Result.Ok(void 0));
238
- const setupTasks = [];
242
+ validateRetryConfiguration() {
243
+ if (!this.contract.consumers) return Future.value(Result.Ok(void 0));
239
244
  for (const consumerName of Object.keys(this.contract.consumers)) {
240
245
  const consumer = this.contract.consumers[consumerName];
241
246
  if (!consumer) continue;
242
247
  const queue = consumer.queue;
243
- const deadLetter = queue.deadLetter;
244
- if (!deadLetter) continue;
245
- const queueName = queue.name;
246
- const waitQueueName = `${queueName}-wait`;
247
- const dlxName = deadLetter.exchange.name;
248
- const setupTask = Future.fromPromise(this.amqpClient.channel.addSetup(async (channel) => {
249
- await channel.assertQueue(waitQueueName, {
250
- durable: queue.durable ?? false,
251
- deadLetterExchange: dlxName,
252
- deadLetterRoutingKey: queueName
253
- });
254
- await channel.bindQueue(waitQueueName, dlxName, `${queueName}-wait`);
255
- this.logger?.info("Wait queue created and bound", {
248
+ if ((queue.retry?.mode ?? "ttl-backoff") === "quorum-native") {
249
+ const validationError = this.validateQuorumNativeConfigForConsumer(String(consumerName), consumer);
250
+ if (validationError) return Future.value(Result.Error(validationError));
251
+ this.logger?.info("Using quorum-native retry mode", {
256
252
  consumerName: String(consumerName),
257
- queueName,
258
- waitQueueName,
259
- dlxName
253
+ queueName: queue.name
260
254
  });
261
- })).mapError((error) => new TechnicalError(`Failed to setup wait queue for "${String(consumerName)}"`, error));
262
- setupTasks.push(setupTask);
255
+ } else if (queue.deadLetter) this.logger?.info("Using TTL-backoff retry mode", {
256
+ consumerName: String(consumerName),
257
+ queueName: queue.name
258
+ });
259
+ else this.logger?.warn("Queue has no deadLetter configured - retries will use nack with requeue", {
260
+ consumerName: String(consumerName),
261
+ queueName: queue.name
262
+ });
263
263
  }
264
- if (setupTasks.length === 0) return Future.value(Result.Ok(void 0));
265
- return Future.all(setupTasks).map(Result.all).mapOk(() => void 0);
264
+ return Future.value(Result.Ok(void 0));
265
+ }
266
+ /**
267
+ * Get the resolved retry configuration for a consumer's queue.
268
+ * Reads retry config from the queue definition in the contract.
269
+ */
270
+ getRetryConfigForConsumer(consumer) {
271
+ const retryOptions = consumer.queue.retry;
272
+ if (retryOptions?.mode === "quorum-native") return {
273
+ mode: "quorum-native",
274
+ maxRetries: 0,
275
+ initialDelayMs: 0,
276
+ maxDelayMs: 0,
277
+ backoffMultiplier: 0,
278
+ jitter: false
279
+ };
280
+ if (retryOptions?.mode === "ttl-backoff") return {
281
+ mode: "ttl-backoff",
282
+ maxRetries: retryOptions.maxRetries ?? 3,
283
+ initialDelayMs: retryOptions.initialDelayMs ?? 1e3,
284
+ maxDelayMs: retryOptions.maxDelayMs ?? 3e4,
285
+ backoffMultiplier: retryOptions.backoffMultiplier ?? 2,
286
+ jitter: retryOptions.jitter ?? true
287
+ };
288
+ return {
289
+ mode: "ttl-backoff",
290
+ maxRetries: 3,
291
+ initialDelayMs: 1e3,
292
+ maxDelayMs: 3e4,
293
+ backoffMultiplier: 2,
294
+ jitter: true
295
+ };
296
+ }
297
+ /**
298
+ * Validate that quorum-native retry mode is properly configured for a specific consumer.
299
+ *
300
+ * Requirements for quorum-native mode:
301
+ * - Consumer queue must be a quorum queue
302
+ * - Consumer queue must have deliveryLimit configured
303
+ * - Consumer queue should have DLX configured (warning if not)
304
+ *
305
+ * @returns TechnicalError if validation fails, null if valid
306
+ */
307
+ validateQuorumNativeConfigForConsumer(consumerName, consumer) {
308
+ const queue = consumer.queue;
309
+ if (queue.type !== "quorum") return new TechnicalError(`Consumer "${consumerName}" uses queue "${queue.name}" with type "${queue.type}". Quorum-native retry mode requires quorum queues.`);
310
+ if (queue.deliveryLimit === void 0) return new TechnicalError(`Consumer "${consumerName}" uses queue "${queue.name}" without deliveryLimit configured. Quorum-native retry mode requires deliveryLimit to be set on the queue definition.`);
311
+ if (!queue.deadLetter) this.logger?.warn(`Consumer "${consumerName}" uses queue "${queue.name}" without deadLetter configured. Messages exceeding deliveryLimit will be dropped instead of dead-lettered.`);
312
+ return null;
266
313
  }
267
314
  /**
268
315
  * Start consuming messages for all consumers
@@ -277,10 +324,6 @@ var TypedAmqpWorker = class TypedAmqpWorker {
277
324
  if (options.prefetch <= 0 || !Number.isInteger(options.prefetch)) return Future.value(Result.Error(new TechnicalError(`Invalid prefetch value for "${String(consumerName)}": must be a positive integer`)));
278
325
  maxPrefetch = Math.max(maxPrefetch, options.prefetch);
279
326
  }
280
- if (options?.batchSize !== void 0) {
281
- const effectivePrefetch = options.prefetch ?? options.batchSize;
282
- maxPrefetch = Math.max(maxPrefetch, effectivePrefetch);
283
- }
284
327
  }
285
328
  if (maxPrefetch > 0) this.amqpClient.channel.addSetup(async (channel) => {
286
329
  await channel.prefetch(maxPrefetch);
@@ -304,19 +347,11 @@ var TypedAmqpWorker = class TypedAmqpWorker {
304
347
  }
305
348
  const handler = this.actualHandlers[consumerName];
306
349
  if (!handler) return Future.value(Result.Error(new TechnicalError(`Handler for "${String(consumerName)}" not provided`)));
307
- const options = this.consumerOptions[consumerName] ?? {};
308
- if (options.batchSize !== void 0) {
309
- if (options.batchSize <= 0 || !Number.isInteger(options.batchSize)) return Future.value(Result.Error(new TechnicalError(`Invalid batchSize for "${String(consumerName)}": must be a positive integer`)));
310
- }
311
- if (options.batchTimeout !== void 0) {
312
- if (typeof options.batchTimeout !== "number" || !Number.isFinite(options.batchTimeout) || options.batchTimeout <= 0) return Future.value(Result.Error(new TechnicalError(`Invalid batchTimeout for "${String(consumerName)}": must be a positive number`)));
313
- }
314
- if (options.batchSize !== void 0 && options.batchSize > 0) return this.consumeBatch(consumerName, consumer, options, handler);
315
- else return this.consumeSingle(consumerName, consumer, handler);
350
+ return this.consumeSingle(consumerName, consumer, handler);
316
351
  }
317
352
  /**
318
353
  * Parse and validate a message from AMQP
319
- * @returns `Future<Result<validated message, void>>` - Ok with validated message, or Error (already handled with nack)
354
+ * @returns `Future<Result<consumed message, void>>` - Ok with validated consumed message (payload + headers), or Error (already handled with nack)
320
355
  */
321
356
  parseAndValidateMessage(msg, consumer, consumerName) {
322
357
  const decompressMessage = Future.fromPromise(decompressBuffer(msg.content, msg.properties.contentEncoding)).tapError((error) => {
@@ -327,7 +362,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
327
362
  error
328
363
  });
329
364
  this.amqpClient.channel.nack(msg, false, false);
330
- });
365
+ }).mapError(() => void 0);
331
366
  const parseMessage = (buffer) => {
332
367
  const parseResult = Result.fromExecution(() => JSON.parse(buffer.toString()));
333
368
  if (parseResult.isError()) {
@@ -341,12 +376,12 @@ var TypedAmqpWorker = class TypedAmqpWorker {
341
376
  }
342
377
  return Future.value(Result.Ok(parseResult.value));
343
378
  };
344
- const validateMessage = (parsedMessage) => {
379
+ const validatePayload = (parsedMessage) => {
345
380
  const rawValidation = consumer.message.payload["~standard"].validate(parsedMessage);
346
- return Future.fromPromise(rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation)).mapOkToResult((validationResult) => {
381
+ return Future.fromPromise(rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation)).mapError(() => void 0).mapOkToResult((validationResult) => {
347
382
  if (validationResult.issues) {
348
383
  const error = new MessageValidationError(String(consumerName), validationResult.issues);
349
- this.logger?.error("Message validation failed", {
384
+ this.logger?.error("Message payload validation failed", {
350
385
  consumerName: String(consumerName),
351
386
  queueName: consumer.queue.name,
352
387
  error
@@ -357,7 +392,45 @@ var TypedAmqpWorker = class TypedAmqpWorker {
357
392
  return Result.Ok(validationResult.value);
358
393
  });
359
394
  };
360
- return decompressMessage.flatMapOk(parseMessage).flatMapOk(validateMessage);
395
+ const validateHeaders = () => {
396
+ const headersSchema = consumer.message.headers;
397
+ if (!headersSchema) return Future.value(Result.Ok(void 0));
398
+ if (!isStandardSchema(headersSchema)) {
399
+ const error = new MessageValidationError(String(consumerName), "Invalid headers schema: not a Standard Schema v1 compliant schema");
400
+ this.logger?.error("Message headers validation failed", {
401
+ consumerName: String(consumerName),
402
+ queueName: consumer.queue.name,
403
+ error
404
+ });
405
+ this.amqpClient.channel.nack(msg, false, false);
406
+ return Future.value(Result.Error(void 0));
407
+ }
408
+ const validSchema = headersSchema;
409
+ const rawHeaders = msg.properties.headers ?? {};
410
+ const rawValidation = validSchema["~standard"].validate(rawHeaders);
411
+ return Future.fromPromise(rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation)).mapError(() => void 0).mapOkToResult((validationResult) => {
412
+ if (validationResult.issues) {
413
+ const error = new MessageValidationError(String(consumerName), validationResult.issues);
414
+ this.logger?.error("Message headers validation failed", {
415
+ consumerName: String(consumerName),
416
+ queueName: consumer.queue.name,
417
+ error
418
+ });
419
+ this.amqpClient.channel.nack(msg, false, false);
420
+ return Result.Error(void 0);
421
+ }
422
+ return Result.Ok(validationResult.value);
423
+ });
424
+ };
425
+ const buildConsumedMessage = (validatedPayload) => {
426
+ return validateHeaders().mapOk((validatedHeaders) => {
427
+ return {
428
+ payload: validatedPayload,
429
+ headers: validatedHeaders
430
+ };
431
+ });
432
+ };
433
+ return decompressMessage.flatMapOk(parseMessage).flatMapOk(validatePayload).flatMapOk(buildConsumedMessage);
361
434
  }
362
435
  /**
363
436
  * Consume messages one at a time
@@ -374,7 +447,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
374
447
  }
375
448
  const startTime = Date.now();
376
449
  const span = startConsumeSpan(this.telemetry, queueName, String(consumerName), { "messaging.rabbitmq.message.delivery_tag": msg.fields.deliveryTag });
377
- await this.parseAndValidateMessage(msg, consumer, consumerName).flatMapOk((validatedMessage) => handler(validatedMessage).flatMapOk(() => {
450
+ await this.parseAndValidateMessage(msg, consumer, consumerName).flatMapOk((validatedMessage) => handler(validatedMessage, msg).flatMapOk(() => {
378
451
  this.logger?.info("Message consumed successfully", {
379
452
  consumerName: String(consumerName),
380
453
  queueName
@@ -405,107 +478,21 @@ var TypedAmqpWorker = class TypedAmqpWorker {
405
478
  }).mapError((error) => new TechnicalError(`Failed to start consuming for "${String(consumerName)}"`, error)).mapOk(() => void 0);
406
479
  }
407
480
  /**
408
- * Handle batch processing error by applying error handling to all messages.
409
- */
410
- handleBatchError(error, currentBatch, consumerName, consumer) {
411
- return Future.all(currentBatch.map((item) => this.handleError(error, item.amqpMessage, consumerName, consumer))).map(Result.all).mapOk(() => void 0);
412
- }
413
- /**
414
- * Consume messages in batches
415
- */
416
- consumeBatch(consumerName, consumer, options, handler) {
417
- const batchSize = options.batchSize;
418
- const batchTimeout = options.batchTimeout ?? 1e3;
419
- const timerKey = String(consumerName);
420
- const queueName = consumer.queue.name;
421
- let batch = [];
422
- let isProcessing = false;
423
- const processBatch = () => {
424
- if (isProcessing || batch.length === 0) return Future.value(Result.Ok(void 0));
425
- isProcessing = true;
426
- const currentBatch = batch;
427
- batch = [];
428
- const timer = this.batchTimers.get(timerKey);
429
- if (timer) {
430
- clearTimeout(timer);
431
- this.batchTimers.delete(timerKey);
432
- }
433
- const messages = currentBatch.map((item) => item.message);
434
- const batchCount = currentBatch.length;
435
- const startTime = Date.now();
436
- const span = startConsumeSpan(this.telemetry, queueName, String(consumerName), { "amqp.batch.size": batchCount });
437
- this.logger?.info("Processing batch", {
438
- consumerName: String(consumerName),
439
- queueName,
440
- batchSize: batchCount
441
- });
442
- return handler(messages).flatMapOk(() => {
443
- for (const item of currentBatch) this.amqpClient.channel.ack(item.amqpMessage);
444
- this.logger?.info("Batch processed successfully", {
445
- consumerName: String(consumerName),
446
- queueName,
447
- batchSize: batchCount
448
- });
449
- const durationMs = Date.now() - startTime;
450
- endSpanSuccess(span);
451
- recordConsumeMetric(this.telemetry, queueName, String(consumerName), true, durationMs);
452
- return Future.value(Result.Ok(void 0));
453
- }).flatMapError((handlerError) => {
454
- this.logger?.error("Error processing batch", {
455
- consumerName: String(consumerName),
456
- queueName,
457
- batchSize: batchCount,
458
- errorType: handlerError.name,
459
- error: handlerError.message
460
- });
461
- const durationMs = Date.now() - startTime;
462
- endSpanError(span, handlerError);
463
- recordConsumeMetric(this.telemetry, queueName, String(consumerName), false, durationMs);
464
- return this.handleBatchError(handlerError, currentBatch, String(consumerName), consumer);
465
- }).tap(() => {
466
- isProcessing = false;
467
- });
468
- };
469
- const scheduleBatchProcessing = () => {
470
- if (isProcessing) return;
471
- const existingTimer = this.batchTimers.get(timerKey);
472
- if (existingTimer) clearTimeout(existingTimer);
473
- const timer = setTimeout(() => {
474
- processBatch().toPromise();
475
- }, batchTimeout);
476
- this.batchTimers.set(timerKey, timer);
477
- };
478
- return Future.fromPromise(this.amqpClient.channel.consume(queueName, async (msg) => {
479
- if (msg === null) {
480
- this.logger?.warn("Consumer cancelled by server", {
481
- consumerName: String(consumerName),
482
- queueName
483
- });
484
- await processBatch().toPromise();
485
- return;
486
- }
487
- const validationResult = await this.parseAndValidateMessage(msg, consumer, consumerName).toPromise();
488
- if (validationResult.isError()) return;
489
- batch.push({
490
- message: validationResult.value,
491
- amqpMessage: msg
492
- });
493
- if (batch.length >= batchSize) {
494
- await processBatch().toPromise();
495
- if (batch.length > 0 && !this.batchTimers.has(timerKey)) scheduleBatchProcessing();
496
- } else if (!this.batchTimers.has(timerKey)) scheduleBatchProcessing();
497
- })).tapOk((reply) => {
498
- this.consumerTags.add(reply.consumerTag);
499
- }).mapError((error) => new TechnicalError(`Failed to start consuming for "${String(consumerName)}"`, error)).mapOk(() => void 0);
500
- }
501
- /**
502
481
  * Handle error in message processing with retry logic.
503
482
  *
504
- * Flow:
483
+ * Flow depends on retry mode:
484
+ *
485
+ * **quorum-native mode:**
505
486
  * 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
487
+ * 2. Otherwise -> nack with requeue=true (RabbitMQ handles delivery count)
488
+ *
489
+ * **ttl-backoff mode:**
490
+ * 1. If NonRetryableError -> send directly to DLQ (no retry)
491
+ * 2. If max retries exceeded -> send to DLQ
492
+ * 3. Otherwise -> publish to wait queue with TTL for retry
493
+ *
494
+ * **Legacy mode (no retry config):**
495
+ * 1. nack with requeue=true (immediate requeue)
509
496
  */
510
497
  handleError(error, msg, consumerName, consumer) {
511
498
  if (error instanceof NonRetryableError) {
@@ -517,16 +504,50 @@ var TypedAmqpWorker = class TypedAmqpWorker {
517
504
  this.sendToDLQ(msg, consumer);
518
505
  return Future.value(Result.Ok(void 0));
519
506
  }
520
- if (this.retryConfig === null) {
521
- this.logger?.warn("Error in handler (legacy mode: immediate requeue)", {
522
- consumerName,
523
- error: error.message
524
- });
525
- this.amqpClient.channel.nack(msg, false, true);
526
- return Future.value(Result.Ok(void 0));
527
- }
507
+ const config = this.getRetryConfigForConsumer(consumer);
508
+ if (config.mode === "quorum-native") return this.handleErrorQuorumNative(error, msg, consumerName, consumer);
509
+ return this.handleErrorTtlBackoff(error, msg, consumerName, consumer, config);
510
+ }
511
+ /**
512
+ * Handle error using quorum queue's native delivery limit feature.
513
+ *
514
+ * Simply requeues the message with nack(requeue=true). RabbitMQ automatically:
515
+ * - Increments x-delivery-count header
516
+ * - Dead-letters the message when count exceeds x-delivery-limit
517
+ *
518
+ * This is simpler than TTL-based retry but provides immediate retries only.
519
+ */
520
+ handleErrorQuorumNative(error, msg, consumerName, consumer) {
521
+ const queue = consumer.queue;
522
+ const queueName = queue.name;
523
+ const deliveryCount = msg.properties.headers?.["x-delivery-count"] ?? 0;
524
+ const deliveryLimit = queue.type === "quorum" ? queue.deliveryLimit : void 0;
525
+ const attemptsBeforeDeadLetter = deliveryLimit !== void 0 ? Math.max(0, deliveryLimit - deliveryCount - 1) : "unknown";
526
+ if (deliveryLimit !== void 0 && deliveryCount >= deliveryLimit - 1) this.logger?.warn("Message at final delivery attempt (quorum-native mode)", {
527
+ consumerName,
528
+ queueName,
529
+ deliveryCount,
530
+ deliveryLimit,
531
+ willDeadLetterOnNextFailure: deliveryCount === deliveryLimit - 1,
532
+ alreadyExceededLimit: deliveryCount >= deliveryLimit,
533
+ error: error.message
534
+ });
535
+ else this.logger?.warn("Retrying message (quorum-native mode)", {
536
+ consumerName,
537
+ queueName,
538
+ deliveryCount,
539
+ deliveryLimit,
540
+ attemptsBeforeDeadLetter,
541
+ error: error.message
542
+ });
543
+ this.amqpClient.channel.nack(msg, false, true);
544
+ return Future.value(Result.Ok(void 0));
545
+ }
546
+ /**
547
+ * Handle error using TTL + wait queue pattern for exponential backoff.
548
+ */
549
+ handleErrorTtlBackoff(error, msg, consumerName, consumer, config) {
528
550
  const retryCount = msg.properties.headers?.["x-retry-count"] ?? 0;
529
- const config = this.retryConfig;
530
551
  if (retryCount >= config.maxRetries) {
531
552
  this.logger?.error("Max retries exceeded, sending to DLQ", {
532
553
  consumerName,
@@ -537,8 +558,8 @@ var TypedAmqpWorker = class TypedAmqpWorker {
537
558
  this.sendToDLQ(msg, consumer);
538
559
  return Future.value(Result.Ok(void 0));
539
560
  }
540
- const delayMs = this.calculateRetryDelay(retryCount);
541
- this.logger?.warn("Retrying message", {
561
+ const delayMs = this.calculateRetryDelay(retryCount, config);
562
+ this.logger?.warn("Retrying message (ttl-backoff mode)", {
542
563
  consumerName,
543
564
  retryCount: retryCount + 1,
544
565
  delayMs,
@@ -549,8 +570,8 @@ var TypedAmqpWorker = class TypedAmqpWorker {
549
570
  /**
550
571
  * Calculate retry delay with exponential backoff and optional jitter.
551
572
  */
552
- calculateRetryDelay(retryCount) {
553
- const { initialDelayMs, maxDelayMs, backoffMultiplier, jitter } = this.retryConfig;
573
+ calculateRetryDelay(retryCount, config) {
574
+ const { initialDelayMs, maxDelayMs, backoffMultiplier, jitter } = config;
554
575
  let delay = Math.min(initialDelayMs * Math.pow(backoffMultiplier, retryCount), maxDelayMs);
555
576
  if (jitter) delay = delay * (.5 + Math.random() * .5);
556
577
  return Math.floor(delay);
@@ -672,19 +693,6 @@ function validateHandlers(contract, handlers) {
672
693
  const availableConsumerNames = availableConsumers.length > 0 ? availableConsumers.join(", ") : "none";
673
694
  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
695
  }
675
- /**
676
- * Wrap a Promise-based handler into a Future-based safe handler.
677
- * This is used internally by defineUnsafeHandler to convert Promise handlers to Future handlers.
678
- */
679
- function wrapUnsafeHandler(handler) {
680
- return (input) => {
681
- return Future.fromPromise(handler(input)).mapOkToResult(() => Result.Ok(void 0)).flatMapError((error) => {
682
- if (error instanceof NonRetryableError || error instanceof RetryableError) return Future.value(Result.Error(error));
683
- const retryableError = new RetryableError(error instanceof Error ? error.message : String(error), error);
684
- return Future.value(Result.Error(retryableError));
685
- });
686
- };
687
- }
688
696
  function defineHandler(contract, consumerName, handler, options) {
689
697
  validateConsumerExists(contract, String(consumerName));
690
698
  if (options) return [handler, options];
@@ -708,12 +716,12 @@ function defineHandler(contract, consumerName, handler, options) {
708
716
  * import { orderContract } from './contract';
709
717
  *
710
718
  * const handlers = defineHandlers(orderContract, {
711
- * processOrder: (message) =>
712
- * Future.fromPromise(processPayment(message))
719
+ * processOrder: ({ payload }) =>
720
+ * Future.fromPromise(processPayment(payload))
713
721
  * .mapOk(() => undefined)
714
722
  * .mapError((error) => new RetryableError('Payment failed', error)),
715
- * notifyOrder: (message) =>
716
- * Future.fromPromise(sendNotification(message))
723
+ * notifyOrder: ({ payload }) =>
724
+ * Future.fromPromise(sendNotification(payload))
717
725
  * .mapOk(() => undefined)
718
726
  * .mapError((error) => new RetryableError('Notification failed', error)),
719
727
  * });
@@ -723,53 +731,7 @@ function defineHandlers(contract, handlers) {
723
731
  validateHandlers(contract, handlers);
724
732
  return handlers;
725
733
  }
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
747
- *
748
- * @example
749
- * ```typescript
750
- * import { defineUnsafeHandlers } from '@amqp-contract/worker';
751
- *
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
- * },
760
- * });
761
- * ```
762
- */
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;
771
- }
772
734
 
773
735
  //#endregion
774
- export { MessageValidationError, NonRetryableError, RetryableError, TechnicalError, TypedAmqpWorker, defineHandler, defineHandlers, defineUnsafeHandler, defineUnsafeHandlers };
736
+ export { MessageValidationError, NonRetryableError, RetryableError, TechnicalError, TypedAmqpWorker, defineHandler, defineHandlers };
775
737
  //# sourceMappingURL=index.mjs.map