@amqp-contract/worker 1.0.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 +2 -2
- package/dist/index.cjs +35 -35
- package/dist/index.d.cts +18 -18
- package/dist/index.d.mts +18 -18
- package/dist/index.mjs +36 -36
- package/dist/index.mjs.map +1 -1
- package/docs/index.md +66 -66
- package/package.json +6 -6
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 { TaggedError, allAsync,
|
|
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
|
|
@@ -26,9 +26,9 @@ function isSupportedEncoding(encoding) {
|
|
|
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
33
|
case "gzip": return fromPromise(gunzipAsync(buffer), (error) => new TechnicalError("Failed to decompress gzip", error));
|
|
34
34
|
case "deflate": return fromPromise(inflateAsync(buffer), (error) => new TechnicalError("Failed to decompress deflate", error));
|
|
@@ -185,17 +185,17 @@ function retryable(message, cause) {
|
|
|
185
185
|
* @example
|
|
186
186
|
* ```typescript
|
|
187
187
|
* import { nonRetryable } from '@amqp-contract/worker';
|
|
188
|
-
* import {
|
|
188
|
+
* import { Err, Ok } from 'unthrown';
|
|
189
189
|
*
|
|
190
190
|
* const handler = ({ payload }) => {
|
|
191
191
|
* if (!isValidPayload(payload)) {
|
|
192
|
-
* return
|
|
192
|
+
* return Err(nonRetryable('Invalid payload format')).toAsync();
|
|
193
193
|
* }
|
|
194
|
-
* return
|
|
194
|
+
* return Ok(undefined).toAsync();
|
|
195
195
|
* };
|
|
196
196
|
*
|
|
197
197
|
* // Equivalent to:
|
|
198
|
-
* // return
|
|
198
|
+
* // return Err(new NonRetryableError('Invalid payload format')).toAsync();
|
|
199
199
|
* ```
|
|
200
200
|
*/
|
|
201
201
|
function nonRetryable(message, cause) {
|
|
@@ -224,7 +224,7 @@ function nonRetryable(message, cause) {
|
|
|
224
224
|
function handleError(ctx, error, msg, consumerName, consumer) {
|
|
225
225
|
if (error instanceof NonRetryableError) {
|
|
226
226
|
sendToDLQ(ctx, msg, consumer);
|
|
227
|
-
return
|
|
227
|
+
return Ok(void 0).toAsync();
|
|
228
228
|
}
|
|
229
229
|
const config = extractQueue(consumer.queue).retry;
|
|
230
230
|
if (config.mode === "immediate-requeue") return handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, config);
|
|
@@ -234,7 +234,7 @@ function handleError(ctx, error, msg, consumerName, consumer) {
|
|
|
234
234
|
queueName: extractQueue(consumer.queue).name
|
|
235
235
|
});
|
|
236
236
|
sendToDLQ(ctx, msg, consumer);
|
|
237
|
-
return
|
|
237
|
+
return Ok(void 0).toAsync();
|
|
238
238
|
}
|
|
239
239
|
/**
|
|
240
240
|
* Handle error by requeuing immediately.
|
|
@@ -257,7 +257,7 @@ function handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, co
|
|
|
257
257
|
maxRetries: config.maxRetries
|
|
258
258
|
});
|
|
259
259
|
sendToDLQ(ctx, msg, consumer);
|
|
260
|
-
return
|
|
260
|
+
return Ok(void 0).toAsync();
|
|
261
261
|
}
|
|
262
262
|
ctx.logger?.info("Retrying message (immediate-requeue mode)", {
|
|
263
263
|
consumerName,
|
|
@@ -267,7 +267,7 @@ function handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, co
|
|
|
267
267
|
});
|
|
268
268
|
if (queue.type === "quorum") {
|
|
269
269
|
ctx.amqpClient.nack(msg, false, true);
|
|
270
|
-
return
|
|
270
|
+
return Ok(void 0).toAsync();
|
|
271
271
|
} else return publishForRetry(ctx, {
|
|
272
272
|
msg,
|
|
273
273
|
exchange: msg.fields.exchange,
|
|
@@ -308,7 +308,7 @@ function handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config)
|
|
|
308
308
|
consumerName,
|
|
309
309
|
queueName: consumer.queue.name
|
|
310
310
|
});
|
|
311
|
-
return
|
|
311
|
+
return Err(new TechnicalError("Queue does not have TTL-backoff infrastructure")).toAsync();
|
|
312
312
|
}
|
|
313
313
|
const queueEntry = consumer.queue;
|
|
314
314
|
const queueName = extractQueue(queueEntry).name;
|
|
@@ -321,7 +321,7 @@ function handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config)
|
|
|
321
321
|
maxRetries: config.maxRetries
|
|
322
322
|
});
|
|
323
323
|
sendToDLQ(ctx, msg, consumer);
|
|
324
|
-
return
|
|
324
|
+
return Ok(void 0).toAsync();
|
|
325
325
|
}
|
|
326
326
|
const delayMs = calculateRetryDelay(retryCount, config);
|
|
327
327
|
ctx.logger?.info("Retrying message (ttl-backoff mode)", {
|
|
@@ -401,7 +401,7 @@ function publishForRetry(ctx, { msg, exchange, routingKey, queueName, waitQueueN
|
|
|
401
401
|
retryCount: newRetryCount,
|
|
402
402
|
...delayMs !== void 0 ? { delayMs } : {}
|
|
403
403
|
});
|
|
404
|
-
return
|
|
404
|
+
return Err(new TechnicalError("Failed to publish message for retry (write buffer full)"));
|
|
405
405
|
}
|
|
406
406
|
ctx.amqpClient.ack(msg);
|
|
407
407
|
ctx.logger?.info("Message published for retry", {
|
|
@@ -409,7 +409,7 @@ function publishForRetry(ctx, { msg, exchange, routingKey, queueName, waitQueueN
|
|
|
409
409
|
retryCount: newRetryCount,
|
|
410
410
|
...delayMs !== void 0 ? { delayMs } : {}
|
|
411
411
|
});
|
|
412
|
-
return
|
|
412
|
+
return Ok(void 0);
|
|
413
413
|
}).orElse((publishError) => {
|
|
414
414
|
ctx.logger?.error("Publish for retry failed; leaving original un-ack'd for redelivery", {
|
|
415
415
|
queueName,
|
|
@@ -417,7 +417,7 @@ function publishForRetry(ctx, { msg, exchange, routingKey, queueName, waitQueueN
|
|
|
417
417
|
...delayMs !== void 0 ? { delayMs } : {},
|
|
418
418
|
error: publishError
|
|
419
419
|
});
|
|
420
|
-
return
|
|
420
|
+
return Err(publishError);
|
|
421
421
|
});
|
|
422
422
|
}
|
|
423
423
|
/**
|
|
@@ -454,7 +454,7 @@ function isHandlerTuple(entry) {
|
|
|
454
454
|
* ```typescript
|
|
455
455
|
* import { TypedAmqpWorker } from '@amqp-contract/worker';
|
|
456
456
|
* import { defineQueue, defineMessage, defineContract, defineConsumer } from '@amqp-contract/contract';
|
|
457
|
-
* import {
|
|
457
|
+
* import { Ok } from 'unthrown';
|
|
458
458
|
* import { z } from 'zod';
|
|
459
459
|
*
|
|
460
460
|
* const orderQueue = defineQueue('order-processing');
|
|
@@ -474,7 +474,7 @@ function isHandlerTuple(entry) {
|
|
|
474
474
|
* handlers: {
|
|
475
475
|
* processOrder: ({ payload }) => {
|
|
476
476
|
* console.log('Processing order', payload.orderId);
|
|
477
|
-
* return
|
|
477
|
+
* return Ok(undefined).toAsync();
|
|
478
478
|
* },
|
|
479
479
|
* },
|
|
480
480
|
* urls: ['amqp://localhost'],
|
|
@@ -570,7 +570,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
570
570
|
* const result = await TypedAmqpWorker.create({
|
|
571
571
|
* contract: myContract,
|
|
572
572
|
* handlers: {
|
|
573
|
-
* processOrder: ({ payload }) =>
|
|
573
|
+
* processOrder: ({ payload }) => Ok(undefined).toAsync(),
|
|
574
574
|
* },
|
|
575
575
|
* urls: ['amqp://localhost'],
|
|
576
576
|
* });
|
|
@@ -612,7 +612,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
612
612
|
consumerTag,
|
|
613
613
|
error
|
|
614
614
|
});
|
|
615
|
-
return
|
|
615
|
+
return Ok(void 0);
|
|
616
616
|
}))).tap(() => {
|
|
617
617
|
this.consumerTags.clear();
|
|
618
618
|
}).flatMap(() => this.amqpClient.close()).map(() => void 0);
|
|
@@ -644,8 +644,8 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
644
644
|
validateSchema(schema, data, context) {
|
|
645
645
|
const rawValidation = schema["~standard"].validate(data);
|
|
646
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
|
|
648
|
-
return
|
|
647
|
+
if (result.issues) return Err(new TechnicalError(`${context.field} validation failed`, new MessageValidationError(context.consumerName, result.issues)));
|
|
648
|
+
return Ok(result.value);
|
|
649
649
|
});
|
|
650
650
|
}
|
|
651
651
|
/**
|
|
@@ -663,7 +663,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
663
663
|
})), consumer.message.headers ? this.validateSchema(consumer.message.headers, msg.properties.headers ?? {}, {
|
|
664
664
|
...context,
|
|
665
665
|
field: "headers"
|
|
666
|
-
}) :
|
|
666
|
+
}) : Ok(void 0).toAsync()]).map(([payload, headers]) => ({
|
|
667
667
|
payload,
|
|
668
668
|
headers
|
|
669
669
|
}));
|
|
@@ -695,7 +695,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
695
695
|
rpcName: String(rpcName),
|
|
696
696
|
queueName
|
|
697
697
|
});
|
|
698
|
-
return
|
|
698
|
+
return Err(new NonRetryableError(`RPC "${String(rpcName)}" received a message without replyTo; cannot deliver response`)).toAsync();
|
|
699
699
|
}
|
|
700
700
|
if (typeof correlationId !== "string" || correlationId.length === 0) {
|
|
701
701
|
this.logger?.error("RPC handler returned a response but the incoming message has no correlationId", {
|
|
@@ -703,21 +703,21 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
703
703
|
queueName,
|
|
704
704
|
replyTo
|
|
705
705
|
});
|
|
706
|
-
return
|
|
706
|
+
return Err(new NonRetryableError(`RPC "${String(rpcName)}" received a message without correlationId; cannot deliver response`)).toAsync();
|
|
707
707
|
}
|
|
708
708
|
let rawValidation;
|
|
709
709
|
try {
|
|
710
710
|
rawValidation = responseSchema["~standard"].validate(response);
|
|
711
711
|
} catch (error) {
|
|
712
|
-
return
|
|
712
|
+
return Err(new NonRetryableError("RPC response schema validation threw", error)).toAsync();
|
|
713
713
|
}
|
|
714
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
|
|
716
|
-
return
|
|
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
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)).flatMap((published) => published ?
|
|
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
|
|
@@ -799,7 +799,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
799
799
|
logger: this.logger
|
|
800
800
|
}, handlerError, msg, String(name), consumer).tap(() => {
|
|
801
801
|
state.messageHandled = true;
|
|
802
|
-
}).flatMap(() =>
|
|
802
|
+
}).flatMap(() => Err(new TechnicalError(`Handler "${String(name)}" failed: ${handlerError.message}`, handlerError)).toAsync());
|
|
803
803
|
})).tap(() => {
|
|
804
804
|
try {
|
|
805
805
|
endSpanSuccess(span);
|
|
@@ -921,7 +921,7 @@ function defineHandler(contract, name, handler, options) {
|
|
|
921
921
|
* @example
|
|
922
922
|
* ```typescript
|
|
923
923
|
* import { defineHandlers, RetryableError } from '@amqp-contract/worker';
|
|
924
|
-
* import { fromPromise,
|
|
924
|
+
* import { fromPromise, Ok } from 'unthrown';
|
|
925
925
|
*
|
|
926
926
|
* const handlers = defineHandlers(orderContract, {
|
|
927
927
|
* processOrder: ({ payload }) =>
|
|
@@ -929,7 +929,7 @@ function defineHandler(contract, name, handler, options) {
|
|
|
929
929
|
* processPayment(payload),
|
|
930
930
|
* (error) => new RetryableError('Payment failed', error),
|
|
931
931
|
* ).map(() => undefined),
|
|
932
|
-
* calculate: ({ payload }) =>
|
|
932
|
+
* calculate: ({ payload }) => Ok({ sum: payload.a + payload.b }).toAsync(),
|
|
933
933
|
* });
|
|
934
934
|
* ```
|
|
935
935
|
*/
|