@amqp-contract/worker 0.24.0 → 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.cjs CHANGED
@@ -38,36 +38,39 @@ function decompressBuffer(buffer, contentEncoding) {
38
38
  //#endregion
39
39
  //#region src/errors.ts
40
40
  /**
41
- * Retryable errors - transient failures that may succeed on retry
42
- * Examples: network timeouts, rate limiting, temporary service unavailability
41
+ * Abstract base class for all handler-signalled errors.
43
42
  *
44
- * Use this error type when the operation might succeed if retried.
45
- * The worker will apply exponential backoff and retry the message.
43
+ * Concrete subclasses (`RetryableError`, `NonRetryableError`) discriminate on
44
+ * the `name` property so exhaustive narrowing in user code keeps working.
45
+ * `error instanceof HandlerError` is true for any handler error.
46
46
  */
47
- var RetryableError = class extends Error {
47
+ var HandlerError = class extends Error {
48
48
  constructor(message, cause) {
49
49
  super(message);
50
50
  this.cause = cause;
51
- this.name = "RetryableError";
52
51
  const ErrorConstructor = Error;
53
52
  if (typeof ErrorConstructor.captureStackTrace === "function") ErrorConstructor.captureStackTrace(this, this.constructor);
54
53
  }
55
54
  };
56
55
  /**
56
+ * Retryable errors - transient failures that may succeed on retry
57
+ * Examples: network timeouts, rate limiting, temporary service unavailability
58
+ *
59
+ * Use this error type when the operation might succeed if retried.
60
+ * The worker will apply exponential backoff and retry the message.
61
+ */
62
+ var RetryableError = class extends HandlerError {
63
+ name = "RetryableError";
64
+ };
65
+ /**
57
66
  * Non-retryable errors - permanent failures that should not be retried
58
67
  * Examples: invalid data, business rule violations, permanent external failures
59
68
  *
60
69
  * Use this error type when retrying would not help - the message will be
61
70
  * immediately sent to the dead letter queue (DLQ) if configured.
62
71
  */
63
- var NonRetryableError = class extends Error {
64
- 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);
70
- }
72
+ var NonRetryableError = class extends HandlerError {
73
+ name = "NonRetryableError";
71
74
  };
72
75
  /**
73
76
  * Type guard to check if an error is a RetryableError.
@@ -138,7 +141,7 @@ function isNonRetryableError(error) {
138
141
  * ```
139
142
  */
140
143
  function isHandlerError(error) {
141
- return isRetryableError(error) || isNonRetryableError(error);
144
+ return error instanceof HandlerError;
142
145
  }
143
146
  /**
144
147
  * Create a RetryableError with less verbosity.
@@ -219,20 +222,15 @@ function nonRetryable(message, cause) {
219
222
  */
220
223
  function handleError(ctx, error, msg, consumerName, consumer) {
221
224
  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
225
  sendToDLQ(ctx, msg, consumer);
228
226
  return (0, neverthrow.okAsync)(void 0);
229
227
  }
230
228
  const config = (0, _amqp_contract_contract.extractQueue)(consumer.queue).retry;
231
229
  if (config.mode === "immediate-requeue") return handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, config);
232
230
  if (config.mode === "ttl-backoff") return handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config);
