@amqp-contract/worker 0.25.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -18
- package/dist/index.cjs +101 -100
- package/dist/index.d.cts +213 -66
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +213 -66
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +102 -100
- package/dist/index.mjs.map +1 -1
- package/docs/index.md +210 -583
- package/package.json +18 -15
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { extractConsumer, extractQueue, isQueueWithTtlBackoffInfrastructure } from "@amqp-contract/contract";
|
|
2
2
|
import { AmqpClient, MessageValidationError, TechnicalError, defaultTelemetryProvider, endSpanError, endSpanSuccess, recordConsumeMetric, safeJsonParse, startConsumeSpan } from "@amqp-contract/core";
|
|
3
|
-
import {
|
|
3
|
+
import { Err, Ok, TaggedError, allAsync, fromPromise, fromSafePromise } from "unthrown";
|
|
4
4
|
import { gunzip, inflate } from "node:zlib";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
//#region src/decompression.ts
|
|
@@ -21,55 +21,57 @@ 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
|
|
24
|
+
* @returns An AsyncResult resolving to the decompressed buffer or a TechnicalError
|
|
25
25
|
*
|
|
26
26
|
* @internal
|
|
27
27
|
*/
|
|
28
28
|
function decompressBuffer(buffer, contentEncoding) {
|
|
29
|
-
if (!contentEncoding) return
|
|
29
|
+
if (!contentEncoding) return Ok(buffer).toAsync();
|
|
30
30
|
const normalizedEncoding = contentEncoding.toLowerCase();
|
|
31
|
-
if (!isSupportedEncoding(normalizedEncoding)) return
|
|
31
|
+
if (!isSupportedEncoding(normalizedEncoding)) return Err(new TechnicalError(`Unsupported content-encoding: "${contentEncoding}". Supported encodings are: ${SUPPORTED_ENCODINGS.join(", ")}. Please check your publisher configuration.`)).toAsync();
|
|
32
32
|
switch (normalizedEncoding) {
|
|
33
|
-
case "gzip": return
|
|
34
|
-
case "deflate": return
|
|
33
|
+
case "gzip": return fromPromise(gunzipAsync(buffer), (error) => new TechnicalError("Failed to decompress gzip", error));
|
|
34
|
+
case "deflate": return fromPromise(inflateAsync(buffer), (error) => new TechnicalError("Failed to decompress deflate", error));
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
//#endregion
|
|
38
38
|
//#region src/errors.ts
|
|
39
39
|
/**
|
|
40
|
-
* Abstract base class for all handler-signalled errors.
|
|
41
|
-
*
|
|
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
|
-
*/
|
|
46
|
-
var HandlerError = class extends Error {
|
|
47
|
-
constructor(message, cause) {
|
|
48
|
-
super(message);
|
|
49
|
-
this.cause = cause;
|
|
50
|
-
const ErrorConstructor = Error;
|
|
51
|
-
if (typeof ErrorConstructor.captureStackTrace === "function") ErrorConstructor.captureStackTrace(this, this.constructor);
|
|
52
|
-
}
|
|
53
|
-
};
|
|
54
|
-
/**
|
|
55
40
|
* Retryable errors - transient failures that may succeed on retry
|
|
56
41
|
* Examples: network timeouts, rate limiting, temporary service unavailability
|
|
57
42
|
*
|
|
58
43
|
* Use this error type when the operation might succeed if retried.
|
|
59
44
|
* The worker will apply exponential backoff and retry the message.
|
|
45
|
+
*
|
|
46
|
+
* Built on unthrown's {@link TaggedError}, so it carries a namespaced `_tag` of
|
|
47
|
+
* `"@amqp-contract/RetryableError"` (to avoid colliding with other libraries'
|
|
48
|
+
* tags in a shared `matchTags`) for exhaustive dispatch; the `Error.name` is
|
|
49
|
+
* kept bare (`"RetryableError"`).
|
|
60
50
|
*/
|
|
61
|
-
var RetryableError = class extends
|
|
62
|
-
|
|
51
|
+
var RetryableError = class extends TaggedError("@amqp-contract/RetryableError", { name: "RetryableError" }) {
|
|
52
|
+
constructor(message, cause) {
|
|
53
|
+
super({
|
|
54
|
+
message,
|
|
55
|
+
cause
|
|
56
|
+
});
|
|
57
|
+
}
|
|
63
58
|
};
|
|
64
59
|
/**
|
|
65
60
|
* Non-retryable errors - permanent failures that should not be retried
|
|
66
61
|
* Examples: invalid data, business rule violations, permanent external failures
|
|
67
62
|
*
|
|
68
63
|
* Use this error type when retrying would not help - the message will be
|
|
69
|
-
* immediately sent to the dead letter queue (DLQ) if configured.
|
|
64
|
+
* immediately sent to the dead letter queue (DLQ) if configured. Carries a
|
|
65
|
+
* namespaced `_tag` of `"@amqp-contract/NonRetryableError"`; the `Error.name` is
|
|
66
|
+
* kept bare (`"NonRetryableError"`).
|
|
70
67
|
*/
|
|
71
|
-
var NonRetryableError = class extends
|
|
72
|
-
|
|
68
|
+
var NonRetryableError = class extends TaggedError("@amqp-contract/NonRetryableError", { name: "NonRetryableError" }) {
|
|
69
|
+
constructor(message, cause) {
|
|
70
|
+
super({
|
|
71
|
+
message,
|
|
72
|
+
cause
|
|
73
|
+
});
|
|
74
|
+
}
|
|
73
75
|
};
|
|
74
76
|
/**
|
|
75
77
|
* Type guard to check if an error is a RetryableError.
|
|
@@ -140,7 +142,7 @@ function isNonRetryableError(error) {
|
|
|
140
142
|
* ```
|
|
141
143
|
*/
|
|
142
144
|
function isHandlerError(error) {
|
|
143
|
-
return error instanceof
|
|
145
|
+
return error instanceof RetryableError || error instanceof NonRetryableError;
|
|
144
146
|
}
|
|
145
147
|
/**
|
|
146
148
|
* Create a RetryableError with less verbosity.
|
|
@@ -155,16 +157,16 @@ function isHandlerError(error) {
|
|
|
155
157
|
* @example
|
|
156
158
|
* ```typescript
|
|
157
159
|
* import { retryable } from '@amqp-contract/worker';
|
|
158
|
-
* import {
|
|
160
|
+
* import { fromPromise } from 'unthrown';
|
|
159
161
|
*
|
|
160
162
|
* const handler = ({ payload }) =>
|
|
161
|
-
*
|
|
163
|
+
* fromPromise(
|
|
162
164
|
* processPayment(payload),
|
|
163
165
|
* (e) => retryable('Payment service unavailable', e),
|
|
164
166
|
* ).map(() => undefined);
|
|
165
167
|
*
|
|
166
168
|
* // Equivalent to:
|
|
167
|
-
* //
|
|
169
|
+
* // fromPromise(processPayment(payload), (e) => new RetryableError('...', e))
|
|
168
170
|
* ```
|
|
169
171
|
*/
|
|
170
172
|
function retryable(message, cause) {
|
|
@@ -183,17 +185,17 @@ function retryable(message, cause) {
|
|
|
183
185
|
* @example
|
|
184
186
|
* ```typescript
|
|
185
187
|
* import { nonRetryable } from '@amqp-contract/worker';
|
|
186
|
-
* import {
|
|
188
|
+
* import { Err, Ok } from 'unthrown';
|
|
187
189
|
*
|
|
188
190
|
* const handler = ({ payload }) => {
|
|
189
191
|
* if (!isValidPayload(payload)) {
|
|
190
|
-
* return
|
|
192
|
+
* return Err(nonRetryable('Invalid payload format')).toAsync();
|
|
191
193
|
* }
|
|
192
|
-
* return
|
|
194
|
+
* return Ok(undefined).toAsync();
|
|
193
195
|
* };
|
|
194
196
|
*
|
|
195
197
|
* // Equivalent to:
|
|
196
|
-
* // return
|
|
198
|
+
* // return Err(new NonRetryableError('Invalid payload format')).toAsync();
|
|
197
199
|
* ```
|
|
198
200
|
*/
|
|
199
201
|
function nonRetryable(message, cause) {
|
|
@@ -222,7 +224,7 @@ function nonRetryable(message, cause) {
|
|
|
222
224
|
function handleError(ctx, error, msg, consumerName, consumer) {
|
|
223
225
|
if (error instanceof NonRetryableError) {
|
|
224
226
|
sendToDLQ(ctx, msg, consumer);
|
|
225
|
-
return
|
|
227
|
+
return Ok(void 0).toAsync();
|
|
226
228
|
}
|
|
227
229
|
const config = extractQueue(consumer.queue).retry;
|
|
228
230
|
if (config.mode === "immediate-requeue") return handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, config);
|
|
@@ -232,7 +234,7 @@ function handleError(ctx, error, msg, consumerName, consumer) {
|
|
|
232
234
|
queueName: extractQueue(consumer.queue).name
|
|
233
235
|
});
|
|
234
236
|
sendToDLQ(ctx, msg, consumer);
|
|
235
|
-
return
|
|
237
|
+
return Ok(void 0).toAsync();
|
|
236
238
|
}
|
|
237
239
|
/**
|
|
238
240
|
* Handle error by requeuing immediately.
|
|
@@ -255,7 +257,7 @@ function handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, co
|
|
|
255
257
|
maxRetries: config.maxRetries
|
|
256
258
|
});
|
|
257
259
|
sendToDLQ(ctx, msg, consumer);
|
|
258
|
-
return
|
|
260
|
+
return Ok(void 0).toAsync();
|
|
259
261
|
}
|
|
260
262
|
ctx.logger?.info("Retrying message (immediate-requeue mode)", {
|
|
261
263
|
consumerName,
|
|
@@ -265,7 +267,7 @@ function handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, co
|
|
|
265
267
|
});
|
|
266
268
|
if (queue.type === "quorum") {
|
|
267
269
|
ctx.amqpClient.nack(msg, false, true);
|
|
268
|
-
return
|
|
270
|
+
return Ok(void 0).toAsync();
|
|
269
271
|
} else return publishForRetry(ctx, {
|
|
270
272
|
msg,
|
|
271
273
|
exchange: msg.fields.exchange,
|
|
@@ -306,7 +308,7 @@ function handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config)
|
|
|
306
308
|
consumerName,
|
|
307
309
|
queueName: consumer.queue.name
|
|
308
310
|
});
|
|
309
|
-
return
|
|
311
|
+
return Err(new TechnicalError("Queue does not have TTL-backoff infrastructure")).toAsync();
|
|
310
312
|
}
|
|
311
313
|
const queueEntry = consumer.queue;
|
|
312
314
|
const queueName = extractQueue(queueEntry).name;
|
|
@@ -319,7 +321,7 @@ function handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config)
|
|
|
319
321
|
maxRetries: config.maxRetries
|
|
320
322
|
});
|
|
321
323
|
sendToDLQ(ctx, msg, consumer);
|
|
322
|
-
return
|
|
324
|
+
return Ok(void 0).toAsync();
|
|
323
325
|
}
|
|
324
326
|
const delayMs = calculateRetryDelay(retryCount, config);
|
|
325
327
|
ctx.logger?.info("Retrying message (ttl-backoff mode)", {
|
|
@@ -392,14 +394,14 @@ function publishForRetry(ctx, { msg, exchange, routingKey, queueName, waitQueueN
|
|
|
392
394
|
"x-retry-queue": queueName
|
|
393
395
|
} : {}
|
|
394
396
|
}
|
|
395
|
-
}).
|
|
397
|
+
}).flatMap((published) => {
|
|
396
398
|
if (!published) {
|
|
397
399
|
ctx.logger?.error("Failed to publish message for retry (write buffer full)", {
|
|
398
400
|
queueName,
|
|
399
401
|
retryCount: newRetryCount,
|
|
400
402
|
...delayMs !== void 0 ? { delayMs } : {}
|
|
401
403
|
});
|
|
402
|
-
return
|
|
404
|
+
return Err(new TechnicalError("Failed to publish message for retry (write buffer full)"));
|
|
403
405
|
}
|
|
404
406
|
ctx.amqpClient.ack(msg);
|
|
405
407
|
ctx.logger?.info("Message published for retry", {
|
|
@@ -407,7 +409,7 @@ function publishForRetry(ctx, { msg, exchange, routingKey, queueName, waitQueueN
|
|
|
407
409
|
retryCount: newRetryCount,
|
|
408
410
|
...delayMs !== void 0 ? { delayMs } : {}
|
|
409
411
|
});
|
|
410
|
-
return
|
|
412
|
+
return Ok(void 0);
|
|
411
413
|
}).orElse((publishError) => {
|
|
412
414
|
ctx.logger?.error("Publish for retry failed; leaving original un-ack'd for redelivery", {
|
|
413
415
|
queueName,
|
|
@@ -415,7 +417,7 @@ function publishForRetry(ctx, { msg, exchange, routingKey, queueName, waitQueueN
|
|
|
415
417
|
...delayMs !== void 0 ? { delayMs } : {},
|
|
416
418
|
error: publishError
|
|
417
419
|
});
|
|
418
|
-
return
|
|
420
|
+
return Err(publishError);
|
|
419
421
|
});
|
|
420
422
|
}
|
|
421
423
|
/**
|
|
@@ -452,7 +454,7 @@ function isHandlerTuple(entry) {
|
|
|
452
454
|
* ```typescript
|
|
453
455
|
* import { TypedAmqpWorker } from '@amqp-contract/worker';
|
|
454
456
|
* import { defineQueue, defineMessage, defineContract, defineConsumer } from '@amqp-contract/contract';
|
|
455
|
-
* import {
|
|
457
|
+
* import { Ok } from 'unthrown';
|
|
456
458
|
* import { z } from 'zod';
|
|
457
459
|
*
|
|
458
460
|
* const orderQueue = defineQueue('order-processing');
|
|
@@ -472,20 +474,23 @@ function isHandlerTuple(entry) {
|
|
|
472
474
|
* handlers: {
|
|
473
475
|
* processOrder: ({ payload }) => {
|
|
474
476
|
* console.log('Processing order', payload.orderId);
|
|
475
|
-
* return
|
|
477
|
+
* return Ok(undefined).toAsync();
|
|
476
478
|
* },
|
|
477
479
|
* },
|
|
478
480
|
* urls: ['amqp://localhost'],
|
|
479
481
|
* });
|
|
480
482
|
*
|
|
481
|
-
*
|
|
482
|
-
* const worker = result.value;
|
|
483
|
+
* const worker = result.unwrap();
|
|
483
484
|
*
|
|
484
485
|
* // Close when done
|
|
485
486
|
* await worker.close();
|
|
486
487
|
* ```
|
|
487
488
|
*/
|
|
488
489
|
var TypedAmqpWorker = class TypedAmqpWorker {
|
|
490
|
+
contract;
|
|
491
|
+
amqpClient;
|
|
492
|
+
defaultConsumerOptions;
|
|
493
|
+
logger;
|
|
489
494
|
/**
|
|
490
495
|
* Internal handler storage. Keyed by handler name (consumer or RPC); the
|
|
491
496
|
* stored function signature is widened so the dispatch loop can call it
|
|
@@ -558,14 +563,14 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
558
563
|
* Connections are automatically shared across clients and workers with the same
|
|
559
564
|
* URLs and connection options, following RabbitMQ best practices.
|
|
560
565
|
*
|
|
561
|
-
* @returns A
|
|
566
|
+
* @returns A AsyncResult that resolves to the worker or a TechnicalError.
|
|
562
567
|
*
|
|
563
568
|
* @example
|
|
564
569
|
* ```typescript
|
|
565
570
|
* const result = await TypedAmqpWorker.create({
|
|
566
571
|
* contract: myContract,
|
|
567
572
|
* handlers: {
|
|
568
|
-
* processOrder: ({ payload }) =>
|
|
573
|
+
* processOrder: ({ payload }) => Ok(undefined).toAsync(),
|
|
569
574
|
* },
|
|
570
575
|
* urls: ['amqp://localhost'],
|
|
571
576
|
* });
|
|
@@ -577,14 +582,15 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
577
582
|
connectionOptions,
|
|
578
583
|
connectTimeoutMs
|
|
579
584
|
}), handlers, defaultConsumerOptions ?? {}, logger, telemetry);
|
|
580
|
-
const setup = worker.waitForConnectionReady().
|
|
581
|
-
return
|
|
585
|
+
const setup = worker.waitForConnectionReady().flatMap(() => worker.consumeAll());
|
|
586
|
+
return fromSafePromise((async () => {
|
|
582
587
|
const setupResult = await setup;
|
|
583
|
-
if (setupResult.isOk())
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
+
if (!setupResult.isOk()) {
|
|
589
|
+
const closeResult = await worker.close();
|
|
590
|
+
if (closeResult.isErr()) logger?.warn("Failed to close worker after setup failure", { error: closeResult.error });
|
|
591
|
+
}
|
|
592
|
+
return setupResult.map(() => worker);
|
|
593
|
+
})()).flatMap((result) => result);
|
|
588
594
|
}
|
|
589
595
|
/**
|
|
590
596
|
* Close the AMQP channel and connection.
|
|
@@ -601,16 +607,15 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
601
607
|
* ```
|
|
602
608
|
*/
|
|
603
609
|
close() {
|
|
604
|
-
|
|
610
|
+
return allAsync(Array.from(this.consumerTags).map((consumerTag) => this.amqpClient.cancel(consumerTag).orElse((error) => {
|
|
605
611
|
this.logger?.warn("Failed to cancel consumer during close", {
|
|
606
612
|
consumerTag,
|
|
607
613
|
error
|
|
608
614
|
});
|
|
609
|
-
return
|
|
610
|
-
}))
|
|
611
|
-
return ResultAsync.combine(cancellations).andTee(() => {
|
|
615
|
+
return Ok(void 0);
|
|
616
|
+
}))).tap(() => {
|
|
612
617
|
this.consumerTags.clear();
|
|
613
|
-
}).
|
|
618
|
+
}).flatMap(() => this.amqpClient.close()).map(() => void 0);
|
|
614
619
|
}
|
|
615
620
|
/**
|
|
616
621
|
* Start consuming for every entry in `contract.consumers` and `contract.rpcs`.
|
|
@@ -618,8 +623,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
618
623
|
consumeAll() {
|
|
619
624
|
const consumerNames = Object.keys(this.contract.consumers ?? {});
|
|
620
625
|
const rpcNames = Object.keys(this.contract.rpcs ?? {});
|
|
621
|
-
|
|
622
|
-
return ResultAsync.combine(allNames.map((name) => this.consume(name))).map(() => void 0);
|
|
626
|
+
return allAsync([...consumerNames, ...rpcNames].map((name) => this.consume(name))).map(() => void 0);
|
|
623
627
|
}
|
|
624
628
|
waitForConnectionReady() {
|
|
625
629
|
return this.amqpClient.waitForConnect();
|
|
@@ -639,10 +643,9 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
639
643
|
*/
|
|
640
644
|
validateSchema(schema, data, context) {
|
|
641
645
|
const rawValidation = schema["~standard"].validate(data);
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
return ok(result.value);
|
|
646
|
+
return fromPromise(rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation), (error) => new TechnicalError(`Error validating ${context.field}`, error)).flatMap((result) => {
|
|
647
|
+
if (result.issues) return Err(new TechnicalError(`${context.field} validation failed`, new MessageValidationError(context.consumerName, result.issues)));
|
|
648
|
+
return Ok(result.value);
|
|
646
649
|
});
|
|
647
650
|
}
|
|
648
651
|
/**
|
|
@@ -654,15 +657,13 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
654
657
|
*/
|
|
655
658
|
parseAndValidateMessage(msg, consumer, consumerName) {
|
|
656
659
|
const context = { consumerName: String(consumerName) };
|
|
657
|
-
|
|
660
|
+
return allAsync([decompressBuffer(msg.content, msg.properties.contentEncoding).flatMap((buffer) => safeJsonParse(buffer, (error) => new TechnicalError("Failed to parse JSON", error))).flatMap((parsed) => this.validateSchema(consumer.message.payload, parsed, {
|
|
658
661
|
...context,
|
|
659
662
|
field: "payload"
|
|
660
|
-
}))
|
|
661
|
-
const parseHeaders = consumer.message.headers ? this.validateSchema(consumer.message.headers, msg.properties.headers ?? {}, {
|
|
663
|
+
})), consumer.message.headers ? this.validateSchema(consumer.message.headers, msg.properties.headers ?? {}, {
|
|
662
664
|
...context,
|
|
663
665
|
field: "headers"
|
|
664
|
-
}) :
|
|
665
|
-
return ResultAsync.combine([parsePayload, parseHeaders]).map(([payload, headers]) => ({
|
|
666
|
+
}) : Ok(void 0).toAsync()]).map(([payload, headers]) => ({
|
|
666
667
|
payload,
|
|
667
668
|
headers
|
|
668
669
|
}));
|
|
@@ -694,7 +695,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
694
695
|
rpcName: String(rpcName),
|
|
695
696
|
queueName
|
|
696
697
|
});
|
|
697
|
-
return
|
|
698
|
+
return Err(new NonRetryableError(`RPC "${String(rpcName)}" received a message without replyTo; cannot deliver response`)).toAsync();
|
|
698
699
|
}
|
|
699
700
|
if (typeof correlationId !== "string" || correlationId.length === 0) {
|
|
700
701
|
this.logger?.error("RPC handler returned a response but the incoming message has no correlationId", {
|
|
@@ -702,22 +703,21 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
702
703
|
queueName,
|
|
703
704
|
replyTo
|
|
704
705
|
});
|
|
705
|
-
return
|
|
706
|
+
return Err(new NonRetryableError(`RPC "${String(rpcName)}" received a message without correlationId; cannot deliver response`)).toAsync();
|
|
706
707
|
}
|
|
707
708
|
let rawValidation;
|
|
708
709
|
try {
|
|
709
710
|
rawValidation = responseSchema["~standard"].validate(response);
|
|
710
711
|
} catch (error) {
|
|
711
|
-
return
|
|
712
|
+
return Err(new NonRetryableError("RPC response schema validation threw", error)).toAsync();
|
|
712
713
|
}
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
}).andThen((validatedResponse) => this.amqpClient.publish("", replyTo, validatedResponse, {
|
|
714
|
+
return fromPromise(rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation), (error) => new NonRetryableError("RPC response schema validation threw", error)).flatMap((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
|
+
}).flatMap((validatedResponse) => this.amqpClient.publish("", replyTo, validatedResponse, {
|
|
718
718
|
correlationId,
|
|
719
719
|
contentType: "application/json"
|
|
720
|
-
}).mapErr((error) => new NonRetryableError("Failed to publish RPC response", error)).
|
|
720
|
+
}).mapErr((error) => new NonRetryableError("Failed to publish RPC response", error)).flatMap((published) => published ? Ok(void 0) : Err(new NonRetryableError("Failed to publish RPC response: channel buffer full"))));
|
|
721
721
|
}
|
|
722
722
|
/**
|
|
723
723
|
* Parse and validate the message; on failure, nack(requeue=false) so the
|
|
@@ -728,7 +728,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
728
728
|
parseAndValidateOrNack(msg, consumer, name) {
|
|
729
729
|
return this.parseAndValidateMessage(msg, consumer, name).orElse((parseError) => {
|
|
730
730
|
this.amqpClient.nack(msg, false, false);
|
|
731
|
-
return
|
|
731
|
+
return Err(parseError).toAsync();
|
|
732
732
|
});
|
|
733
733
|
}
|
|
734
734
|
/**
|
|
@@ -743,10 +743,10 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
743
743
|
/**
|
|
744
744
|
* For RPC handlers, validate and publish the reply on the caller's
|
|
745
745
|
* `replyTo` / `correlationId`. For non-RPC consumers, this is a no-op that
|
|
746
|
-
* resolves to `
|
|
746
|
+
* resolves to `Ok(undefined).toAsync()`.
|
|
747
747
|
*/
|
|
748
748
|
publishReplyIfRpc(msg, view, name, handlerResponse) {
|
|
749
|
-
if (!view.isRpc || !view.responseSchema) return
|
|
749
|
+
if (!view.isRpc || !view.responseSchema) return Ok(void 0).toAsync();
|
|
750
750
|
const queueName = extractQueue(view.consumer.queue).name;
|
|
751
751
|
return this.publishRpcResponse(msg, queueName, name, view.responseSchema, handlerResponse);
|
|
752
752
|
}
|
|
@@ -759,7 +759,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
759
759
|
* is still needed (see {@link consumeSingle}).
|
|
760
760
|
*
|
|
761
761
|
* Success-vs-failure telemetry is data-driven: the chain resolves to
|
|
762
|
-
* `
|
|
762
|
+
* `Ok(undefined)` only on handler success (and reply-publish success for
|
|
763
763
|
* RPC). Handler failures — even when {@link handleError} routes them
|
|
764
764
|
* successfully to retry/DLQ — are classified as failures for metrics by
|
|
765
765
|
* re-failing the chain with a `TechnicalError` whose `cause` is the
|
|
@@ -772,14 +772,14 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
772
772
|
const queueName = extractQueue(consumer.queue).name;
|
|
773
773
|
const startTime = Date.now();
|
|
774
774
|
const span = startConsumeSpan(this.telemetry, queueName, String(name), { "messaging.rabbitmq.message.delivery_tag": msg.fields.deliveryTag });
|
|
775
|
-
return this.parseAndValidateOrNack(msg, consumer, name).
|
|
775
|
+
return this.parseAndValidateOrNack(msg, consumer, name).tapErr((parseError) => {
|
|
776
776
|
this.logger?.error("Failed to parse/validate message; sending to DLQ", {
|
|
777
777
|
consumerName: String(name),
|
|
778
778
|
queueName,
|
|
779
779
|
error: parseError
|
|
780
780
|
});
|
|
781
781
|
state.messageHandled = true;
|
|
782
|
-
}).
|
|
782
|
+
}).flatMap((validatedMessage) => this.runHandler(handler, validatedMessage, msg).flatMap((handlerResponse) => this.publishReplyIfRpc(msg, view, name, handlerResponse).tap(() => {
|
|
783
783
|
this.logger?.info("Message consumed successfully", {
|
|
784
784
|
consumerName: String(name),
|
|
785
785
|
queueName
|
|
@@ -797,10 +797,10 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
797
797
|
return handleError({
|
|
798
798
|
amqpClient: this.amqpClient,
|
|
799
799
|
logger: this.logger
|
|
800
|
-
}, handlerError, msg, String(name), consumer).
|
|
800
|
+
}, handlerError, msg, String(name), consumer).tap(() => {
|
|
801
801
|
state.messageHandled = true;
|
|
802
|
-
}).
|
|
803
|
-
})).
|
|
802
|
+
}).flatMap(() => Err(new TechnicalError(`Handler "${String(name)}" failed: ${handlerError.message}`, handlerError)).toAsync());
|
|
803
|
+
})).tap(() => {
|
|
804
804
|
try {
|
|
805
805
|
endSpanSuccess(span);
|
|
806
806
|
recordConsumeMetric(this.telemetry, queueName, String(name), true, Date.now() - startTime);
|
|
@@ -811,7 +811,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
811
811
|
error: telemetryError
|
|
812
812
|
});
|
|
813
813
|
}
|
|
814
|
-
}).
|
|
814
|
+
}).tapErr((error) => {
|
|
815
815
|
const reportedError = error.cause instanceof Error ? error.cause : error;
|
|
816
816
|
try {
|
|
817
817
|
endSpanError(span, reportedError);
|
|
@@ -857,7 +857,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
857
857
|
});
|
|
858
858
|
this.amqpClient.nack(msg, false, false);
|
|
859
859
|
}
|
|
860
|
-
}, this.consumerOptions[name]).
|
|
860
|
+
}, this.consumerOptions[name]).tap((consumerTag) => {
|
|
861
861
|
this.consumerTags.add(consumerTag);
|
|
862
862
|
}).map(() => void 0).mapErr((error) => new TechnicalError(`Failed to start consuming for "${String(name)}"`, error));
|
|
863
863
|
}
|
|
@@ -883,7 +883,9 @@ function formatAvailable(names) {
|
|
|
883
883
|
function validateHandlerTargetExists(contract, name) {
|
|
884
884
|
const consumers = contract.consumers;
|
|
885
885
|
const rpcs = contract.rpcs;
|
|
886
|
-
|
|
886
|
+
const isConsumer = !!consumers && Object.hasOwn(consumers, name);
|
|
887
|
+
const isRpc = !!rpcs && Object.hasOwn(rpcs, name);
|
|
888
|
+
if (!isConsumer && !isRpc) {
|
|
887
889
|
const available = formatAvailable(availableHandlerNames(contract));
|
|
888
890
|
throw new Error(`Handler target "${name}" not found in contract. Available consumers and RPCs: ${available}`);
|
|
889
891
|
}
|
|
@@ -904,8 +906,8 @@ function defineHandler(contract, name, handler, options) {
|
|
|
904
906
|
* Define multiple type-safe handlers for consumers and RPCs in a contract.
|
|
905
907
|
*
|
|
906
908
|
* **Recommended:** This function creates handlers that return
|
|
907
|
-
* `
|
|
908
|
-
* `
|
|
909
|
+
* `AsyncResult<void, HandlerError>` (consumers) or
|
|
910
|
+
* `AsyncResult<TResponse, HandlerError>` (RPCs), providing explicit error
|
|
909
911
|
* handling and better control over retry behavior.
|
|
910
912
|
*
|
|
911
913
|
* The handlers object must contain exactly one entry per `consumers` and
|
|
@@ -919,15 +921,15 @@ function defineHandler(contract, name, handler, options) {
|
|
|
919
921
|
* @example
|
|
920
922
|
* ```typescript
|
|
921
923
|
* import { defineHandlers, RetryableError } from '@amqp-contract/worker';
|
|
922
|
-
* import {
|
|
924
|
+
* import { fromPromise, Ok } from 'unthrown';
|
|
923
925
|
*
|
|
924
926
|
* const handlers = defineHandlers(orderContract, {
|
|
925
927
|
* processOrder: ({ payload }) =>
|
|
926
|
-
*
|
|
928
|
+
* fromPromise(
|
|
927
929
|
* processPayment(payload),
|
|
928
930
|
* (error) => new RetryableError('Payment failed', error),
|
|
929
931
|
* ).map(() => undefined),
|
|
930
|
-
* calculate: ({ payload }) =>
|
|
932
|
+
* calculate: ({ payload }) => Ok({ sum: payload.a + payload.b }).toAsync(),
|
|
931
933
|
* });
|
|
932
934
|
* ```
|
|
933
935
|
*/
|
|
@@ -936,6 +938,6 @@ function defineHandlers(contract, handlers) {
|
|
|
936
938
|
return handlers;
|
|
937
939
|
}
|
|
938
940
|
//#endregion
|
|
939
|
-
export {
|
|
941
|
+
export { MessageValidationError, NonRetryableError, RetryableError, TypedAmqpWorker, defineHandler, defineHandlers, isHandlerError, isNonRetryableError, isRetryableError, nonRetryable, retryable };
|
|
940
942
|
|
|
941
943
|
//# sourceMappingURL=index.mjs.map
|