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