@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.cjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
let _amqp_contract_contract = require("@amqp-contract/contract");
|
|
3
3
|
let _amqp_contract_core = require("@amqp-contract/core");
|
|
4
|
-
let
|
|
4
|
+
let unthrown = require("unthrown");
|
|
5
5
|
let node_zlib = require("node:zlib");
|
|
6
6
|
let node_util = require("node:util");
|
|
7
7
|
//#region src/decompression.ts
|
|
@@ -22,17 +22,17 @@ function isSupportedEncoding(encoding) {
|
|
|
22
22
|
*
|
|
23
23
|
* @param buffer - The buffer to decompress
|
|
24
24
|
* @param contentEncoding - The content-encoding header value (e.g., 'gzip', 'deflate')
|
|
25
|
-
* @returns
|
|
25
|
+
* @returns An AsyncResult resolving to the decompressed buffer or a TechnicalError
|
|
26
26
|
*
|
|
27
27
|
* @internal
|
|
28
28
|
*/
|
|
29
29
|
function decompressBuffer(buffer, contentEncoding) {
|
|
30
|
-
if (!contentEncoding) return (0,
|
|
30
|
+
if (!contentEncoding) return (0, unthrown.ok)(buffer).toAsync();
|
|
31
31
|
const normalizedEncoding = contentEncoding.toLowerCase();
|
|
32
|
-
if (!isSupportedEncoding(normalizedEncoding)) return (0,
|
|
32
|
+
if (!isSupportedEncoding(normalizedEncoding)) return (0, unthrown.err)(new _amqp_contract_core.TechnicalError(`Unsupported content-encoding: "${contentEncoding}". Supported encodings are: ${SUPPORTED_ENCODINGS.join(", ")}. Please check your publisher configuration.`)).toAsync();
|
|
33
33
|
switch (normalizedEncoding) {
|
|
34
|
-
case "gzip": return
|
|
35
|
-
case "deflate": return
|
|
34
|
+
case "gzip": return (0, unthrown.fromPromise)(gunzipAsync(buffer), (error) => new _amqp_contract_core.TechnicalError("Failed to decompress gzip", error));
|
|
35
|
+
case "deflate": return (0, unthrown.fromPromise)(inflateAsync(buffer), (error) => new _amqp_contract_core.TechnicalError("Failed to decompress deflate", error));
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
//#endregion
|
|
@@ -43,14 +43,18 @@ function decompressBuffer(buffer, contentEncoding) {
|
|
|
43
43
|
*
|
|
44
44
|
* Use this error type when the operation might succeed if retried.
|
|
45
45
|
* The worker will apply exponential backoff and retry the message.
|
|
46
|
+
*
|
|
47
|
+
* Built on unthrown's {@link TaggedError}, so it carries a namespaced `_tag` of
|
|
48
|
+
* `"@amqp-contract/RetryableError"` (to avoid colliding with other libraries'
|
|
49
|
+
* tags in a shared `matchTags`) for exhaustive dispatch; the `Error.name` is
|
|
50
|
+
* kept bare (`"RetryableError"`).
|
|
46
51
|
*/
|
|
47
|
-
var RetryableError = class extends
|
|
52
|
+
var RetryableError = class extends (0, unthrown.TaggedError)("@amqp-contract/RetryableError", { name: "RetryableError" }) {
|
|
48
53
|
constructor(message, cause) {
|
|
49
|
-
super(
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (typeof ErrorConstructor.captureStackTrace === "function") ErrorConstructor.captureStackTrace(this, this.constructor);
|
|
54
|
+
super({
|
|
55
|
+
message,
|
|
56
|
+
cause
|
|
57
|
+
});
|
|
54
58
|
}
|
|
55
59
|
};
|
|
56
60
|
/**
|
|
@@ -58,15 +62,16 @@ var RetryableError = class extends Error {
|
|
|
58
62
|
* Examples: invalid data, business rule violations, permanent external failures
|
|
59
63
|
*
|
|
60
64
|
* Use this error type when retrying would not help - the message will be
|
|
61
|
-
* immediately sent to the dead letter queue (DLQ) if configured.
|
|
65
|
+
* immediately sent to the dead letter queue (DLQ) if configured. Carries a
|
|
66
|
+
* namespaced `_tag` of `"@amqp-contract/NonRetryableError"`; the `Error.name` is
|
|
67
|
+
* kept bare (`"NonRetryableError"`).
|
|
62
68
|
*/
|
|
63
|
-
var NonRetryableError = class extends
|
|
69
|
+
var NonRetryableError = class extends (0, unthrown.TaggedError)("@amqp-contract/NonRetryableError", { name: "NonRetryableError" }) {
|
|
64
70
|
constructor(message, cause) {
|
|
65
|
-
super(
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (typeof ErrorConstructor.captureStackTrace === "function") ErrorConstructor.captureStackTrace(this, this.constructor);
|
|
71
|
+
super({
|
|
72
|
+
message,
|
|
73
|
+
cause
|
|
74
|
+
});
|
|
70
75
|
}
|
|
71
76
|
};
|
|
72
77
|
/**
|
|
@@ -138,7 +143,7 @@ function isNonRetryableError(error) {
|
|
|
138
143
|
* ```
|
|
139
144
|
*/
|
|
140
145
|
function isHandlerError(error) {
|
|
141
|
-
return
|
|
146
|
+
return error instanceof RetryableError || error instanceof NonRetryableError;
|
|
142
147
|
}
|
|
143
148
|
/**
|
|
144
149
|
* Create a RetryableError with less verbosity.
|
|
@@ -153,16 +158,16 @@ function isHandlerError(error) {
|
|
|
153
158
|
* @example
|
|
154
159
|
* ```typescript
|
|
155
160
|
* import { retryable } from '@amqp-contract/worker';
|
|
156
|
-
* import {
|
|
161
|
+
* import { fromPromise } from 'unthrown';
|
|
157
162
|
*
|
|
158
163
|
* const handler = ({ payload }) =>
|
|
159
|
-
*
|
|
164
|
+
* fromPromise(
|
|
160
165
|
* processPayment(payload),
|
|
161
166
|
* (e) => retryable('Payment service unavailable', e),
|
|
162
167
|
* ).map(() => undefined);
|
|
163
168
|
*
|
|
164
169
|
* // Equivalent to:
|
|
165
|
-
* //
|
|
170
|
+
* // fromPromise(processPayment(payload), (e) => new RetryableError('...', e))
|
|
166
171
|
* ```
|
|
167
172
|
*/
|
|
168
173
|
function retryable(message, cause) {
|
|
@@ -181,17 +186,17 @@ function retryable(message, cause) {
|
|
|
181
186
|
* @example
|
|
182
187
|
* ```typescript
|
|
183
188
|
* import { nonRetryable } from '@amqp-contract/worker';
|
|
184
|
-
* import {
|
|
189
|
+
* import { err, ok } from 'unthrown';
|
|
185
190
|
*
|
|
186
191
|
* const handler = ({ payload }) => {
|
|
187
192
|
* if (!isValidPayload(payload)) {
|
|
188
|
-
* return
|
|
193
|
+
* return err(nonRetryable('Invalid payload format')).toAsync();
|
|
189
194
|
* }
|
|
190
|
-
* return
|
|
195
|
+
* return ok(undefined).toAsync();
|
|
191
196
|
* };
|
|
192
197
|
*
|
|
193
198
|
* // Equivalent to:
|
|
194
|
-
* // return
|
|
199
|
+
* // return err(new NonRetryableError('Invalid payload format')).toAsync();
|
|
195
200
|
* ```
|
|
196
201
|
*/
|
|
197
202
|
function nonRetryable(message, cause) {
|
|
@@ -219,23 +224,18 @@ function nonRetryable(message, cause) {
|
|
|
219
224
|
*/
|
|
220
225
|
function handleError(ctx, error, msg, consumerName, consumer) {
|
|
221
226
|
if (error instanceof NonRetryableError) {
|
|
222
|
-
ctx.logger?.error("Non-retryable error, sending to DLQ immediately", {
|
|
223
|
-
consumerName,
|
|
224
|
-
errorType: error.name,
|
|
225
|
-
error: error.message
|
|
226
|
-
});
|
|
227
227
|
sendToDLQ(ctx, msg, consumer);
|
|
228
|
-
return (0,
|
|
228
|
+
return (0, unthrown.ok)(void 0).toAsync();
|
|
229
229
|
}
|
|
230
230
|
const config = (0, _amqp_contract_contract.extractQueue)(consumer.queue).retry;
|
|
231
231
|
if (config.mode === "immediate-requeue") return handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, config);
|
|
232
232
|
if (config.mode === "ttl-backoff") return handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config);
|
|
233
|
-
ctx.logger?.
|
|
233
|
+
ctx.logger?.info("Retry disabled (none mode), sending to DLQ", {
|
|
234
234
|
consumerName,
|
|
235
|
-
|
|
235
|
+
queueName: (0, _amqp_contract_contract.extractQueue)(consumer.queue).name
|
|
236
236
|
});
|
|
237
237
|
sendToDLQ(ctx, msg, consumer);
|
|
238
|
-
return (0,
|
|
238
|
+
return (0, unthrown.ok)(void 0).toAsync();
|
|
239
239
|
}
|
|
240
240
|
/**
|
|
241
241
|
* Handle error by requeuing immediately.
|
|
@@ -251,26 +251,24 @@ function handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, co
|
|
|
251
251
|
const queueName = queue.name;
|
|
252
252
|
const retryCount = queue.type === "quorum" ? msg.properties.headers?.["x-delivery-count"] ?? 0 : msg.properties.headers?.["x-retry-count"] ?? 0;
|
|
253
253
|
if (retryCount >= config.maxRetries) {
|
|
254
|
-
ctx.logger?.
|
|
254
|
+
ctx.logger?.info("Max retries exceeded, sending to DLQ (immediate-requeue mode)", {
|
|
255
255
|
consumerName,
|
|
256
256
|
queueName,
|
|
257
257
|
retryCount,
|
|
258
|
-
maxRetries: config.maxRetries
|
|
259
|
-
error: error.message
|
|
258
|
+
maxRetries: config.maxRetries
|
|
260
259
|
});
|
|
261
260
|
sendToDLQ(ctx, msg, consumer);
|
|
262
|
-
return (0,
|
|
261
|
+
return (0, unthrown.ok)(void 0).toAsync();
|
|
263
262
|
}
|
|
264
|
-
ctx.logger?.
|
|
263
|
+
ctx.logger?.info("Retrying message (immediate-requeue mode)", {
|
|
265
264
|
consumerName,
|
|
266
265
|
queueName,
|
|
267
266
|
retryCount,
|
|
268
|
-
maxRetries: config.maxRetries
|
|
269
|
-
error: error.message
|
|
267
|
+
maxRetries: config.maxRetries
|
|
270
268
|
});
|
|
271
269
|
if (queue.type === "quorum") {
|
|
272
270
|
ctx.amqpClient.nack(msg, false, true);
|
|
273
|
-
return (0,
|
|
271
|
+
return (0, unthrown.ok)(void 0).toAsync();
|
|
274
272
|
} else return publishForRetry(ctx, {
|
|
275
273
|
msg,
|
|
276
274
|
exchange: msg.fields.exchange,
|
|
@@ -311,30 +309,28 @@ function handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config)
|
|
|
311
309
|
consumerName,
|
|
312
310
|
queueName: consumer.queue.name
|
|
313
311
|
});
|
|
314
|
-
return (0,
|
|
312
|
+
return (0, unthrown.err)(new _amqp_contract_core.TechnicalError("Queue does not have TTL-backoff infrastructure")).toAsync();
|
|
315
313
|
}
|
|
316
314
|
const queueEntry = consumer.queue;
|
|
317
315
|
const queueName = (0, _amqp_contract_contract.extractQueue)(queueEntry).name;
|
|
318
316
|
const retryCount = msg.properties.headers?.["x-retry-count"] ?? 0;
|
|
319
317
|
if (retryCount >= config.maxRetries) {
|
|
320
|
-
ctx.logger?.
|
|
318
|
+
ctx.logger?.info("Max retries exceeded, sending to DLQ (ttl-backoff mode)", {
|
|
321
319
|
consumerName,
|
|
322
320
|
queueName,
|
|
323
321
|
retryCount,
|
|
324
|
-
maxRetries: config.maxRetries
|
|
325
|
-
error: error.message
|
|
322
|
+
maxRetries: config.maxRetries
|
|
326
323
|
});
|
|
327
324
|
sendToDLQ(ctx, msg, consumer);
|
|
328
|
-
return (0,
|
|
325
|
+
return (0, unthrown.ok)(void 0).toAsync();
|
|
329
326
|
}
|
|
330
327
|
const delayMs = calculateRetryDelay(retryCount, config);
|
|
331
|
-
ctx.logger?.
|
|
328
|
+
ctx.logger?.info("Retrying message (ttl-backoff mode)", {
|
|
332
329
|
consumerName,
|
|
333
330
|
queueName,
|
|
334
331
|
retryCount: retryCount + 1,
|
|
335
332
|
maxRetries: config.maxRetries,
|
|
336
|
-
delayMs
|
|
337
|
-
error: error.message
|
|
333
|
+
delayMs
|
|
338
334
|
});
|
|
339
335
|
return publishForRetry(ctx, {
|
|
340
336
|
msg,
|
|
@@ -351,9 +347,9 @@ function handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config)
|
|
|
351
347
|
*/
|
|
352
348
|
function calculateRetryDelay(retryCount, config) {
|
|
353
349
|
const { initialDelayMs, maxDelayMs, backoffMultiplier, jitter } = config;
|
|
354
|
-
let delay =
|
|
355
|
-
if (jitter) delay = delay * (.5 + Math.random()
|
|
356
|
-
return Math.floor(delay);
|
|
350
|
+
let delay = initialDelayMs * Math.pow(backoffMultiplier, retryCount);
|
|
351
|
+
if (jitter) delay = delay * (.5 + Math.random());
|
|
352
|
+
return Math.floor(Math.min(delay, maxDelayMs));
|
|
357
353
|
}
|
|
358
354
|
/**
|
|
359
355
|
* Parse message content for republishing.
|
|
@@ -385,7 +381,6 @@ function parseMessageContentForRetry(ctx, msg, queueName) {
|
|
|
385
381
|
*/
|
|
386
382
|
function publishForRetry(ctx, { msg, exchange, routingKey, queueName, waitQueueName, delayMs, error }) {
|
|
387
383
|
const newRetryCount = (msg.properties.headers?.["x-retry-count"] ?? 0) + 1;
|
|
388
|
-
ctx.amqpClient.ack(msg);
|
|
389
384
|
const content = parseMessageContentForRetry(ctx, msg, queueName);
|
|
390
385
|
return ctx.amqpClient.publish(exchange, routingKey, content, {
|
|
391
386
|
...msg.properties,
|
|
@@ -400,21 +395,30 @@ function publishForRetry(ctx, { msg, exchange, routingKey, queueName, waitQueueN
|
|
|
400
395
|
"x-retry-queue": queueName
|
|
401
396
|
} : {}
|
|
402
397
|
}
|
|
403
|
-
}).
|
|
398
|
+
}).flatMap((published) => {
|
|
404
399
|
if (!published) {
|
|
405
400
|
ctx.logger?.error("Failed to publish message for retry (write buffer full)", {
|
|
406
401
|
queueName,
|
|
407
402
|
retryCount: newRetryCount,
|
|
408
403
|
...delayMs !== void 0 ? { delayMs } : {}
|
|
409
404
|
});
|
|
410
|
-
return (0,
|
|
405
|
+
return (0, unthrown.err)(new _amqp_contract_core.TechnicalError("Failed to publish message for retry (write buffer full)"));
|
|
411
406
|
}
|
|
407
|
+
ctx.amqpClient.ack(msg);
|
|
412
408
|
ctx.logger?.info("Message published for retry", {
|
|
413
409
|
queueName,
|
|
414
410
|
retryCount: newRetryCount,
|
|
415
411
|
...delayMs !== void 0 ? { delayMs } : {}
|
|
416
412
|
});
|
|
417
|
-
return (0,
|
|
413
|
+
return (0, unthrown.ok)(void 0);
|
|
414
|
+
}).orElse((publishError) => {
|
|
415
|
+
ctx.logger?.error("Publish for retry failed; leaving original un-ack'd for redelivery", {
|
|
416
|
+
queueName,
|
|
417
|
+
retryCount: newRetryCount,
|
|
418
|
+
...delayMs !== void 0 ? { delayMs } : {},
|
|
419
|
+
error: publishError
|
|
420
|
+
});
|
|
421
|
+
return (0, unthrown.err)(publishError);
|
|
418
422
|
});
|
|
419
423
|
}
|
|
420
424
|
/**
|
|
@@ -451,7 +455,7 @@ function isHandlerTuple(entry) {
|
|
|
451
455
|
* ```typescript
|
|
452
456
|
* import { TypedAmqpWorker } from '@amqp-contract/worker';
|
|
453
457
|
* import { defineQueue, defineMessage, defineContract, defineConsumer } from '@amqp-contract/contract';
|
|
454
|
-
* import {
|
|
458
|
+
* import { ok } from 'unthrown';
|
|
455
459
|
* import { z } from 'zod';
|
|
456
460
|
*
|
|
457
461
|
* const orderQueue = defineQueue('order-processing');
|
|
@@ -471,20 +475,23 @@ function isHandlerTuple(entry) {
|
|
|
471
475
|
* handlers: {
|
|
472
476
|
* processOrder: ({ payload }) => {
|
|
473
477
|
* console.log('Processing order', payload.orderId);
|
|
474
|
-
* return
|
|
478
|
+
* return ok(undefined).toAsync();
|
|
475
479
|
* },
|
|
476
480
|
* },
|
|
477
481
|
* urls: ['amqp://localhost'],
|
|
478
482
|
* });
|
|
479
483
|
*
|
|
480
|
-
*
|
|
481
|
-
* const worker = result.value;
|
|
484
|
+
* const worker = result.unwrap();
|
|
482
485
|
*
|
|
483
486
|
* // Close when done
|
|
484
487
|
* await worker.close();
|
|
485
488
|
* ```
|
|
486
489
|
*/
|
|
487
490
|
var TypedAmqpWorker = class TypedAmqpWorker {
|
|
491
|
+
contract;
|
|
492
|
+
amqpClient;
|
|
493
|
+
defaultConsumerOptions;
|
|
494
|
+
logger;
|
|
488
495
|
/**
|
|
489
496
|
* Internal handler storage. Keyed by handler name (consumer or RPC); the
|
|
490
497
|
* stored function signature is widened so the dispatch loop can call it
|
|
@@ -557,14 +564,14 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
557
564
|
* Connections are automatically shared across clients and workers with the same
|
|
558
565
|
* URLs and connection options, following RabbitMQ best practices.
|
|
559
566
|
*
|
|
560
|
-
* @returns A
|
|
567
|
+
* @returns A AsyncResult that resolves to the worker or a TechnicalError.
|
|
561
568
|
*
|
|
562
569
|
* @example
|
|
563
570
|
* ```typescript
|
|
564
571
|
* const result = await TypedAmqpWorker.create({
|
|
565
572
|
* contract: myContract,
|
|
566
573
|
* handlers: {
|
|
567
|
-
* processOrder: ({ payload }) =>
|
|
574
|
+
* processOrder: ({ payload }) => ok(undefined).toAsync(),
|
|
568
575
|
* },
|
|
569
576
|
* urls: ['amqp://localhost'],
|
|
570
577
|
* });
|
|
@@ -576,14 +583,15 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
576
583
|
connectionOptions,
|
|
577
584
|
connectTimeoutMs
|
|
578
585
|
}), handlers, defaultConsumerOptions ?? {}, logger, telemetry);
|
|
579
|
-
const setup = worker.waitForConnectionReady().
|
|
580
|
-
return
|
|
586
|
+
const setup = worker.waitForConnectionReady().flatMap(() => worker.consumeAll());
|
|
587
|
+
return (0, unthrown.fromSafePromise)((async () => {
|
|
581
588
|
const setupResult = await setup;
|
|
582
|
-
if (setupResult.isOk())
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
589
|
+
if (!setupResult.isOk()) {
|
|
590
|
+
const closeResult = await worker.close();
|
|
591
|
+
if (closeResult.isErr()) logger?.warn("Failed to close worker after setup failure", { error: closeResult.error });
|
|
592
|
+
}
|
|
593
|
+
return setupResult.map(() => worker);
|
|
594
|
+
})()).flatMap((result) => result);
|
|
587
595
|
}
|
|
588
596
|
/**
|
|
589
597
|
* Close the AMQP channel and connection.
|
|
@@ -600,16 +608,15 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
600
608
|
* ```
|
|
601
609
|
*/
|
|
602
610
|
close() {
|
|
603
|
-
|
|
611
|
+
return (0, unthrown.allAsync)(Array.from(this.consumerTags).map((consumerTag) => this.amqpClient.cancel(consumerTag).orElse((error) => {
|
|
604
612
|
this.logger?.warn("Failed to cancel consumer during close", {
|
|
605
613
|
consumerTag,
|
|
606
614
|
error
|
|
607
615
|
});
|
|
608
|
-
return (0,
|
|
609
|
-
}))
|
|
610
|
-
return neverthrow.ResultAsync.combine(cancellations).andTee(() => {
|
|
616
|
+
return (0, unthrown.ok)(void 0);
|
|
617
|
+
}))).tap(() => {
|
|
611
618
|
this.consumerTags.clear();
|
|
612
|
-
}).
|
|
619
|
+
}).flatMap(() => this.amqpClient.close()).map(() => void 0);
|
|
613
620
|
}
|
|
614
621
|
/**
|
|
615
622
|
* Start consuming for every entry in `contract.consumers` and `contract.rpcs`.
|
|
@@ -617,8 +624,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
617
624
|
consumeAll() {
|
|
618
625
|
const consumerNames = Object.keys(this.contract.consumers ?? {});
|
|
619
626
|
const rpcNames = Object.keys(this.contract.rpcs ?? {});
|
|
620
|
-
|
|
621
|
-
return neverthrow.ResultAsync.combine(allNames.map((name) => this.consume(name))).map(() => void 0);
|
|
627
|
+
return (0, unthrown.allAsync)([...consumerNames, ...rpcNames].map((name) => this.consume(name))).map(() => void 0);
|
|
622
628
|
}
|
|
623
629
|
waitForConnectionReady() {
|
|
624
630
|
return this.amqpClient.waitForConnect();
|
|
@@ -638,10 +644,9 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
638
644
|
*/
|
|
639
645
|
validateSchema(schema, data, context) {
|
|
640
646
|
const rawValidation = schema["~standard"].validate(data);
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
return (0, neverthrow.ok)(result.value);
|
|
647
|
+
return (0, unthrown.fromPromise)(rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation), (error) => new _amqp_contract_core.TechnicalError(`Error validating ${context.field}`, error)).flatMap((result) => {
|
|
648
|
+
if (result.issues) return (0, unthrown.err)(new _amqp_contract_core.TechnicalError(`${context.field} validation failed`, new _amqp_contract_core.MessageValidationError(context.consumerName, result.issues)));
|
|
649
|
+
return (0, unthrown.ok)(result.value);
|
|
645
650
|
});
|
|
646
651
|
}
|
|
647
652
|
/**
|
|
@@ -653,15 +658,13 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
653
658
|
*/
|
|
654
659
|
parseAndValidateMessage(msg, consumer, consumerName) {
|
|
655
660
|
const context = { consumerName: String(consumerName) };
|
|
656
|
-
|
|
661
|
+
return (0, unthrown.allAsync)([decompressBuffer(msg.content, msg.properties.contentEncoding).flatMap((buffer) => (0, _amqp_contract_core.safeJsonParse)(buffer, (error) => new _amqp_contract_core.TechnicalError("Failed to parse JSON", error))).flatMap((parsed) => this.validateSchema(consumer.message.payload, parsed, {
|
|
657
662
|
...context,
|
|
658
663
|
field: "payload"
|
|
659
|
-
}))
|
|
660
|
-
const parseHeaders = consumer.message.headers ? this.validateSchema(consumer.message.headers, msg.properties.headers ?? {}, {
|
|
664
|
+
})), consumer.message.headers ? this.validateSchema(consumer.message.headers, msg.properties.headers ?? {}, {
|
|
661
665
|
...context,
|
|
662
666
|
field: "headers"
|
|
663
|
-
}) : (0,
|
|
664
|
-
return neverthrow.ResultAsync.combine([parsePayload, parseHeaders]).map(([payload, headers]) => ({
|
|
667
|
+
}) : (0, unthrown.ok)(void 0).toAsync()]).map(([payload, headers]) => ({
|
|
665
668
|
payload,
|
|
666
669
|
headers
|
|
667
670
|
}));
|
|
@@ -693,7 +696,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
693
696
|
rpcName: String(rpcName),
|
|
694
697
|
queueName
|
|
695
698
|
});
|
|
696
|
-
return (0,
|
|
699
|
+
return (0, unthrown.err)(new NonRetryableError(`RPC "${String(rpcName)}" received a message without replyTo; cannot deliver response`)).toAsync();
|
|
697
700
|
}
|
|
698
701
|
if (typeof correlationId !== "string" || correlationId.length === 0) {
|
|
699
702
|
this.logger?.error("RPC handler returned a response but the incoming message has no correlationId", {
|
|
@@ -701,84 +704,127 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
701
704
|
queueName,
|
|
702
705
|
replyTo
|
|
703
706
|
});
|
|
704
|
-
return (0,
|
|
707
|
+
return (0, unthrown.err)(new NonRetryableError(`RPC "${String(rpcName)}" received a message without correlationId; cannot deliver response`)).toAsync();
|
|
705
708
|
}
|
|
706
709
|
let rawValidation;
|
|
707
710
|
try {
|
|
708
711
|
rawValidation = responseSchema["~standard"].validate(response);
|
|
709
712
|
} catch (error) {
|
|
710
|
-
return (0,
|
|
713
|
+
return (0, unthrown.err)(new NonRetryableError("RPC response schema validation threw", error)).toAsync();
|
|
711
714
|
}
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
}).andThen((validatedResponse) => this.amqpClient.publish("", replyTo, validatedResponse, {
|
|
715
|
+
return (0, unthrown.fromPromise)(rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation), (error) => new NonRetryableError("RPC response schema validation threw", error)).flatMap((validation) => {
|
|
716
|
+
if (validation.issues) return (0, unthrown.err)(new NonRetryableError(`RPC response for "${String(rpcName)}" failed schema validation`, new _amqp_contract_core.MessageValidationError(String(rpcName), validation.issues)));
|
|
717
|
+
return (0, unthrown.ok)(validation.value);
|
|
718
|
+
}).flatMap((validatedResponse) => this.amqpClient.publish("", replyTo, validatedResponse, {
|
|
717
719
|
correlationId,
|
|
718
720
|
contentType: "application/json"
|
|
719
|
-
}).mapErr((error) => new NonRetryableError("Failed to publish RPC response", error)).
|
|
721
|
+
}).mapErr((error) => new NonRetryableError("Failed to publish RPC response", error)).flatMap((published) => published ? (0, unthrown.ok)(void 0) : (0, unthrown.err)(new NonRetryableError("Failed to publish RPC response: channel buffer full"))));
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Parse and validate the message; on failure, nack(requeue=false) so the
|
|
725
|
+
* queue's DLX (if configured) receives the poison message and bypass the
|
|
726
|
+
* retry pipeline — a malformed payload is deterministic and retrying it
|
|
727
|
+
* would burn the queue's retry budget on a guaranteed failure.
|
|
728
|
+
*/
|
|
729
|
+
parseAndValidateOrNack(msg, consumer, name) {
|
|
730
|
+
return this.parseAndValidateMessage(msg, consumer, name).orElse((parseError) => {
|
|
731
|
+
this.amqpClient.nack(msg, false, false);
|
|
732
|
+
return (0, unthrown.err)(parseError).toAsync();
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Invoke the handler and ack the message on success. Returns the handler's
|
|
737
|
+
* response (RPC) or `undefined` (regular consumer). Errors propagate as
|
|
738
|
+
* `HandlerError` for downstream RPC reply publishing or routing via
|
|
739
|
+
* {@link handleError}.
|
|
740
|
+
*/
|
|
741
|
+
runHandler(handler, validatedMessage, msg) {
|
|
742
|
+
return handler(validatedMessage, msg);
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* For RPC handlers, validate and publish the reply on the caller's
|
|
746
|
+
* `replyTo` / `correlationId`. For non-RPC consumers, this is a no-op that
|
|
747
|
+
* resolves to `ok(undefined).toAsync()`.
|
|
748
|
+
*/
|
|
749
|
+
publishReplyIfRpc(msg, view, name, handlerResponse) {
|
|
750
|
+
if (!view.isRpc || !view.responseSchema) return (0, unthrown.ok)(void 0).toAsync();
|
|
751
|
+
const queueName = (0, _amqp_contract_contract.extractQueue)(view.consumer.queue).name;
|
|
752
|
+
return this.publishRpcResponse(msg, queueName, name, view.responseSchema, handlerResponse);
|
|
720
753
|
}
|
|
721
754
|
/**
|
|
722
755
|
* Process a single consumed message: validate, invoke handler, optionally
|
|
723
|
-
* publish the RPC response, record telemetry, and
|
|
756
|
+
* publish the RPC response, record telemetry, and route errors.
|
|
757
|
+
*
|
|
758
|
+
* The caller-supplied `state` is mutated as the message is ack'd/nack'd so
|
|
759
|
+
* the consume callback's catch-all guard can tell whether a defensive nack
|
|
760
|
+
* is still needed (see {@link consumeSingle}).
|
|
761
|
+
*
|
|
762
|
+
* Success-vs-failure telemetry is data-driven: the chain resolves to
|
|
763
|
+
* `ok(undefined)` only on handler success (and reply-publish success for
|
|
764
|
+
* RPC). Handler failures — even when {@link handleError} routes them
|
|
765
|
+
* successfully to retry/DLQ — are classified as failures for metrics by
|
|
766
|
+
* re-failing the chain with a `TechnicalError` whose `cause` is the
|
|
767
|
+
* original `HandlerError`. The terminal `orTee` unwraps the cause before
|
|
768
|
+
* recording the span exception so traces keep the original
|
|
769
|
+
* `RetryableError` / `NonRetryableError` class as the exception type.
|
|
724
770
|
*/
|
|
725
|
-
processMessage(msg, view, name, handler) {
|
|
726
|
-
const { consumer
|
|
771
|
+
processMessage(msg, view, name, handler, state) {
|
|
772
|
+
const { consumer } = view;
|
|
727
773
|
const queueName = (0, _amqp_contract_contract.extractQueue)(consumer.queue).name;
|
|
728
774
|
const startTime = Date.now();
|
|
729
775
|
const span = (0, _amqp_contract_core.startConsumeSpan)(this.telemetry, queueName, String(name), { "messaging.rabbitmq.message.delivery_tag": msg.fields.deliveryTag });
|
|
730
|
-
|
|
731
|
-
let firstError;
|
|
732
|
-
const inner = this.parseAndValidateMessage(msg, consumer, name).orElse((parseError) => {
|
|
733
|
-
firstError = parseError;
|
|
776
|
+
return this.parseAndValidateOrNack(msg, consumer, name).tapErr((parseError) => {
|
|
734
777
|
this.logger?.error("Failed to parse/validate message; sending to DLQ", {
|
|
735
778
|
consumerName: String(name),
|
|
736
779
|
queueName,
|
|
737
780
|
error: parseError
|
|
738
781
|
});
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
}).andThen((validatedMessage) => handler(validatedMessage, msg).andThen((handlerResponse) => {
|
|
742
|
-
if (isRpc && responseSchema) return this.publishRpcResponse(msg, queueName, name, responseSchema, handlerResponse).map(() => {
|
|
743
|
-
this.logger?.info("Message consumed successfully", {
|
|
744
|
-
consumerName: String(name),
|
|
745
|
-
queueName
|
|
746
|
-
});
|
|
747
|
-
this.amqpClient.ack(msg);
|
|
748
|
-
messageHandled = true;
|
|
749
|
-
});
|
|
782
|
+
state.messageHandled = true;
|
|
783
|
+
}).flatMap((validatedMessage) => this.runHandler(handler, validatedMessage, msg).flatMap((handlerResponse) => this.publishReplyIfRpc(msg, view, name, handlerResponse).tap(() => {
|
|
750
784
|
this.logger?.info("Message consumed successfully", {
|
|
751
785
|
consumerName: String(name),
|
|
752
786
|
queueName
|
|
753
787
|
});
|
|
754
788
|
this.amqpClient.ack(msg);
|
|
755
|
-
messageHandled = true;
|
|
756
|
-
|
|
757
|
-
}).orElse((handlerError) => {
|
|
789
|
+
state.messageHandled = true;
|
|
790
|
+
})).orElse((handlerError) => {
|
|
758
791
|
this.logger?.error("Error processing message", {
|
|
759
792
|
consumerName: String(name),
|
|
760
793
|
queueName,
|
|
761
794
|
errorType: handlerError.name,
|
|
795
|
+
retryCount: msg.properties.headers?.["x-delivery-count"] ?? msg.properties.headers?.["x-retry-count"] ?? 0,
|
|
762
796
|
error: handlerError.message
|
|
763
797
|
});
|
|
764
|
-
firstError = handlerError;
|
|
765
798
|
return handleError({
|
|
766
799
|
amqpClient: this.amqpClient,
|
|
767
800
|
logger: this.logger
|
|
768
|
-
}, handlerError, msg, String(name), consumer)
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
if (messageHandled) {
|
|
801
|
+
}, handlerError, msg, String(name), consumer).tap(() => {
|
|
802
|
+
state.messageHandled = true;
|
|
803
|
+
}).flatMap(() => (0, unthrown.err)(new _amqp_contract_core.TechnicalError(`Handler "${String(name)}" failed: ${handlerError.message}`, handlerError)).toAsync());
|
|
804
|
+
})).tap(() => {
|
|
805
|
+
try {
|
|
774
806
|
(0, _amqp_contract_core.endSpanSuccess)(span);
|
|
775
|
-
(0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(name), true,
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
|
|
807
|
+
(0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(name), true, Date.now() - startTime);
|
|
808
|
+
} catch (telemetryError) {
|
|
809
|
+
this.logger?.warn("Telemetry recording threw; ignoring", {
|
|
810
|
+
consumerName: String(name),
|
|
811
|
+
queueName,
|
|
812
|
+
error: telemetryError
|
|
813
|
+
});
|
|
779
814
|
}
|
|
780
|
-
|
|
781
|
-
|
|
815
|
+
}).tapErr((error) => {
|
|
816
|
+
const reportedError = error.cause instanceof Error ? error.cause : error;
|
|
817
|
+
try {
|
|
818
|
+
(0, _amqp_contract_core.endSpanError)(span, reportedError);
|
|
819
|
+
(0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(name), false, Date.now() - startTime);
|
|
820
|
+
} catch (telemetryError) {
|
|
821
|
+
this.logger?.warn("Telemetry recording threw; ignoring", {
|
|
822
|
+
consumerName: String(name),
|
|
823
|
+
queueName,
|
|
824
|
+
error: telemetryError
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
});
|
|
782
828
|
}
|
|
783
829
|
/**
|
|
784
830
|
* Consume messages one at a time.
|
|
@@ -793,9 +839,18 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
793
839
|
});
|
|
794
840
|
return;
|
|
795
841
|
}
|
|
842
|
+
const state = { messageHandled: false };
|
|
796
843
|
try {
|
|
797
|
-
await this.processMessage(msg, view, name, handler);
|
|
844
|
+
await this.processMessage(msg, view, name, handler, state);
|
|
798
845
|
} catch (error) {
|
|
846
|
+
if (state.messageHandled) {
|
|
847
|
+
this.logger?.error("Uncaught error in consume callback after message was already handled; not nacking", {
|
|
848
|
+
consumerName: String(name),
|
|
849
|
+
queueName,
|
|
850
|
+
error
|
|
851
|
+
});
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
799
854
|
this.logger?.error("Uncaught error in consume callback; nacking message", {
|
|
800
855
|
consumerName: String(name),
|
|
801
856
|
queueName,
|
|
@@ -803,7 +858,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
803
858
|
});
|
|
804
859
|
this.amqpClient.nack(msg, false, false);
|
|
805
860
|
}
|
|
806
|
-
}, this.consumerOptions[name]).
|
|
861
|
+
}, this.consumerOptions[name]).tap((consumerTag) => {
|
|
807
862
|
this.consumerTags.add(consumerTag);
|
|
808
863
|
}).map(() => void 0).mapErr((error) => new _amqp_contract_core.TechnicalError(`Failed to start consuming for "${String(name)}"`, error));
|
|
809
864
|
}
|
|
@@ -811,58 +866,71 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
811
866
|
//#endregion
|
|
812
867
|
//#region src/handlers.ts
|
|
813
868
|
/**
|
|
814
|
-
*
|
|
869
|
+
* Build the list of available handler-target names — every key under
|
|
870
|
+
* `contract.consumers` plus every key under `contract.rpcs`.
|
|
871
|
+
*/
|
|
872
|
+
function availableHandlerNames(contract) {
|
|
873
|
+
const consumers = contract.consumers ? Object.keys(contract.consumers) : [];
|
|
874
|
+
const rpcs = contract.rpcs ? Object.keys(contract.rpcs) : [];
|
|
875
|
+
return [...consumers, ...rpcs];
|
|
876
|
+
}
|
|
877
|
+
function formatAvailable(names) {
|
|
878
|
+
return names.length > 0 ? names.join(", ") : "none";
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* Validate that a name maps to a contract entry — either a `consumers` key
|
|
882
|
+
* or an `rpcs` key. The two name spaces are disjoint by contract definition.
|
|
815
883
|
*/
|
|
816
|
-
function
|
|
884
|
+
function validateHandlerTargetExists(contract, name) {
|
|
817
885
|
const consumers = contract.consumers;
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
886
|
+
const rpcs = contract.rpcs;
|
|
887
|
+
const isConsumer = !!consumers && Object.hasOwn(consumers, name);
|
|
888
|
+
const isRpc = !!rpcs && Object.hasOwn(rpcs, name);
|
|
889
|
+
if (!isConsumer && !isRpc) {
|
|
890
|
+
const available = formatAvailable(availableHandlerNames(contract));
|
|
891
|
+
throw new Error(`Handler target "${name}" not found in contract. Available consumers and RPCs: ${available}`);
|
|
822
892
|
}
|
|
823
893
|
}
|
|
824
894
|
/**
|
|
825
|
-
* Validate that
|
|
895
|
+
* Validate that every key in `handlers` maps to a contract entry —
|
|
896
|
+
* either a `consumers` key or an `rpcs` key.
|
|
826
897
|
*/
|
|
827
898
|
function validateHandlers(contract, handlers) {
|
|
828
|
-
const
|
|
829
|
-
const availableConsumers = Object.keys(consumers ?? {});
|
|
830
|
-
const availableConsumerNames = availableConsumers.length > 0 ? availableConsumers.join(", ") : "none";
|
|
831
|
-
for (const handlerName of Object.keys(handlers)) if (!consumers || !(handlerName in consumers)) throw new Error(`Consumer "${handlerName}" not found in contract. Available consumers: ${availableConsumerNames}`);
|
|
899
|
+
for (const handlerName of Object.keys(handlers)) validateHandlerTargetExists(contract, handlerName);
|
|
832
900
|
}
|
|
833
|
-
function defineHandler(contract,
|
|
834
|
-
|
|
901
|
+
function defineHandler(contract, name, handler, options) {
|
|
902
|
+
validateHandlerTargetExists(contract, String(name));
|
|
835
903
|
if (options) return [handler, options];
|
|
836
904
|
return handler;
|
|
837
905
|
}
|
|
838
906
|
/**
|
|
839
|
-
* Define multiple type-safe handlers for consumers in a contract.
|
|
907
|
+
* Define multiple type-safe handlers for consumers and RPCs in a contract.
|
|
908
|
+
*
|
|
909
|
+
* **Recommended:** This function creates handlers that return
|
|
910
|
+
* `AsyncResult<void, HandlerError>` (consumers) or
|
|
911
|
+
* `AsyncResult<TResponse, HandlerError>` (RPCs), providing explicit error
|
|
912
|
+
* handling and better control over retry behavior.
|
|
840
913
|
*
|
|
841
|
-
*
|
|
842
|
-
*
|
|
914
|
+
* The handlers object must contain exactly one entry per `consumers` and
|
|
915
|
+
* `rpcs` key in the contract — see {@link WorkerInferHandlers}.
|
|
843
916
|
*
|
|
844
917
|
* @template TContract - The contract definition type
|
|
845
|
-
* @param contract - The contract definition containing the consumers
|
|
846
|
-
* @param handlers - An object with handler functions for each consumer
|
|
918
|
+
* @param contract - The contract definition containing the consumers and RPCs
|
|
919
|
+
* @param handlers - An object with handler functions for each consumer and RPC
|
|
847
920
|
* @returns A type-safe handlers object that can be used with TypedAmqpWorker
|
|
848
921
|
*
|
|
849
922
|
* @example
|
|
850
923
|
* ```typescript
|
|
851
924
|
* import { defineHandlers, RetryableError } from '@amqp-contract/worker';
|
|
852
|
-
* import {
|
|
853
|
-
* import { orderContract } from './contract';
|
|
925
|
+
* import { fromPromise, ok } from 'unthrown';
|
|
854
926
|
*
|
|
855
927
|
* const handlers = defineHandlers(orderContract, {
|
|
856
928
|
* processOrder: ({ payload }) =>
|
|
857
|
-
*
|
|
929
|
+
* fromPromise(
|
|
858
930
|
* processPayment(payload),
|
|
859
931
|
* (error) => new RetryableError('Payment failed', error),
|
|
860
932
|
* ).map(() => undefined),
|
|
861
|
-
*
|
|
862
|
-
* ResultAsync.fromPromise(
|
|
863
|
-
* sendNotification(payload),
|
|
864
|
-
* (error) => new RetryableError('Notification failed', error),
|
|
865
|
-
* ).map(() => undefined),
|
|
933
|
+
* calculate: ({ payload }) => ok({ sum: payload.a + payload.b }).toAsync(),
|
|
866
934
|
* });
|
|
867
935
|
* ```
|
|
868
936
|
*/
|