@amqp-contract/worker 0.23.1 → 0.25.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 { Future, Result } from "@swan-io/boxed";
2
+ import { AmqpClient, MessageValidationError, TechnicalError, defaultTelemetryProvider, endSpanError, endSpanSuccess, recordConsumeMetric, safeJsonParse, startConsumeSpan } from "@amqp-contract/core";
3
+ import { ResultAsync, err, errAsync, ok, okAsync } from "neverthrow";
4
4
  import { gunzip, inflate } from "node:zlib";
5
5
  import { promisify } from "node:util";
6
6
  //#region src/decompression.ts
@@ -21,52 +21,55 @@ 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 Future with the decompressed buffer or a TechnicalError
24
+ * @returns A ResultAsync 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 Future.value(Result.Ok(buffer));
29
+ if (!contentEncoding) return okAsync(buffer);
30
30
  const normalizedEncoding = contentEncoding.toLowerCase();
31
- if (!isSupportedEncoding(normalizedEncoding)) return Future.value(Result.Error(new TechnicalError(`Unsupported content-encoding: "${contentEncoding}". Supported encodings are: ${SUPPORTED_ENCODINGS.join(", ")}. Please check your publisher configuration.`)));
31
+ if (!isSupportedEncoding(normalizedEncoding)) return errAsync(new TechnicalError(`Unsupported content-encoding: "${contentEncoding}". Supported encodings are: ${SUPPORTED_ENCODINGS.join(", ")}. Please check your publisher configuration.`));
32
32
  switch (normalizedEncoding) {
33
- case "gzip": return Future.fromPromise(gunzipAsync(buffer)).mapError((error) => new TechnicalError("Failed to decompress gzip", error));
34
- case "deflate": return Future.fromPromise(inflateAsync(buffer)).mapError((error) => new TechnicalError("Failed to decompress deflate", error));
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));
35
35
  }
36
36
  }
37
37
  //#endregion
38
38
  //#region src/errors.ts
39
39
  /**
40
- * Retryable errors - transient failures that may succeed on retry
41
- * Examples: network timeouts, rate limiting, temporary service unavailability
40
+ * Abstract base class for all handler-signalled errors.
42
41
  *
43
- * Use this error type when the operation might succeed if retried.
44
- * The worker will apply exponential backoff and retry the message.
42
+ * Concrete subclasses (`RetryableError`, `NonRetryableError`) discriminate on
43
+ * the `name` property so exhaustive narrowing in user code keeps working.
44
+ * `error instanceof HandlerError` is true for any handler error.
45
45
  */
46
- var RetryableError = class extends Error {
46
+ var HandlerError = class extends Error {
47
47
  constructor(message, cause) {
48
48
  super(message);
49
49
  this.cause = cause;
50
- this.name = "RetryableError";
51
50
  const ErrorConstructor = Error;
52
51
  if (typeof ErrorConstructor.captureStackTrace === "function") ErrorConstructor.captureStackTrace(this, this.constructor);
53
52
  }
54
53
  };
55
54
  /**
55
+ * Retryable errors - transient failures that may succeed on retry
56
+ * Examples: network timeouts, rate limiting, temporary service unavailability
57
+ *
58
+ * Use this error type when the operation might succeed if retried.
59
+ * The worker will apply exponential backoff and retry the message.
60
+ */
61
+ var RetryableError = class extends HandlerError {
62
+ name = "RetryableError";
63
+ };
64
+ /**
56
65
  * Non-retryable errors - permanent failures that should not be retried
57
66
  * Examples: invalid data, business rule violations, permanent external failures
58
67
  *
59
68
  * Use this error type when retrying would not help - the message will be
60
69
  * immediately sent to the dead letter queue (DLQ) if configured.
61
70
  */
62
- var NonRetryableError = class extends Error {
63
- 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);
69
- }
71
+ var NonRetryableError = class extends HandlerError {
72
+ name = "NonRetryableError";
70
73
  };
71
74
  /**
72
75
  * Type guard to check if an error is a RetryableError.
@@ -137,7 +140,7 @@ function isNonRetryableError(error) {
137
140
  * ```
138
141
  */
139
142
  function isHandlerError(error) {
140
- return isRetryableError(error) || isNonRetryableError(error);
143
+ return error instanceof HandlerError;
141
144
  }
