@amqp-contract/worker 0.10.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,41 +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
- await channel.bindQueue(queueName, dlxName, queueName);
256
- 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", {
257
252
  consumerName: String(consumerName),
258
- queueName,
259
- waitQueueName,
260
- dlxName
253
+ queueName: queue.name
261
254
  });
262
- })).mapError((error) => new TechnicalError(`Failed to setup wait queue for "${String(consumerName)}"`, error));
263
- 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
+ });
264
263
  }
265
- if (setupTasks.length === 0) return Future.value(Result.Ok(void 0));
266
- 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;
267
313
  }
268
314
  /**
269
315
  * Start consuming messages for all consumers
@@ -278,10 +324,6 @@ var TypedAmqpWorker = class TypedAmqpWorker {
278
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`)));
279
325
  maxPrefetch = Math.max(maxPrefetch, options.prefetch);
280
326
  }
281
- if (options?.batchSize !== void 0) {
282
- const effectivePrefetch = options.prefetch ?? options.batchSize;
283
- maxPrefetch = Math.max(maxPrefetch, effectivePrefetch);
284
- }
285
327
  }
286
328
  if (maxPrefetch > 0) this.amqpClient.channel.addSetup(async (channel) => {
287
329
  await channel.prefetch(maxPrefetch);
@@ -305,19 +347,11 @@ var TypedAmqpWorker = class TypedAmqpWorker {
305
347
  }
306
348
  const handler = this.actualHandlers[consumerName];
307
349
  if (!handler) return Future.value(Result.Error(new TechnicalError(`Handler for "${String(consumerName)}" not provided`)));
308
- const options = this.consumerOptions[consumerName] ?? {};
309
- if (options.batchSize !== void 0) {
310
- 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`)));
311
- }
312
- if (options.batchTimeout !== void 0) {
313
- 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`)));
314
- }
315
- if (options.batchSize !== void 0 && options.batchSize > 0) return this.consumeBatch(consumerName, consumer, options, handler);
316
- else return this.consumeSingle(consumerName, consumer, handler);
350
+ return this.consumeSingle(consumerName, consumer, handler);
317
351
  }
318
352
  /**
319
353
  * Parse and validate a message from AMQP
320
- * @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)
321
355
  */