233
- ctx.logger?.warn("Retry disabled (none mode), sending to DLQ", {
231
+ ctx.logger?.info("Retry disabled (none mode), sending to DLQ", {
234
232
  consumerName,
235
- error: error.message
233
+ queueName: (0, _amqp_contract_contract.extractQueue)(consumer.queue).name
236
234
  });
237
235
  sendToDLQ(ctx, msg, consumer);
238
236
  return (0, neverthrow.okAsync)(void 0);
@@ -251,22 +249,20 @@ function handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, co
251
249
  const queueName = queue.name;
252
250
  const retryCount = queue.type === "quorum" ? msg.properties.headers?.["x-delivery-count"] ?? 0 : msg.properties.headers?.["x-retry-count"] ?? 0;
253
251
  if (retryCount >= config.maxRetries) {
254
- ctx.logger?.error("Max retries exceeded, sending to DLQ (immediate-requeue mode)", {
252
+ ctx.logger?.info("Max retries exceeded, sending to DLQ (immediate-requeue mode)", {
255
253
  consumerName,
256
254
  queueName,
257
255
  retryCount,
258
- maxRetries: config.maxRetries,
259
- error: error.message
256
+ maxRetries: config.maxRetries
260
257
  });
261
258
  sendToDLQ(ctx, msg, consumer);
262
259
  return (0, neverthrow.okAsync)(void 0);
263
260
  }
264
- ctx.logger?.warn("Retrying message (immediate-requeue mode)", {
261
+ ctx.logger?.info("Retrying message (immediate-requeue mode)", {
265
262
  consumerName,
266
263
  queueName,
267
264
  retryCount,
268
- maxRetries: config.maxRetries,
269
- error: error.message
265
+ maxRetries: config.maxRetries
270
266
  });
271
267
  if (queue.type === "quorum") {
272
268
  ctx.amqpClient.nack(msg, false, true);
@@ -317,24 +313,22 @@ function handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config)
317
313
  const queueName = (0, _amqp_contract_contract.extractQueue)(queueEntry).name;
318
314
  const retryCount = msg.properties.headers?.["x-retry-count"] ?? 0;
319
315
  if (retryCount >= config.maxRetries) {
320
- ctx.logger?.error("Max retries exceeded, sending to DLQ (ttl-backoff mode)", {
316
+ ctx.logger?.info("Max retries exceeded, sending to DLQ (ttl-backoff mode)", {
321
317
  consumerName,
322
318
  queueName,
323
319
  retryCount,
324
- maxRetries: config.maxRetries,
325
- error: error.message
320
+ maxRetries: config.maxRetries
326
321
  });
327
322
  sendToDLQ(ctx, msg, consumer);
328
323
  return (0, neverthrow.okAsync)(void 0);
329
324
  }
330
325
  const delayMs = calculateRetryDelay(retryCount, config);
331
- ctx.logger?.warn("Retrying message (ttl-backoff mode)", {
326
+ ctx.logger?.info("Retrying message (ttl-backoff mode)", {
332
327
  consumerName,
333
328
  queueName,
334
329
  retryCount: retryCount + 1,
335
330
  maxRetries: config.maxRetries,
336
- delayMs,
337
- error: error.message
331
+ delayMs
338
332
  });
339
333
  return publishForRetry(ctx, {
340
334
  msg,
@@ -351,9 +345,9 @@ function handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config)
351
345
  */
352
346
  function calculateRetryDelay(retryCount, config) {
353
347
  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);
348
+ let delay = initialDelayMs * Math.pow(backoffMultiplier, retryCount);
349
+ if (jitter) delay = delay * (.5 + Math.random());
350
+ return Math.floor(Math.min(delay, maxDelayMs));
357
351
  }
358
352
  /**
359
353
  * Parse message content for republishing.
@@ -385,7 +379,6 @@ function parseMessageContentForRetry(ctx, msg, queueName) {
385
379
  */
386
380
  function publishForRetry(ctx, { msg, exchange, routingKey, queueName, waitQueueName, delayMs, error }) {
387
381
  const newRetryCount = (msg.properties.headers?.["x-retry-count"] ?? 0) + 1;
388
- ctx.amqpClient.ack(msg);
389
382
  const content = parseMessageContentForRetry(ctx, msg, queueName);
390
383
  return ctx.amqpClient.publish(exchange, routingKey, content, {
391
384
  ...msg.properties,
@@ -409,12 +402,21 @@ function publishForRetry(ctx, { msg, exchange, routingKey, queueName, waitQueueN
409
402
  });
410
403
  return (0, neverthrow.err)(new _amqp_contract_core.TechnicalError("Failed to publish message for retry (write buffer full)"));
411
404
  }
405
+ ctx.amqpClient.ack(msg);
412
406
  ctx.logger?.info("Message published for retry", {
413
407
  queueName,
414
408
  retryCount: newRetryCount,
415
409
  ...delayMs !== void 0 ? { delayMs } : {}
416
410
  });
417
411
  return (0, neverthrow.ok)(void 0);
412
+ }).orElse((publishError) => {
413
+ ctx.logger?.error("Publish for retry failed; leaving original un-ack'd for redelivery", {
414
+ queueName,
415
+ retryCount: newRetryCount,
416
+ ...delayMs !== void 0 ? { delayMs } : {},
417
+ error: publishError
418
+ });
419
+ return (0, neverthrow.err)(publishError);
418
420
  });
419
421
  }
420
422
  /**
@@ -653,7 +655,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
653
655
  */
654
656
  parseAndValidateMessage(msg, consumer, consumerName) {
655
657
  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, {
658
+ const parsePayload = decompressBuffer(msg.content, msg.properties.contentEncoding).andThen((buffer) => (0, _amqp_contract_core.safeJsonParse)(buffer, (error) => new _amqp_contract_core.TechnicalError("Failed to parse JSON", error))).andThen((parsed) => this.validateSchema(consumer.message.payload, parsed, {
657
659
  ...context,
658
660
  field: "payload"
659
661
  }));
@@ -719,66 +721,110 @@ var TypedAmqpWorker = class TypedAmqpWorker {
719
721
  }).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"))));
720
722
  }
721
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, neverthrow.errAsync)(parseError);
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 `okAsync(undefined)`.
748
+ */
749
+ publishReplyIfRpc(msg, view, name, handlerResponse) {
750
+ if (!view.isRpc || !view.responseSchema) return (0, neverthrow.okAsync)(void 0);
751
+ const queueName = (0, _amqp_contract_contract.extractQueue)(view.consumer.queue).name;
752
+ return this.publishRpcResponse(msg, queueName, name, view.responseSchema, handlerResponse);
753
+ }
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).orTee((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
+ }).andThen((validatedMessage) => this.runHandler(handler, validatedMessage, msg).andThen((handlerResponse) => this.publishReplyIfRpc(msg, view, name, handlerResponse).andTee(() => {
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).andTee(() => {
802
+ state.messageHandled = true;
803
+ }).andThen(() => (0, neverthrow.errAsync)(new _amqp_contract_core.TechnicalError(`Handler "${String(name)}" failed: ${handlerError.message}`, handlerError)));
804
+ })).andTee(() => {
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
+ }).orTee((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,
@@ -811,46 +866,61 @@ 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`.
815
871
  */
816
- function validateConsumerExists(contract, consumerName) {
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.
883
+ */
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
+ if (!(!!consumers && Object.hasOwn(consumers, name)) && !(!!rpcs && Object.hasOwn(rpcs, name))) {
888
+ const available = formatAvailable(availableHandlerNames(contract));
889
+ throw new Error(`Handler target "${name}" not found in contract. Available consumers and RPCs: ${available}`);
822
890
  }
823
891
  }
824
892
  /**
825
- * Validate that all handlers reference valid consumers
893
+ * Validate that every key in `handlers` maps to a contract entry —
894
+ * either a `consumers` key or an `rpcs` key.
826
895
  */
827
896
  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}`);
897
+ for (const handlerName of Object.keys(handlers)) validateHandlerTargetExists(contract, handlerName);
832
898
  }
833
- function defineHandler(contract, consumerName, handler, options) {
834
- validateConsumerExists(contract, String(consumerName));
899
+ function defineHandler(contract, name, handler, options) {
900
+ validateHandlerTargetExists(contract, String(name));
835
901
  if (options) return [handler, options];
836
902
  return handler;
837
903
  }
838
904
  /**
839
- * Define multiple type-safe handlers for consumers in a contract.
905
+ * Define multiple type-safe handlers for consumers and RPCs in a contract.
840
906
  *
841
- * **Recommended:** This function creates handlers that return `ResultAsync<void, HandlerError>`,
842
- * providing explicit error handling and better control over retry behavior.
907
+ * **Recommended:** This function creates handlers that return
908
+ * `ResultAsync<void, HandlerError>` (consumers) or
909
+ * `ResultAsync<TResponse, HandlerError>` (RPCs), providing explicit error
910
+ * handling and better control over retry behavior.
911
+ *
912
+ * The handlers object must contain exactly one entry per `consumers` and
913
+ * `rpcs` key in the contract — see {@link WorkerInferHandlers}.
843
914
  *
844
915
  * @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
916
+ * @param contract - The contract definition containing the consumers and RPCs
917
+ * @param handlers - An object with handler functions for each consumer and RPC
847
918
  * @returns A type-safe handlers object that can be used with TypedAmqpWorker
848
919
  *
849
920
  * @example
850
921
  * ```typescript
851
922
  * import { defineHandlers, RetryableError } from '@amqp-contract/worker';
852
- * import { ResultAsync } from 'neverthrow';
853
- * import { orderContract } from './contract';
923
+ * import { okAsync, ResultAsync } from 'neverthrow';
854
924
  *
855
925
  * const handlers = defineHandlers(orderContract, {
856
926
  * processOrder: ({ payload }) =>
@@ -858,11 +928,7 @@ function defineHandler(contract, consumerName, handler, options) {
858
928
  * processPayment(payload),
859
929
  * (error) => new RetryableError('Payment failed', error),
860
930
  * ).map(() => undefined),
861
- * notifyOrder: ({ payload }) =>
862
- * ResultAsync.fromPromise(
863
- * sendNotification(payload),
864
- * (error) => new RetryableError('Notification failed', error),
865
- * ).map(() => undefined),
931
+ * calculate: ({ payload }) => okAsync({ sum: payload.a + payload.b }),
866
932
  * });
867
933
  * ```
868
934
  */
@@ -871,6 +937,7 @@ function defineHandlers(contract, handlers) {
871
937
  return handlers;
872
938
  }
873
939
  //#endregion
940
+ exports.HandlerError = HandlerError;
874
941
  Object.defineProperty(exports, "MessageValidationError", {
875
942
  enumerable: true,
876
943
  get: function() {