@amqp-contract/worker 0.21.0 → 0.23.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
@@ -197,6 +197,240 @@ function nonRetryable(message, cause) {
197
197
  return new NonRetryableError(message, cause);
198
198
  }
199
199
  //#endregion
200
+ //#region src/retry.ts
201
+ /**
202
+ * Handle error in message processing with retry logic.
203
+ *
204
+ * Flow depends on retry mode:
205
+ *
206
+ * **immediate-requeue mode:**
207
+ * 1. If NonRetryableError -> send directly to DLQ (no retry)
208
+ * 2. If max retries exceeded -> send to DLQ
209
+ * 3. Otherwise -> requeue immediately for retry
210
+ *
211
+ * **ttl-backoff mode:**
212
+ * 1. If NonRetryableError -> send directly to DLQ (no retry)
213
+ * 2. If max retries exceeded -> send to DLQ
214
+ * 3. Otherwise -> publish to wait queue with TTL for retry
215
+ *
216
+ * **none mode (no retry config):**
217
+ * 1. send directly to DLQ (no retry)
218
+ */
219
+ function handleError(ctx, error, msg, consumerName, consumer) {
220
+ if (error instanceof NonRetryableError) {
221
+ ctx.logger?.error("Non-retryable error, sending to DLQ immediately", {
222
+ consumerName,
223
+ errorType: error.name,
224
+ error: error.message
225
+ });
226
+ sendToDLQ(ctx, msg, consumer);
227
+ return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
228
+ }
229
+ const config = (0, _amqp_contract_contract.extractQueue)(consumer.queue).retry;
230
+ if (config.mode === "immediate-requeue") return handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, config);
231
+ if (config.mode === "ttl-backoff") return handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config);
232
+ ctx.logger?.warn("Retry disabled (none mode), sending to DLQ", {
233
+ consumerName,
234
+ error: error.message
235
+ });
236
+ sendToDLQ(ctx, msg, consumer);
237
+ return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
238
+ }
239
+ /**
240
+ * Handle error by requeuing immediately.
241
+ *
242
+ * For quorum queues, messages are requeued with `nack(requeue=true)`, and the worker tracks delivery count via the native RabbitMQ `x-delivery-count` header.
243
+ * For classic queues, messages are re-published on the same queue, and the worker tracks delivery count via a custom `x-retry-count` header.
244
+ * When the count exceeds `maxRetries`, the message is automatically dead-lettered (if DLX is configured) or dropped.
245
+ *
246
+ * This is simpler than TTL-based retry but provides immediate retries only.
247
+ */
248
+ function handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, config) {
249
+ const queue = (0, _amqp_contract_contract.extractQueue)(consumer.queue);
250
+ const queueName = queue.name;
251
+ const retryCount = queue.type === "quorum" ? msg.properties.headers?.["x-delivery-count"] ?? 0 : msg.properties.headers?.["x-retry-count"] ?? 0;
252
+ if (retryCount >= config.maxRetries) {
253
+ ctx.logger?.error("Max retries exceeded, sending to DLQ (immediate-requeue mode)", {
254
+ consumerName,
255
+ queueName,
256
+ retryCount,
257
+ maxRetries: config.maxRetries,
258
+ error: error.message
259
+ });
260
+ sendToDLQ(ctx, msg, consumer);
261
+ return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
262
+ }
263
+ ctx.logger?.warn("Retrying message (immediate-requeue mode)", {
264
+ consumerName,
265
+ queueName,
266
+ retryCount,
267
+ maxRetries: config.maxRetries,
268
+ error: error.message
269
+ });
270
+ if (queue.type === "quorum") {
271
+ ctx.amqpClient.nack(msg, false, true);
272
+ return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
273
+ } else return publishForRetry(ctx, {
274
+ msg,
275
+ exchange: msg.fields.exchange,
276
+ routingKey: msg.fields.routingKey,
277
+ queueName,
278
+ error
279
+ });
280
+ }
281
+ /**
282
+ * Handle error using TTL + wait queue pattern for exponential backoff.
283
+ *
284
+ * ┌─────────────────────────────────────────────────────────────────┐
285
+ * │ Retry Flow (Native RabbitMQ TTL + Wait queue pattern) │
286
+ * ├─────────────────────────────────────────────────────────────────┤
287
+ * │ │
288
+ * │ 1. Handler throws any Error │
289
+ * │ ↓ │
290
+ * │ 2. Worker publishes to wait exchange |
291
+ * | (with header `x-wait-queue` set to the wait queue name) │
292
+ * │ ↓ │
293
+ * │ 3. Wait exchange routes to wait queue │
294
+ * │ (with expiration: calculated backoff delay) │
295
+ * │ ↓ │
296
+ * │ 4. Message waits in queue until TTL expires │
297
+ * │ ↓ │
298
+ * │ 5. Expired message dead-lettered to retry exchange |
299
+ * | (with header `x-retry-queue` set to the main queue name) │
300
+ * │ ↓ │
301
+ * │ 6. Retry exchange routes back to main queue → RETRY │
302
+ * │ ↓ │
303
+ * │ 7. If retries exhausted: nack without requeue → DLQ │
304
+ * │ │
305
+ * └─────────────────────────────────────────────────────────────────┘
306
+ */
307
+ function handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config) {
308
+ if (!(0, _amqp_contract_contract.isQueueWithTtlBackoffInfrastructure)(consumer.queue)) {
309
+ ctx.logger?.error("Queue does not have TTL-backoff infrastructure", {
310
+ consumerName,
311
+ queueName: consumer.queue.name
312
+ });
313
+ return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError("Queue does not have TTL-backoff infrastructure")));
314
+ }
315
+ const queueEntry = consumer.queue;
316
+ const queueName = (0, _amqp_contract_contract.extractQueue)(queueEntry).name;
317
+ const retryCount = msg.properties.headers?.["x-retry-count"] ?? 0;
318
+ if (retryCount >= config.maxRetries) {
319
+ ctx.logger?.error("Max retries exceeded, sending to DLQ (ttl-backoff mode)", {
320
+ consumerName,
321
+ queueName,
322
+ retryCount,
323
+ maxRetries: config.maxRetries,
324
+ error: error.message
325
+ });
326
+ sendToDLQ(ctx, msg, consumer);
327
+ return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
328
+ }
329
+ const delayMs = calculateRetryDelay(retryCount, config);
330
+ ctx.logger?.warn("Retrying message (ttl-backoff mode)", {
331
+ consumerName,
332
+ queueName,
333
+ retryCount: retryCount + 1,
334
+ maxRetries: config.maxRetries,
335
+ delayMs,
336
+ error: error.message
337
+ });
338
+ return publishForRetry(ctx, {
339
+ msg,
340
+ exchange: queueEntry.waitExchange.name,
341
+ routingKey: msg.fields.routingKey,
342
+ waitQueueName: queueEntry.waitQueue.name,
343
+ queueName,
344
+ delayMs,
345
+ error
346
+ });
347
+ }
348
+ /**
349
+ * Calculate retry delay with exponential backoff and optional jitter.
350
+ */
351
+ function calculateRetryDelay(retryCount, config) {
352
+ const { initialDelayMs, maxDelayMs, backoffMultiplier, jitter } = config;
353
+ let delay = Math.min(initialDelayMs * Math.pow(backoffMultiplier, retryCount), maxDelayMs);
354
+ if (jitter) delay = delay * (.5 + Math.random() * .5);
355
+ return Math.floor(delay);
356
+ }
357
+ /**
358
+ * Parse message content for republishing.
359
+ *
360
+ * The channel is configured with `json: true`, so values published as plain
361
+ * objects are encoded once at publish time. Re-publishing the raw `Buffer`
362
+ * would then trigger a *second* JSON.stringify (turning the bytes into a
363
+ * stringified base64 blob), so for JSON payloads we must round-trip back to
364
+ * the parsed value. For any other content type — or when the message is
365
+ * compressed — we pass the bytes through untouched, since re-parsing would
366
+ * either fail or silently corrupt binary data.
367
+ */
368
+ function parseMessageContentForRetry(ctx, msg, queueName) {
369
+ if (msg.properties.contentEncoding) return msg.content;
370
+ const contentType = msg.properties.contentType;
371
+ if (!(contentType === void 0 || contentType === "application/json" || contentType.startsWith("application/json;") || contentType.endsWith("+json"))) return msg.content;
372
+ try {
373
+ return JSON.parse(msg.content.toString());
374
+ } catch (err) {
375
+ ctx.logger?.warn("Failed to parse JSON message for retry, using original buffer", {
376
+ queueName,
377
+ error: err
378
+ });
379
+ return msg.content;
380
+ }
381
+ }
382
+ /**
383
+ * Publish message with an incremented x-retry-count header and optional TTL.
384
+ */
385
+ function publishForRetry(ctx, { msg, exchange, routingKey, queueName, waitQueueName, delayMs, error }) {
386
+ const newRetryCount = (msg.properties.headers?.["x-retry-count"] ?? 0) + 1;
387
+ ctx.amqpClient.ack(msg);
388
+ const content = parseMessageContentForRetry(ctx, msg, queueName);
389
+ return ctx.amqpClient.publish(exchange, routingKey, content, {
390
+ ...msg.properties,
391
+ ...delayMs !== void 0 ? { expiration: delayMs.toString() } : {},
392
+ headers: {
393
+ ...msg.properties.headers,
394
+ "x-retry-count": newRetryCount,
395
+ "x-last-error": error.message,
396
+ "x-first-failure-timestamp": msg.properties.headers?.["x-first-failure-timestamp"] ?? Date.now(),
397
+ ...waitQueueName !== void 0 ? {
398
+ "x-wait-queue": waitQueueName,
399
+ "x-retry-queue": queueName
400
+ } : {}
401
+ }
402
+ }).mapOkToResult((published) => {
403
+ if (!published) {
404
+ ctx.logger?.error("Failed to publish message for retry (write buffer full)", {
405
+ queueName,
406
+ retryCount: newRetryCount,
407
+ ...delayMs !== void 0 ? { delayMs } : {}
408
+ });
409
+ return _swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError("Failed to publish message for retry (write buffer full)"));
410
+ }
411
+ ctx.logger?.info("Message published for retry", {
412
+ queueName,
413
+ retryCount: newRetryCount,
414
+ ...delayMs !== void 0 ? { delayMs } : {}
415
+ });
416
+ return _swan_io_boxed.Result.Ok(void 0);
417
+ });
418
+ }
419
+ /**
420
+ * Send message to dead letter queue.
421
+ * Nacks the message without requeue, relying on DLX configuration.
422
+ */
423
+ function sendToDLQ(ctx, msg, consumer) {
424
+ const queue = (0, _amqp_contract_contract.extractQueue)(consumer.queue);
425
+ const queueName = queue.name;
426
+ if (!(queue.deadLetter !== void 0)) ctx.logger?.warn("Queue does not have DLX configured - message will be lost on nack", { queueName });
427
+ ctx.logger?.info("Sending message to DLQ", {
428
+ queueName,
429
+ deliveryTag: msg.fields.deliveryTag
430
+ });
431
+ ctx.amqpClient.nack(msg, false, false);
432
+ }
433
+ //#endregion
200
434
  //#region src/worker.ts