322
356
  parseAndValidateMessage(msg, consumer, consumerName) {
323
357
  const decompressMessage = Future.fromPromise(decompressBuffer(msg.content, msg.properties.contentEncoding)).tapError((error) => {
@@ -328,7 +362,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
328
362
  error
329
363
  });
330
364
  this.amqpClient.channel.nack(msg, false, false);
331
- });
365
+ }).mapError(() => void 0);
332
366
  const parseMessage = (buffer) => {
333
367
  const parseResult = Result.fromExecution(() => JSON.parse(buffer.toString()));
334
368
  if (parseResult.isError()) {
@@ -342,12 +376,12 @@ var TypedAmqpWorker = class TypedAmqpWorker {
342
376
  }
343
377
  return Future.value(Result.Ok(parseResult.value));
344
378
  };
345
- const validateMessage = (parsedMessage) => {
379
+ const validatePayload = (parsedMessage) => {
346
380
  const rawValidation = consumer.message.payload["~standard"].validate(parsedMessage);
347
- 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) => {
348
382
  if (validationResult.issues) {
349
383
  const error = new MessageValidationError(String(consumerName), validationResult.issues);
350
- this.logger?.error("Message validation failed", {
384
+ this.logger?.error("Message payload validation failed", {
351
385
  consumerName: String(consumerName),
352
386
  queueName: consumer.queue.name,
353
387
  error
@@ -358,7 +392,45 @@ var TypedAmqpWorker = class TypedAmqpWorker {
358
392
  return Result.Ok(validationResult.value);
359
393
  });
360
394
  };
361
- 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);
362
434
  }
363
435
  /**
364
436
  * Consume messages one at a time
@@ -375,7 +447,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
375
447
  }
376
448
  const startTime = Date.now();
377
449
  const span = startConsumeSpan(this.telemetry, queueName, String(consumerName), { "messaging.rabbitmq.message.delivery_tag": msg.fields.deliveryTag });
378
- await this.parseAndValidateMessage(msg, consumer, consumerName).flatMapOk((validatedMessage) => handler(validatedMessage).flatMapOk(() => {
450
+ await this.parseAndValidateMessage(msg, consumer, consumerName).flatMapOk((validatedMessage) => handler(validatedMessage, msg).flatMapOk(() => {
379
451
  this.logger?.info("Message consumed successfully", {
380
452
  consumerName: String(consumerName),
381
453
  queueName
@@ -406,107 +478,21 @@ var TypedAmqpWorker = class TypedAmqpWorker {
406
478
  }).mapError((error) => new TechnicalError(`Failed to start consuming for "${String(consumerName)}"`, error)).mapOk(() => void 0);
407
479
  }
408
480
  /**
409
- * Handle batch processing error by applying error handling to all messages.
410
- */
411
- handleBatchError(error, currentBatch, consumerName, consumer) {
412
- return Future.all(currentBatch.map((item) => this.handleError(error, item.amqpMessage, consumerName, consumer))).map(Result.all).mapOk(() => void 0);
413
- }
414
- /**
415
- * Consume messages in batches
416
- */
417
- consumeBatch(consumerName, consumer, options, handler) {
418
- const batchSize = options.batchSize;
419
- const batchTimeout = options.batchTimeout ?? 1e3;
420
- const timerKey = String(consumerName);
421
- const queueName = consumer.queue.name;
422
- let batch = [];
423
- let isProcessing = false;
424
- const processBatch = () => {
425
- if (isProcessing || batch.length === 0) return Future.value(Result.Ok(void 0));
426
- isProcessing = true;
427
- const currentBatch = batch;
428
- batch = [];
429
- const timer = this.batchTimers.get(timerKey);
430
- if (timer) {
431
- clearTimeout(timer);
432
- this.batchTimers.delete(timerKey);
433
- }
434
- const messages = currentBatch.map((item) => item.message);
435
- const batchCount = currentBatch.length;
436
- const startTime = Date.now();
437
- const span = startConsumeSpan(this.telemetry, queueName, String(consumerName), { "amqp.batch.size": batchCount });
438
- this.logger?.info("Processing batch", {
439
- consumerName: String(consumerName),
440
- queueName,
441
- batchSize: batchCount
442
- });
443
- return handler(messages).flatMapOk(() => {
444
- for (const item of currentBatch) this.amqpClient.channel.ack(item.amqpMessage);
445
- this.logger?.info("Batch processed successfully", {
446
- consumerName: String(consumerName),
447
- queueName,
448
- batchSize: batchCount
449
- });
450
- const durationMs = Date.now() - startTime;
451
- endSpanSuccess(span);
452
- recordConsumeMetric(this.telemetry, queueName, String(consumerName), true, durationMs);
453
- return Future.value(Result.Ok(void 0));
454
- }).flatMapError((handlerError) => {
455
- this.logger?.error("Error processing batch", {
456
- consumerName: String(consumerName),
457
- queueName,
458
- batchSize: batchCount,
459
- errorType: handlerError.name,
460
- error: handlerError.message
461
- });
462
- const durationMs = Date.now() - startTime;
463
- endSpanError(span, handlerError);
464
- recordConsumeMetric(this.telemetry, queueName, String(consumerName), false, durationMs);
465
- return this.handleBatchError(handlerError, currentBatch, String(consumerName), consumer);
466
- }).tap(() => {
467
- isProcessing = false;
468
- });
469
- };
470
- const scheduleBatchProcessing = () => {
471
- if (isProcessing) return;
472
- const existingTimer = this.batchTimers.get(timerKey);
473
- if (existingTimer) clearTimeout(existingTimer);
474
- const timer = setTimeout(() => {
475
- processBatch().toPromise();
476
- }, batchTimeout);
477
- this.batchTimers.set(timerKey, timer);
478
- };
479
- return Future.fromPromise(this.amqpClient.channel.consume(queueName, async (msg) => {
480
- if (msg === null) {
481
- this.logger?.warn("Consumer cancelled by server", {
482
- consumerName: String(consumerName),
483
- queueName
484
- });
485
- await processBatch().toPromise();
486
- return;
487
- }
488
- const validationResult = await this.parseAndValidateMessage(msg, consumer, consumerName).toPromise();
489
- if (validationResult.isError()) return;
490
- batch.push({
491
- message: validationResult.value,
492
- amqpMessage: msg
493
- });
494
- if (batch.length >= batchSize) {
495
- await processBatch().toPromise();
496
- if (batch.length > 0 && !this.batchTimers.has(timerKey)) scheduleBatchProcessing();
497
- } else if (!this.batchTimers.has(timerKey)) scheduleBatchProcessing();
498
- })).tapOk((reply) => {
499
- this.consumerTags.add(reply.consumerTag);
500
- }).mapError((error) => new TechnicalError(`Failed to start consuming for "${String(consumerName)}"`, error)).mapOk(() => void 0);
501
- }
502
- /**
503
481
  * Handle error in message processing with retry logic.
504
482
  *
505
- * Flow:
483
+ * Flow depends on retry mode:
484
+ *
485
+ * **quorum-native mode:**
506
486
  * 1. If NonRetryableError -> send directly to DLQ (no retry)
507
- * 2. If no retry config -> legacy behavior (immediate requeue)
508
- * 3. If max retries exceeded -> send to DLQ
509
- * 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)
510
496
  */
511
497
  handleError(error, msg, consumerName, consumer) {
512
498
  if (error instanceof NonRetryableError) {
@@ -518,16 +504,50 @@ var TypedAmqpWorker = class TypedAmqpWorker {
518
504
  this.sendToDLQ(msg, consumer);
519
505
  return Future.value(Result.Ok(void 0));
520
506
  }
521
- if (this.retryConfig === null) {
522
- this.logger?.warn("Error in handler (legacy mode: immediate requeue)", {
523
- consumerName,
524
- error: error.message
525
- });
526
- this.amqpClient.channel.nack(msg, false, true);
527
- return Future.value(Result.Ok(void 0));
528
- }
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) {
529
550
  const retryCount = msg.properties.headers?.["x-retry-count"] ?? 0;
530
- const config = this.retryConfig;
531
551
  if (retryCount >= config.maxRetries) {
532
552
  this.logger?.error("Max retries exceeded, sending to DLQ", {
533
553
  consumerName,
@@ -538,8 +558,8 @@ var TypedAmqpWorker = class TypedAmqpWorker {
538
558
  this.sendToDLQ(msg, consumer);
539
559
  return Future.value(Result.Ok(void 0));
540
560
  }
541
- const delayMs = this.calculateRetryDelay(retryCount);
542
- this.logger?.warn("Retrying message", {
561
+ const delayMs = this.calculateRetryDelay(retryCount, config);
562
+ this.logger?.warn("Retrying message (ttl-backoff mode)", {
543
563
  consumerName,
544
564
  retryCount: retryCount + 1,
545
565
  delayMs,
@@ -550,8 +570,8 @@ var TypedAmqpWorker = class TypedAmqpWorker {
550
570
  /**
551
571
  * Calculate retry delay with exponential backoff and optional jitter.
552
572
  */
553
- calculateRetryDelay(retryCount) {
554
- const { initialDelayMs, maxDelayMs, backoffMultiplier, jitter } = this.retryConfig;
573
+ calculateRetryDelay(retryCount, config) {
574
+ const { initialDelayMs, maxDelayMs, backoffMultiplier, jitter } = config;
555
575
  let delay = Math.min(initialDelayMs * Math.pow(backoffMultiplier, retryCount), maxDelayMs);
556
576
  if (jitter) delay = delay * (.5 + Math.random() * .5);
557
577
  return Math.floor(delay);
@@ -673,19 +693,6 @@ function validateHandlers(contract, handlers) {
673
693
  const availableConsumerNames = availableConsumers.length > 0 ? availableConsumers.join(", ") : "none";
674
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}`);
675
695
  }
676
- /**
677
- * Wrap a Promise-based handler into a Future-based safe handler.
678
- * This is used internally by defineUnsafeHandler to convert Promise handlers to Future handlers.
679
- */
680
- function wrapUnsafeHandler(handler) {
681
- return (input) => {
682
- return Future.fromPromise(handler(input)).mapOkToResult(() => Result.Ok(void 0)).flatMapError((error) => {
683
- if (error instanceof NonRetryableError || error instanceof RetryableError) return Future.value(Result.Error(error));
684
- const retryableError = new RetryableError(error instanceof Error ? error.message : String(error), error);
685
- return Future.value(Result.Error(retryableError));
686
- });
687
- };
688
- }
689
696
  function defineHandler(contract, consumerName, handler, options) {
690
697
  validateConsumerExists(contract, String(consumerName));
691
698
  if (options) return [handler, options];
@@ -709,12 +716,12 @@ function defineHandler(contract, consumerName, handler, options) {
709
716
  * import { orderContract } from './contract';
710
717
  *
711
718
  * const handlers = defineHandlers(orderContract, {
712
- * processOrder: (message) =>
713
- * Future.fromPromise(processPayment(message))
719
+ * processOrder: ({ payload }) =>
720
+ * Future.fromPromise(processPayment(payload))
714
721
  * .mapOk(() => undefined)
715
722
  * .mapError((error) => new RetryableError('Payment failed', error)),
716
- * notifyOrder: (message) =>
717
- * Future.fromPromise(sendNotification(message))
723
+ * notifyOrder: ({ payload }) =>
724
+ * Future.fromPromise(sendNotification(payload))
718
725
  * .mapOk(() => undefined)
719
726
  * .mapError((error) => new RetryableError('Notification failed', error)),
720
727
  * });
@@ -724,53 +731,7 @@ function defineHandlers(contract, handlers) {
724
731
  validateHandlers(contract, handlers);
725
732
  return handlers;
726
733
  }
727
- function defineUnsafeHandler(contract, consumerName, handler, options) {
728
- validateConsumerExists(contract, String(consumerName));
729
- const wrappedHandler = wrapUnsafeHandler(handler);
730
- if (options) return [wrappedHandler, options];
731
- return wrappedHandler;
732
- }
733
- /**
734
- * Define multiple unsafe handlers for consumers in a contract.
735
- *
736
- * @deprecated Use `defineHandlers` instead for explicit error handling with `Future<Result>`.
737
- *
738
- * **Warning:** Unsafe handlers use exception-based error handling.
739
- * Consider migrating to safe handlers for better error control.
740
- *
741
- * **Note:** Internally, this function wraps all Promise-based handlers into Future-based
742
- * safe handlers for consistent processing in the worker.
743
- *
744
- * @template TContract - The contract definition type
745
- * @param contract - The contract definition containing the consumers
746
- * @param handlers - An object with async handler functions for each consumer
747
- * @returns A type-safe handlers object that can be used with TypedAmqpWorker
748
- *
749
- * @example
750
- * ```typescript
751
- * import { defineUnsafeHandlers } from '@amqp-contract/worker';
752
- *
753
- * // ⚠️ Consider using defineHandlers for better error handling
754
- * const handlers = defineUnsafeHandlers(orderContract, {
755
- * processOrder: async (message) => {
756
- * await processPayment(message);
757
- * },
758
- * notifyOrder: async (message) => {
759
- * await sendNotification(message);
760
- * },
761
- * });
762
- * ```
763
- */
764
- function defineUnsafeHandlers(contract, handlers) {
765
- validateHandlers(contract, handlers);
766
- const result = {};
767
- for (const [name, entry] of Object.entries(handlers)) if (Array.isArray(entry)) {
768
- const [handler, options] = entry;
769
- result[name] = [wrapUnsafeHandler(handler), options];
770
- } else result[name] = wrapUnsafeHandler(entry);
771
- return result;
772
- }
773
734
 
774
735
  //#endregion
775
- export { MessageValidationError, NonRetryableError, RetryableError, TechnicalError, TypedAmqpWorker, defineHandler, defineHandlers, defineUnsafeHandler, defineUnsafeHandlers };
736
+ export { MessageValidationError, NonRetryableError, RetryableError, TechnicalError, TypedAmqpWorker, defineHandler, defineHandlers };
776
737
  //# sourceMappingURL=index.mjs.map