@amqp-contract/worker 0.24.0 → 1.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 +227 -159
- package/dist/index.d.cts +269 -88
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +269 -88
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +221 -153
- package/dist/index.mjs.map +1 -1
- package/docs/index.md +499 -466
- 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
|
-
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 { TaggedError, allAsync, err, fromPromise, fromSafePromise, ok } from "unthrown";
|
|
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
|
|
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
|
|
@@ -42,14 +42,18 @@ function decompressBuffer(buffer, contentEncoding) {
|
|
|
42
42
|
*
|
|
43
43
|
* Use this error type when the operation might succeed if retried.
|
|
44
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"`).
|
|
45
50
|
*/
|
|
46
|
-
var RetryableError = class extends
|
|
51
|
+
var RetryableError = class extends TaggedError("@amqp-contract/RetryableError", { name: "RetryableError" }) {
|
|
47
52
|
constructor(message, cause) {
|
|
48
|
-
super(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (typeof ErrorConstructor.captureStackTrace === "function") ErrorConstructor.captureStackTrace(this, this.constructor);
|
|
53
|
+
super({
|
|
54
|
+
message,
|
|
55
|
+
cause
|
|
56
|
+
});
|
|
53
57
|
}
|
|
54
58
|
};
|
|
55
59
|
/**
|
|
@@ -57,15 +61,16 @@ var RetryableError = class extends Error {
|
|
|
57
61
|
* Examples: invalid data, business rule violations, permanent external failures
|
|
58
62
|
*
|
|
59
63
|
* Use this error type when retrying would not help - the message will be
|
|
60
|
-
* 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"`).
|
|
61
67
|
*/
|
|
62
|
-
var NonRetryableError = class extends
|
|
68
|
+
var NonRetryableError = class extends TaggedError("@amqp-contract/NonRetryableError", { name: "NonRetryableError" }) {
|
|
63
69
|
constructor(message, cause) {
|
|
64
|
-
super(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (typeof ErrorConstructor.captureStackTrace === "function") ErrorConstructor.captureStackTrace(this, this.constructor);
|
|
70
|
+
super({
|
|
71
|
+
message,
|
|
72
|
+
cause
|
|
73
|
+
});
|
|
69
74
|
}
|
|
70
75
|
};
|
|
71
76
|
/**
|
|
@@ -137,7 +142,7 @@ function isNonRetryableError(error) {
|
|
|
137
142
|
* ```
|
|
138
143
|
*/
|
|
139
144
|
function isHandlerError(error) {
|
|
140
|
-
return
|
|
145
|
+
return error instanceof RetryableError || error instanceof NonRetryableError;
|
|
141
146
|
}
|
|
142
147
|
/**
|
|
143
148
|
* Create a RetryableError with less verbosity.
|
|
@@ -152,16 +157,16 @@ function isHandlerError(error) {
|
|
|
152
157
|
* @example
|
|
153
158
|
* ```typescript
|
|
154
159
|
* import { retryable } from '@amqp-contract/worker';
|
|
155
|
-
* import {
|
|
160
|
+
* import { fromPromise } from 'unthrown';
|
|
156
161
|
*
|
|
157
162
|
* const handler = ({ payload }) =>
|
|
158
|
-
*
|
|
163
|
+
* fromPromise(
|
|
159
164
|
* processPayment(payload),
|
|
160
165
|
* (e) => retryable('Payment service unavailable', e),
|
|
161
166
|
* ).map(() => undefined);
|
|
162
167
|
*
|
|
163
168
|
* // Equivalent to:
|
|
164
|
-
* //
|
|
169
|
+
* // fromPromise(processPayment(payload), (e) => new RetryableError('...', e))
|
|
165
170
|
* ```
|
|
166
171
|
*/
|
|
167
172
|
function retryable(message, cause) {
|
|
@@ -180,17 +185,17 @@ function retryable(message, cause) {
|
|
|
180
185
|
* @example
|
|
181
186
|
* ```typescript
|
|
182
187
|
* import { nonRetryable } from '@amqp-contract/worker';
|
|
183
|
-
* import {
|
|
188
|
+
* import { err, ok } from 'unthrown';
|
|
184
189
|
*
|
|
185
190
|
* const handler = ({ payload }) => {
|
|
186
191
|
* if (!isValidPayload(payload)) {
|
|
187
|
-
* return
|
|
192
|
+
* return err(nonRetryable('Invalid payload format')).toAsync();
|
|
188
193
|
* }
|
|
189
|
-
* return
|
|
194
|
+
* return ok(undefined).toAsync();
|
|
190
195
|
* };
|
|
191
196
|
*
|
|
192
197
|
* // Equivalent to:
|
|
193
|
-
* // return
|
|
198
|
+
* // return err(new NonRetryableError('Invalid payload format')).toAsync();
|
|
194
199
|
* ```
|
|
195
200
|
*/
|
|
196
201
|
function nonRetryable(message, cause) {
|
|
@@ -218,23 +223,18 @@ function nonRetryable(message, cause) {
|
|
|
218
223
|
*/
|
|
219
224
|
function handleError(ctx, error, msg, consumerName, consumer) {
|
|
220
225
|
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
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);
|
|
231
231
|
if (config.mode === "ttl-backoff") return handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config);
|
|
232
|
-
ctx.logger?.
|
|
232
|
+
ctx.logger?.info("Retry disabled (none mode), sending to DLQ", {
|
|
233
233
|
consumerName,
|
|
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.
|
|
@@ -250,26 +250,24 @@ function handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, co
|
|
|
250
250
|
const queueName = queue.name;
|
|
251
251
|
const retryCount = queue.type === "quorum" ? msg.properties.headers?.["x-delivery-count"] ?? 0 : msg.properties.headers?.["x-retry-count"] ?? 0;
|
|
252
252
|
if (retryCount >= config.maxRetries) {
|
|
253
|
-
ctx.logger?.
|
|
253
|
+
ctx.logger?.info("Max retries exceeded, sending to DLQ (immediate-requeue mode)", {
|
|
254
254
|
consumerName,
|
|
255
255
|
queueName,
|
|
256
256
|
retryCount,
|
|
257
|
-
maxRetries: config.maxRetries
|
|
258
|
-
error: error.message
|
|
257
|
+
maxRetries: config.maxRetries
|
|
259
258
|
});
|
|
260
259
|
sendToDLQ(ctx, msg, consumer);
|
|
261
|
-
return
|
|
260
|
+
return ok(void 0).toAsync();
|
|
262
261
|
}
|
|
263
|
-
ctx.logger?.
|
|
262
|
+
ctx.logger?.info("Retrying message (immediate-requeue mode)", {
|
|
264
263
|
consumerName,
|
|
265
264
|
queueName,
|
|
266
265
|
retryCount,
|
|
267
|
-
maxRetries: config.maxRetries
|
|
268
|
-
error: error.message
|
|
266
|
+
maxRetries: config.maxRetries
|
|
269
267
|
});
|
|
270
268
|
if (queue.type === "quorum") {
|
|
271
269
|
ctx.amqpClient.nack(msg, false, true);
|
|
272
|
-
return
|
|
270
|
+
return ok(void 0).toAsync();
|
|
273
271
|
} else return publishForRetry(ctx, {
|
|
274
272
|
msg,
|
|
275
273
|
exchange: msg.fields.exchange,
|
|
@@ -310,30 +308,28 @@ function handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config)
|
|
|
310
308
|
consumerName,
|
|
311
309
|
queueName: consumer.queue.name
|
|
312
310
|
});
|
|
313
|
-
return
|
|
311
|
+
return err(new TechnicalError("Queue does not have TTL-backoff infrastructure")).toAsync();
|
|
314
312
|
}
|
|
315
313
|
const queueEntry = consumer.queue;
|
|
316
314
|
const queueName = extractQueue(queueEntry).name;
|
|
317
315
|
const retryCount = msg.properties.headers?.["x-retry-count"] ?? 0;
|
|
318
316
|
if (retryCount >= config.maxRetries) {
|
|
319
|
-
ctx.logger?.
|
|
317
|
+
ctx.logger?.info("Max retries exceeded, sending to DLQ (ttl-backoff mode)", {
|
|
320
318
|
consumerName,
|
|
321
319
|
queueName,
|
|
322
320
|
retryCount,
|
|
323
|
-
maxRetries: config.maxRetries
|
|
324
|
-
error: error.message
|
|
321
|
+
maxRetries: config.maxRetries
|
|
325
322
|
});
|
|
326
323
|
sendToDLQ(ctx, msg, consumer);
|
|
327
|
-
return
|
|
324
|
+
return ok(void 0).toAsync();
|
|
328
325
|
}
|
|
329
326
|
const delayMs = calculateRetryDelay(retryCount, config);
|
|
330
|
-
ctx.logger?.
|
|
327
|
+
ctx.logger?.info("Retrying message (ttl-backoff mode)", {
|
|
331
328
|
consumerName,
|
|
332
329
|
queueName,
|
|
333
330
|
retryCount: retryCount + 1,
|
|
334
331
|
maxRetries: config.maxRetries,
|
|
335
|
-
delayMs
|
|
336
|
-
error: error.message
|
|
332
|
+
delayMs
|
|
337
333
|
});
|
|
338
334
|
return publishForRetry(ctx, {
|
|
339
335
|
msg,
|
|
@@ -350,9 +346,9 @@ function handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config)
|
|
|
350
346
|
*/
|
|
351
347
|
function calculateRetryDelay(retryCount, config) {
|
|
352
348
|
const { initialDelayMs, maxDelayMs, backoffMultiplier, jitter } = config;
|
|
353
|
-
let delay =
|
|
354
|
-
if (jitter) delay = delay * (.5 + Math.random()
|
|
355
|
-
return Math.floor(delay);
|
|
349
|
+
let delay = initialDelayMs * Math.pow(backoffMultiplier, retryCount);
|
|
350
|
+
if (jitter) delay = delay * (.5 + Math.random());
|
|
351
|
+
return Math.floor(Math.min(delay, maxDelayMs));
|
|
356
352
|
}
|
|
357
353
|
/**
|
|
358
354
|
* Parse message content for republishing.
|
|
@@ -384,7 +380,6 @@ function parseMessageContentForRetry(ctx, msg, queueName) {
|
|
|
384
380
|
*/
|
|
385
381
|
function publishForRetry(ctx, { msg, exchange, routingKey, queueName, waitQueueName, delayMs, error }) {
|
|
386
382
|
const newRetryCount = (msg.properties.headers?.["x-retry-count"] ?? 0) + 1;
|
|
387
|
-
ctx.amqpClient.ack(msg);
|
|
388
383
|
const content = parseMessageContentForRetry(ctx, msg, queueName);
|
|
389
384
|
return ctx.amqpClient.publish(exchange, routingKey, content, {
|
|
390
385
|
...msg.properties,
|
|
@@ -399,7 +394,7 @@ function publishForRetry(ctx, { msg, exchange, routingKey, queueName, waitQueueN
|
|
|
399
394
|
"x-retry-queue": queueName
|
|
400
395
|
} : {}
|
|
401
396
|
}
|
|
402
|
-
}).
|
|
397
|
+
}).flatMap((published) => {
|
|
403
398
|
if (!published) {
|
|
404
399
|
ctx.logger?.error("Failed to publish message for retry (write buffer full)", {
|
|
405
400
|
queueName,
|
|
@@ -408,12 +403,21 @@ function publishForRetry(ctx, { msg, exchange, routingKey, queueName, waitQueueN
|
|
|
408
403
|
});
|
|
409
404
|
return err(new TechnicalError("Failed to publish message for retry (write buffer full)"));
|
|
410
405
|
}
|
|
406
|
+
ctx.amqpClient.ack(msg);
|
|
411
407
|
ctx.logger?.info("Message published for retry", {
|
|
412
408
|
queueName,
|
|
413
409
|
retryCount: newRetryCount,
|
|
414
410
|
...delayMs !== void 0 ? { delayMs } : {}
|
|
415
411
|
});
|
|
416
412
|
return ok(void 0);
|
|
413
|
+
}).orElse((publishError) => {
|
|
414
|
+
ctx.logger?.error("Publish for retry failed; leaving original un-ack'd for redelivery", {
|
|
415
|
+
queueName,
|
|
416
|
+
retryCount: newRetryCount,
|
|
417
|
+
...delayMs !== void 0 ? { delayMs } : {},
|
|
418
|
+
error: publishError
|
|
419
|
+
});
|
|
420
|
+
return err(publishError);
|
|
417
421
|
});
|
|
418
422
|
}
|
|
419
423
|
/**
|
|
@@ -450,7 +454,7 @@ function isHandlerTuple(entry) {
|
|
|
450
454
|
* ```typescript
|
|
451
455
|
* import { TypedAmqpWorker } from '@amqp-contract/worker';
|
|
452
456
|
* import { defineQueue, defineMessage, defineContract, defineConsumer } from '@amqp-contract/contract';
|
|
453
|
-
* import {
|
|
457
|
+
* import { ok } from 'unthrown';
|
|
454
458
|
* import { z } from 'zod';
|
|
455
459
|
*
|
|
456
460
|
* const orderQueue = defineQueue('order-processing');
|
|
@@ -470,20 +474,23 @@ function isHandlerTuple(entry) {
|
|
|
470
474
|
* handlers: {
|
|
471
475
|
* processOrder: ({ payload }) => {
|
|
472
476
|
* console.log('Processing order', payload.orderId);
|
|
473
|
-
* return
|
|
477
|
+
* return ok(undefined).toAsync();
|
|
474
478
|
* },
|
|
475
479
|
* },
|
|
476
480
|
* urls: ['amqp://localhost'],
|
|
477
481
|
* });
|
|
478
482
|
*
|
|
479
|
-
*
|
|
480
|
-
* const worker = result.value;
|
|
483
|
+
* const worker = result.unwrap();
|
|
481
484
|
*
|
|
482
485
|
* // Close when done
|
|
483
486
|
* await worker.close();
|
|
484
487
|
* ```
|
|
485
488
|
*/
|
|
486
489
|
var TypedAmqpWorker = class TypedAmqpWorker {
|
|
490
|
+
contract;
|
|
491
|
+
amqpClient;
|
|
492
|
+
defaultConsumerOptions;
|
|
493
|
+
logger;
|
|
487
494
|
/**
|
|
488
495
|
* Internal handler storage. Keyed by handler name (consumer or RPC); the
|
|
489
496
|
* stored function signature is widened so the dispatch loop can call it
|
|
@@ -556,14 +563,14 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
556
563
|
* Connections are automatically shared across clients and workers with the same
|
|
557
564
|
* URLs and connection options, following RabbitMQ best practices.
|
|
558
565
|
*
|
|
559
|
-
* @returns A
|
|
566
|
+
* @returns A AsyncResult that resolves to the worker or a TechnicalError.
|
|
560
567
|
*
|
|
561
568
|
* @example
|
|
562
569
|
* ```typescript
|
|
563
570
|
* const result = await TypedAmqpWorker.create({
|
|
564
571
|
* contract: myContract,
|
|
565
572
|
* handlers: {
|
|
566
|
-
* processOrder: ({ payload }) =>
|
|
573
|
+
* processOrder: ({ payload }) => ok(undefined).toAsync(),
|
|
567
574
|
* },
|
|
568
575
|
* urls: ['amqp://localhost'],
|
|
569
576
|
* });
|
|
@@ -575,14 +582,15 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
575
582
|
connectionOptions,
|
|
576
583
|
connectTimeoutMs
|
|
577
584
|
}), handlers, defaultConsumerOptions ?? {}, logger, telemetry);
|
|
578
|
-
const setup = worker.waitForConnectionReady().
|
|
579
|
-
return
|
|
585
|
+
const setup = worker.waitForConnectionReady().flatMap(() => worker.consumeAll());
|
|
586
|
+
return fromSafePromise((async () => {
|
|
580
587
|
const setupResult = await setup;
|
|
581
|
-
if (setupResult.isOk())
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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);
|
|
586
594
|
}
|
|
587
595
|
/**
|
|
588
596
|
* Close the AMQP channel and connection.
|
|
@@ -599,16 +607,15 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
599
607
|
* ```
|
|
600
608
|
*/
|
|
601
609
|
close() {
|
|
602
|
-
|
|
610
|
+
return allAsync(Array.from(this.consumerTags).map((consumerTag) => this.amqpClient.cancel(consumerTag).orElse((error) => {
|
|
603
611
|
this.logger?.warn("Failed to cancel consumer during close", {
|
|
604
612
|
consumerTag,
|
|
605
613
|
error
|
|
606
614
|
});
|
|
607
615
|
return ok(void 0);
|
|
608
|
-
}))
|
|
609
|
-
return ResultAsync.combine(cancellations).andTee(() => {
|
|
616
|
+
}))).tap(() => {
|
|
610
617
|
this.consumerTags.clear();
|
|
611
|
-
}).
|
|
618
|
+
}).flatMap(() => this.amqpClient.close()).map(() => void 0);
|
|
612
619
|
}
|
|
613
620
|
/**
|
|
614
621
|
* Start consuming for every entry in `contract.consumers` and `contract.rpcs`.
|
|
@@ -616,8 +623,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
616
623
|
consumeAll() {
|
|
617
624
|
const consumerNames = Object.keys(this.contract.consumers ?? {});
|
|
618
625
|
const rpcNames = Object.keys(this.contract.rpcs ?? {});
|
|
619
|
-
|
|
620
|
-
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);
|
|
621
627
|
}
|
|
622
628
|
waitForConnectionReady() {
|
|
623
629
|
return this.amqpClient.waitForConnect();
|
|
@@ -637,8 +643,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
637
643
|
*/
|
|
638
644
|
validateSchema(schema, data, context) {
|
|
639
645
|
const rawValidation = schema["~standard"].validate(data);
|
|
640
|
-
|
|
641
|
-
return ResultAsync.fromPromise(validationPromise, (error) => new TechnicalError(`Error validating ${context.field}`, error)).andThen((result) => {
|
|
646
|
+
return fromPromise(rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation), (error) => new TechnicalError(`Error validating ${context.field}`, error)).flatMap((result) => {
|
|
642
647
|
if (result.issues) return err(new TechnicalError(`${context.field} validation failed`, new MessageValidationError(context.consumerName, result.issues)));
|
|
643
648
|
return ok(result.value);
|
|
644
649
|
});
|
|
@@ -652,15 +657,13 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
652
657
|
*/
|
|
653
658
|
parseAndValidateMessage(msg, consumer, consumerName) {
|
|
654
659
|
const context = { consumerName: String(consumerName) };
|
|
655
|
-
|
|
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, {
|
|
656
661
|
...context,
|
|
657
662
|
field: "payload"
|
|
658
|
-
}))
|
|
659
|
-
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 ?? {}, {
|
|
660
664
|
...context,
|
|
661
665
|
field: "headers"
|
|
662
|
-
}) :
|
|
663
|
-
return ResultAsync.combine([parsePayload, parseHeaders]).map(([payload, headers]) => ({
|
|
666
|
+
}) : ok(void 0).toAsync()]).map(([payload, headers]) => ({
|
|
664
667
|
payload,
|
|
665
668
|
headers
|
|
666
669
|
}));
|
|
@@ -692,7 +695,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
692
695
|
rpcName: String(rpcName),
|
|
693
696
|
queueName
|
|
694
697
|
});
|
|
695
|
-
return
|
|
698
|
+
return err(new NonRetryableError(`RPC "${String(rpcName)}" received a message without replyTo; cannot deliver response`)).toAsync();
|
|
696
699
|
}
|
|
697
700
|
if (typeof correlationId !== "string" || correlationId.length === 0) {
|
|
698
701
|
this.logger?.error("RPC handler returned a response but the incoming message has no correlationId", {
|
|
@@ -700,84 +703,127 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
700
703
|
queueName,
|
|
701
704
|
replyTo
|
|
702
705
|
});
|
|
703
|
-
return
|
|
706
|
+
return err(new NonRetryableError(`RPC "${String(rpcName)}" received a message without correlationId; cannot deliver response`)).toAsync();
|
|
704
707
|
}
|
|
705
708
|
let rawValidation;
|
|
706
709
|
try {
|
|
707
710
|
rawValidation = responseSchema["~standard"].validate(response);
|
|
708
711
|
} catch (error) {
|
|
709
|
-
return
|
|
712
|
+
return err(new NonRetryableError("RPC response schema validation threw", error)).toAsync();
|
|
710
713
|
}
|
|
711
|
-
|
|
712
|
-
return ResultAsync.fromPromise(validationPromise, (error) => new NonRetryableError("RPC response schema validation threw", error)).andThen((validation) => {
|
|
714
|
+
return fromPromise(rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation), (error) => new NonRetryableError("RPC response schema validation threw", error)).flatMap((validation) => {
|
|
713
715
|
if (validation.issues) return err(new NonRetryableError(`RPC response for "${String(rpcName)}" failed schema validation`, new MessageValidationError(String(rpcName), validation.issues)));
|
|
714
716
|
return ok(validation.value);
|
|
715
|
-
}).
|
|
717
|
+
}).flatMap((validatedResponse) => this.amqpClient.publish("", replyTo, validatedResponse, {
|
|
716
718
|
correlationId,
|
|
717
719
|
contentType: "application/json"
|
|
718
|
-
}).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
|
+
}
|
|
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 err(parseError).toAsync();
|
|
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 `ok(undefined).toAsync()`.
|
|
747
|
+
*/
|
|
748
|
+
publishReplyIfRpc(msg, view, name, handlerResponse) {
|
|
749
|
+
if (!view.isRpc || !view.responseSchema) return ok(void 0).toAsync();
|
|
750
|
+
const queueName = extractQueue(view.consumer.queue).name;
|
|
751
|
+
return this.publishRpcResponse(msg, queueName, name, view.responseSchema, handlerResponse);
|
|
719
752
|
}
|
|
720
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).tapErr((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
|
+
}).flatMap((validatedMessage) => this.runHandler(handler, validatedMessage, msg).flatMap((handlerResponse) => this.publishReplyIfRpc(msg, view, name, handlerResponse).tap(() => {
|
|
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).tap(() => {
|
|
801
|
+
state.messageHandled = true;
|
|
802
|
+
}).flatMap(() => err(new TechnicalError(`Handler "${String(name)}" failed: ${handlerError.message}`, handlerError)).toAsync());
|
|
803
|
+
})).tap(() => {
|
|
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
|
+
}).tapErr((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,
|
|
@@ -802,7 +857,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
802
857
|
});
|
|
803
858
|
this.amqpClient.nack(msg, false, false);
|
|
804
859
|
}
|
|
805
|
-
}, this.consumerOptions[name]).
|
|
860
|
+
}, this.consumerOptions[name]).tap((consumerTag) => {
|
|
806
861
|
this.consumerTags.add(consumerTag);
|
|
807
862
|
}).map(() => void 0).mapErr((error) => new TechnicalError(`Failed to start consuming for "${String(name)}"`, error));
|
|
808
863
|
}
|
|
@@ -810,58 +865,71 @@ 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`.
|
|
870
|
+
*/
|
|
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.
|
|
814
882
|
*/
|
|
815
|
-
function
|
|
883
|
+
function validateHandlerTargetExists(contract, name) {
|
|
816
884
|
const consumers = contract.consumers;
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
885
|
+
const rpcs = contract.rpcs;
|
|
886
|
+
const isConsumer = !!consumers && Object.hasOwn(consumers, name);
|
|
887
|
+
const isRpc = !!rpcs && Object.hasOwn(rpcs, name);
|
|
888
|
+
if (!isConsumer && !isRpc) {
|
|
889
|
+
const available = formatAvailable(availableHandlerNames(contract));
|
|
890
|
+
throw new Error(`Handler target "${name}" not found in contract. Available consumers and RPCs: ${available}`);
|
|
821
891
|
}
|
|
822
892
|
}
|
|
823
893
|
/**
|
|
824
|
-
* Validate that
|
|
894
|
+
* Validate that every key in `handlers` maps to a contract entry —
|
|
895
|
+
* either a `consumers` key or an `rpcs` key.
|
|
825
896
|
*/
|
|
826
897
|
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}`);
|
|
898
|
+
for (const handlerName of Object.keys(handlers)) validateHandlerTargetExists(contract, handlerName);
|
|
831
899
|
}
|
|
832
|
-
function defineHandler(contract,
|
|
833
|
-
|
|
900
|
+
function defineHandler(contract, name, handler, options) {
|
|
901
|
+
validateHandlerTargetExists(contract, String(name));
|
|
834
902
|
if (options) return [handler, options];
|
|
835
903
|
return handler;
|
|
836
904
|
}
|
|
837
905
|
/**
|
|
838
|
-
* Define multiple type-safe handlers for consumers in a contract.
|
|
906
|
+
* Define multiple type-safe handlers for consumers and RPCs in a contract.
|
|
907
|
+
*
|
|
908
|
+
* **Recommended:** This function creates handlers that return
|
|
909
|
+
* `AsyncResult<void, HandlerError>` (consumers) or
|
|
910
|
+
* `AsyncResult<TResponse, HandlerError>` (RPCs), providing explicit error
|
|
911
|
+
* handling and better control over retry behavior.
|
|
839
912
|
*
|
|
840
|
-
*
|
|
841
|
-
*
|
|
913
|
+
* The handlers object must contain exactly one entry per `consumers` and
|
|
914
|
+
* `rpcs` key in the contract — see {@link WorkerInferHandlers}.
|
|
842
915
|
*
|
|
843
916
|
* @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
|
|
917
|
+
* @param contract - The contract definition containing the consumers and RPCs
|
|
918
|
+
* @param handlers - An object with handler functions for each consumer and RPC
|
|
846
919
|
* @returns A type-safe handlers object that can be used with TypedAmqpWorker
|
|
847
920
|
*
|
|
848
921
|
* @example
|
|
849
922
|
* ```typescript
|
|
850
923
|
* import { defineHandlers, RetryableError } from '@amqp-contract/worker';
|
|
851
|
-
* import {
|
|
852
|
-
* import { orderContract } from './contract';
|
|
924
|
+
* import { fromPromise, ok } from 'unthrown';
|
|
853
925
|
*
|
|
854
926
|
* const handlers = defineHandlers(orderContract, {
|
|
855
927
|
* processOrder: ({ payload }) =>
|
|
856
|
-
*
|
|
928
|
+
* fromPromise(
|
|
857
929
|
* processPayment(payload),
|
|
858
930
|
* (error) => new RetryableError('Payment failed', error),
|
|
859
931
|
* ).map(() => undefined),
|
|
860
|
-
*
|
|
861
|
-
* ResultAsync.fromPromise(
|
|
862
|
-
* sendNotification(payload),
|
|
863
|
-
* (error) => new RetryableError('Notification failed', error),
|
|
864
|
-
* ).map(() => undefined),
|
|
932
|
+
* calculate: ({ payload }) => ok({ sum: payload.a + payload.b }).toAsync(),
|
|
865
933
|
* });
|
|
866
934
|
* ```
|
|
867
935
|
*/
|