201
435
  /**
202
436
  * Type guard to check if a handler entry is a tuple format [handler, options].
@@ -247,7 +481,10 @@ function isHandlerTuple(entry) {
247
481
  */
248
482
  var TypedAmqpWorker = class TypedAmqpWorker {
249
483
  /**
250
- * Internal handler storage - handlers returning `Future<Result>`.
484
+ * Internal handler storage. Keyed by handler name (consumer or RPC); the
485
+ * stored function signature is widened so the dispatch loop can call it
486
+ * uniformly. The actual handler is type-checked at the worker's public API
487
+ * boundary via `WorkerInferHandlers<TContract>`.
251
488
  */
252
489
  actualHandlers;
253
490
  consumerOptions;
@@ -262,23 +499,49 @@ var TypedAmqpWorker = class TypedAmqpWorker {
262
499
  this.actualHandlers = {};
263
500
  this.consumerOptions = {};
264
501
  const handlersRecord = handlers;
265
- for (const consumerName of Object.keys(handlersRecord)) {
266
- const handlerEntry = handlersRecord[consumerName];
267
- const typedConsumerName = consumerName;
502
+ for (const handlerName of Object.keys(handlersRecord)) {
503
+ const handlerEntry = handlersRecord[handlerName];
504
+ const typedName = handlerName;
268
505
  if (isHandlerTuple(handlerEntry)) {
269
506
  const [handler, options] = handlerEntry;
270
- this.actualHandlers[typedConsumerName] = handler;
271
- this.consumerOptions[typedConsumerName] = {
507
+ this.actualHandlers[typedName] = handler;
508
+ this.consumerOptions[typedName] = {
272
509
  ...this.defaultConsumerOptions,
273
510
  ...options
274
511
  };
275
512
  } else {
276
- this.actualHandlers[typedConsumerName] = handlerEntry;
277
- this.consumerOptions[typedConsumerName] = this.defaultConsumerOptions;
513
+ this.actualHandlers[typedName] = handlerEntry;
514
+ this.consumerOptions[typedName] = this.defaultConsumerOptions;
278
515
  }
279
516
  }
280
517
  }
281
518
  /**
519
+ * Build a `ConsumerDefinition`-shaped view for a handler name, regardless
520
+ * of whether it came from `contract.consumers` or `contract.rpcs`. The
521
+ * dispatch path treats both uniformly; the returned `isRpc` flag (and the
522
+ * accompanying `responseSchema`) tells `processMessage` whether to validate
523
+ * the handler return value and publish a reply.
524
+ */
525
+ resolveConsumerView(name) {
526
+ const rpcs = this.contract.rpcs;
527
+ if (rpcs && Object.hasOwn(rpcs, name)) {
528
+ const rpc = rpcs[name];
529
+ return {
530
+ consumer: {
531
+ queue: rpc.queue,
532
+ message: rpc.request
533
+ },
534
+ isRpc: true,
535
+ responseSchema: rpc.response.payload
536
+ };
537
+ }
538
+ const consumerEntry = this.contract.consumers[name];
539
+ return {
540
+ consumer: (0, _amqp_contract_contract.extractConsumer)(consumerEntry),
541
+ isRpc: false
542
+ };
543
+ }
544
+ /**
282
545
  * Create a type-safe AMQP worker from a contract.
283
546
  *
284
547
  * Connection management (including automatic reconnection) is handled internally
@@ -303,12 +566,18 @@ var TypedAmqpWorker = class TypedAmqpWorker {
303
566
  * }).resultToPromise();
304
567
  * ```
305
568
  */
306
- static create({ contract, handlers, urls, connectionOptions, defaultConsumerOptions, logger, telemetry }) {
569
+ static create({ contract, handlers, urls, connectionOptions, defaultConsumerOptions, logger, telemetry, connectTimeoutMs }) {
307
570
  const worker = new TypedAmqpWorker(contract, new _amqp_contract_core.AmqpClient(contract, {
308
571
  urls,
309
- connectionOptions
572
+ connectionOptions,
573
+ connectTimeoutMs
310
574
  }), handlers, defaultConsumerOptions ?? {}, logger, telemetry);
311
- return worker.waitForConnectionReady().flatMapOk(() => worker.consumeAll()).mapOk(() => worker);
575
+ return worker.waitForConnectionReady().flatMapOk(() => worker.consumeAll()).flatMap((result) => result.match({
576
+ Ok: () => _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(worker)),
577
+ Error: (error) => worker.close().tapError((closeError) => {
578
+ logger?.warn("Failed to close worker after setup failure", { error: closeError });
579
+ }).map(() => _swan_io_boxed.Result.Error(error))
580
+ }));
312
581
  }
313
582
  /**
314
583
  * Close the AMQP channel and connection.
@@ -338,356 +607,202 @@ var TypedAmqpWorker = class TypedAmqpWorker {
338
607
  }).flatMapOk(() => this.amqpClient.close()).mapOk(() => void 0);
339
608
  }
340
609
  /**
341
- * Get the retry configuration for a consumer's queue.
342
- * Defaults are applied in the contract's defineQueue, so we just return the config.
343
- */
344
- getRetryConfigForConsumer(consumer) {
345
- return (0, _amqp_contract_contract.extractQueue)(consumer.queue).retry;
346
- }
347
- /**
348
- * Start consuming messages for all consumers.
349
- * TypeScript guarantees consumers exist (handlers require matching consumers).
610
+ * Start consuming for every entry in `contract.consumers` and `contract.rpcs`.
350
611
  */
351
612
  consumeAll() {
352
- const consumers = this.contract.consumers;
353
- const consumerNames = Object.keys(consumers);
354
- return _swan_io_boxed.Future.all(consumerNames.map((name) => this.consume(name))).map(_swan_io_boxed.Result.all).mapOk(() => void 0);
613
+ const consumerNames = Object.keys(this.contract.consumers ?? {});
614
+ const rpcNames = Object.keys(this.contract.rpcs ?? {});
615
+ const allNames = [...consumerNames, ...rpcNames];
616
+ return _swan_io_boxed.Future.all(allNames.map((name) => this.consume(name))).map(_swan_io_boxed.Result.all).mapOk(() => void 0);
355
617
  }
356
618
  waitForConnectionReady() {
357
619
  return this.amqpClient.waitForConnect();
358
620
  }
359
621
  /**
360
- * Start consuming messages for a specific consumer.
361
- * TypeScript guarantees consumer and handler exist for valid consumer names.
622
+ * Start consuming messages for a specific handler — either a `consumers`
623
+ * entry (regular event/command consumer) or an `rpcs` entry (RPC server).
362
624
  */
363
- consume(consumerName) {
364
- const consumerEntry = this.contract.consumers[consumerName];
365
- const consumer = (0, _amqp_contract_contract.extractConsumer)(consumerEntry);
366
- const handler = this.actualHandlers[consumerName];
367
- return this.consumeSingle(consumerName, consumer, handler);
625
+ consume(name) {
626
+ const view = this.resolveConsumerView(name);
627
+ const handler = this.actualHandlers[name];
628
+ return this.consumeSingle(name, view, handler);
368
629
  }
369
630
  /**
370
- * Validate data against a Standard Schema and handle errors.
631
+ * Validate data against a Standard Schema. No side effects; the caller is
632
+ * responsible for ack/nack based on the Result.
371
633
  */
372
- validateSchema(schema, data, context, msg) {
634
+ validateSchema(schema, data, context) {
373
635
  const rawValidation = schema["~standard"].validate(data);
374
636
  const validationPromise = rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation);
375
637
  return _swan_io_boxed.Future.fromPromise(validationPromise).mapError((error) => new _amqp_contract_core.TechnicalError(`Error validating ${context.field}`, error)).mapOkToResult((result) => {
376
638
  if (result.issues) return _swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError(`${context.field} validation failed`, new _amqp_contract_core.MessageValidationError(context.consumerName, result.issues)));
377
639
  return _swan_io_boxed.Result.Ok(result.value);
378
- }).tapError((error) => {
379
- this.logger?.error(`${context.field} validation failed`, {
380
- consumerName: context.consumerName,
381
- queueName: context.queueName,
382
- error
383
- });
384
- this.amqpClient.nack(msg, false, false);
385
640
  });
386
641
  }
387
642
  /**
388
- * Parse and validate a message from AMQP.
389
- * @returns Ok with validated message (payload + headers), or Error (message already nacked)
643
+ * Parse and validate a message from AMQP. Pure: returns the validated payload
644
+ * and headers, or an error. The dispatch path in {@link processMessage} routes
645
+ * validation/parse errors directly to the DLQ (single nack) — they never enter
646
+ * the retry pipeline because retrying an unparseable or schema-violating
647
+ * payload cannot succeed.
390
648
  */
391
649
  parseAndValidateMessage(msg, consumer, consumerName) {
392
- const queue = (0, _amqp_contract_contract.extractQueue)(consumer.queue);
393
- const context = {
394
- consumerName: String(consumerName),
395
- queueName: queue.name
396
- };
397
- const nackAndError = (message, error) => {
398
- this.logger?.error(message, {
399
- ...context,
400
- error
401
- });
402
- this.amqpClient.nack(msg, false, false);
403
- return new _amqp_contract_core.TechnicalError(message, error);
404
- };
405
- const parsePayload = decompressBuffer(msg.content, msg.properties.contentEncoding).tapError((error) => {
406
- this.logger?.error("Failed to decompress message", {
407
- ...context,
408
- error
409
- });
410
- this.amqpClient.nack(msg, false, false);
411
- }).mapOkToResult((buffer) => _swan_io_boxed.Result.fromExecution(() => JSON.parse(buffer.toString())).mapError((error) => nackAndError("Failed to parse JSON", error))).flatMapOk((parsed) => this.validateSchema(consumer.message.payload, parsed, {
650
+ const context = { consumerName: String(consumerName) };
651
+ const parsePayload = decompressBuffer(msg.content, msg.properties.contentEncoding).mapErrorToResult((error) => _swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError("Failed to decompress message", error))).mapOkToResult((buffer) => _swan_io_boxed.Result.fromExecution(() => JSON.parse(buffer.toString())).mapError((error) => new _amqp_contract_core.TechnicalError("Failed to parse JSON", error))).flatMapOk((parsed) => this.validateSchema(consumer.message.payload, parsed, {
412
652
  ...context,
413
653
  field: "payload"
414
- }, msg));
654
+ }));
415
655
  const parseHeaders = consumer.message.headers ? this.validateSchema(consumer.message.headers, msg.properties.headers ?? {}, {
416
656
  ...context,
417
657
  field: "headers"
418
- }, msg) : _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
658
+ }) : _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
419
659
  return _swan_io_boxed.Future.allFromDict({
420
660
  payload: parsePayload,
421
661
  headers: parseHeaders
422
662
  }).map(_swan_io_boxed.Result.allFromDict);
423
663
  }
