@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 +166 -99
- package/dist/index.d.cts +88 -54
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +88 -54
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +168 -102
- package/dist/index.mjs.map +1 -1
- package/docs/index.md +564 -158
- package/package.json +4 -4
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 {
|
|
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
|
|
@@ -37,36 +37,39 @@ function decompressBuffer(buffer, contentEncoding) {
|
|
|
37
37
|
//#endregion
|
|
38
38
|
//#region src/errors.ts
|
|
39
39
|
/**
|
|
40
|
-
*
|
|
41
|
-
* Examples: network timeouts, rate limiting, temporary service unavailability
|
|
40
|
+
* Abstract base class for all handler-signalled errors.
|
|
42
41
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
|
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
|
|
63
|
-
|
|
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
|
|
143
|
+
return error instanceof HandlerError;
|
|
141
144
|
}
|
|
142
145
|
/**
|
|
143
146
|
* Create a RetryableError with less verbosity.
|
|
@@ -218,20 +221,15 @@ function nonRetryable(message, cause) {
|
|
|
218
221
|
*/
|
|
219
222
|
function handleError(ctx, error, msg, consumerName, consumer) {
|
|
220
223
|
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
224
|
sendToDLQ(ctx, msg, consumer);
|
|
227
225
|
return okAsync(void 0);
|
|
228
226
|
}
|
|
229
227
|
const config = extractQueue(consumer.queue).retry;
|
|
230
228
|
if (config.mode === "immediate-requeue") return handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, config);
|
|
231
229
|
if (config.mode === "ttl-backoff") return handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config);
|
|
232
|
-
ctx.logger?.
|
|
230
|
+
ctx.logger?.info("Retry disabled (none mode), sending to DLQ", {
|
|
233
231
|
consumerName,
|
|
234
|
-
|
|
232
|
+
queueName: extractQueue(consumer.queue).name
|
|
235
233
|
});
|
|
236
234
|
sendToDLQ(ctx, msg, consumer);
|
|
237
235
|
return okAsync(void 0);
|
|
@@ -250,22 +248,20 @@ function handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, co
|
|
|
250
248
|
const queueName = queue.name;
|
|
251
249
|
const retryCount = queue.type === "quorum" ? msg.properties.headers?.["x-delivery-count"] ?? 0 : msg.properties.headers?.["x-retry-count"] ?? 0;
|
|
252
250
|
if (retryCount >= config.maxRetries) {
|
|
253
|
-
ctx.logger?.
|
|
251
|
+
ctx.logger?.info("Max retries exceeded, sending to DLQ (immediate-requeue mode)", {
|
|
254
252
|
consumerName,
|
|
255
253
|
queueName,
|
|
256
254
|
retryCount,
|
|
257
|
-
maxRetries: config.maxRetries
|
|
258
|
-
error: error.message
|
|
255
|
+
maxRetries: config.maxRetries
|
|
259
256
|
});
|
|
260
257
|
sendToDLQ(ctx, msg, consumer);
|
|
261
258
|
return okAsync(void 0);
|
|
262
259
|
}
|
|
263
|
-
ctx.logger?.
|
|
260
|
+
ctx.logger?.info("Retrying message (immediate-requeue mode)", {
|
|
264
261
|
consumerName,
|
|
265
262
|
queueName,
|
|
266
263
|
retryCount,
|
|
267
|
-
maxRetries: config.maxRetries
|
|
268
|
-
error: error.message
|
|
264
|
+
maxRetries: config.maxRetries
|
|
269
265
|
});
|
|
270
266
|
if (queue.type === "quorum") {
|
|
271
267
|
ctx.amqpClient.nack(msg, false, true);
|
|
@@ -316,24 +312,22 @@ function handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config)
|
|
|
316
312
|
const queueName = extractQueue(queueEntry).name;
|
|
317
313
|
const retryCount = msg.properties.headers?.["x-retry-count"] ?? 0;
|
|
318
314
|
if (retryCount >= config.maxRetries) {
|
|
319
|
-
ctx.logger?.
|
|
315
|
+
ctx.logger?.info("Max retries exceeded, sending to DLQ (ttl-backoff mode)", {
|
|
320
316
|
consumerName,
|
|
321
317
|
queueName,
|
|
322
318
|
retryCount,
|
|
323
|
-
maxRetries: config.maxRetries
|
|
324
|
-
error: error.message
|
|
319
|
+
maxRetries: config.maxRetries
|
|
325
320
|
});
|
|
326
321
|
sendToDLQ(ctx, msg, consumer);
|
|
327
322
|
return okAsync(void 0);
|
|
328
323
|
}
|
|
329
324
|
const delayMs = calculateRetryDelay(retryCount, config);
|
|
330
|
-
ctx.logger?.
|
|
325
|
+
ctx.logger?.info("Retrying message (ttl-backoff mode)", {
|
|
331
326
|
consumerName,
|
|
332
327
|
queueName,
|
|
333
328
|
retryCount: retryCount + 1,
|
|
334
329
|
maxRetries: config.maxRetries,
|
|
335
|
-
delayMs
|
|
336
|
-
error: error.message
|
|
330
|
+
delayMs
|
|
337
331
|
});
|
|
338
332
|
return publishForRetry(ctx, {
|
|
339
333
|
msg,
|
|
@@ -350,9 +344,9 @@ function handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config)
|
|
|
350
344
|
*/
|
|
351
345
|
function calculateRetryDelay(retryCount, config) {
|
|
352
346
|
const { initialDelayMs, maxDelayMs, backoffMultiplier, jitter } = config;
|
|
353
|
-
let delay =
|
|
354
|
-
if (jitter) delay = delay * (.5 + Math.random()
|
|
355
|
-
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));
|
|
356
350
|
}
|
|
357
351
|
/**
|
|
358
352
|
* Parse message content for republishing.
|
|
@@ -384,7 +378,6 @@ function parseMessageContentForRetry(ctx, msg, queueName) {
|
|
|
384
378
|
*/
|
|
385
379
|
function publishForRetry(ctx, { msg, exchange, routingKey, queueName, waitQueueName, delayMs, error }) {
|
|
386
380
|
const newRetryCount = (msg.properties.headers?.["x-retry-count"] ?? 0) + 1;
|
|
387
|
-
ctx.amqpClient.ack(msg);
|
|
388
381
|
const content = parseMessageContentForRetry(ctx, msg, queueName);
|
|
389
382
|
return ctx.amqpClient.publish(exchange, routingKey, content, {
|
|
390
383
|
...msg.properties,
|
|
@@ -408,12 +401,21 @@ function publishForRetry(ctx, { msg, exchange, routingKey, queueName, waitQueueN
|
|
|
408
401
|
});
|
|
409
402
|
return err(new TechnicalError("Failed to publish message for retry (write buffer full)"));
|
|
410
403
|
}
|
|
404
|
+
ctx.amqpClient.ack(msg);
|
|
411
405
|
ctx.logger?.info("Message published for retry", {
|
|
412
406
|
queueName,
|
|
413
407
|
retryCount: newRetryCount,
|
|
414
408
|
...delayMs !== void 0 ? { delayMs } : {}
|
|
415
409
|
});
|
|
416
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);
|
|
417
419
|
});
|
|
418
420
|
}
|
|
419
421
|
/**
|
|
@@ -652,7 +654,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
652
654
|
*/
|
|
653
655
|
parseAndValidateMessage(msg, consumer, consumerName) {
|
|
654
656
|
const context = { consumerName: String(consumerName) };
|
|
655
|
-
const parsePayload = decompressBuffer(msg.content, msg.properties.contentEncoding).andThen((buffer) =>
|
|
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, {
|
|
656
658
|
...context,
|
|
657
659
|
field: "payload"
|
|
658
660
|
}));
|
|
@@ -718,66 +720,110 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
718
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"))));
|
|
719
721
|
}
|
|
720
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);
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
721
754
|
* Process a single consumed message: validate, invoke handler, optionally
|
|
722
|
-
* publish the RPC response, record telemetry, and
|
|
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
|
|
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
|
-
|
|
730
|
-
let firstError;
|
|
731
|
-
const inner = this.parseAndValidateMessage(msg, consumer, name).orElse((parseError) => {
|
|
732
|
-
firstError = parseError;
|
|
775
|
+
return this.parseAndValidateOrNack(msg, consumer, name).orTee((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
|
-
|
|
739
|
-
|
|
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
|
+
}).andThen((validatedMessage) => this.runHandler(handler, validatedMessage, msg).andThen((handlerResponse) => this.publishReplyIfRpc(msg, view, name, handlerResponse).andTee(() => {
|
|
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
|
-
|
|
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
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
if (messageHandled) {
|
|
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 {
|
|
773
805
|
endSpanSuccess(span);
|
|
774
|
-
recordConsumeMetric(this.telemetry, queueName, String(name), true,
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
|
|
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
|
-
|
|
780
|
-
|
|
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", {
|
|
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,
|
|
@@ -810,46 +865,61 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
810
865
|
//#endregion
|
|
811
866
|
//#region src/handlers.ts
|
|
812
867
|
/**
|
|
813
|
-
*
|
|
868
|
+
* Build the list of available handler-target names — every key under
|
|
869
|
+
* `contract.consumers` plus every key under `contract.rpcs`.
|
|
814
870
|
*/
|
|
815
|
-
function
|
|
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) {
|
|
816
884
|
const consumers = contract.consumers;
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
const available =
|
|
820
|
-
throw new Error(`
|
|
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}`);
|
|
821
889
|
}
|
|
822
890
|
}
|
|
823
891
|
/**
|
|
824
|
-
* Validate that
|
|
892
|
+
* Validate that every key in `handlers` maps to a contract entry —
|
|
893
|
+
* either a `consumers` key or an `rpcs` key.
|
|
825
894
|
*/
|
|
826
895
|
function validateHandlers(contract, handlers) {
|
|
827
|
-
const
|
|
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}`);
|
|
896
|
+
for (const handlerName of Object.keys(handlers)) validateHandlerTargetExists(contract, handlerName);
|
|
831
897
|
}
|
|
832
|
-
function defineHandler(contract,
|
|
833
|
-
|
|
898
|
+
function defineHandler(contract, name, handler, options) {
|
|
899
|
+
validateHandlerTargetExists(contract, String(name));
|
|
834
900
|
if (options) return [handler, options];
|
|
835
901
|
return handler;
|
|
836
902
|
}
|
|
837
903
|
/**
|
|
838
|
-
* Define multiple type-safe handlers for consumers in a contract.
|
|
904
|
+
* Define multiple type-safe handlers for consumers and RPCs in a contract.
|
|
839
905
|
*
|
|
840
|
-
* **Recommended:** This function creates handlers that return
|
|
841
|
-
*
|
|
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.
|
|
910
|
+
*
|
|
911
|
+
* The handlers object must contain exactly one entry per `consumers` and
|
|
912
|
+
* `rpcs` key in the contract — see {@link WorkerInferHandlers}.
|
|
842
913
|
*
|
|
843
914
|
* @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
|
|
915
|
+
* @param contract - The contract definition containing the consumers and RPCs
|
|
916
|
+
* @param handlers - An object with handler functions for each consumer and RPC
|
|
846
917
|
* @returns A type-safe handlers object that can be used with TypedAmqpWorker
|
|
847
918
|
*
|
|
848
919
|
* @example
|
|
849
920
|
* ```typescript
|
|
850
921
|
* import { defineHandlers, RetryableError } from '@amqp-contract/worker';
|
|
851
|
-
* import { ResultAsync } from 'neverthrow';
|
|
852
|
-
* import { orderContract } from './contract';
|
|
922
|
+
* import { okAsync, ResultAsync } from 'neverthrow';
|
|
853
923
|
*
|
|
854
924
|
* const handlers = defineHandlers(orderContract, {
|
|
855
925
|
* processOrder: ({ payload }) =>
|
|
@@ -857,11 +927,7 @@ function defineHandler(contract, consumerName, handler, options) {
|
|
|
857
927
|
* processPayment(payload),
|
|
858
928
|
* (error) => new RetryableError('Payment failed', error),
|
|
859
929
|
* ).map(() => undefined),
|
|
860
|
-
*
|
|
861
|
-
* ResultAsync.fromPromise(
|
|
862
|
-
* sendNotification(payload),
|
|
863
|
-
* (error) => new RetryableError('Notification failed', error),
|
|
864
|
-
* ).map(() => undefined),
|
|
930
|
+
* calculate: ({ payload }) => okAsync({ sum: payload.a + payload.b }),
|
|
865
931
|
* });
|
|
866
932
|
* ```
|
|
867
933
|
*/
|
|
@@ -870,6 +936,6 @@ function defineHandlers(contract, handlers) {
|
|
|
870
936
|
return handlers;
|
|
871
937
|
}
|
|
872
938
|
//#endregion
|
|
873
|
-
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 };
|
|
874
940
|
|
|
875
941
|
//# sourceMappingURL=index.mjs.map
|