@amqp-contract/worker 0.20.0 → 0.22.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
@@ -1,10 +1,41 @@
1
- Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
- let _amqp_contract_core = require("@amqp-contract/core");
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
2
  let _amqp_contract_contract = require("@amqp-contract/contract");
3
+ let _amqp_contract_core = require("@amqp-contract/core");
4
4
  let _swan_io_boxed = require("@swan-io/boxed");
5
5
  let node_zlib = require("node:zlib");
6
6
  let node_util = require("node:util");
7
-
7
+ //#region src/decompression.ts
8
+ const gunzipAsync = (0, node_util.promisify)(node_zlib.gunzip);
9
+ const inflateAsync = (0, node_util.promisify)(node_zlib.inflate);
10
+ /**
11
+ * Supported content encodings for message decompression.
12
+ */
13
+ const SUPPORTED_ENCODINGS = ["gzip", "deflate"];
14
+ /**
15
+ * Type guard to check if a string is a supported encoding.
16
+ */
17
+ function isSupportedEncoding(encoding) {
18
+ return SUPPORTED_ENCODINGS.includes(encoding.toLowerCase());
19
+ }
20
+ /**
21
+ * Decompress a buffer based on the content-encoding header.
22
+ *
23
+ * @param buffer - The buffer to decompress
24
+ * @param contentEncoding - The content-encoding header value (e.g., 'gzip', 'deflate')
25
+ * @returns A Future with the decompressed buffer or a TechnicalError
26
+ *
27
+ * @internal
28
+ */
29
+ function decompressBuffer(buffer, contentEncoding) {
30
+ if (!contentEncoding) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(buffer));
31
+ const normalizedEncoding = contentEncoding.toLowerCase();
32
+ if (!isSupportedEncoding(normalizedEncoding)) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError(`Unsupported content-encoding: "${contentEncoding}". Supported encodings are: ${SUPPORTED_ENCODINGS.join(", ")}. Please check your publisher configuration.`)));
33
+ switch (normalizedEncoding) {
34
+ case "gzip": return _swan_io_boxed.Future.fromPromise(gunzipAsync(buffer)).mapError((error) => new _amqp_contract_core.TechnicalError("Failed to decompress gzip", error));
35
+ case "deflate": return _swan_io_boxed.Future.fromPromise(inflateAsync(buffer)).mapError((error) => new _amqp_contract_core.TechnicalError("Failed to decompress deflate", error));
36
+ }
37
+ }
38
+ //#endregion
8
39
  //#region src/errors.ts
9
40
  /**
10
41
  * Retryable errors - transient failures that may succeed on retry
@@ -165,40 +196,231 @@ function retryable(message, cause) {
165
196
  function nonRetryable(message, cause) {
166
197
  return new NonRetryableError(message, cause);
167
198
  }
168
-
169
199
  //#endregion
170
- //#region src/decompression.ts
171
- const gunzipAsync = (0, node_util.promisify)(node_zlib.gunzip);
172
- const inflateAsync = (0, node_util.promisify)(node_zlib.inflate);
200
+ //#region src/retry.ts
173
201
  /**
174
- * Supported content encodings for message decompression.
175
- */
176
- const SUPPORTED_ENCODINGS = ["gzip", "deflate"];
177
- /**
178
- * Type guard to check if a string is a supported encoding.
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)
179
218
  */
180
- function isSupportedEncoding(encoding) {
181
- return SUPPORTED_ENCODINGS.includes(encoding.toLowerCase());
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));
182
238
  }
183
239
  /**
184
- * Decompress a buffer based on the content-encoding header.
240
+ * Handle error by requeuing immediately.
185
241
  *
186
- * @param buffer - The buffer to decompress
187
- * @param contentEncoding - The content-encoding header value (e.g., 'gzip', 'deflate')
188
- * @returns A Future with the decompressed buffer or a TechnicalError
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.
189
245
  *
190
- * @internal
246
+ * This is simpler than TTL-based retry but provides immediate retries only.
191
247
  */