142
145
  /**
143
146
  * Create a RetryableError with less verbosity.
@@ -152,15 +155,16 @@ function isHandlerError(error) {
152
155
  * @example
153
156
  * ```typescript
154
157
  * import { retryable } from '@amqp-contract/worker';
155
- * import { Future, Result } from '@swan-io/boxed';
158
+ * import { ResultAsync } from 'neverthrow';
156
159
  *
157
160
  * const handler = ({ payload }) =>
158
- * Future.fromPromise(processPayment(payload))
159
- * .mapOk(() => undefined)
160
- * .mapError((e) => retryable('Payment service unavailable', e));
161
+ * ResultAsync.fromPromise(
162
+ * processPayment(payload),
163
+ * (e) => retryable('Payment service unavailable', e),
164
+ * ).map(() => undefined);
161
165
  *
162
166
  * // Equivalent to:
163
- * // .mapError((e) => new RetryableError('Payment service unavailable', e));
167
+ * // ResultAsync.fromPromise(processPayment(payload), (e) => new RetryableError('...', e))
164
168
  * ```
165
169
  */
166
170
  function retryable(message, cause) {
@@ -179,17 +183,17 @@ function retryable(message, cause) {
179
183
  * @example
180
184
  * ```typescript
181
185
  * import { nonRetryable } from '@amqp-contract/worker';
182
- * import { Future, Result } from '@swan-io/boxed';
186
+ * import { errAsync, okAsync } from 'neverthrow';
183
187
  *
184
188
  * const handler = ({ payload }) => {
185
189
  * if (!isValidPayload(payload)) {
186
- * return Future.value(Result.Error(nonRetryable('Invalid payload format')));
190
+ * return errAsync(nonRetryable('Invalid payload format'));
187
191
  * }
188
- * return Future.value(Result.Ok(undefined));
192
+ * return okAsync(undefined);
189
193
  * };
190
194
  *
191
195
  * // Equivalent to:
192
- * // return Future.value(Result.Error(new NonRetryableError('Invalid payload format')));
196
+ * // return errAsync(new NonRetryableError('Invalid payload format'));
193
197
  * ```
194
198
  */
195
199
  function nonRetryable(message, cause) {
@@ -217,23 +221,18 @@ function nonRetryable(message, cause) {
217
221
  */
218
222
  function handleError(ctx, error, msg, consumerName, consumer) {
219
223
  if (error instanceof NonRetryableError) {
220
- ctx.logger?.error("Non-retryable error, sending to DLQ immediately", {
221
- consumerName,
222
- errorType: error.name,
223
- error: error.message
224
- });
225
224
  sendToDLQ(ctx, msg, consumer);
226
- return Future.value(Result.Ok(void 0));
225
+ return okAsync(void 0);
227
226
  }
228
227
  const config = extractQueue(consumer.queue).retry;
229
228
  if (config.mode === "immediate-requeue") return handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, config);
230
229
  if (config.mode === "ttl-backoff") return handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config);
231
- ctx.logger?.warn("Retry disabled (none mode), sending to DLQ", {
230
+ ctx.logger?.info("Retry disabled (none mode), sending to DLQ", {
232
231
  consumerName,
233
- error: error.message
232
+ queueName: extractQueue(consumer.queue).name
234
233
  });
235
234
  sendToDLQ(ctx, msg, consumer);
236
- return Future.value(Result.Ok(void 0));
235
+ return okAsync(void 0);
237
236
  }
238
237
  /**
239
238
  * Handle error by requeuing immediately.
@@ -249,26 +248,24 @@ function handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, co
249
248
  const queueName = queue.name;
250
249
  const retryCount = queue.type === "quorum" ? msg.properties.headers?.["x-delivery-count"] ?? 0 : msg.properties.headers?.["x-retry-count"] ?? 0;
251
250
  if (retryCount >= config.maxRetries) {
252
- ctx.logger?.error("Max retries exceeded, sending to DLQ (immediate-requeue mode)", {
251
+ ctx.logger?.info("Max retries exceeded, sending to DLQ (immediate-requeue mode)", {
253
252
  consumerName,
254
253
  queueName,
255
254
  retryCount,
256
- maxRetries: config.maxRetries,
257
- error: error.message
255
+ maxRetries: config.maxRetries
258
256
  });
259
257
  sendToDLQ(ctx, msg, consumer);
260
- return Future.value(Result.Ok(void 0));
258
+ return okAsync(void 0);
261
259
  }
262
- ctx.logger?.warn("Retrying message (immediate-requeue mode)", {
260
+ ctx.logger?.info("Retrying message (immediate-requeue mode)", {
263
261
  consumerName,
264
262
  queueName,
265
263
  retryCount,
266
- maxRetries: config.maxRetries,
267
- error: error.message
264
+ maxRetries: config.maxRetries
268
265
  });
269
266
  if (queue.type === "quorum") {
270
267
  ctx.amqpClient.nack(msg, false, true);
271
- return Future.value(Result.Ok(void 0));
268
+ return okAsync(void 0);
272
269
  } else return publishForRetry(ctx, {
273
270
  msg,
274
271
  exchange: msg.fields.exchange,
@@ -309,30 +306,28 @@ function handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config)
309
306
  consumerName,
310
307
  queueName: consumer.queue.name
311
308
  });
312
- return Future.value(Result.Error(new TechnicalError("Queue does not have TTL-backoff infrastructure")));
309
+ return errAsync(new TechnicalError("Queue does not have TTL-backoff infrastructure"));
313
310
  }
314
311
  const queueEntry = consumer.queue;
315
312
  const queueName = extractQueue(queueEntry).name;
316
313
  const retryCount = msg.properties.headers?.["x-retry-count"] ?? 0;
317
314
  if (retryCount >= config.maxRetries) {
318
- ctx.logger?.error("Max retries exceeded, sending to DLQ (ttl-backoff mode)", {
315
+ ctx.logger?.info("Max retries exceeded, sending to DLQ (ttl-backoff mode)", {
319
316
  consumerName,
320
317
  queueName,
321
318
  retryCount,
322
- maxRetries: config.maxRetries,
323
- error: error.message
319
+ maxRetries: config.maxRetries
324
320
  });
325
321
  sendToDLQ(ctx, msg, consumer);
326
- return Future.value(Result.Ok(void 0));
322
+ return okAsync(void 0);
327
323
  }
328
324
  const delayMs = calculateRetryDelay(retryCount, config);
329
- ctx.logger?.warn("Retrying message (ttl-backoff mode)", {
325
+ ctx.logger?.info("Retrying message (ttl-backoff mode)", {
330
326
  consumerName,
331
327
  queueName,
332
328
  retryCount: retryCount + 1,
333
329
  maxRetries: config.maxRetries,
334
- delayMs,
335
- error: error.message
330
+ delayMs
336
331
  });
337
332
  return publishForRetry(ctx, {
338
333
  msg,
@@ -349,9 +344,9 @@ function handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config)
349
344
  */
350
345
  function calculateRetryDelay(retryCount, config) {
351
346
  const { initialDelayMs, maxDelayMs, backoffMultiplier, jitter } = config;
352
- let delay = Math.min(initialDelayMs * Math.pow(backoffMultiplier, retryCount), maxDelayMs);
353
- if (jitter) delay = delay * (.5 + Math.random() * .5);
354
- return Math.floor(delay);
347
+ let delay = initialDelayMs * Math.pow(backoffMultiplier, retryCount);
348
+ if (jitter) delay = delay * (.5 + Math.random());
349
+ return Math.floor(Math.min(delay, maxDelayMs));
355
350
  }
356
351
  /**
357
352
  * Parse message content for republishing.
@@ -370,10 +365,10 @@ function parseMessageContentForRetry(ctx, msg, queueName) {
370
365
  if (!(contentType === void 0 || contentType === "application/json" || contentType.startsWith("application/json;") || contentType.endsWith("+json"))) return msg.content;
371
366
  try {
372
367
  return JSON.parse(msg.content.toString());
373
- } catch (err) {
368
+ } catch (parseErr) {
374
369
  ctx.logger?.warn("Failed to parse JSON message for retry, using original buffer", {
375
370
  queueName,
376
- error: err
371
+ error: parseErr
377
372
  });
378
373
  return msg.content;
379
374
  }
@@ -383,7 +378,6 @@ function parseMessageContentForRetry(ctx, msg, queueName) {
383
378
  */
384
379
  function publishForRetry(ctx, { msg, exchange, routingKey, queueName, waitQueueName, delayMs, error }) {
385
380
  const newRetryCount = (msg.properties.headers?.["x-retry-count"] ?? 0) + 1;
386
- ctx.amqpClient.ack(msg);
387
381
  const content = parseMessageContentForRetry(ctx, msg, queueName);
388
382
  return ctx.amqpClient.publish(exchange, routingKey, content, {
389
383
  ...msg.properties,
@@ -398,21 +392,30 @@ function publishForRetry(ctx, { msg, exchange, routingKey, queueName, waitQueueN
398
392
  "x-retry-queue": queueName
399
393
  } : {}
400
394
  }
401
- }).mapOkToResult((published) => {
395
+ }).andThen((published) => {
402
396
  if (!published) {
403
397
  ctx.logger?.error("Failed to publish message for retry (write buffer full)", {
404
398
  queueName,
405
399
  retryCount: newRetryCount,
406
400
  ...delayMs !== void 0 ? { delayMs } : {}
407
401
  });
408
- return Result.Error(new TechnicalError("Failed to publish message for retry (write buffer full)"));
402
+ return err(new TechnicalError("Failed to publish message for retry (write buffer full)"));
409
403
  }
404
+ ctx.amqpClient.ack(msg);
410
405
  ctx.logger?.info("Message published for retry", {
411
406
  queueName,
412
407
  retryCount: newRetryCount,
413
408
  ...delayMs !== void 0 ? { delayMs } : {}
414
409
  });
415
- return Result.Ok(void 0);
410
+ return ok(void 0);
411
+ }).orElse((publishError) => {
412
+ ctx.logger?.error("Publish for retry failed; leaving original un-ack'd for redelivery", {
413
+ queueName,
414
+ retryCount: newRetryCount,
415
+ ...delayMs !== void 0 ? { delayMs } : {},
416
+ error: publishError
417
+ });
418
+ return err(publishError);
416
419
  });
417
420
  }
418
421
  /**
@@ -449,6 +452,7 @@ function isHandlerTuple(entry) {
449
452
  * ```typescript
450
453
  * import { TypedAmqpWorker } from '@amqp-contract/worker';
451
454
  * import { defineQueue, defineMessage, defineContract, defineConsumer } from '@amqp-contract/contract';
455
+ * import { okAsync } from 'neverthrow';
452
456
  * import { z } from 'zod';
453
457
  *
454
458
  * const orderQueue = defineQueue('order-processing');
@@ -463,19 +467,22 @@ function isHandlerTuple(entry) {
463
467
  * }
464
468
  * });
465
469
  *
466
- * const worker = await TypedAmqpWorker.create({
470
+ * const result = await TypedAmqpWorker.create({
467
471
  * contract,
468
472
  * handlers: {
469
- * processOrder: async (message) => {
470
- * console.log('Processing order', message.orderId);
471
- * // Process the order...
472
- * }
473
+ * processOrder: ({ payload }) => {
474
+ * console.log('Processing order', payload.orderId);
475
+ * return okAsync(undefined);
476
+ * },
473
477
  * },
474
- * urls: ['amqp://localhost']
475
- * }).resultToPromise();
478
+ * urls: ['amqp://localhost'],
479
+ * });
480
+ *
481
+ * if (result.isErr()) throw result.error;
482
+ * const worker = result.value;
476
483
  *
477
484
  * // Close when done
478
- * await worker.close().resultToPromise();
485
+ * await worker.close();
479
486
  * ```
480
487
  */
481
488
  var TypedAmqpWorker = class TypedAmqpWorker {
@@ -551,18 +558,17 @@ var TypedAmqpWorker = class TypedAmqpWorker {
551
558
  * Connections are automatically shared across clients and workers with the same
552
559
  * URLs and connection options, following RabbitMQ best practices.
553
560
  *
554
- * @param options - Configuration options for the worker
555
- * @returns A Future that resolves to a Result containing the worker or an error
561
+ * @returns A ResultAsync that resolves to the worker or a TechnicalError.
556
562
  *
557
563
  * @example
558
564
  * ```typescript
559
- * const worker = await TypedAmqpWorker.create({
565
+ * const result = await TypedAmqpWorker.create({
560
566
  * contract: myContract,
561
567
  * handlers: {
562
- * processOrder: async ({ payload }) => console.log('Order:', payload.orderId)
568
+ * processOrder: ({ payload }) => okAsync(undefined),
563
569
  * },
564
- * urls: ['amqp://localhost']
565
- * }).resultToPromise();
570
+ * urls: ['amqp://localhost'],
571
+ * });
566
572
  * ```
567
573
  */
568
574
  static create({ contract, handlers, urls, connectionOptions, defaultConsumerOptions, logger, telemetry, connectTimeoutMs }) {
@@ -571,12 +577,14 @@ var TypedAmqpWorker = class TypedAmqpWorker {
571
577
  connectionOptions,
572
578
  connectTimeoutMs
573
579
  }), handlers, defaultConsumerOptions ?? {}, logger, telemetry);
574
- return worker.waitForConnectionReady().flatMapOk(() => worker.consumeAll()).flatMap((result) => result.match({
575
- Ok: () => Future.value(Result.Ok(worker)),
576
- Error: (error) => worker.close().tapError((closeError) => {
577
- logger?.warn("Failed to close worker after setup failure", { error: closeError });
578
- }).map(() => Result.Error(error))
579
- }));
580
+ const setup = worker.waitForConnectionReady().andThen(() => worker.consumeAll());
581
+ return new ResultAsync((async () => {
582
+ const setupResult = await setup;
583
+ if (setupResult.isOk()) return ok(worker);
584
+ const closeResult = await worker.close();
585
+ if (closeResult.isErr()) logger?.warn("Failed to close worker after setup failure", { error: closeResult.error });
586
+ return err(setupResult.error);
587
+ })());
580
588
  }
581
589
  /**
582
590
  * Close the AMQP channel and connection.
@@ -584,26 +592,25 @@ var TypedAmqpWorker = class TypedAmqpWorker {
584
592
  * This gracefully closes the connection to the AMQP broker,
585
593
  * stopping all message consumption and cleaning up resources.
586
594
  *
587
- * @returns A Future that resolves to a Result indicating success or failure
588
- *
589
595
  * @example
590
596
  * ```typescript
591
- * const closeResult = await worker.close().resultToPromise();
597
+ * const closeResult = await worker.close();
592
598
  * if (closeResult.isOk()) {
593
599
  * console.log('Worker closed successfully');
594
600
  * }
595
601
  * ```
596
602
  */
597
603
  close() {
598
- return Future.all(Array.from(this.consumerTags).map((consumerTag) => this.amqpClient.cancel(consumerTag).mapErrorToResult((error) => {
604
+ const cancellations = Array.from(this.consumerTags).map((consumerTag) => this.amqpClient.cancel(consumerTag).orElse((error) => {
599
605
  this.logger?.warn("Failed to cancel consumer during close", {
600
606
  consumerTag,
601
607
  error
602
608
  });
603
- return Result.Ok(void 0);
604
- }))).map(Result.all).tapOk(() => {
609
+ return ok(void 0);
610
+ }));
611
+ return ResultAsync.combine(cancellations).andTee(() => {
605
612
  this.consumerTags.clear();
606
- }).flatMapOk(() => this.amqpClient.close()).mapOk(() => void 0);
613
+ }).andThen(() => this.amqpClient.close()).map(() => void 0);
607
614
  }
608
615
  /**
609
616
  * Start consuming for every entry in `contract.consumers` and `contract.rpcs`.
@@ -612,7 +619,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
612
619
  const consumerNames = Object.keys(this.contract.consumers ?? {});
613
620
  const rpcNames = Object.keys(this.contract.rpcs ?? {});
614
621
  const allNames = [...consumerNames, ...rpcNames];
615
- return Future.all(allNames.map((name) => this.consume(name))).map(Result.all).mapOk(() => void 0);
622
+ return ResultAsync.combine(allNames.map((name) => this.consume(name))).map(() => void 0);
616
623
  }
617
624
  waitForConnectionReady() {
618
625
  return this.amqpClient.waitForConnect();
@@ -633,9 +640,9 @@ var TypedAmqpWorker = class TypedAmqpWorker {
633
640
  validateSchema(schema, data, context) {
634
641
  const rawValidation = schema["~standard"].validate(data);
635
642
  const validationPromise = rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation);
636
- return Future.fromPromise(validationPromise).mapError((error) => new TechnicalError(`Error validating ${context.field}`, error)).mapOkToResult((result) => {
637
- if (result.issues) return Result.Error(new TechnicalError(`${context.field} validation failed`, new MessageValidationError(context.consumerName, result.issues)));
638
- return Result.Ok(result.value);
643
+ return ResultAsync.fromPromise(validationPromise, (error) => new TechnicalError(`Error validating ${context.field}`, error)).andThen((result) => {
644
+ if (result.issues) return err(new TechnicalError(`${context.field} validation failed`, new MessageValidationError(context.consumerName, result.issues)));
645
+ return ok(result.value);
639
646
  });
640
647
  }
641
648
  /**
@@ -647,18 +654,18 @@ var TypedAmqpWorker = class TypedAmqpWorker {
647
654
  */
648
655
  parseAndValidateMessage(msg, consumer, consumerName) {
649
656
  const context = { consumerName: String(consumerName) };
650
- const parsePayload = decompressBuffer(msg.content, msg.properties.contentEncoding).mapErrorToResult((error) => Result.Error(new TechnicalError("Failed to decompress message", error))).mapOkToResult((buffer) => Result.fromExecution(() => JSON.parse(buffer.toString())).mapError((error) => new TechnicalError("Failed to parse JSON", error))).flatMapOk((parsed) => this.validateSchema(consumer.message.payload, parsed, {
657
+ const parsePayload = decompressBuffer(msg.content, msg.properties.contentEncoding).andThen((buffer) => safeJsonParse(buffer, (error) => new TechnicalError("Failed to parse JSON", error))).andThen((parsed) => this.validateSchema(consumer.message.payload, parsed, {
651
658
  ...context,
652
659
  field: "payload"
653
660
  }));
654
661
  const parseHeaders = consumer.message.headers ? this.validateSchema(consumer.message.headers, msg.properties.headers ?? {}, {
655
662
  ...context,
656
663
  field: "headers"
657
- }) : Future.value(Result.Ok(void 0));
658
- return Future.allFromDict({
659
- payload: parsePayload,
660
- headers: parseHeaders
661
- }).map(Result.allFromDict);
664
+ }) : okAsync(void 0);
665
+ return ResultAsync.combine([parsePayload, parseHeaders]).map(([payload, headers]) => ({
666
+ payload,
667
+ headers
668
+ }));
662
669
  }
663
670
  /**
664
671
  * Validate an RPC handler's response and publish it back to the caller's reply
@@ -687,7 +694,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
687
694
  rpcName: String(rpcName),
688
695
  queueName
689
696
  });
690
- return Future.value(Result.Error(new NonRetryableError(`RPC "${String(rpcName)}" received a message without replyTo; cannot deliver response`)));
697
+ return errAsync(new NonRetryableError(`RPC "${String(rpcName)}" received a message without replyTo; cannot deliver response`));
691
698
  }
692
699
  if (typeof correlationId !== "string" || correlationId.length === 0) {
693
700
  this.logger?.error("RPC handler returned a response but the incoming message has no correlationId", {
@@ -695,85 +702,127 @@ var TypedAmqpWorker = class TypedAmqpWorker {
695
702
  queueName,
696
703
  replyTo
697
704
  });
698
- return Future.value(Result.Error(new NonRetryableError(`RPC "${String(rpcName)}" received a message without correlationId; cannot deliver response`)));
705
+ return errAsync(new NonRetryableError(`RPC "${String(rpcName)}" received a message without correlationId; cannot deliver response`));
699
706
  }
700
707
  let rawValidation;
701
708
  try {
702
709
  rawValidation = responseSchema["~standard"].validate(response);
703
710
  } catch (error) {
704
- return Future.value(Result.Error(new NonRetryableError("RPC response schema validation threw", error)));
711
+ return errAsync(new NonRetryableError("RPC response schema validation threw", error));
705
712
  }
706
713
  const validationPromise = rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation);
707
- return Future.fromPromise(validationPromise).mapError((error) => new NonRetryableError("RPC response schema validation threw", error)).mapOkToResult((validation) => {
708
- if (validation.issues) return Result.Error(new NonRetryableError(`RPC response for "${String(rpcName)}" failed schema validation`, new MessageValidationError(String(rpcName), validation.issues)));
709
- return Result.Ok(validation.value);
710
- }).flatMapOk((validatedResponse) => this.amqpClient.publish("", replyTo, validatedResponse, {
714
+ return ResultAsync.fromPromise(validationPromise, (error) => new NonRetryableError("RPC response schema validation threw", error)).andThen((validation) => {
715
+ if (validation.issues) return err(new NonRetryableError(`RPC response for "${String(rpcName)}" failed schema validation`, new MessageValidationError(String(rpcName), validation.issues)));
716
+ return ok(validation.value);
717
+ }).andThen((validatedResponse) => this.amqpClient.publish("", replyTo, validatedResponse, {
711
718
  correlationId,
712
719
  contentType: "application/json"
713
- }).mapErrorToResult((error) => Result.Error(new NonRetryableError("Failed to publish RPC response", error))).mapOkToResult((published) => published ? Result.Ok(void 0) : Result.Error(new NonRetryableError("Failed to publish RPC response: channel buffer full"))));
720
+ }).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"))));
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 errAsync(parseError);
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 `okAsync(undefined)`.
747
+ */
748
+ publishReplyIfRpc(msg, view, name, handlerResponse) {
749
+ if (!view.isRpc || !view.responseSchema) return okAsync(void 0);
750
+ const queueName = extractQueue(view.consumer.queue).name;
751
+ return this.publishRpcResponse(msg, queueName, name, view.responseSchema, handlerResponse);
714
752
  }
715
753
  /**
716
754
  * Process a single consumed message: validate, invoke handler, optionally
717
- * 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.
718
769
  */
719
- processMessage(msg, view, name, handler) {
720
- const { consumer, isRpc, responseSchema } = view;
770
+ processMessage(msg, view, name, handler, state) {
771
+ const { consumer } = view;
721
772
  const queueName = extractQueue(consumer.queue).name;
722
773
  const startTime = Date.now();
723
774
  const span = startConsumeSpan(this.telemetry, queueName, String(name), { "messaging.rabbitmq.message.delivery_tag": msg.fields.deliveryTag });
724
- let messageHandled = false;
725
- let firstError;
726
- return this.parseAndValidateMessage(msg, consumer, name).flatMap((parseResult) => parseResult.match({
727
- Ok: (validatedMessage) => handler(validatedMessage, msg).flatMapOk((handlerResponse) => {
728
- if (isRpc && responseSchema) return this.publishRpcResponse(msg, queueName, name, responseSchema, handlerResponse).flatMapOk(() => {
729
- this.logger?.info("Message consumed successfully", {
730
- consumerName: String(name),
731
- queueName
732
- });
733
- this.amqpClient.ack(msg);
734
- messageHandled = true;
735
- return Future.value(Result.Ok(void 0));
736
- });
737
- this.logger?.info("Message consumed successfully", {
738
- consumerName: String(name),
739
- queueName
740
- });
741
- this.amqpClient.ack(msg);
742
- messageHandled = true;
743
- return Future.value(Result.Ok(void 0));
744
- }).flatMapError((handlerError) => {
745
- this.logger?.error("Error processing message", {
775
+ return this.parseAndValidateOrNack(msg, consumer, name).orTee((parseError) => {
776
+ this.logger?.error("Failed to parse/validate message; sending to DLQ", {
777
+ consumerName: String(name),
778
+ queueName,
779
+ error: parseError
780
+ });
781
+ state.messageHandled = true;
782
+ }).andThen((validatedMessage) => this.runHandler(handler, validatedMessage, msg).andThen((handlerResponse) => this.publishReplyIfRpc(msg, view, name, handlerResponse).andTee(() => {
783
+ this.logger?.info("Message consumed successfully", {
784
+ consumerName: String(name),
785
+ queueName
786
+ });
787
+ this.amqpClient.ack(msg);
788
+ state.messageHandled = true;
789
+ })).orElse((handlerError) => {
790
+ this.logger?.error("Error processing message", {
791
+ consumerName: String(name),
792
+ queueName,
793
+ errorType: handlerError.name,
794
+ retryCount: msg.properties.headers?.["x-delivery-count"] ?? msg.properties.headers?.["x-retry-count"] ?? 0,
795
+ error: handlerError.message
796
+ });
797
+ return handleError({
798
+ amqpClient: this.amqpClient,
799
+ logger: this.logger
800
+ }, handlerError, msg, String(name), consumer).andTee(() => {
801
+ state.messageHandled = true;
802
+ }).andThen(() => errAsync(new TechnicalError(`Handler "${String(name)}" failed: ${handlerError.message}`, handlerError)));
803
+ })).andTee(() => {
804
+ try {
805
+ endSpanSuccess(span);
806
+ recordConsumeMetric(this.telemetry, queueName, String(name), true, Date.now() - startTime);
807
+ } catch (telemetryError) {
808
+ this.logger?.warn("Telemetry recording threw; ignoring", {
746
809
  consumerName: String(name),
747
810
  queueName,
748
- errorType: handlerError.name,
749
- error: handlerError.message
811
+ error: telemetryError
750
812
  });
751
- firstError = handlerError;
752
- return handleError({
753
- amqpClient: this.amqpClient,
754
- logger: this.logger
755
- }, handlerError, msg, String(name), consumer);
756
- }),
757
- Error: (parseError) => {
758
- firstError = parseError;
759
- this.logger?.error("Failed to parse/validate message; sending to DLQ", {
813
+ }
814
+ }).orTee((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", {
760
821
  consumerName: String(name),
761
822
  queueName,
762
- error: parseError
823
+ error: telemetryError
763
824
  });
764
- this.amqpClient.nack(msg, false, false);
765
- return Future.value(Result.Error(parseError));
766
- }
767
- })).map((result) => {
768
- const durationMs = Date.now() - startTime;
769
- if (messageHandled) {
770
- endSpanSuccess(span);
771
- recordConsumeMetric(this.telemetry, queueName, String(name), true, durationMs);
772
- } else {
773
- endSpanError(span, result.isError() ? result.error : firstError ?? /* @__PURE__ */ new Error("Unknown error"));
774
- recordConsumeMetric(this.telemetry, queueName, String(name), false, durationMs);
775
825
  }
776
- return result;
777
826
  });
778
827
  }
779
828
  /**
@@ -789,9 +838,18 @@ var TypedAmqpWorker = class TypedAmqpWorker {
789
838
  });
790
839
  return;
791
840
  }
841
+ const state = { messageHandled: false };
792
842
  try {
793
- await this.processMessage(msg, view, name, handler).toPromise();
843
+ await this.processMessage(msg, view, name, handler, state);
794
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
+ }
795
853
  this.logger?.error("Uncaught error in consume callback; nacking message", {
796
854
  consumerName: String(name),
797
855
  queueName,
@@ -799,64 +857,77 @@ var TypedAmqpWorker = class TypedAmqpWorker {
799
857
  });
800
858
  this.amqpClient.nack(msg, false, false);
801
859
  }
802
- }, this.consumerOptions[name]).tapOk((consumerTag) => {
860
+ }, this.consumerOptions[name]).andTee((consumerTag) => {
803
861
  this.consumerTags.add(consumerTag);
804
- }).mapError((error) => new TechnicalError(`Failed to start consuming for "${String(name)}"`, error)).mapOk(() => void 0);
862
+ }).map(() => void 0).mapErr((error) => new TechnicalError(`Failed to start consuming for "${String(name)}"`, error));
805
863
  }
806
864
  };
807
865
  //#endregion
808
866
  //#region src/handlers.ts
809
867
  /**
810
- * 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`.
811
870
  */
812
- function validateConsumerExists(contract, consumerName) {
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.
882
+ */
883
+ function validateHandlerTargetExists(contract, name) {
813
884
  const consumers = contract.consumers;
814
- if (!consumers || !(consumerName in consumers)) {
815
- const availableConsumers = consumers ? Object.keys(consumers) : [];
816
- const available = availableConsumers.length > 0 ? availableConsumers.join(", ") : "none";
817
- throw new Error(`Consumer "${consumerName}" not found in contract. Available consumers: ${available}`);
885
+ const rpcs = contract.rpcs;
886
+ if (!(!!consumers && Object.hasOwn(consumers, name)) && !(!!rpcs && Object.hasOwn(rpcs, name))) {
887
+ const available = formatAvailable(availableHandlerNames(contract));
888
+ throw new Error(`Handler target "${name}" not found in contract. Available consumers and RPCs: ${available}`);
818
889
  }
819
890
  }
820
891
  /**
821
- * Validate that all handlers reference valid consumers
892
+ * Validate that every key in `handlers` maps to a contract entry —
893
+ * either a `consumers` key or an `rpcs` key.
822
894
  */
823
895
  function validateHandlers(contract, handlers) {
824
- const consumers = contract.consumers;
825
- const availableConsumers = Object.keys(consumers ?? {});
826
- const availableConsumerNames = availableConsumers.length > 0 ? availableConsumers.join(", ") : "none";
827
- for (const handlerName of Object.keys(handlers)) if (!consumers || !(handlerName in consumers)) throw new Error(`Consumer "${handlerName}" not found in contract. Available consumers: ${availableConsumerNames}`);
896
+ for (const handlerName of Object.keys(handlers)) validateHandlerTargetExists(contract, handlerName);
828
897
  }
829
- function defineHandler(contract, consumerName, handler, options) {
830
- validateConsumerExists(contract, String(consumerName));
898
+ function defineHandler(contract, name, handler, options) {
899
+ validateHandlerTargetExists(contract, String(name));
831
900
  if (options) return [handler, options];
832
901
  return handler;
833
902
  }
834
903
  /**
835
- * Define multiple type-safe handlers for consumers in a contract.
904
+ * Define multiple type-safe handlers for consumers and RPCs in a contract.
905
+ *
906
+ * **Recommended:** This function creates handlers that return
907
+ * `ResultAsync<void, HandlerError>` (consumers) or
908
+ * `ResultAsync<TResponse, HandlerError>` (RPCs), providing explicit error
909
+ * handling and better control over retry behavior.
836
910
  *
837
- * **Recommended:** This function creates handlers that return `Future<Result<void, HandlerError>>`,
838
- * providing explicit error handling and better control over retry behavior.
911
+ * The handlers object must contain exactly one entry per `consumers` and
912
+ * `rpcs` key in the contract see {@link WorkerInferHandlers}.
839
913
  *
840
914
  * @template TContract - The contract definition type
841
- * @param contract - The contract definition containing the consumers
842
- * @param handlers - An object with handler functions for each consumer
915
+ * @param contract - The contract definition containing the consumers and RPCs
916
+ * @param handlers - An object with handler functions for each consumer and RPC
843
917
  * @returns A type-safe handlers object that can be used with TypedAmqpWorker
844
918
  *
845
919
  * @example
846
920
  * ```typescript
847
921
  * import { defineHandlers, RetryableError } from '@amqp-contract/worker';
848
- * import { Future } from '@swan-io/boxed';
849
- * import { orderContract } from './contract';
922
+ * import { okAsync, ResultAsync } from 'neverthrow';
850
923
  *
851
924
  * const handlers = defineHandlers(orderContract, {
852
925
  * processOrder: ({ payload }) =>
853
- * Future.fromPromise(processPayment(payload))
854
- * .mapOk(() => undefined)
855
- * .mapError((error) => new RetryableError('Payment failed', error)),
856
- * notifyOrder: ({ payload }) =>
857
- * Future.fromPromise(sendNotification(payload))
858
- * .mapOk(() => undefined)
859
- * .mapError((error) => new RetryableError('Notification failed', error)),
926
+ * ResultAsync.fromPromise(
927
+ * processPayment(payload),
928
+ * (error) => new RetryableError('Payment failed', error),
929
+ * ).map(() => undefined),
930
+ * calculate: ({ payload }) => okAsync({ sum: payload.a + payload.b }),
860
931
  * });
861
932
  * ```
862
933
  */
@@ -865,6 +936,6 @@ function defineHandlers(contract, handlers) {
865
936
  return handlers;
866
937
  }
867
938
  //#endregion
868
- export { MessageValidationError, NonRetryableError, RetryableError, TypedAmqpWorker, defineHandler, defineHandlers, isHandlerError, isNonRetryableError, isRetryableError, nonRetryable, retryable };
939
+ export { HandlerError, MessageValidationError, NonRetryableError, RetryableError, TypedAmqpWorker, defineHandler, defineHandlers, isHandlerError, isNonRetryableError, isRetryableError, nonRetryable, retryable };
869
940
 
870
941
  //# sourceMappingURL=index.mjs.map