424
664
  /**
425
- * Consume messages one at a time
665
+ * Validate an RPC handler's response and publish it back to the caller's reply
666
+ * queue with the same `correlationId`. Published via the AMQP default exchange
667
+ * with `routingKey = msg.properties.replyTo`, which works for both
668
+ * `amq.rabbitmq.reply-to` and any anonymous queue declared by the caller.
669
+ *
670
+ * Failure semantics:
671
+ * - **Missing replyTo / correlationId**: NonRetryableError. The caller is
672
+ * already lost; retrying the original message cannot recover the reply
673
+ * path. The poison message lands in DLQ for inspection rather than being
674
+ * silently ack'd (which would mask a contract violation).
675
+ * - **Schema validation failure**: NonRetryableError — the handler returned
676
+ * the wrong shape; retrying the same input will not fix it.
677
+ * - **Publish failure**: NonRetryableError. The caller has already timed out
678
+ * (or will shortly), so retrying the message wastes the queue's retry
679
+ * budget on a reply that no one is waiting for. The message is logged and
680
+ * DLQ'd; the original work is treated as completed for the purpose of the
681
+ * inbox.
682
+ */
683
+ publishRpcResponse(msg, queueName, rpcName, responseSchema, response) {
684
+ const replyTo = msg.properties.replyTo;
685
+ const correlationId = msg.properties.correlationId;
686
+ if (typeof replyTo !== "string" || replyTo.length === 0) {
687
+ this.logger?.error("RPC handler returned a response but the incoming message has no replyTo", {
688
+ rpcName: String(rpcName),
689
+ queueName
690
+ });
691
+ return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new NonRetryableError(`RPC "${String(rpcName)}" received a message without replyTo; cannot deliver response`)));
692
+ }
693
+ if (typeof correlationId !== "string" || correlationId.length === 0) {
694
+ this.logger?.error("RPC handler returned a response but the incoming message has no correlationId", {
695
+ rpcName: String(rpcName),
696
+ queueName,
697
+ replyTo
698
+ });
699
+ return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new NonRetryableError(`RPC "${String(rpcName)}" received a message without correlationId; cannot deliver response`)));
700
+ }
701
+ let rawValidation;
702
+ try {
703
+ rawValidation = responseSchema["~standard"].validate(response);
704
+ } catch (error) {
705
+ return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new NonRetryableError("RPC response schema validation threw", error)));
706
+ }
707
+ const validationPromise = rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation);
708
+ return _swan_io_boxed.Future.fromPromise(validationPromise).mapError((error) => new NonRetryableError("RPC response schema validation threw", error)).mapOkToResult((validation) => {
709
+ if (validation.issues) return _swan_io_boxed.Result.Error(new NonRetryableError(`RPC response for "${String(rpcName)}" failed schema validation`, new _amqp_contract_core.MessageValidationError(String(rpcName), validation.issues)));
710
+ return _swan_io_boxed.Result.Ok(validation.value);
711
+ }).flatMapOk((validatedResponse) => this.amqpClient.publish("", replyTo, validatedResponse, {
712
+ correlationId,
713
+ contentType: "application/json"
714
+ }).mapErrorToResult((error) => _swan_io_boxed.Result.Error(new NonRetryableError("Failed to publish RPC response", error))).mapOkToResult((published) => published ? _swan_io_boxed.Result.Ok(void 0) : _swan_io_boxed.Result.Error(new NonRetryableError("Failed to publish RPC response: channel buffer full"))));
715
+ }
716
+ /**
717
+ * Process a single consumed message: validate, invoke handler, optionally
718
+ * publish the RPC response, record telemetry, and handle errors.
426
719
  */