192
- function decompressBuffer(buffer, contentEncoding) {
193
- if (!contentEncoding) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(buffer));
194
- const normalizedEncoding = contentEncoding.toLowerCase();
195
- if (!isSupportedEncoding(normalizedEncoding)) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError(`Unsupported content-encoding: "${contentEncoding}". Supported encodings are: ${SUPPORTED_ENCODINGS.join(", ")}. Please check your publisher configuration.`)));
196
- switch (normalizedEncoding) {
197
- case "gzip": return _swan_io_boxed.Future.fromPromise(gunzipAsync(buffer)).mapError((error) => new _amqp_contract_core.TechnicalError("Failed to decompress gzip", error));
198
- case "deflate": return _swan_io_boxed.Future.fromPromise(inflateAsync(buffer)).mapError((error) => new _amqp_contract_core.TechnicalError("Failed to decompress deflate", error));
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));
199
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
+ * Prevents double JSON serialization by converting Buffer to object when possible.
360
+ */
361
+ function parseMessageContentForRetry(ctx, msg, queueName) {
362
+ let content = msg.content;
363
+ if (!msg.properties.contentEncoding) try {
364
+ content = JSON.parse(msg.content.toString());
365
+ } catch (err) {
366
+ ctx.logger?.warn("Failed to parse message for retry, using original buffer", {
367
+ queueName,
368
+ error: err
369
+ });
370
+ }
371
+ return content;
372
+ }
373
+ /**
374
+ * Publish message with an incremented x-retry-count header and optional TTL.
375
+ */
376
+ function publishForRetry(ctx, { msg, exchange, routingKey, queueName, waitQueueName, delayMs, error }) {
377
+ const newRetryCount = (msg.properties.headers?.["x-retry-count"] ?? 0) + 1;
378
+ ctx.amqpClient.ack(msg);
379
+ const content = parseMessageContentForRetry(ctx, msg, queueName);
380
+ return ctx.amqpClient.publish(exchange, routingKey, content, {
381
+ ...msg.properties,
382
+ ...delayMs !== void 0 ? { expiration: delayMs.toString() } : {},
383
+ headers: {
384
+ ...msg.properties.headers,
385
+ "x-retry-count": newRetryCount,
386
+ "x-last-error": error.message,
387
+ "x-first-failure-timestamp": msg.properties.headers?.["x-first-failure-timestamp"] ?? Date.now(),
388
+ ...waitQueueName !== void 0 ? {
389
+ "x-wait-queue": waitQueueName,
390
+ "x-retry-queue": queueName
391
+ } : {}
392
+ }
393
+ }).mapOkToResult((published) => {
394
+ if (!published) {
395
+ ctx.logger?.error("Failed to publish message for retry (write buffer full)", {
396
+ queueName,
397
+ retryCount: newRetryCount,
398
+ ...delayMs !== void 0 ? { delayMs } : {}
399
+ });
400
+ return _swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError("Failed to publish message for retry (write buffer full)"));
401
+ }
402
+ ctx.logger?.info("Message published for retry", {
403
+ queueName,
404
+ retryCount: newRetryCount,
405
+ ...delayMs !== void 0 ? { delayMs } : {}
406
+ });
407
+ return _swan_io_boxed.Result.Ok(void 0);
408
+ });
409
+ }
410
+ /**
411
+ * Send message to dead letter queue.
412
+ * Nacks the message without requeue, relying on DLX configuration.
413
+ */
414
+ function sendToDLQ(ctx, msg, consumer) {
415
+ const queue = (0, _amqp_contract_contract.extractQueue)(consumer.queue);
416
+ const queueName = queue.name;
417
+ if (!(queue.deadLetter !== void 0)) ctx.logger?.warn("Queue does not have DLX configured - message will be lost on nack", { queueName });
418
+ ctx.logger?.info("Sending message to DLQ", {
419
+ queueName,
420
+ deliveryTag: msg.fields.deliveryTag
421
+ });
422
+ ctx.amqpClient.nack(msg, false, false);
200
423
  }
201
-
202
424
  //#endregion
203
425
  //#region src/worker.ts
204
426
  /**
@@ -221,7 +443,7 @@ function isHandlerTuple(entry) {
221
443
  * import { defineQueue, defineMessage, defineContract, defineConsumer } from '@amqp-contract/contract';
222
444
  * import { z } from 'zod';
223
445
  *
224
- * const orderQueue = defineQueue('order-processing', { durable: true });
446
+ * const orderQueue = defineQueue('order-processing');
225
447
  * const orderMessage = defineMessage(z.object({
226
448
  * orderId: z.string(),
227
449
  * amount: z.number()
@@ -250,31 +472,67 @@ function isHandlerTuple(entry) {
250
472
  */
