@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.cjs
CHANGED
|
@@ -38,36 +38,39 @@ function decompressBuffer(buffer, contentEncoding) {
|
|
|
38
38
|
//#endregion
|
|
39
39
|
//#region src/errors.ts
|
|
40
40
|
/**
|
|
41
|
-
*
|
|
42
|
-
* Examples: network timeouts, rate limiting, temporary service unavailability
|
|
41
|
+
* Abstract base class for all handler-signalled errors.
|
|
43
42
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
|
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
|
|
64
|
-
|
|
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
|
|
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?.
|
|
231
|
+
ctx.logger?.info("Retry disabled (none mode), sending to DLQ", {
|
|
234
232
|
consumerName,
|
|
235
|
-
|
|
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?.
|
|
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?.
|
|
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?.
|
|
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?.
|
|
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 =
|
|
355
|
-
if (jitter) delay = delay * (.5 + Math.random()
|
|
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) =>
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
740
|
-
|
|
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
|
-
|
|
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
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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,
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
const available =
|
|
821
|
-
throw new Error(`
|
|
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
|
|
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
|
|
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,
|
|
834
|
-
|
|
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
|
|
842
|
-
*
|
|
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
|
-
*
|
|
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() {
|