@amqp-contract/worker 0.24.0 → 1.0.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,7 +1,7 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  let _amqp_contract_contract = require("@amqp-contract/contract");
3
3
  let _amqp_contract_core = require("@amqp-contract/core");
4
- let neverthrow = require("neverthrow");
4
+ let unthrown = require("unthrown");
5
5
  let node_zlib = require("node:zlib");
6
6
  let node_util = require("node:util");
7
7
  //#region src/decompression.ts
@@ -22,17 +22,17 @@ function isSupportedEncoding(encoding) {
22
22
  *
23
23
  * @param buffer - The buffer to decompress
24
24
  * @param contentEncoding - The content-encoding header value (e.g., 'gzip', 'deflate')
25
- * @returns A ResultAsync resolving to the decompressed buffer or a TechnicalError
25
+ * @returns An AsyncResult resolving to the decompressed buffer or a TechnicalError
26
26
  *
27
27
  * @internal
28
28
  */
29
29
  function decompressBuffer(buffer, contentEncoding) {
30
- if (!contentEncoding) return (0, neverthrow.okAsync)(buffer);
30
+ if (!contentEncoding) return (0, unthrown.ok)(buffer).toAsync();
31
31
  const normalizedEncoding = contentEncoding.toLowerCase();
32
- if (!isSupportedEncoding(normalizedEncoding)) return (0, neverthrow.errAsync)(new _amqp_contract_core.TechnicalError(`Unsupported content-encoding: "${contentEncoding}". Supported encodings are: ${SUPPORTED_ENCODINGS.join(", ")}. Please check your publisher configuration.`));
32
+ if (!isSupportedEncoding(normalizedEncoding)) return (0, unthrown.err)(new _amqp_contract_core.TechnicalError(`Unsupported content-encoding: "${contentEncoding}". Supported encodings are: ${SUPPORTED_ENCODINGS.join(", ")}. Please check your publisher configuration.`)).toAsync();
33
33
  switch (normalizedEncoding) {
34
- case "gzip": return neverthrow.ResultAsync.fromPromise(gunzipAsync(buffer), (error) => new _amqp_contract_core.TechnicalError("Failed to decompress gzip", error));
35
- case "deflate": return neverthrow.ResultAsync.fromPromise(inflateAsync(buffer), (error) => new _amqp_contract_core.TechnicalError("Failed to decompress deflate", error));
34
+ case "gzip": return (0, unthrown.fromPromise)(gunzipAsync(buffer), (error) => new _amqp_contract_core.TechnicalError("Failed to decompress gzip", error));
35
+ case "deflate": return (0, unthrown.fromPromise)(inflateAsync(buffer), (error) => new _amqp_contract_core.TechnicalError("Failed to decompress deflate", error));
36
36
  }
37
37
  }
38
38
  //#endregion
@@ -43,14 +43,18 @@ function decompressBuffer(buffer, contentEncoding) {
43
43
  *
44
44
  * Use this error type when the operation might succeed if retried.
45
45
  * The worker will apply exponential backoff and retry the message.
46
+ *
47
+ * Built on unthrown's {@link TaggedError}, so it carries a namespaced `_tag` of
48
+ * `"@amqp-contract/RetryableError"` (to avoid colliding with other libraries'
49
+ * tags in a shared `matchTags`) for exhaustive dispatch; the `Error.name` is
50
+ * kept bare (`"RetryableError"`).
46
51
  */
47
- var RetryableError = class extends Error {
52
+ var RetryableError = class extends (0, unthrown.TaggedError)("@amqp-contract/RetryableError", { name: "RetryableError" }) {
48
53
  constructor(message, cause) {
49
- super(message);
50
- this.cause = cause;
51
- this.name = "RetryableError";
52
- const ErrorConstructor = Error;
53
- if (typeof ErrorConstructor.captureStackTrace === "function") ErrorConstructor.captureStackTrace(this, this.constructor);
54
+ super({
55
+ message,
56
+ cause
57
+ });
54
58
  }
55
59
  };
56
60
  /**
@@ -58,15 +62,16 @@ var RetryableError = class extends Error {
58
62
  * Examples: invalid data, business rule violations, permanent external failures
59
63
  *
60
64
  * Use this error type when retrying would not help - the message will be
61
- * immediately sent to the dead letter queue (DLQ) if configured.
65
+ * immediately sent to the dead letter queue (DLQ) if configured. Carries a
66
+ * namespaced `_tag` of `"@amqp-contract/NonRetryableError"`; the `Error.name` is
67
+ * kept bare (`"NonRetryableError"`).
62
68
  */
63
- var NonRetryableError = class extends Error {
69
+ var NonRetryableError = class extends (0, unthrown.TaggedError)("@amqp-contract/NonRetryableError", { name: "NonRetryableError" }) {
64
70
  constructor(message, cause) {
65
- super(message);
66
- this.cause = cause;
67
- this.name = "NonRetryableError";
68
- const ErrorConstructor = Error;
69
- if (typeof ErrorConstructor.captureStackTrace === "function") ErrorConstructor.captureStackTrace(this, this.constructor);
71
+ super({
72
+ message,
73
+ cause
74
+ });
70
75
  }
71
76
  };
72
77
  /**
@@ -138,7 +143,7 @@ function isNonRetryableError(error) {
138
143
  * ```
139
144
  */
140
145
  function isHandlerError(error) {
141
- return isRetryableError(error) || isNonRetryableError(error);
146
+ return error instanceof RetryableError || error instanceof NonRetryableError;
142
147
  }
143
148
  /**
144
149
  * Create a RetryableError with less verbosity.
@@ -153,16 +158,16 @@ function isHandlerError(error) {
153
158
  * @example
154
159
  * ```typescript
155
160
  * import { retryable } from '@amqp-contract/worker';
156
- * import { ResultAsync } from 'neverthrow';
161
+ * import { fromPromise } from 'unthrown';
157
162
  *
158
163
  * const handler = ({ payload }) =>
159
- * ResultAsync.fromPromise(
164
+ * fromPromise(
160
165
  * processPayment(payload),
161
166
  * (e) => retryable('Payment service unavailable', e),
162
167
  * ).map(() => undefined);
163
168
  *
164
169
  * // Equivalent to:
165
- * // ResultAsync.fromPromise(processPayment(payload), (e) => new RetryableError('...', e))
170
+ * // fromPromise(processPayment(payload), (e) => new RetryableError('...', e))
166
171
  * ```
167
172
  */
168
173
  function retryable(message, cause) {
@@ -181,17 +186,17 @@ function retryable(message, cause) {
181
186
  * @example
182
187
  * ```typescript
183
188
  * import { nonRetryable } from '@amqp-contract/worker';
184
- * import { errAsync, okAsync } from 'neverthrow';
189
+ * import { err, ok } from 'unthrown';
185
190
  *
186
191
  * const handler = ({ payload }) => {
187
192
  * if (!isValidPayload(payload)) {
188
- * return errAsync(nonRetryable('Invalid payload format'));
193
+ * return err(nonRetryable('Invalid payload format')).toAsync();
189
194
  * }
190
- * return okAsync(undefined);
195
+ * return ok(undefined).toAsync();
191
196
  * };
192
197
  *
193
198
  * // Equivalent to:
194
- * // return errAsync(new NonRetryableError('Invalid payload format'));
199
+ * // return err(new NonRetryableError('Invalid payload format')).toAsync();
195
200
  * ```
196
201
  */
197
202
  function nonRetryable(message, cause) {
@@ -219,23 +224,18 @@ function nonRetryable(message, cause) {
219
224
  */
220
225
  function handleError(ctx, error, msg, consumerName, consumer) {
221
226
  if (error instanceof NonRetryableError) {
222
- ctx.logger?.error("Non-retryable error, sending to DLQ immediately", {
223
- consumerName,
224
- errorType: error.name,
225
- error: error.message
226
- });
227
227
  sendToDLQ(ctx, msg, consumer);
228
- return (0, neverthrow.okAsync)(void 0);
228
+ return (0, unthrown.ok)(void 0).toAsync();
229
229
  }
230
230
  const config = (0, _amqp_contract_contract.extractQueue)(consumer.queue).retry;
231
231
  if (config.mode === "immediate-requeue") return handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, config);
232
232
  if (config.mode === "ttl-backoff") return handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config);
233
- ctx.logger?.warn("Retry disabled (none mode), sending to DLQ", {
233
+ ctx.logger?.info("Retry disabled (none mode), sending to DLQ", {
234
234
  consumerName,
235
- error: error.message
235
+ queueName: (0, _amqp_contract_contract.extractQueue)(consumer.queue).name
236
236
  });
237
237
  sendToDLQ(ctx, msg, consumer);
238
- return (0, neverthrow.okAsync)(void 0);
238
+ return (0, unthrown.ok)(void 0).toAsync();
239
239
  }
240
240
  /**
241
241
  * Handle error by requeuing immediately.
@@ -251,26 +251,24 @@ function handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, co
251
251
  const queueName = queue.name;
252
252
  const retryCount = queue.type === "quorum" ? msg.properties.headers?.["x-delivery-count"] ?? 0 : msg.properties.headers?.["x-retry-count"] ?? 0;
253
253
  if (retryCount >= config.maxRetries) {
254
- ctx.logger?.error("Max retries exceeded, sending to DLQ (immediate-requeue mode)", {
254
+ ctx.logger?.info("Max retries exceeded, sending to DLQ (immediate-requeue mode)", {
255
255
  consumerName,
256
256
  queueName,
257
257
  retryCount,
258
- maxRetries: config.maxRetries,
259
- error: error.message
258
+ maxRetries: config.maxRetries
260
259
  });
261
260
  sendToDLQ(ctx, msg, consumer);
262
- return (0, neverthrow.okAsync)(void 0);
261
+ return (0, unthrown.ok)(void 0).toAsync();
263
262
  }
264
- ctx.logger?.warn("Retrying message (immediate-requeue mode)", {
263
+ ctx.logger?.info("Retrying message (immediate-requeue mode)", {
265
264
  consumerName,
266
265
  queueName,
267
266
  retryCount,
268
- maxRetries: config.maxRetries,
269
- error: error.message
267
+ maxRetries: config.maxRetries
270
268
  });
271
269
  if (queue.type === "quorum") {
272
270
  ctx.amqpClient.nack(msg, false, true);
273
- return (0, neverthrow.okAsync)(void 0);
271
+ return (0, unthrown.ok)(void 0).toAsync();
274
272
  } else return publishForRetry(ctx, {
275
273
  msg,
276
274
  exchange: msg.fields.exchange,
@@ -311,30 +309,28 @@ function handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config)
311
309
  consumerName,
312
310
  queueName: consumer.queue.name
313
311
  });
314
- return (0, neverthrow.errAsync)(new _amqp_contract_core.TechnicalError("Queue does not have TTL-backoff infrastructure"));
312
+ return (0, unthrown.err)(new _amqp_contract_core.TechnicalError("Queue does not have TTL-backoff infrastructure")).toAsync();
315
313
  }
316
314
  const queueEntry = consumer.queue;
317
315
  const queueName = (0, _amqp_contract_contract.extractQueue)(queueEntry).name;
318
316
  const retryCount = msg.properties.headers?.["x-retry-count"] ?? 0;
319
317
  if (retryCount >= config.maxRetries) {
320
- ctx.logger?.error("Max retries exceeded, sending to DLQ (ttl-backoff mode)", {
318
+ ctx.logger?.info("Max retries exceeded, sending to DLQ (ttl-backoff mode)", {
321
319
  consumerName,
322
320
  queueName,
323
321
  retryCount,
324
- maxRetries: config.maxRetries,
325
- error: error.message
322
+ maxRetries: config.maxRetries
326
323
  });
327
324
  sendToDLQ(ctx, msg, consumer);
328
- return (0, neverthrow.okAsync)(void 0);
325
+ return (0, unthrown.ok)(void 0).toAsync();
329
326
  }
330
327
  const delayMs = calculateRetryDelay(retryCount, config);
331
- ctx.logger?.warn("Retrying message (ttl-backoff mode)", {
328
+ ctx.logger?.info("Retrying message (ttl-backoff mode)", {
332
329
  consumerName,
333
330
  queueName,
334
331
  retryCount: retryCount + 1,
335
332
  maxRetries: config.maxRetries,
336
- delayMs,
337
- error: error.message
333
+ delayMs
338
334
  });
339
335
  return publishForRetry(ctx, {
340
336
  msg,
@@ -351,9 +347,9 @@ function handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config)
351
347
  */
352
348
  function calculateRetryDelay(retryCount, config) {
353
349
  const { initialDelayMs, maxDelayMs, backoffMultiplier, jitter } = config;
354
- let delay = Math.min(initialDelayMs * Math.pow(backoffMultiplier, retryCount), maxDelayMs);
355
- if (jitter) delay = delay * (.5 + Math.random() * .5);
356
- return Math.floor(delay);
350
+ let delay = initialDelayMs * Math.pow(backoffMultiplier, retryCount);
351
+ if (jitter) delay = delay * (.5 + Math.random());
352
+ return Math.floor(Math.min(delay, maxDelayMs));
357
353
  }
358
354
  /**
359
355
  * Parse message content for republishing.
@@ -385,7 +381,6 @@ function parseMessageContentForRetry(ctx, msg, queueName) {
385
381
  */
386
382
  function publishForRetry(ctx, { msg, exchange, routingKey, queueName, waitQueueName, delayMs, error }) {
387
383
  const newRetryCount = (msg.properties.headers?.["x-retry-count"] ?? 0) + 1;
388
- ctx.amqpClient.ack(msg);
389
384
  const content = parseMessageContentForRetry(ctx, msg, queueName);
390
385
  return ctx.amqpClient.publish(exchange, routingKey, content, {
391
386
  ...msg.properties,
@@ -400,21 +395,30 @@ function publishForRetry(ctx, { msg, exchange, routingKey, queueName, waitQueueN
400
395
  "x-retry-queue": queueName
401
396
  } : {}
402
397
  }
403
- }).andThen((published) => {
398
+ }).flatMap((published) => {
404
399
  if (!published) {
405
400
  ctx.logger?.error("Failed to publish message for retry (write buffer full)", {
406
401
  queueName,
407
402
  retryCount: newRetryCount,
408
403
  ...delayMs !== void 0 ? { delayMs } : {}
409
404
  });
410
- return (0, neverthrow.err)(new _amqp_contract_core.TechnicalError("Failed to publish message for retry (write buffer full)"));
405
+ return (0, unthrown.err)(new _amqp_contract_core.TechnicalError("Failed to publish message for retry (write buffer full)"));
411
406
  }
407
+ ctx.amqpClient.ack(msg);
412
408
  ctx.logger?.info("Message published for retry", {
413
409
  queueName,
414
410
  retryCount: newRetryCount,
415
411
  ...delayMs !== void 0 ? { delayMs } : {}
416
412
  });
417
- return (0, neverthrow.ok)(void 0);
413
+ return (0, unthrown.ok)(void 0);
414
+ }).orElse((publishError) => {
415
+ ctx.logger?.error("Publish for retry failed; leaving original un-ack'd for redelivery", {
416
+ queueName,
417
+ retryCount: newRetryCount,
418
+ ...delayMs !== void 0 ? { delayMs } : {},
419
+ error: publishError
420
+ });
421
+ return (0, unthrown.err)(publishError);
418
422
  });
419
423
  }
420
424
  /**
@@ -451,7 +455,7 @@ function isHandlerTuple(entry) {
451
455
  * ```typescript
452
456
  * import { TypedAmqpWorker } from '@amqp-contract/worker';
453
457
  * import { defineQueue, defineMessage, defineContract, defineConsumer } from '@amqp-contract/contract';
454
- * import { okAsync } from 'neverthrow';
458
+ * import { ok } from 'unthrown';
455
459
  * import { z } from 'zod';
456
460
  *
457
461
  * const orderQueue = defineQueue('order-processing');
@@ -471,20 +475,23 @@ function isHandlerTuple(entry) {
471
475
  * handlers: {
472
476
  * processOrder: ({ payload }) => {
473
477
  * console.log('Processing order', payload.orderId);
474
- * return okAsync(undefined);
478
+ * return ok(undefined).toAsync();
475
479
  * },
476
480
  * },
477
481
  * urls: ['amqp://localhost'],
478
482
  * });
479
483
  *
480
- * if (result.isErr()) throw result.error;
481
- * const worker = result.value;
484
+ * const worker = result.unwrap();
482
485
  *
483
486
  * // Close when done
484
487
  * await worker.close();
485
488
  * ```
486
489
  */
487
490
  var TypedAmqpWorker = class TypedAmqpWorker {
491
+ contract;
492
+ amqpClient;
493
+ defaultConsumerOptions;
494
+ logger;
488
495
  /**
489
496
  * Internal handler storage. Keyed by handler name (consumer or RPC); the
490
497
  * stored function signature is widened so the dispatch loop can call it
@@ -557,14 +564,14 @@ var TypedAmqpWorker = class TypedAmqpWorker {
557
564
  * Connections are automatically shared across clients and workers with the same
558
565
  * URLs and connection options, following RabbitMQ best practices.
559
566
  *
560
- * @returns A ResultAsync that resolves to the worker or a TechnicalError.
567
+ * @returns A AsyncResult that resolves to the worker or a TechnicalError.
561
568
  *
562
569
  * @example
563
570
  * ```typescript
564
571
  * const result = await TypedAmqpWorker.create({
565
572
  * contract: myContract,
566
573
  * handlers: {
567
- * processOrder: ({ payload }) => okAsync(undefined),
574
+ * processOrder: ({ payload }) => ok(undefined).toAsync(),
568
575
  * },
569
576
  * urls: ['amqp://localhost'],
570
577
  * });
@@ -576,14 +583,15 @@ var TypedAmqpWorker = class TypedAmqpWorker {
576
583
  connectionOptions,
577
584
  connectTimeoutMs
578
585
  }), handlers, defaultConsumerOptions ?? {}, logger, telemetry);
579
- const setup = worker.waitForConnectionReady().andThen(() => worker.consumeAll());
580
- return new neverthrow.ResultAsync((async () => {
586
+ const setup = worker.waitForConnectionReady().flatMap(() => worker.consumeAll());
587
+ return (0, unthrown.fromSafePromise)((async () => {
581
588
  const setupResult = await setup;
582
- if (setupResult.isOk()) return (0, neverthrow.ok)(worker);
583
- const closeResult = await worker.close();
584
- if (closeResult.isErr()) logger?.warn("Failed to close worker after setup failure", { error: closeResult.error });
585
- return (0, neverthrow.err)(setupResult.error);
586
- })());
589
+ if (!setupResult.isOk()) {
590
+ const closeResult = await worker.close();
591
+ if (closeResult.isErr()) logger?.warn("Failed to close worker after setup failure", { error: closeResult.error });
592
+ }
593
+ return setupResult.map(() => worker);
594
+ })()).flatMap((result) => result);
587
595
  }
588
596
  /**
589
597
  * Close the AMQP channel and connection.
@@ -600,16 +608,15 @@ var TypedAmqpWorker = class TypedAmqpWorker {
600
608
  * ```
601
609
  */
602
610
  close() {
603
- const cancellations = Array.from(this.consumerTags).map((consumerTag) => this.amqpClient.cancel(consumerTag).orElse((error) => {
611
+ return (0, unthrown.allAsync)(Array.from(this.consumerTags).map((consumerTag) => this.amqpClient.cancel(consumerTag).orElse((error) => {
604
612
  this.logger?.warn("Failed to cancel consumer during close", {
605
613
  consumerTag,
606
614
  error
607
615
  });
608
- return (0, neverthrow.ok)(void 0);
609
- }));
610
- return neverthrow.ResultAsync.combine(cancellations).andTee(() => {
616
+ return (0, unthrown.ok)(void 0);
617
+ }))).tap(() => {
611
618
  this.consumerTags.clear();
612
- }).andThen(() => this.amqpClient.close()).map(() => void 0);
619
+ }).flatMap(() => this.amqpClient.close()).map(() => void 0);
613
620
  }
614
621
  /**
615
622
  * Start consuming for every entry in `contract.consumers` and `contract.rpcs`.
@@ -617,8 +624,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
617
624
  consumeAll() {
618
625
  const consumerNames = Object.keys(this.contract.consumers ?? {});
619
626
  const rpcNames = Object.keys(this.contract.rpcs ?? {});
620
- const allNames = [...consumerNames, ...rpcNames];
621
- return neverthrow.ResultAsync.combine(allNames.map((name) => this.consume(name))).map(() => void 0);
627
+ return (0, unthrown.allAsync)([...consumerNames, ...rpcNames].map((name) => this.consume(name))).map(() => void 0);
622
628
  }
623
629
  waitForConnectionReady() {
624
630
  return this.amqpClient.waitForConnect();
@@ -638,10 +644,9 @@ var TypedAmqpWorker = class TypedAmqpWorker {
638
644
  */
639
645
  validateSchema(schema, data, context) {
640
646
  const rawValidation = schema["~standard"].validate(data);
641
- const validationPromise = rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation);
642
- return neverthrow.ResultAsync.fromPromise(validationPromise, (error) => new _amqp_contract_core.TechnicalError(`Error validating ${context.field}`, error)).andThen((result) => {
643
- if (result.issues) return (0, neverthrow.err)(new _amqp_contract_core.TechnicalError(`${context.field} validation failed`, new _amqp_contract_core.MessageValidationError(context.consumerName, result.issues)));
644
- return (0, neverthrow.ok)(result.value);
647
+ return (0, unthrown.fromPromise)(rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation), (error) => new _amqp_contract_core.TechnicalError(`Error validating ${context.field}`, error)).flatMap((result) => {
648
+ if (result.issues) return (0, unthrown.err)(new _amqp_contract_core.TechnicalError(`${context.field} validation failed`, new _amqp_contract_core.MessageValidationError(context.consumerName, result.issues)));
649
+ return (0, unthrown.ok)(result.value);
645
650
  });
646
651
  }
647
652
  /**
@@ -653,15 +658,13 @@ var TypedAmqpWorker = class TypedAmqpWorker {
653
658
  */
654
659
  parseAndValidateMessage(msg, consumer, consumerName) {
655
660
  const context = { consumerName: String(consumerName) };
656
- const parsePayload = decompressBuffer(msg.content, msg.properties.contentEncoding).andThen((buffer) => neverthrow.Result.fromThrowable(() => JSON.parse(buffer.toString()), (error) => new _amqp_contract_core.TechnicalError("Failed to parse JSON", error))()).andThen((parsed) => this.validateSchema(consumer.message.payload, parsed, {
661
+ return (0, unthrown.allAsync)([decompressBuffer(msg.content, msg.properties.contentEncoding).flatMap((buffer) => (0, _amqp_contract_core.safeJsonParse)(buffer, (error) => new _amqp_contract_core.TechnicalError("Failed to parse JSON", error))).flatMap((parsed) => this.validateSchema(consumer.message.payload, parsed, {
657
662
  ...context,
658
663
  field: "payload"
659
- }));
660
- const parseHeaders = consumer.message.headers ? this.validateSchema(consumer.message.headers, msg.properties.headers ?? {}, {
664
+ })), consumer.message.headers ? this.validateSchema(consumer.message.headers, msg.properties.headers ?? {}, {
661
665
  ...context,
662
666
  field: "headers"
663
- }) : (0, neverthrow.okAsync)(void 0);
664
- return neverthrow.ResultAsync.combine([parsePayload, parseHeaders]).map(([payload, headers]) => ({
667
+ }) : (0, unthrown.ok)(void 0).toAsync()]).map(([payload, headers]) => ({
665
668
  payload,
666
669
  headers
667
670
  }));
@@ -693,7 +696,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
693
696
  rpcName: String(rpcName),
694
697
  queueName
695
698
  });
696
- return (0, neverthrow.errAsync)(new NonRetryableError(`RPC "${String(rpcName)}" received a message without replyTo; cannot deliver response`));
699
+ return (0, unthrown.err)(new NonRetryableError(`RPC "${String(rpcName)}" received a message without replyTo; cannot deliver response`)).toAsync();
697
700
  }
698
701
  if (typeof correlationId !== "string" || correlationId.length === 0) {
699
702
  this.logger?.error("RPC handler returned a response but the incoming message has no correlationId", {
@@ -701,84 +704,127 @@ var TypedAmqpWorker = class TypedAmqpWorker {
701
704
  queueName,
702
705
  replyTo
703
706
  });
704
- return (0, neverthrow.errAsync)(new NonRetryableError(`RPC "${String(rpcName)}" received a message without correlationId; cannot deliver response`));
707
+ return (0, unthrown.err)(new NonRetryableError(`RPC "${String(rpcName)}" received a message without correlationId; cannot deliver response`)).toAsync();
705
708
  }
706
709
  let rawValidation;
707
710
  try {
708
711
  rawValidation = responseSchema["~standard"].validate(response);
709
712
  } catch (error) {
710
- return (0, neverthrow.errAsync)(new NonRetryableError("RPC response schema validation threw", error));
713
+ return (0, unthrown.err)(new NonRetryableError("RPC response schema validation threw", error)).toAsync();
711
714
  }
712
- const validationPromise = rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation);
713
- return neverthrow.ResultAsync.fromPromise(validationPromise, (error) => new NonRetryableError("RPC response schema validation threw", error)).andThen((validation) => {
714
- if (validation.issues) return (0, neverthrow.err)(new NonRetryableError(`RPC response for "${String(rpcName)}" failed schema validation`, new _amqp_contract_core.MessageValidationError(String(rpcName), validation.issues)));
715
- return (0, neverthrow.ok)(validation.value);
716
- }).andThen((validatedResponse) => this.amqpClient.publish("", replyTo, validatedResponse, {
715
+ return (0, unthrown.fromPromise)(rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation), (error) => new NonRetryableError("RPC response schema validation threw", error)).flatMap((validation) => {
716
+ if (validation.issues) return (0, unthrown.err)(new NonRetryableError(`RPC response for "${String(rpcName)}" failed schema validation`, new _amqp_contract_core.MessageValidationError(String(rpcName), validation.issues)));
717
+ return (0, unthrown.ok)(validation.value);
718
+ }).flatMap((validatedResponse) => this.amqpClient.publish("", replyTo, validatedResponse, {
717
719
  correlationId,
718
720
  contentType: "application/json"
719
- }).mapErr((error) => new NonRetryableError("Failed to publish RPC response", error)).andThen((published) => published ? (0, neverthrow.ok)(void 0) : (0, neverthrow.err)(new NonRetryableError("Failed to publish RPC response: channel buffer full"))));
721
+ }).mapErr((error) => new NonRetryableError("Failed to publish RPC response", error)).flatMap((published) => published ? (0, unthrown.ok)(void 0) : (0, unthrown.err)(new NonRetryableError("Failed to publish RPC response: channel buffer full"))));
722
+ }
723
+ /**
724
+ * Parse and validate the message; on failure, nack(requeue=false) so the
725
+ * queue's DLX (if configured) receives the poison message and bypass the
726
+ * retry pipeline — a malformed payload is deterministic and retrying it
727
+ * would burn the queue's retry budget on a guaranteed failure.
728
+ */
729
+ parseAndValidateOrNack(msg, consumer, name) {
730
+ return this.parseAndValidateMessage(msg, consumer, name).orElse((parseError) => {
731
+ this.amqpClient.nack(msg, false, false);
732
+ return (0, unthrown.err)(parseError).toAsync();
733
+ });
734
+ }
735
+ /**
736
+ * Invoke the handler and ack the message on success. Returns the handler's
737
+ * response (RPC) or `undefined` (regular consumer). Errors propagate as
738
+ * `HandlerError` for downstream RPC reply publishing or routing via
739
+ * {@link handleError}.
740
+ */
741
+ runHandler(handler, validatedMessage, msg) {
742
+ return handler(validatedMessage, msg);
743
+ }
744
+ /**
745
+ * For RPC handlers, validate and publish the reply on the caller's
746
+ * `replyTo` / `correlationId`. For non-RPC consumers, this is a no-op that
747
+ * resolves to `ok(undefined).toAsync()`.
748
+ */
749
+ publishReplyIfRpc(msg, view, name, handlerResponse) {
750
+ if (!view.isRpc || !view.responseSchema) return (0, unthrown.ok)(void 0).toAsync();
751
+ const queueName = (0, _amqp_contract_contract.extractQueue)(view.consumer.queue).name;
752
+ return this.publishRpcResponse(msg, queueName, name, view.responseSchema, handlerResponse);
720
753
  }
721
754
  /**
722
755
  * Process a single consumed message: validate, invoke handler, optionally
723
- * publish the RPC response, record telemetry, and handle errors.
756
+ * publish the RPC response, record telemetry, and route errors.
757
+ *
758
+ * The caller-supplied `state` is mutated as the message is ack'd/nack'd so
759
+ * the consume callback's catch-all guard can tell whether a defensive nack
760
+ * is still needed (see {@link consumeSingle}).
761
+ *
762
+ * Success-vs-failure telemetry is data-driven: the chain resolves to
763
+ * `ok(undefined)` only on handler success (and reply-publish success for
764
+ * RPC). Handler failures — even when {@link handleError} routes them
765
+ * successfully to retry/DLQ — are classified as failures for metrics by
766
+ * re-failing the chain with a `TechnicalError` whose `cause` is the
767
+ * original `HandlerError`. The terminal `orTee` unwraps the cause before
768
+ * recording the span exception so traces keep the original
769
+ * `RetryableError` / `NonRetryableError` class as the exception type.
724
770
  */
725
- processMessage(msg, view, name, handler) {
726
- const { consumer, isRpc, responseSchema } = view;
771
+ processMessage(msg, view, name, handler, state) {
772
+ const { consumer } = view;
727
773
  const queueName = (0, _amqp_contract_contract.extractQueue)(consumer.queue).name;
728
774
  const startTime = Date.now();
729
775
  const span = (0, _amqp_contract_core.startConsumeSpan)(this.telemetry, queueName, String(name), { "messaging.rabbitmq.message.delivery_tag": msg.fields.deliveryTag });
730
- let messageHandled = false;
731
- let firstError;
732
- const inner = this.parseAndValidateMessage(msg, consumer, name).orElse((parseError) => {
733
- firstError = parseError;
776
+ return this.parseAndValidateOrNack(msg, consumer, name).tapErr((parseError) => {
734
777
  this.logger?.error("Failed to parse/validate message; sending to DLQ", {
735
778
  consumerName: String(name),
736
779
  queueName,
737
780
  error: parseError
738
781
  });
739
- this.amqpClient.nack(msg, false, false);
740
- return (0, neverthrow.errAsync)(parseError);
741
- }).andThen((validatedMessage) => handler(validatedMessage, msg).andThen((handlerResponse) => {
742
- if (isRpc && responseSchema) return this.publishRpcResponse(msg, queueName, name, responseSchema, handlerResponse).map(() => {
743
- this.logger?.info("Message consumed successfully", {
744
- consumerName: String(name),
745
- queueName
746
- });
747
- this.amqpClient.ack(msg);
748
- messageHandled = true;
749
- });
782
+ state.messageHandled = true;
783
+ }).flatMap((validatedMessage) => this.runHandler(handler, validatedMessage, msg).flatMap((handlerResponse) => this.publishReplyIfRpc(msg, view, name, handlerResponse).tap(() => {
750
784
  this.logger?.info("Message consumed successfully", {
751
785
  consumerName: String(name),
752
786
  queueName
753
787
  });
754
788
  this.amqpClient.ack(msg);
755
- messageHandled = true;
756
- return (0, neverthrow.okAsync)(void 0);
757
- }).orElse((handlerError) => {
789
+ state.messageHandled = true;
790
+ })).orElse((handlerError) => {
758
791
  this.logger?.error("Error processing message", {
759
792
  consumerName: String(name),
760
793
  queueName,
761
794
  errorType: handlerError.name,
795
+ retryCount: msg.properties.headers?.["x-delivery-count"] ?? msg.properties.headers?.["x-retry-count"] ?? 0,
762
796
  error: handlerError.message
763
797
  });
764
- firstError = handlerError;
765
798
  return handleError({
766
799
  amqpClient: this.amqpClient,
767
800
  logger: this.logger
768
- }, handlerError, msg, String(name), consumer);
769
- }));
770
- return new neverthrow.ResultAsync((async () => {
771
- const result = await inner;
772
- const durationMs = Date.now() - startTime;
773
- if (messageHandled) {
801
+ }, handlerError, msg, String(name), consumer).tap(() => {
802
+ state.messageHandled = true;
803
+ }).flatMap(() => (0, unthrown.err)(new _amqp_contract_core.TechnicalError(`Handler "${String(name)}" failed: ${handlerError.message}`, handlerError)).toAsync());
804
+ })).tap(() => {
805
+ try {
774
806
  (0, _amqp_contract_core.endSpanSuccess)(span);
775
- (0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(name), true, durationMs);
776
- } else {
777
- (0, _amqp_contract_core.endSpanError)(span, result.isErr() ? result.error : firstError ?? /* @__PURE__ */ new Error("Unknown error"));
778
- (0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(name), false, durationMs);
807
+ (0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(name), true, Date.now() - startTime);
808
+ } catch (telemetryError) {
809
+ this.logger?.warn("Telemetry recording threw; ignoring", {
810
+ consumerName: String(name),
811
+ queueName,
812
+ error: telemetryError
813
+ });
779
814
  }
780
- return result;
781
- })());
815
+ }).tapErr((error) => {
816
+ const reportedError = error.cause instanceof Error ? error.cause : error;
817
+ try {
818
+ (0, _amqp_contract_core.endSpanError)(span, reportedError);
819
+ (0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(name), false, Date.now() - startTime);
820
+ } catch (telemetryError) {
821
+ this.logger?.warn("Telemetry recording threw; ignoring", {
822
+ consumerName: String(name),
823
+ queueName,
824
+ error: telemetryError
825
+ });
826
+ }
827
+ });
782
828
  }
783
829
  /**
784
830
  * Consume messages one at a time.
@@ -793,9 +839,18 @@ var TypedAmqpWorker = class TypedAmqpWorker {
793
839
  });
794
840
  return;
795
841
  }
842
+ const state = { messageHandled: false };
796
843
  try {
797
- await this.processMessage(msg, view, name, handler);
844
+ await this.processMessage(msg, view, name, handler, state);
798
845
  } catch (error) {
846
+ if (state.messageHandled) {
847
+ this.logger?.error("Uncaught error in consume callback after message was already handled; not nacking", {
848
+ consumerName: String(name),
849
+ queueName,
850
+ error
851
+ });
852
+ return;
853
+ }
799
854
  this.logger?.error("Uncaught error in consume callback; nacking message", {
800
855
  consumerName: String(name),
801
856
  queueName,
@@ -803,7 +858,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
803
858
  });
804
859
  this.amqpClient.nack(msg, false, false);
805
860
  }
806
- }, this.consumerOptions[name]).andTee((consumerTag) => {
861
+ }, this.consumerOptions[name]).tap((consumerTag) => {
807
862
  this.consumerTags.add(consumerTag);
808
863
  }).map(() => void 0).mapErr((error) => new _amqp_contract_core.TechnicalError(`Failed to start consuming for "${String(name)}"`, error));
809
864
  }
@@ -811,58 +866,71 @@ var TypedAmqpWorker = class TypedAmqpWorker {
811
866
  //#endregion
812
867
  //#region src/handlers.ts
813
868
  /**
814
- * Validate that a consumer exists in the contract
869
+ * Build the list of available handler-target names — every key under
870
+ * `contract.consumers` plus every key under `contract.rpcs`.
871
+ */
872
+ function availableHandlerNames(contract) {
873
+ const consumers = contract.consumers ? Object.keys(contract.consumers) : [];
874
+ const rpcs = contract.rpcs ? Object.keys(contract.rpcs) : [];
875
+ return [...consumers, ...rpcs];
876
+ }
877
+ function formatAvailable(names) {
878
+ return names.length > 0 ? names.join(", ") : "none";
879
+ }
880
+ /**
881
+ * Validate that a name maps to a contract entry — either a `consumers` key
882
+ * or an `rpcs` key. The two name spaces are disjoint by contract definition.
815
883
  */
816
- function validateConsumerExists(contract, consumerName) {
884
+ function validateHandlerTargetExists(contract, name) {
817
885
  const consumers = contract.consumers;
818
- if (!consumers || !(consumerName in consumers)) {
819
- const availableConsumers = consumers ? Object.keys(consumers) : [];
820
- const available = availableConsumers.length > 0 ? availableConsumers.join(", ") : "none";
821
- throw new Error(`Consumer "${consumerName}" not found in contract. Available consumers: ${available}`);
886
+ const rpcs = contract.rpcs;
887
+ const isConsumer = !!consumers && Object.hasOwn(consumers, name);
888
+ const isRpc = !!rpcs && Object.hasOwn(rpcs, name);
889
+ if (!isConsumer && !isRpc) {
890
+ const available = formatAvailable(availableHandlerNames(contract));
891
+ throw new Error(`Handler target "${name}" not found in contract. Available consumers and RPCs: ${available}`);
822
892
  }
823
893
  }
824
894
  /**
825
- * Validate that all handlers reference valid consumers
895
+ * Validate that every key in `handlers` maps to a contract entry —
896
+ * either a `consumers` key or an `rpcs` key.
826
897
  */
827
898
  function validateHandlers(contract, handlers) {
828
- const consumers = contract.consumers;
829
- const availableConsumers = Object.keys(consumers ?? {});
830
- const availableConsumerNames = availableConsumers.length > 0 ? availableConsumers.join(", ") : "none";
831
- for (const handlerName of Object.keys(handlers)) if (!consumers || !(handlerName in consumers)) throw new Error(`Consumer "${handlerName}" not found in contract. Available consumers: ${availableConsumerNames}`);
899
+ for (const handlerName of Object.keys(handlers)) validateHandlerTargetExists(contract, handlerName);
832
900
  }
833
- function defineHandler(contract, consumerName, handler, options) {
834
- validateConsumerExists(contract, String(consumerName));
901
+ function defineHandler(contract, name, handler, options) {
902
+ validateHandlerTargetExists(contract, String(name));
835
903
  if (options) return [handler, options];
836
904
  return handler;
837
905
  }
838
906
  /**
839
- * Define multiple type-safe handlers for consumers in a contract.
907
+ * Define multiple type-safe handlers for consumers and RPCs in a contract.
908
+ *
909
+ * **Recommended:** This function creates handlers that return
910
+ * `AsyncResult<void, HandlerError>` (consumers) or
911
+ * `AsyncResult<TResponse, HandlerError>` (RPCs), providing explicit error
912
+ * handling and better control over retry behavior.
840
913
  *
841
- * **Recommended:** This function creates handlers that return `ResultAsync<void, HandlerError>`,
842
- * providing explicit error handling and better control over retry behavior.
914
+ * The handlers object must contain exactly one entry per `consumers` and
915
+ * `rpcs` key in the contract see {@link WorkerInferHandlers}.
843
916
  *
844
917
  * @template TContract - The contract definition type
845
- * @param contract - The contract definition containing the consumers
846
- * @param handlers - An object with handler functions for each consumer
918
+ * @param contract - The contract definition containing the consumers and RPCs
919
+ * @param handlers - An object with handler functions for each consumer and RPC
847
920
  * @returns A type-safe handlers object that can be used with TypedAmqpWorker
848
921
  *
849
922
  * @example
850
923
  * ```typescript
851
924
  * import { defineHandlers, RetryableError } from '@amqp-contract/worker';
852
- * import { ResultAsync } from 'neverthrow';
853
- * import { orderContract } from './contract';
925
+ * import { fromPromise, ok } from 'unthrown';
854
926
  *
855
927
  * const handlers = defineHandlers(orderContract, {
856
928
  * processOrder: ({ payload }) =>
857
- * ResultAsync.fromPromise(
929
+ * fromPromise(
858
930
  * processPayment(payload),
859
931
  * (error) => new RetryableError('Payment failed', error),
860
932
  * ).map(() => undefined),
861
- * notifyOrder: ({ payload }) =>
862
- * ResultAsync.fromPromise(
863
- * sendNotification(payload),
864
- * (error) => new RetryableError('Notification failed', error),
865
- * ).map(() => undefined),
933
+ * calculate: ({ payload }) => ok({ sum: payload.a + payload.b }).toAsync(),
866
934
  * });
867
935
  * ```
868
936
  */