251
473
  var TypedAmqpWorker = class TypedAmqpWorker {
252
474
  /**
253
- * Internal handler storage - handlers returning `Future<Result>`.
475
+ * Internal handler storage. Keyed by handler name (consumer or RPC); the
476
+ * stored function signature is widened so the dispatch loop can call it
477
+ * uniformly. The actual handler is type-checked at the worker's public API
478
+ * boundary via `WorkerInferHandlers<TContract>`.
254
479
  */
255
480
  actualHandlers;
256
481
  consumerOptions;
257
482
  consumerTags = /* @__PURE__ */ new Set();
258
483
  telemetry;
259
- constructor(contract, amqpClient, handlers, logger, telemetry) {
484
+ constructor(contract, amqpClient, handlers, defaultConsumerOptions, logger, telemetry) {
260
485
  this.contract = contract;
261
486
  this.amqpClient = amqpClient;
487
+ this.defaultConsumerOptions = defaultConsumerOptions;
262
488
  this.logger = logger;
263
489
  this.telemetry = telemetry ?? _amqp_contract_core.defaultTelemetryProvider;
264
490
  this.actualHandlers = {};
265
491
  this.consumerOptions = {};
266
492
  const handlersRecord = handlers;
267
- for (const consumerName of Object.keys(handlersRecord)) {
268
- const handlerEntry = handlersRecord[consumerName];
269
- const typedConsumerName = consumerName;
493
+ for (const handlerName of Object.keys(handlersRecord)) {
494
+ const handlerEntry = handlersRecord[handlerName];
495
+ const typedName = handlerName;
270
496
  if (isHandlerTuple(handlerEntry)) {
271
497
  const [handler, options] = handlerEntry;
272
- this.actualHandlers[typedConsumerName] = handler;
273
- this.consumerOptions[typedConsumerName] = options;
274
- } else this.actualHandlers[typedConsumerName] = handlerEntry;
498
+ this.actualHandlers[typedName] = handler;
499
+ this.consumerOptions[typedName] = {
500
+ ...this.defaultConsumerOptions,
501
+ ...options
502
+ };
503
+ } else {
504
+ this.actualHandlers[typedName] = handlerEntry;
505
+ this.consumerOptions[typedName] = this.defaultConsumerOptions;
506
+ }
275
507
  }
276
508
  }
277
509
  /**
510
+ * Build a `ConsumerDefinition`-shaped view for a handler name, regardless
511
+ * of whether it came from `contract.consumers` or `contract.rpcs`. The
512
+ * dispatch path treats both uniformly; the returned `isRpc` flag (and the
513
+ * accompanying `responseSchema`) tells `processMessage` whether to validate
514
+ * the handler return value and publish a reply.
515
+ */
516
+ resolveConsumerView(name) {
517
+ const rpcs = this.contract.rpcs;
518
+ if (rpcs && Object.hasOwn(rpcs, name)) {
519
+ const rpc = rpcs[name];
520
+ return {
521
+ consumer: {
522
+ queue: rpc.queue,
523
+ message: rpc.request
524
+ },
525
+ isRpc: true,
526
+ responseSchema: rpc.response.payload
527
+ };
528
+ }
529
+ const consumerEntry = this.contract.consumers[name];
530
+ return {
531
+ consumer: (0, _amqp_contract_contract.extractConsumer)(consumerEntry),
532
+ isRpc: false
533
+ };
534
+ }
535
+ /**
278
536
  * Create a type-safe AMQP worker from a contract.
279
537
  *
280
538
  * Connection management (including automatic reconnection) is handled internally
@@ -299,12 +557,18 @@ var TypedAmqpWorker = class TypedAmqpWorker {
299
557
  * }).resultToPromise();
300
558
  * ```
301
559
  */
302
- static create({ contract, handlers, urls, connectionOptions, logger, telemetry }) {
560
+ static create({ contract, handlers, urls, connectionOptions, defaultConsumerOptions, logger, telemetry, connectTimeoutMs }) {
303
561
  const worker = new TypedAmqpWorker(contract, new _amqp_contract_core.AmqpClient(contract, {
304
562
  urls,
305
- connectionOptions
306
- }), handlers, logger, telemetry);
307
- return worker.waitForConnectionReady().flatMapOk(() => worker.consumeAll()).mapOk(() => worker);
563
+ connectionOptions,
564
+ connectTimeoutMs
565
+ }), handlers, defaultConsumerOptions ?? {}, logger, telemetry);
566
+ return worker.waitForConnectionReady().flatMapOk(() => worker.consumeAll()).flatMap((result) => result.match({
567
+ Ok: () => _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(worker)),
568
+ Error: (error) => worker.close().tapError((closeError) => {
569
+ logger?.warn("Failed to close worker after setup failure", { error: closeError });
570
+ }).map(() => _swan_io_boxed.Result.Error(error))
571
+ }));
308
572
  }