427
- consumeSingle(consumerName, consumer, handler) {
720
+ processMessage(msg, view, name, handler) {
721
+ const { consumer, isRpc, responseSchema } = view;
428
722
  const queueName = (0, _amqp_contract_contract.extractQueue)(consumer.queue).name;
429
- return this.amqpClient.consume(queueName, async (msg) => {
430
- if (msg === null) {
431
- this.logger?.warn("Consumer cancelled by server", {
432
- consumerName: String(consumerName),
433
- queueName
723
+ const startTime = Date.now();
724
+ const span = (0, _amqp_contract_core.startConsumeSpan)(this.telemetry, queueName, String(name), { "messaging.rabbitmq.message.delivery_tag": msg.fields.deliveryTag });
725
+ let messageHandled = false;
726
+ let firstError;
727
+ return this.parseAndValidateMessage(msg, consumer, name).flatMap((parseResult) => parseResult.match({
728
+ Ok: (validatedMessage) => handler(validatedMessage, msg).flatMapOk((handlerResponse) => {
729
+ if (isRpc && responseSchema) return this.publishRpcResponse(msg, queueName, name, responseSchema, handlerResponse).flatMapOk(() => {
730
+ this.logger?.info("Message consumed successfully", {
731
+ consumerName: String(name),
732
+ queueName
733
+ });
734
+ this.amqpClient.ack(msg);
735
+ messageHandled = true;
736
+ return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
434
737
  });
435
- return;
436
- }
437
- const startTime = Date.now();
438
- const span = (0, _amqp_contract_core.startConsumeSpan)(this.telemetry, queueName, String(consumerName), { "messaging.rabbitmq.message.delivery_tag": msg.fields.deliveryTag });
439
- await this.parseAndValidateMessage(msg, consumer, consumerName).flatMapOk((validatedMessage) => handler(validatedMessage, msg).flatMapOk(() => {
440
738
  this.logger?.info("Message consumed successfully", {
441
- consumerName: String(consumerName),
739
+ consumerName: String(name),
442
740
  queueName
443
741
  });
444
742
  this.amqpClient.ack(msg);
445
- const durationMs = Date.now() - startTime;
446
- (0, _amqp_contract_core.endSpanSuccess)(span);
447
- (0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(consumerName), true, durationMs);
743
+ messageHandled = true;
448
744
  return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
449
745
  }).flatMapError((handlerError) => {
450
746
  this.logger?.error("Error processing message", {
451
- consumerName: String(consumerName),
747
+ consumerName: String(name),
452
748
  queueName,
453
749
  errorType: handlerError.name,
454
750
  error: handlerError.message
455
751
  });
456
- const durationMs = Date.now() - startTime;
457
- (0, _amqp_contract_core.endSpanError)(span, handlerError);
458
- (0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(consumerName), false, durationMs);
459
- return this.handleError(handlerError, msg, String(consumerName), consumer);
460
- })).tapError(() => {
461
- const durationMs = Date.now() - startTime;
462
- (0, _amqp_contract_core.endSpanError)(span, /* @__PURE__ */ new Error("Message validation failed"));
463
- (0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(consumerName), false, durationMs);
464
- }).toPromise();
465
- }, this.consumerOptions[consumerName]).tapOk((consumerTag) => {
466
- this.consumerTags.add(consumerTag);
467
- }).mapError((error) => new _amqp_contract_core.TechnicalError(`Failed to start consuming for "${String(consumerName)}"`, error)).mapOk(() => void 0);
468
- }
469
- /**
470
- * Handle error in message processing with retry logic.
471
- *
472
- * Flow depends on retry mode:
473
- *
474
- * **immediate-requeue mode:**
475
- * 1. If NonRetryableError -> send directly to DLQ (no retry)
476
- * 2. If max retries exceeded -> send to DLQ
477
- * 3. Otherwise -> requeue immediately for retry
478
- *
479
- * **ttl-backoff mode:**
480
- * 1. If NonRetryableError -> send directly to DLQ (no retry)
481
- * 2. If max retries exceeded -> send to DLQ
482
- * 3. Otherwise -> publish to wait queue with TTL for retry
483
- *
484
- * **none mode (no retry config):**
485
- * 1. send directly to DLQ (no retry)
486
- */
487
- handleError(error, msg, consumerName, consumer) {
488
- if (error instanceof NonRetryableError) {
489
- this.logger?.error("Non-retryable error, sending to DLQ immediately", {
490
- consumerName,
491
- errorType: error.name,
492
- error: error.message
493
- });
494
- this.sendToDLQ(msg, consumer);
495
- return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
496
- }
497
- const config = this.getRetryConfigForConsumer(consumer);
498
- if (config.mode === "immediate-requeue") return this.handleErrorImmediateRequeue(error, msg, consumerName, consumer, config);
499
- if (config.mode === "ttl-backoff") return this.handleErrorTtlBackoff(error, msg, consumerName, consumer, config);
500
- this.logger?.warn("Retry disabled (none mode), sending to DLQ", {
501
- consumerName,
502
- error: error.message
503
- });
504
- this.sendToDLQ(msg, consumer);
505
- return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
506
- }
507
- /**
508
- * Handle error by requeuing immediately.
509
- *
510
- * For quorum queues, messages are requeued with `nack(requeue=true)`, and the worker tracks delivery count via the native RabbitMQ `x-delivery-count` header.
511
- * For classic queues, messages are re-published on the same queue, and the worker tracks delivery count via a custom `x-retry-count` header.
512
- * When the count exceeds `maxRetries`, the message is automatically dead-lettered (if DLX is configured) or dropped.
513
- *
514
- * This is simpler than TTL-based retry but provides immediate retries only.
515
- */
516
- handleErrorImmediateRequeue(error, msg, consumerName, consumer, config) {
517
- const queue = (0, _amqp_contract_contract.extractQueue)(consumer.queue);
518
- const queueName = queue.name;
519
- const retryCount = queue.type === "quorum" ? msg.properties.headers?.["x-delivery-count"] ?? 0 : msg.properties.headers?.["x-retry-count"] ?? 0;
520
- if (retryCount >= config.maxRetries) {
521
- this.logger?.error("Max retries exceeded, sending to DLQ (immediate-requeue mode)", {
522
- consumerName,
523
- queueName,
524
- retryCount,
525
- maxRetries: config.maxRetries,
526
- error: error.message
527
- });
528
- this.sendToDLQ(msg, consumer);
529
- return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
530
- }
531
- this.logger?.warn("Retrying message (immediate-requeue mode)", {
532
- consumerName,
533
- queueName,
534
- retryCount,
535
- maxRetries: config.maxRetries,
536
- error: error.message
537
- });
538
- if (queue.type === "quorum") {
539
- this.amqpClient.nack(msg, false, true);
540
- return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
541
- } else return this.publishForRetry({
542
- msg,
543
- exchange: msg.fields.exchange,
544
- routingKey: msg.fields.routingKey,
545
- queueName,
546
- error
547
- });
548
- }
549
- /**
550
- * Handle error using TTL + wait queue pattern for exponential backoff.
551
- *
552
- * ┌─────────────────────────────────────────────────────────────────┐
553
- * │ Retry Flow (Native RabbitMQ TTL + Wait queue pattern) │
554
- * ├─────────────────────────────────────────────────────────────────┤
555
- * │ │
556
- * │ 1. Handler throws any Error │
557
- * │ ↓ │
558
- * │ 2. Worker publishes to wait exchange |
559
- * | (with header `x-wait-queue` set to the wait queue name) │
560
- * │ ↓ │
561
- * │ 3. Wait exchange routes to wait queue │
562
- * │ (with expiration: calculated backoff delay) │
563
- * │ ↓ │
564
- * │ 4. Message waits in queue until TTL expires │
565
- * │ ↓ │
566
- * │ 5. Expired message dead-lettered to retry exchange |
567
- * | (with header `x-retry-queue` set to the main queue name) │
568
- * │ ↓ │
569
- * │ 6. Retry exchange routes back to main queue → RETRY │
570
- * │ ↓ │
571
- * │ 7. If retries exhausted: nack without requeue → DLQ │
572
- * │ │
573
- * └─────────────────────────────────────────────────────────────────┘
574
- */
575
- handleErrorTtlBackoff(error, msg, consumerName, consumer, config) {
576
- if (!(0, _amqp_contract_contract.isQueueWithTtlBackoffInfrastructure)(consumer.queue)) {
577
- this.logger?.error("Queue does not have TTL-backoff infrastructure", {
578
- consumerName,
579
- queueName: consumer.queue.name
580
- });
581
- return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError("Queue does not have TTL-backoff infrastructure")));
582
- }
583
- const queueEntry = consumer.queue;
584
- const queueName = (0, _amqp_contract_contract.extractQueue)(queueEntry).name;
585
- const retryCount = msg.properties.headers?.["x-retry-count"] ?? 0;
586
- if (retryCount >= config.maxRetries) {
587
- this.logger?.error("Max retries exceeded, sending to DLQ (ttl-backoff mode)", {
588
- consumerName,
589
- queueName,
590
- retryCount,
591
- maxRetries: config.maxRetries,
592
- error: error.message
593
- });
594
- this.sendToDLQ(msg, consumer);
595
- return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
596
- }
597
- const delayMs = this.calculateRetryDelay(retryCount, config);
598
- this.logger?.warn("Retrying message (ttl-backoff mode)", {
599
- consumerName,
600
- queueName,
601
- retryCount: retryCount + 1,
602
- maxRetries: config.maxRetries,
603
- delayMs,
604
- error: error.message
605
- });
606
- return this.publishForRetry({
607
- msg,
608
- exchange: queueEntry.waitExchange.name,
609
- routingKey: msg.fields.routingKey,
610
- waitQueueName: queueEntry.waitQueue.name,
611
- queueName,
612
- delayMs,
613
- error
752
+ firstError = handlerError;
753
+ return handleError({
754
+ amqpClient: this.amqpClient,
755
+ logger: this.logger
756
+ }, handlerError, msg, String(name), consumer);
757
+ }),
758
+ Error: (parseError) => {
759
+ firstError = parseError;
760
+ this.logger?.error("Failed to parse/validate message; sending to DLQ", {
761
+ consumerName: String(name),
762
+ queueName,
763
+ error: parseError
764
+ });
765
+ this.amqpClient.nack(msg, false, false);
766
+ return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(parseError));
767
+ }
768
+ })).map((result) => {
769
+ const durationMs = Date.now() - startTime;
770
+ if (messageHandled) {
771
+ (0, _amqp_contract_core.endSpanSuccess)(span);
772
+ (0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(name), true, durationMs);
773
+ } else {
774
+ (0, _amqp_contract_core.endSpanError)(span, result.isError() ? result.error : firstError ?? /* @__PURE__ */ new Error("Unknown error"));
775
+ (0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(name), false, durationMs);
776
+ }
777
+ return result;
614
778
  });
615
779
  }
616
780
  /**
617
- * Calculate retry delay with exponential backoff and optional jitter.
618
- */
619
- calculateRetryDelay(retryCount, config) {
620
- const { initialDelayMs, maxDelayMs, backoffMultiplier, jitter } = config;
621
- let delay = Math.min(initialDelayMs * Math.pow(backoffMultiplier, retryCount), maxDelayMs);
622
- if (jitter) delay = delay * (.5 + Math.random() * .5);
623
- return Math.floor(delay);
624
- }
625
- /**
626
- * Parse message content for republishing.
627
- * Prevents double JSON serialization by converting Buffer to object when possible.
628
- */
629
- parseMessageContentForRetry(msg, queueName) {
630
- let content = msg.content;
631
- if (!msg.properties.contentEncoding) try {
632
- content = JSON.parse(msg.content.toString());
633
- } catch (err) {
634
- this.logger?.warn("Failed to parse message for retry, using original buffer", {
635
- queueName,
636
- error: err
637
- });
638
- }
639
- return content;
640
- }
641
- /**
642
- * Publish message with an incremented x-retry-count header and optional TTL.
781
+ * Consume messages one at a time.
643
782
  */
644
- publishForRetry({ msg, exchange, routingKey, queueName, waitQueueName, delayMs, error }) {
645
- const newRetryCount = (msg.properties.headers?.["x-retry-count"] ?? 0) + 1;
646
- this.amqpClient.ack(msg);
647
- const content = this.parseMessageContentForRetry(msg, queueName);
648
- return this.amqpClient.publish(exchange, routingKey, content, {
649
- ...msg.properties,
650
- ...delayMs !== void 0 ? { expiration: delayMs.toString() } : {},
651
- headers: {
652
- ...msg.properties.headers,
653
- "x-retry-count": newRetryCount,
654
- "x-last-error": error.message,
655
- "x-first-failure-timestamp": msg.properties.headers?.["x-first-failure-timestamp"] ?? Date.now(),
656
- ...waitQueueName !== void 0 ? {
657
- "x-wait-queue": waitQueueName,
658
- "x-retry-queue": queueName
659
- } : {}
783
+ consumeSingle(name, view, handler) {
784
+ const queueName = (0, _amqp_contract_contract.extractQueue)(view.consumer.queue).name;
785
+ return this.amqpClient.consume(queueName, async (msg) => {
786
+ if (msg === null) {
787
+ this.logger?.warn("Consumer cancelled by server", {
788
+ consumerName: String(name),
789
+ queueName
790
+ });
791
+ return;
660
792
  }
661
- }).mapOkToResult((published) => {
662
- if (!published) {
663
- this.logger?.error("Failed to publish message for retry (write buffer full)", {
793
+ try {
794
+ await this.processMessage(msg, view, name, handler).toPromise();
795
+ } catch (error) {
796
+ this.logger?.error("Uncaught error in consume callback; nacking message", {
797
+ consumerName: String(name),
664
798
  queueName,
665
- retryCount: newRetryCount,
666
- ...delayMs !== void 0 ? { delayMs } : {}
799
+ error
667
800
  });
668
- return _swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError("Failed to publish message for retry (write buffer full)"));
801
+ this.amqpClient.nack(msg, false, false);
669
802
  }
670
- this.logger?.info("Message published for retry", {
671
- queueName,
672
- retryCount: newRetryCount,
673
- ...delayMs !== void 0 ? { delayMs } : {}
674
- });
675
- return _swan_io_boxed.Result.Ok(void 0);
676
- });
677
- }
678
- /**
679
- * Send message to dead letter queue.
680
- * Nacks the message without requeue, relying on DLX configuration.
681
- */
682
- sendToDLQ(msg, consumer) {
683
- const queue = (0, _amqp_contract_contract.extractQueue)(consumer.queue);
684
- const queueName = queue.name;
685
- if (!(queue.deadLetter !== void 0)) this.logger?.warn("Queue does not have DLX configured - message will be lost on nack", { queueName });
686
- this.logger?.info("Sending message to DLQ", {
687
- queueName,
688
- deliveryTag: msg.fields.deliveryTag
689
- });
690
- this.amqpClient.nack(msg, false, false);
803
+ }, this.consumerOptions[name]).tapOk((consumerTag) => {
804
+ this.consumerTags.add(consumerTag);
805
+ }).mapError((error) => new _amqp_contract_core.TechnicalError(`Failed to start consuming for "${String(name)}"`, error)).mapOk(() => void 0);
691
806
  }
692
807
  };
693
808
  //#endregion