@amqp-contract/worker 0.23.0 → 0.24.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 +41 -36
- package/dist/index.cjs +125 -120
- package/dist/index.d.cts +69 -64
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +69 -64
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +125 -120
- package/dist/index.mjs.map +1 -1
- package/docs/index.md +109 -102
- package/package.json +7 -7
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, startConsumeSpan } from "@amqp-contract/core";
|
|
3
|
-
import {
|
|
3
|
+
import { Result, ResultAsync, err, errAsync, ok, okAsync } from "neverthrow";
|
|
4
4
|
import { gunzip, inflate } from "node:zlib";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
//#region src/decompression.ts
|
|
@@ -21,17 +21,17 @@ function isSupportedEncoding(encoding) {
|
|
|
21
21
|
*
|
|
22
22
|
* @param buffer - The buffer to decompress
|
|
23
23
|
* @param contentEncoding - The content-encoding header value (e.g., 'gzip', 'deflate')
|
|
24
|
-
* @returns A
|
|
24
|
+
* @returns A ResultAsync resolving to the decompressed buffer or a TechnicalError
|
|
25
25
|
*
|
|
26
26
|
* @internal
|
|
27
27
|
*/
|
|
28
28
|
function decompressBuffer(buffer, contentEncoding) {
|
|
29
|
-
if (!contentEncoding) return
|
|
29
|
+
if (!contentEncoding) return okAsync(buffer);
|
|
30
30
|
const normalizedEncoding = contentEncoding.toLowerCase();
|
|
31
|
-
if (!isSupportedEncoding(normalizedEncoding)) return
|
|
31
|
+
if (!isSupportedEncoding(normalizedEncoding)) return errAsync(new TechnicalError(`Unsupported content-encoding: "${contentEncoding}". Supported encodings are: ${SUPPORTED_ENCODINGS.join(", ")}. Please check your publisher configuration.`));
|
|
32
32
|
switch (normalizedEncoding) {
|
|
33
|
-
case "gzip": return
|
|
34
|
-
case "deflate": return
|
|
33
|
+
case "gzip": return ResultAsync.fromPromise(gunzipAsync(buffer), (error) => new TechnicalError("Failed to decompress gzip", error));
|
|
34
|
+
case "deflate": return ResultAsync.fromPromise(inflateAsync(buffer), (error) => new TechnicalError("Failed to decompress deflate", error));
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
//#endregion
|
|
@@ -152,15 +152,16 @@ function isHandlerError(error) {
|
|
|
152
152
|
* @example
|
|
153
153
|
* ```typescript
|
|
154
154
|
* import { retryable } from '@amqp-contract/worker';
|
|
155
|
-
* import {
|
|
155
|
+
* import { ResultAsync } from 'neverthrow';
|
|
156
156
|
*
|
|
157
157
|
* const handler = ({ payload }) =>
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
158
|
+
* ResultAsync.fromPromise(
|
|
159
|
+
* processPayment(payload),
|
|
160
|
+
* (e) => retryable('Payment service unavailable', e),
|
|
161
|
+
* ).map(() => undefined);
|
|
161
162
|
*
|
|
162
163
|
* // Equivalent to:
|
|
163
|
-
* // .
|
|
164
|
+
* // ResultAsync.fromPromise(processPayment(payload), (e) => new RetryableError('...', e))
|
|
164
165
|
* ```
|
|
165
166
|
*/
|
|
166
167
|
function retryable(message, cause) {
|
|
@@ -179,17 +180,17 @@ function retryable(message, cause) {
|
|
|
179
180
|
* @example
|
|
180
181
|
* ```typescript
|
|
181
182
|
* import { nonRetryable } from '@amqp-contract/worker';
|
|
182
|
-
* import {
|
|
183
|
+
* import { errAsync, okAsync } from 'neverthrow';
|
|
183
184
|
*
|
|
184
185
|
* const handler = ({ payload }) => {
|
|
185
186
|
* if (!isValidPayload(payload)) {
|
|
186
|
-
* return
|
|
187
|
+
* return errAsync(nonRetryable('Invalid payload format'));
|
|
187
188
|
* }
|
|
188
|
-
* return
|
|
189
|
+
* return okAsync(undefined);
|
|
189
190
|
* };
|
|
190
191
|
*
|
|
191
192
|
* // Equivalent to:
|
|
192
|
-
* // return
|
|
193
|
+
* // return errAsync(new NonRetryableError('Invalid payload format'));
|
|
193
194
|
* ```
|
|
194
195
|
*/
|
|
195
196
|
function nonRetryable(message, cause) {
|
|
@@ -223,7 +224,7 @@ function handleError(ctx, error, msg, consumerName, consumer) {
|
|
|
223
224
|
error: error.message
|
|
224
225
|
});
|
|
225
226
|
sendToDLQ(ctx, msg, consumer);
|
|
226
|
-
return
|
|
227
|
+
return okAsync(void 0);
|
|
227
228
|
}
|
|
228
229
|
const config = extractQueue(consumer.queue).retry;
|
|
229
230
|
if (config.mode === "immediate-requeue") return handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, config);
|
|
@@ -233,7 +234,7 @@ function handleError(ctx, error, msg, consumerName, consumer) {
|
|
|
233
234
|
error: error.message
|
|
234
235
|
});
|
|
235
236
|
sendToDLQ(ctx, msg, consumer);
|
|
236
|
-
return
|
|
237
|
+
return okAsync(void 0);
|
|
237
238
|
}
|
|
238
239
|
/**
|
|
239
240
|
* Handle error by requeuing immediately.
|
|
@@ -257,7 +258,7 @@ function handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, co
|
|
|
257
258
|
error: error.message
|
|
258
259
|
});
|
|
259
260
|
sendToDLQ(ctx, msg, consumer);
|
|
260
|
-
return
|
|
261
|
+
return okAsync(void 0);
|
|
261
262
|
}
|
|
262
263
|
ctx.logger?.warn("Retrying message (immediate-requeue mode)", {
|
|
263
264
|
consumerName,
|
|
@@ -268,7 +269,7 @@ function handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, co
|
|
|
268
269
|
});
|
|
269
270
|
if (queue.type === "quorum") {
|
|
270
271
|
ctx.amqpClient.nack(msg, false, true);
|
|
271
|
-
return
|
|
272
|
+
return okAsync(void 0);
|
|
272
273
|
} else return publishForRetry(ctx, {
|
|
273
274
|
msg,
|
|
274
275
|
exchange: msg.fields.exchange,
|
|
@@ -309,7 +310,7 @@ function handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config)
|
|
|
309
310
|
consumerName,
|
|
310
311
|
queueName: consumer.queue.name
|
|
311
312
|
});
|
|
312
|
-
return
|
|
313
|
+
return errAsync(new TechnicalError("Queue does not have TTL-backoff infrastructure"));
|
|
313
314
|
}
|
|
314
315
|
const queueEntry = consumer.queue;
|
|
315
316
|
const queueName = extractQueue(queueEntry).name;
|
|
@@ -323,7 +324,7 @@ function handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config)
|
|
|
323
324
|
error: error.message
|
|
324
325
|
});
|
|
325
326
|
sendToDLQ(ctx, msg, consumer);
|
|
326
|
-
return
|
|
327
|
+
return okAsync(void 0);
|
|
327
328
|
}
|
|
328
329
|
const delayMs = calculateRetryDelay(retryCount, config);
|
|
329
330
|
ctx.logger?.warn("Retrying message (ttl-backoff mode)", {
|
|
@@ -370,10 +371,10 @@ function parseMessageContentForRetry(ctx, msg, queueName) {
|
|
|
370
371
|
if (!(contentType === void 0 || contentType === "application/json" || contentType.startsWith("application/json;") || contentType.endsWith("+json"))) return msg.content;
|
|
371
372
|
try {
|
|
372
373
|
return JSON.parse(msg.content.toString());
|
|
373
|
-
} catch (
|
|
374
|
+
} catch (parseErr) {
|
|
374
375
|
ctx.logger?.warn("Failed to parse JSON message for retry, using original buffer", {
|
|
375
376
|
queueName,
|
|
376
|
-
error:
|
|
377
|
+
error: parseErr
|
|
377
378
|
});
|
|
378
379
|
return msg.content;
|
|
379
380
|
}
|
|
@@ -398,21 +399,21 @@ function publishForRetry(ctx, { msg, exchange, routingKey, queueName, waitQueueN
|
|
|
398
399
|
"x-retry-queue": queueName
|
|
399
400
|
} : {}
|
|
400
401
|
}
|
|
401
|
-
}).
|
|
402
|
+
}).andThen((published) => {
|
|
402
403
|
if (!published) {
|
|
403
404
|
ctx.logger?.error("Failed to publish message for retry (write buffer full)", {
|
|
404
405
|
queueName,
|
|
405
406
|
retryCount: newRetryCount,
|
|
406
407
|
...delayMs !== void 0 ? { delayMs } : {}
|
|
407
408
|
});
|
|
408
|
-
return
|
|
409
|
+
return err(new TechnicalError("Failed to publish message for retry (write buffer full)"));
|
|
409
410
|
}
|
|
410
411
|
ctx.logger?.info("Message published for retry", {
|
|
411
412
|
queueName,
|
|
412
413
|
retryCount: newRetryCount,
|
|
413
414
|
...delayMs !== void 0 ? { delayMs } : {}
|
|
414
415
|
});
|
|
415
|
-
return
|
|
416
|
+
return ok(void 0);
|
|
416
417
|
});
|
|
417
418
|
}
|
|
418
419
|
/**
|
|
@@ -449,6 +450,7 @@ function isHandlerTuple(entry) {
|
|
|
449
450
|
* ```typescript
|
|
450
451
|
* import { TypedAmqpWorker } from '@amqp-contract/worker';
|
|
451
452
|
* import { defineQueue, defineMessage, defineContract, defineConsumer } from '@amqp-contract/contract';
|
|
453
|
+
* import { okAsync } from 'neverthrow';
|
|
452
454
|
* import { z } from 'zod';
|
|
453
455
|
*
|
|
454
456
|
* const orderQueue = defineQueue('order-processing');
|
|
@@ -463,19 +465,22 @@ function isHandlerTuple(entry) {
|
|
|
463
465
|
* }
|
|
464
466
|
* });
|
|
465
467
|
*
|
|
466
|
-
* const
|
|
468
|
+
* const result = await TypedAmqpWorker.create({
|
|
467
469
|
* contract,
|
|
468
470
|
* handlers: {
|
|
469
|
-
* processOrder:
|
|
470
|
-
* console.log('Processing order',
|
|
471
|
-
*
|
|
472
|
-
* }
|
|
471
|
+
* processOrder: ({ payload }) => {
|
|
472
|
+
* console.log('Processing order', payload.orderId);
|
|
473
|
+
* return okAsync(undefined);
|
|
474
|
+
* },
|
|
473
475
|
* },
|
|
474
|
-
* urls: ['amqp://localhost']
|
|
475
|
-
* })
|
|
476
|
+
* urls: ['amqp://localhost'],
|
|
477
|
+
* });
|
|
478
|
+
*
|
|
479
|
+
* if (result.isErr()) throw result.error;
|
|
480
|
+
* const worker = result.value;
|
|
476
481
|
*
|
|
477
482
|
* // Close when done
|
|
478
|
-
* await worker.close()
|
|
483
|
+
* await worker.close();
|
|
479
484
|
* ```
|
|
480
485
|
*/
|
|
481
486
|
var TypedAmqpWorker = class TypedAmqpWorker {
|
|
@@ -551,18 +556,17 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
551
556
|
* Connections are automatically shared across clients and workers with the same
|
|
552
557
|
* URLs and connection options, following RabbitMQ best practices.
|
|
553
558
|
*
|
|
554
|
-
* @
|
|
555
|
-
* @returns A Future that resolves to a Result containing the worker or an error
|
|
559
|
+
* @returns A ResultAsync that resolves to the worker or a TechnicalError.
|
|
556
560
|
*
|
|
557
561
|
* @example
|
|
558
562
|
* ```typescript
|
|
559
|
-
* const
|
|
563
|
+
* const result = await TypedAmqpWorker.create({
|
|
560
564
|
* contract: myContract,
|
|
561
565
|
* handlers: {
|
|
562
|
-
* processOrder:
|
|
566
|
+
* processOrder: ({ payload }) => okAsync(undefined),
|
|
563
567
|
* },
|
|
564
|
-
* urls: ['amqp://localhost']
|
|
565
|
-
* })
|
|
568
|
+
* urls: ['amqp://localhost'],
|
|
569
|
+
* });
|
|
566
570
|
* ```
|
|
567
571
|
*/
|
|
568
572
|
static create({ contract, handlers, urls, connectionOptions, defaultConsumerOptions, logger, telemetry, connectTimeoutMs }) {
|
|
@@ -571,12 +575,14 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
571
575
|
connectionOptions,
|
|
572
576
|
connectTimeoutMs
|
|
573
577
|
}), handlers, defaultConsumerOptions ?? {}, logger, telemetry);
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
578
|
+
const setup = worker.waitForConnectionReady().andThen(() => worker.consumeAll());
|
|
579
|
+
return new ResultAsync((async () => {
|
|
580
|
+
const setupResult = await setup;
|
|
581
|
+
if (setupResult.isOk()) return ok(worker);
|
|
582
|
+
const closeResult = await worker.close();
|
|
583
|
+
if (closeResult.isErr()) logger?.warn("Failed to close worker after setup failure", { error: closeResult.error });
|
|
584
|
+
return err(setupResult.error);
|
|
585
|
+
})());
|
|
580
586
|
}
|
|
581
587
|
/**
|
|
582
588
|
* Close the AMQP channel and connection.
|
|
@@ -584,26 +590,25 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
584
590
|
* This gracefully closes the connection to the AMQP broker,
|
|
585
591
|
* stopping all message consumption and cleaning up resources.
|
|
586
592
|
*
|
|
587
|
-
* @returns A Future that resolves to a Result indicating success or failure
|
|
588
|
-
*
|
|
589
593
|
* @example
|
|
590
594
|
* ```typescript
|
|
591
|
-
* const closeResult = await worker.close()
|
|
595
|
+
* const closeResult = await worker.close();
|
|
592
596
|
* if (closeResult.isOk()) {
|
|
593
597
|
* console.log('Worker closed successfully');
|
|
594
598
|
* }
|
|
595
599
|
* ```
|
|
596
600
|
*/
|
|
597
601
|
close() {
|
|
598
|
-
|
|
602
|
+
const cancellations = Array.from(this.consumerTags).map((consumerTag) => this.amqpClient.cancel(consumerTag).orElse((error) => {
|
|
599
603
|
this.logger?.warn("Failed to cancel consumer during close", {
|
|
600
604
|
consumerTag,
|
|
601
605
|
error
|
|
602
606
|
});
|
|
603
|
-
return
|
|
604
|
-
}))
|
|
607
|
+
return ok(void 0);
|
|
608
|
+
}));
|
|
609
|
+
return ResultAsync.combine(cancellations).andTee(() => {
|
|
605
610
|
this.consumerTags.clear();
|
|
606
|
-
}).
|
|
611
|
+
}).andThen(() => this.amqpClient.close()).map(() => void 0);
|
|
607
612
|
}
|
|
608
613
|
/**
|
|
609
614
|
* Start consuming for every entry in `contract.consumers` and `contract.rpcs`.
|
|
@@ -612,7 +617,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
612
617
|
const consumerNames = Object.keys(this.contract.consumers ?? {});
|
|
613
618
|
const rpcNames = Object.keys(this.contract.rpcs ?? {});
|
|
614
619
|
const allNames = [...consumerNames, ...rpcNames];
|
|
615
|
-
return
|
|
620
|
+
return ResultAsync.combine(allNames.map((name) => this.consume(name))).map(() => void 0);
|
|
616
621
|
}
|
|
617
622
|
waitForConnectionReady() {
|
|
618
623
|
return this.amqpClient.waitForConnect();
|
|
@@ -633,9 +638,9 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
633
638
|
validateSchema(schema, data, context) {
|
|
634
639
|
const rawValidation = schema["~standard"].validate(data);
|
|
635
640
|
const validationPromise = rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation);
|
|
636
|
-
return
|
|
637
|
-
if (result.issues) return
|
|
638
|
-
return
|
|
641
|
+
return ResultAsync.fromPromise(validationPromise, (error) => new TechnicalError(`Error validating ${context.field}`, error)).andThen((result) => {
|
|
642
|
+
if (result.issues) return err(new TechnicalError(`${context.field} validation failed`, new MessageValidationError(context.consumerName, result.issues)));
|
|
643
|
+
return ok(result.value);
|
|
639
644
|
});
|
|
640
645
|
}
|
|
641
646
|
/**
|
|
@@ -647,18 +652,18 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
647
652
|
*/
|
|
648
653
|
parseAndValidateMessage(msg, consumer, consumerName) {
|
|
649
654
|
const context = { consumerName: String(consumerName) };
|
|
650
|
-
const parsePayload = decompressBuffer(msg.content, msg.properties.contentEncoding).
|
|
655
|
+
const parsePayload = decompressBuffer(msg.content, msg.properties.contentEncoding).andThen((buffer) => Result.fromThrowable(() => JSON.parse(buffer.toString()), (error) => new TechnicalError("Failed to parse JSON", error))()).andThen((parsed) => this.validateSchema(consumer.message.payload, parsed, {
|
|
651
656
|
...context,
|
|
652
657
|
field: "payload"
|
|
653
658
|
}));
|
|
654
659
|
const parseHeaders = consumer.message.headers ? this.validateSchema(consumer.message.headers, msg.properties.headers ?? {}, {
|
|
655
660
|
...context,
|
|
656
661
|
field: "headers"
|
|
657
|
-
}) :
|
|
658
|
-
return
|
|
659
|
-
payload
|
|
660
|
-
headers
|
|
661
|
-
})
|
|
662
|
+
}) : okAsync(void 0);
|
|
663
|
+
return ResultAsync.combine([parsePayload, parseHeaders]).map(([payload, headers]) => ({
|
|
664
|
+
payload,
|
|
665
|
+
headers
|
|
666
|
+
}));
|
|
662
667
|
}
|
|
663
668
|
/**
|
|
664
669
|
* Validate an RPC handler's response and publish it back to the caller's reply
|
|
@@ -687,7 +692,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
687
692
|
rpcName: String(rpcName),
|
|
688
693
|
queueName
|
|
689
694
|
});
|
|
690
|
-
return
|
|
695
|
+
return errAsync(new NonRetryableError(`RPC "${String(rpcName)}" received a message without replyTo; cannot deliver response`));
|
|
691
696
|
}
|
|
692
697
|
if (typeof correlationId !== "string" || correlationId.length === 0) {
|
|
693
698
|
this.logger?.error("RPC handler returned a response but the incoming message has no correlationId", {
|
|
@@ -695,22 +700,22 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
695
700
|
queueName,
|
|
696
701
|
replyTo
|
|
697
702
|
});
|
|
698
|
-
return
|
|
703
|
+
return errAsync(new NonRetryableError(`RPC "${String(rpcName)}" received a message without correlationId; cannot deliver response`));
|
|
699
704
|
}
|
|
700
705
|
let rawValidation;
|
|
701
706
|
try {
|
|
702
707
|
rawValidation = responseSchema["~standard"].validate(response);
|
|
703
708
|
} catch (error) {
|
|
704
|
-
return
|
|
709
|
+
return errAsync(new NonRetryableError("RPC response schema validation threw", error));
|
|
705
710
|
}
|
|
706
711
|
const validationPromise = rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation);
|
|
707
|
-
return
|
|
708
|
-
if (validation.issues) return
|
|
709
|
-
return
|
|
710
|
-
}).
|
|
712
|
+
return ResultAsync.fromPromise(validationPromise, (error) => new NonRetryableError("RPC response schema validation threw", error)).andThen((validation) => {
|
|
713
|
+
if (validation.issues) return err(new NonRetryableError(`RPC response for "${String(rpcName)}" failed schema validation`, new MessageValidationError(String(rpcName), validation.issues)));
|
|
714
|
+
return ok(validation.value);
|
|
715
|
+
}).andThen((validatedResponse) => this.amqpClient.publish("", replyTo, validatedResponse, {
|
|
711
716
|
correlationId,
|
|
712
717
|
contentType: "application/json"
|
|
713
|
-
}).
|
|
718
|
+
}).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"))));
|
|
714
719
|
}
|
|
715
720
|
/**
|
|
716
721
|
* Process a single consumed message: validate, invoke handler, optionally
|
|
@@ -723,58 +728,56 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
723
728
|
const span = startConsumeSpan(this.telemetry, queueName, String(name), { "messaging.rabbitmq.message.delivery_tag": msg.fields.deliveryTag });
|
|
724
729
|
let messageHandled = false;
|
|
725
730
|
let firstError;
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
731
|
+
const inner = this.parseAndValidateMessage(msg, consumer, name).orElse((parseError) => {
|
|
732
|
+
firstError = parseError;
|
|
733
|
+
this.logger?.error("Failed to parse/validate message; sending to DLQ", {
|
|
734
|
+
consumerName: String(name),
|
|
735
|
+
queueName,
|
|
736
|
+
error: parseError
|
|
737
|
+
});
|
|
738
|
+
this.amqpClient.nack(msg, false, false);
|
|
739
|
+
return errAsync(parseError);
|
|
740
|
+
}).andThen((validatedMessage) => handler(validatedMessage, msg).andThen((handlerResponse) => {
|
|
741
|
+
if (isRpc && responseSchema) return this.publishRpcResponse(msg, queueName, name, responseSchema, handlerResponse).map(() => {
|
|
737
742
|
this.logger?.info("Message consumed successfully", {
|
|
738
743
|
consumerName: String(name),
|
|
739
744
|
queueName
|
|
740
745
|
});
|
|
741
746
|
this.amqpClient.ack(msg);
|
|
742
747
|
messageHandled = true;
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
}
|
|
767
|
-
})).map((result) => {
|
|
748
|
+
});
|
|
749
|
+
this.logger?.info("Message consumed successfully", {
|
|
750
|
+
consumerName: String(name),
|
|
751
|
+
queueName
|
|
752
|
+
});
|
|
753
|
+
this.amqpClient.ack(msg);
|
|
754
|
+
messageHandled = true;
|
|
755
|
+
return okAsync(void 0);
|
|
756
|
+
}).orElse((handlerError) => {
|
|
757
|
+
this.logger?.error("Error processing message", {
|
|
758
|
+
consumerName: String(name),
|
|
759
|
+
queueName,
|
|
760
|
+
errorType: handlerError.name,
|
|
761
|
+
error: handlerError.message
|
|
762
|
+
});
|
|
763
|
+
firstError = handlerError;
|
|
764
|
+
return handleError({
|
|
765
|
+
amqpClient: this.amqpClient,
|
|
766
|
+
logger: this.logger
|
|
767
|
+
}, handlerError, msg, String(name), consumer);
|
|
768
|
+
}));
|
|
769
|
+
return new ResultAsync((async () => {
|
|
770
|
+
const result = await inner;
|
|
768
771
|
const durationMs = Date.now() - startTime;
|
|
769
772
|
if (messageHandled) {
|
|
770
773
|
endSpanSuccess(span);
|
|
771
774
|
recordConsumeMetric(this.telemetry, queueName, String(name), true, durationMs);
|
|
772
775
|
} else {
|
|
773
|
-
endSpanError(span, result.
|
|
776
|
+
endSpanError(span, result.isErr() ? result.error : firstError ?? /* @__PURE__ */ new Error("Unknown error"));
|
|
774
777
|
recordConsumeMetric(this.telemetry, queueName, String(name), false, durationMs);
|
|
775
778
|
}
|
|
776
779
|
return result;
|
|
777
|
-
});
|
|
780
|
+
})());
|
|
778
781
|
}
|
|
779
782
|
/**
|
|
780
783
|
* Consume messages one at a time.
|
|
@@ -790,7 +793,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
790
793
|
return;
|
|
791
794
|
}
|
|
792
795
|
try {
|
|
793
|
-
await this.processMessage(msg, view, name, handler)
|
|
796
|
+
await this.processMessage(msg, view, name, handler);
|
|
794
797
|
} catch (error) {
|
|
795
798
|
this.logger?.error("Uncaught error in consume callback; nacking message", {
|
|
796
799
|
consumerName: String(name),
|
|
@@ -799,9 +802,9 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
799
802
|
});
|
|
800
803
|
this.amqpClient.nack(msg, false, false);
|
|
801
804
|
}
|
|
802
|
-
}, this.consumerOptions[name]).
|
|
805
|
+
}, this.consumerOptions[name]).andTee((consumerTag) => {
|
|
803
806
|
this.consumerTags.add(consumerTag);
|
|
804
|
-
}).
|
|
807
|
+
}).map(() => void 0).mapErr((error) => new TechnicalError(`Failed to start consuming for "${String(name)}"`, error));
|
|
805
808
|
}
|
|
806
809
|
};
|
|
807
810
|
//#endregion
|
|
@@ -834,7 +837,7 @@ function defineHandler(contract, consumerName, handler, options) {
|
|
|
834
837
|
/**
|
|
835
838
|
* Define multiple type-safe handlers for consumers in a contract.
|
|
836
839
|
*
|
|
837
|
-
* **Recommended:** This function creates handlers that return `
|
|
840
|
+
* **Recommended:** This function creates handlers that return `ResultAsync<void, HandlerError>`,
|
|
838
841
|
* providing explicit error handling and better control over retry behavior.
|
|
839
842
|
*
|
|
840
843
|
* @template TContract - The contract definition type
|
|
@@ -845,18 +848,20 @@ function defineHandler(contract, consumerName, handler, options) {
|
|
|
845
848
|
* @example
|
|
846
849
|
* ```typescript
|
|
847
850
|
* import { defineHandlers, RetryableError } from '@amqp-contract/worker';
|
|
848
|
-
* import {
|
|
851
|
+
* import { ResultAsync } from 'neverthrow';
|
|
849
852
|
* import { orderContract } from './contract';
|
|
850
853
|
*
|
|
851
854
|
* const handlers = defineHandlers(orderContract, {
|
|
852
855
|
* processOrder: ({ payload }) =>
|
|
853
|
-
*
|
|
854
|
-
*
|
|
855
|
-
*
|
|
856
|
+
* ResultAsync.fromPromise(
|
|
857
|
+
* processPayment(payload),
|
|
858
|
+
* (error) => new RetryableError('Payment failed', error),
|
|
859
|
+
* ).map(() => undefined),
|
|
856
860
|
* notifyOrder: ({ payload }) =>
|
|
857
|
-
*
|
|
858
|
-
*
|
|
859
|
-
*
|
|
861
|
+
* ResultAsync.fromPromise(
|
|
862
|
+
* sendNotification(payload),
|
|
863
|
+
* (error) => new RetryableError('Notification failed', error),
|
|
864
|
+
* ).map(() => undefined),
|
|
860
865
|
* });
|
|
861
866
|
* ```
|
|
862
867
|
*/
|