309
573
  /**
310
574
  * Close the AMQP channel and connection.
@@ -334,40 +598,25 @@ var TypedAmqpWorker = class TypedAmqpWorker {
334
598
  }).flatMapOk(() => this.amqpClient.close()).mapOk(() => void 0);
335
599
  }
336
600
  /**
337
- * Get the retry configuration for a consumer's queue.
338
- * Defaults are applied in the contract's defineQueue, so we just return the config.
339
- */
340
- getRetryConfigForConsumer(consumer) {
341
- return consumer.queue.retry;
342
- }
343
- /**
344
- * Start consuming messages for all consumers.
345
- * TypeScript guarantees consumers exist (handlers require matching consumers).
601
+ * Start consuming for every entry in `contract.consumers` and `contract.rpcs`.
346
602
  */
347
603
  consumeAll() {
348
- const consumers = this.contract.consumers;
349
- const consumerNames = Object.keys(consumers);
350
- const maxPrefetch = consumerNames.reduce((max, name) => {
351
- const prefetch = this.consumerOptions[name]?.prefetch;
352
- return prefetch ? Math.max(max, prefetch) : max;
353
- }, 0);
354
- if (maxPrefetch > 0) this.amqpClient.addSetup(async (channel) => {
355
- await channel.prefetch(maxPrefetch);
356
- });
357
- return _swan_io_boxed.Future.all(consumerNames.map((name) => this.consume(name))).map(_swan_io_boxed.Result.all).mapOk(() => void 0);
604
+ const consumerNames = Object.keys(this.contract.consumers ?? {});
605
+ const rpcNames = Object.keys(this.contract.rpcs ?? {});
606
+ const allNames = [...consumerNames, ...rpcNames];
607
+ return _swan_io_boxed.Future.all(allNames.map((name) => this.consume(name))).map(_swan_io_boxed.Result.all).mapOk(() => void 0);
358
608
  }
359
609
  waitForConnectionReady() {
360
610
  return this.amqpClient.waitForConnect();
361
611
  }
362
612
  /**
363
- * Start consuming messages for a specific consumer.
364
- * TypeScript guarantees consumer and handler exist for valid consumer names.
613
+ * Start consuming messages for a specific handler — either a `consumers`
614
+ * entry (regular event/command consumer) or an `rpcs` entry (RPC server).
365
615
  */
366
- consume(consumerName) {
367
- const consumerEntry = this.contract.consumers[consumerName];
368
- const consumer = (0, _amqp_contract_contract.extractConsumer)(consumerEntry);
369
- const handler = this.actualHandlers[consumerName];
370
- return this.consumeSingle(consumerName, consumer, handler);
616
+ consume(name) {
617
+ const view = this.resolveConsumerView(name);
618
+ const handler = this.actualHandlers[name];
619
+ return this.consumeSingle(name, view, handler);
371
620
  }
372
621
  /**
373
622
  * Validate data against a Standard Schema and handle errors.
@@ -392,9 +641,10 @@ var TypedAmqpWorker = class TypedAmqpWorker {
392
641
  * @returns Ok with validated message (payload + headers), or Error (message already nacked)
393
642
  */
394
643
  parseAndValidateMessage(msg, consumer, consumerName) {
644
+ const queue = (0, _amqp_contract_contract.extractQueue)(consumer.queue);
395
645
  const context = {
396
646
  consumerName: String(consumerName),
397
- queueName: consumer.queue.name
647
+ queueName: queue.name
398
648
  };
399
649
  const nackAndError = (message, error) => {
400
650
  this.logger?.error(message, {
@@ -424,244 +674,119 @@ var TypedAmqpWorker = class TypedAmqpWorker {
424
674
  }).map(_swan_io_boxed.Result.allFromDict);
425
675
  }
426
676
  /**
427
- * Consume messages one at a time
428
- */
429
- consumeSingle(consumerName, consumer, handler) {
430
- const queueName = consumer.queue.name;
431
- return this.amqpClient.consume(queueName, async (msg) => {
432
- if (msg === null) {
433
- this.logger?.warn("Consumer cancelled by server", {
434
- consumerName: String(consumerName),
435
- queueName
436
- });
437
- return;
438
- }
439
- const startTime = Date.now();
440
- const span = (0, _amqp_contract_core.startConsumeSpan)(this.telemetry, queueName, String(consumerName), { "messaging.rabbitmq.message.delivery_tag": msg.fields.deliveryTag });
441
- await this.parseAndValidateMessage(msg, consumer, consumerName).flatMapOk((validatedMessage) => handler(validatedMessage, msg).flatMapOk(() => {
442
- this.logger?.info("Message consumed successfully", {
443
- consumerName: String(consumerName),
444
- queueName
445
- });
446
- this.amqpClient.ack(msg);
447
- const durationMs = Date.now() - startTime;
448
- (0, _amqp_contract_core.endSpanSuccess)(span);
449
- (0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(consumerName), true, durationMs);
450
- return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
451
- }).flatMapError((handlerError) => {
452
- this.logger?.error("Error processing message", {
453
- consumerName: String(consumerName),
454
- queueName,
455
- errorType: handlerError.name,
456
- error: handlerError.message
457
- });
458
- const durationMs = Date.now() - startTime;
459
- (0, _amqp_contract_core.endSpanError)(span, handlerError);
460
- (0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(consumerName), false, durationMs);
461
- return this.handleError(handlerError, msg, String(consumerName), consumer);
462
- })).tapError(() => {
463
- const durationMs = Date.now() - startTime;
464
- (0, _amqp_contract_core.endSpanError)(span, /* @__PURE__ */ new Error("Message validation failed"));
465
- (0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(consumerName), false, durationMs);
466
- }).toPromise();
467
- }).tapOk((consumerTag) => {
468
- this.consumerTags.add(consumerTag);
469
- }).mapError((error) => new _amqp_contract_core.TechnicalError(`Failed to start consuming for "${String(consumerName)}"`, error)).mapOk(() => void 0);
470
- }
471
- /**
472
- * Handle error in message processing with retry logic.
677
+ * Validate an RPC handler's response and publish it back to the caller's reply
678
+ * queue with the same `correlationId`. Published via the AMQP default exchange
679
+ * with `routingKey = msg.properties.replyTo`, which works for both
680
+ * `amq.rabbitmq.reply-to` and any anonymous queue declared by the caller.
473
681
  *
474
- * Flow depends on retry mode:
475
- *
476
- * **quorum-native mode:**
477
- * 1. If NonRetryableError -> send directly to DLQ (no retry)
478
- * 2. Otherwise -> nack with requeue=true (RabbitMQ handles delivery count)
479
- *
480
- * **ttl-backoff mode:**
481
- * 1. If NonRetryableError -> send directly to DLQ (no retry)
482
- * 2. If max retries exceeded -> send to DLQ
483
- * 3. Otherwise -> publish to wait queue with TTL for retry
484
- *
485
- * **Legacy mode (no retry config):**
486
- * 1. nack with requeue=true (immediate requeue)
682
+ * Validation errors are surfaced as NonRetryableError (handler returned the
683
+ * wrong shape — retrying the same input will not fix it). Publish errors are
684
+ * surfaced as RetryableError so the worker's existing retry logic applies.
487
685
  */
488
- handleError(error, msg, consumerName, consumer) {
489
- if (error instanceof NonRetryableError) {
490
- this.logger?.error("Non-retryable error, sending to DLQ immediately", {
491
- consumerName,
492
- errorType: error.name,
493
- error: error.message
686
+ publishRpcResponse(msg, queueName, rpcName, responseSchema, response) {
687
+ const replyTo = msg.properties.replyTo;
688
+ const correlationId = msg.properties.correlationId;
689
+ if (typeof replyTo !== "string" || replyTo.length === 0) {
690
+ this.logger?.warn("RPC handler returned a response but the incoming message has no replyTo; dropping response", {
691
+ rpcName: String(rpcName),
692
+ queueName
494
693
  });
495
- this.sendToDLQ(msg, consumer);
496
694
  return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
497
695
  }
498
- const config = this.getRetryConfigForConsumer(consumer);
499
- if (config.mode === "quorum-native") return this.handleErrorQuorumNative(error, msg, consumerName, consumer);
500
- return this.handleErrorTtlBackoff(error, msg, consumerName, consumer, config);
501
- }
502
- /**
503
- * Handle error using quorum queue's native delivery limit feature.
504
- *
505
- * Simply requeues the message with nack(requeue=true). RabbitMQ automatically:
506
- * - Increments x-delivery-count header
507
- * - Dead-letters the message when count exceeds x-delivery-limit
508
- *
509
- * This is simpler than TTL-based retry but provides immediate retries only.
510
- */
511
- handleErrorQuorumNative(error, msg, consumerName, consumer) {
512
- const queue = consumer.queue;
513
- const queueName = queue.name;
514
- const deliveryCount = msg.properties.headers?.["x-delivery-count"] ?? 0;
515
- const deliveryLimit = queue.type === "quorum" ? queue.deliveryLimit : void 0;
516
- const attemptsBeforeDeadLetter = deliveryLimit !== void 0 ? Math.max(0, deliveryLimit - deliveryCount - 1) : "unknown";
517
- if (deliveryLimit !== void 0 && deliveryCount >= deliveryLimit - 1) this.logger?.warn("Message at final delivery attempt (quorum-native mode)", {
518
- consumerName,
519
- queueName,
520
- deliveryCount,
521
- deliveryLimit,
522
- willDeadLetterOnNextFailure: deliveryCount === deliveryLimit - 1,
523
- alreadyExceededLimit: deliveryCount >= deliveryLimit,
524
- error: error.message
525
- });
526
- else this.logger?.warn("Retrying message (quorum-native mode)", {
527
- consumerName,
528
- queueName,
529
- deliveryCount,
530
- deliveryLimit,
531
- attemptsBeforeDeadLetter,
532
- error: error.message
533
- });
534
- this.amqpClient.nack(msg, false, true);
535
- return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
536
- }
537
- /**
538
- * Handle error using TTL + wait queue pattern for exponential backoff.
539
- */
540
- handleErrorTtlBackoff(error, msg, consumerName, consumer, config) {
541
- const retryCount = msg.properties.headers?.["x-retry-count"] ?? 0;
542
- if (retryCount >= config.maxRetries) {
543
- this.logger?.error("Max retries exceeded, sending to DLQ", {
544
- consumerName,
545
- retryCount,
546
- maxRetries: config.maxRetries,
547
- error: error.message
696
+ if (typeof correlationId !== "string" || correlationId.length === 0) {
697
+ this.logger?.warn("RPC handler returned a response but the incoming message has no correlationId; dropping response", {
698
+ rpcName: String(rpcName),
699
+ queueName,
700
+ replyTo
548
701
  });
549
- this.sendToDLQ(msg, consumer);
550
702
  return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
551
703
  }
552
- const delayMs = this.calculateRetryDelay(retryCount, config);
553
- this.logger?.warn("Retrying message (ttl-backoff mode)", {
554
- consumerName,
555
- retryCount: retryCount + 1,
556
- delayMs,
557
- error: error.message
558
- });
559
- return this.publishForRetry(msg, consumer, retryCount + 1, delayMs, error);
560
- }
561
- /**
562
- * Calculate retry delay with exponential backoff and optional jitter.
563
- */
564
- calculateRetryDelay(retryCount, config) {
565
- const { initialDelayMs, maxDelayMs, backoffMultiplier, jitter } = config;
566
- let delay = Math.min(initialDelayMs * Math.pow(backoffMultiplier, retryCount), maxDelayMs);
567
- if (jitter) delay = delay * (.5 + Math.random() * .5);
568
- return Math.floor(delay);
569
- }
570
- /**
571
- * Parse message content for republishing.
572
- * Prevents double JSON serialization by converting Buffer to object when possible.
573
- */
574
- parseMessageContentForRetry(msg, queueName) {
575
- let content = msg.content;
576
- if (!msg.properties.contentEncoding) try {
577
- content = JSON.parse(msg.content.toString());
578
- } catch (err) {
579
- this.logger?.warn("Failed to parse message for retry, using original buffer", {
580
- queueName,
581
- error: err
582
- });
704
+ let rawValidation;
705
+ try {
706
+ rawValidation = responseSchema["~standard"].validate(response);
707
+ } catch (error) {
708
+ return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new NonRetryableError("RPC response schema validation threw", error)));
583
709
  }
584
- return content;
710
+ const validationPromise = rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation);
711
+ return _swan_io_boxed.Future.fromPromise(validationPromise).mapError((error) => new NonRetryableError("RPC response schema validation threw", error)).mapOkToResult((validation) => {
712
+ 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)));
713
+ return _swan_io_boxed.Result.Ok(validation.value);
714
+ }).flatMapOk((validatedResponse) => this.amqpClient.publish("", replyTo, validatedResponse, {
715
+ correlationId,
716
+ contentType: "application/json"
717
+ }).mapErrorToResult((error) => _swan_io_boxed.Result.Error(new RetryableError("Failed to publish RPC response", error))).mapOkToResult((published) => published ? _swan_io_boxed.Result.Ok(void 0) : _swan_io_boxed.Result.Error(new RetryableError("Failed to publish RPC response: channel buffer full"))));
585
718
  }
586
719
  /**
587
- * Publish message to wait queue for retry after TTL expires.
588
- *
589
- * ┌─────────────────────────────────────────────────────────────────┐
590
- * │ Retry Flow (Native RabbitMQ TTL + DLX Pattern) │
591
- * ├─────────────────────────────────────────────────────────────────┤
592
- * │ │
593
- * │ 1. Handler throws any Error │
594
- * │ ↓ │
595
- * │ 2. Worker publishes to DLX with routing key: {queue}-wait │
596
- * │ ↓ │
597
- * │ 3. DLX routes to wait queue: {queue}-wait │
598
- * │ (with expiration: calculated backoff delay) │
599
- * │ ↓ │
600
- * │ 4. Message waits in queue until TTL expires │
601
- * │ ↓ │
602
- * │ 5. Expired message dead-lettered to DLX │
603
- * │ (with routing key: {queue}) │
604
- * │ ↓ │
605
- * │ 6. DLX routes back to main queue → RETRY │
606
- * │ ↓ │
607
- * │ 7. If retries exhausted: nack without requeue → DLQ │
608
- * │ │
609
- * └─────────────────────────────────────────────────────────────────┘
720
+ * Process a single consumed message: validate, invoke handler, optionally
721
+ * publish the RPC response, record telemetry, and handle errors.
610
722
  */
611
- publishForRetry(msg, consumer, newRetryCount, delayMs, error) {
612
- const queueName = consumer.queue.name;
613
- const deadLetter = consumer.queue.deadLetter;
614
- if (!deadLetter) {
615
- this.logger?.warn("Cannot retry: queue does not have DLX configured, falling back to nack with requeue", { queueName });
616
- this.amqpClient.nack(msg, false, true);
617
- return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
618
- }
619
- const dlxName = deadLetter.exchange.name;
620
- const waitRoutingKey = `${queueName}-wait`;
621
- this.amqpClient.ack(msg);
622
- const content = this.parseMessageContentForRetry(msg, queueName);
623
- return this.amqpClient.publish(dlxName, waitRoutingKey, content, {
624
- ...msg.properties,
625
- expiration: delayMs.toString(),
626
- headers: {
627
- ...msg.properties.headers,
628
- "x-retry-count": newRetryCount,
629
- "x-last-error": error.message,
630
- "x-first-failure-timestamp": msg.properties.headers?.["x-first-failure-timestamp"] ?? Date.now()
631
- }
632
- }).mapOkToResult((published) => {
633
- if (!published) {
634
- this.logger?.error("Failed to publish message for retry (write buffer full)", {
635
- queueName,
636
- waitRoutingKey,
637
- retryCount: newRetryCount
723
+ processMessage(msg, view, name, handler) {
724
+ const { consumer, isRpc, responseSchema } = view;
725
+ const queueName = (0, _amqp_contract_contract.extractQueue)(consumer.queue).name;
726
+ const startTime = Date.now();
727
+ const span = (0, _amqp_contract_core.startConsumeSpan)(this.telemetry, queueName, String(name), { "messaging.rabbitmq.message.delivery_tag": msg.fields.deliveryTag });
728
+ let messageHandled = false;
729
+ let firstError;
730
+ return this.parseAndValidateMessage(msg, consumer, name).flatMapOk((validatedMessage) => handler(validatedMessage, msg).flatMapOk((handlerResponse) => {
731
+ if (isRpc && responseSchema) return this.publishRpcResponse(msg, queueName, name, responseSchema, handlerResponse).flatMapOk(() => {
732
+ this.logger?.info("Message consumed successfully", {
733
+ consumerName: String(name),
734
+ queueName
638
735
  });
639
- return _swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError("Failed to publish message for retry (write buffer full)"));
640
- }
641
- this.logger?.info("Message published for retry", {
736
+ this.amqpClient.ack(msg);
737
+ messageHandled = true;
738
+ return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
739
+ });
740
+ this.logger?.info("Message consumed successfully", {
741
+ consumerName: String(name),
742
+ queueName
743
+ });
744
+ this.amqpClient.ack(msg);
745
+ messageHandled = true;
746
+ return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
747
+ }).flatMapError((handlerError) => {
748
+ this.logger?.error("Error processing message", {
749
+ consumerName: String(name),
642
750
  queueName,
643
- waitRoutingKey,
644
- retryCount: newRetryCount,
645
- delayMs
751
+ errorType: handlerError.name,
752
+ error: handlerError.message
646
753
  });
647
- return _swan_io_boxed.Result.Ok(void 0);
754
+ firstError = handlerError;
755
+ return handleError({
756
+ amqpClient: this.amqpClient,
757
+ logger: this.logger
758
+ }, handlerError, msg, String(name), consumer);
759
+ })).map((result) => {
760
+ const durationMs = Date.now() - startTime;
761
+ if (messageHandled) {
762
+ (0, _amqp_contract_core.endSpanSuccess)(span);
763
+ (0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(name), true, durationMs);
764
+ } else {
765
+ (0, _amqp_contract_core.endSpanError)(span, result.isError() ? result.error : firstError ?? /* @__PURE__ */ new Error("Unknown error"));
766
+ (0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(name), false, durationMs);
767
+ }
768
+ return result;
648
769
  });
649
770
  }
650
771
  /**
651
- * Send message to dead letter queue.
652
- * Nacks the message without requeue, relying on DLX configuration.
772
+ * Consume messages one at a time.
653
773
  */
654
- sendToDLQ(msg, consumer) {
655
- const queueName = consumer.queue.name;
656
- if (!(consumer.queue.deadLetter !== void 0)) this.logger?.warn("Queue does not have DLX configured - message will be lost on nack", { queueName });
657
- this.logger?.info("Sending message to DLQ", {
658
- queueName,
659
- deliveryTag: msg.fields.deliveryTag
660
- });
661
- this.amqpClient.nack(msg, false, false);
774
+ consumeSingle(name, view, handler) {
775
+ const queueName = (0, _amqp_contract_contract.extractQueue)(view.consumer.queue).name;
776
+ return this.amqpClient.consume(queueName, async (msg) => {
777
+ if (msg === null) {
778
+ this.logger?.warn("Consumer cancelled by server", {
779
+ consumerName: String(name),
780
+ queueName
781
+ });
782
+ return;
783
+ }
784
+ await this.processMessage(msg, view, name, handler).toPromise();
785
+ }, this.consumerOptions[name]).tapOk((consumerTag) => {
786
+ this.consumerTags.add(consumerTag);
787
+ }).mapError((error) => new _amqp_contract_core.TechnicalError(`Failed to start consuming for "${String(name)}"`, error)).mapOk(() => void 0);
662
788
  }
663
789
  };
664
-
665
790
  //#endregion
666
791
  //#region src/handlers.ts
667
792
  /**
@@ -722,13 +847,12 @@ function defineHandlers(contract, handlers) {
722
847
  validateHandlers(contract, handlers);
723
848
  return handlers;
724
849
  }
725
-
726
850
  //#endregion
727
- Object.defineProperty(exports, 'MessageValidationError', {
728
- enumerable: true,
729
- get: function () {
730
- return _amqp_contract_core.MessageValidationError;
731
- }
851
+ Object.defineProperty(exports, "MessageValidationError", {
852
+ enumerable: true,
853
+ get: function() {
854
+ return _amqp_contract_core.MessageValidationError;
855
+ }
732
856
  });
733
857
  exports.NonRetryableError = NonRetryableError;
734
858
  exports.RetryableError = RetryableError;
@@ -739,4 +863,4 @@ exports.isHandlerError = isHandlerError;
739
863
  exports.isNonRetryableError = isNonRetryableError;
740
864
  exports.isRetryableError = isRetryableError;
741
865
  exports.nonRetryable = nonRetryable;
742
- exports.retryable = retryable;
866
+ exports.retryable = retryable;