@amqp-contract/worker 0.23.1 → 0.25.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 +260 -188
- package/dist/index.d.cts +144 -105
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +144 -105
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +261 -190
- package/dist/index.mjs.map +1 -1
- package/docs/index.md +618 -205
- package/package.json +5 -5
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 { 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,52 +21,55 @@ 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
|
|
38
38
|
//#region src/errors.ts
|
|
39
39
|
/**
|
|
40
|
-
*
|
|
41
|
-
* Examples: network timeouts, rate limiting, temporary service unavailability
|
|
40
|
+
* Abstract base class for all handler-signalled errors.
|
|
42
41
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
42
|
+
* Concrete subclasses (`RetryableError`, `NonRetryableError`) discriminate on
|
|
43
|
+
* the `name` property so exhaustive narrowing in user code keeps working.
|
|
44
|
+
* `error instanceof HandlerError` is true for any handler error.
|
|
45
45
|
*/
|
|
46
|
-
var
|
|
46
|
+
var HandlerError = class extends Error {
|
|
47
47
|
constructor(message, cause) {
|
|
48
48
|
super(message);
|
|
49
49
|
this.cause = cause;
|
|
50
|
-
this.name = "RetryableError";
|
|
51
50
|
const ErrorConstructor = Error;
|
|
52
51
|
if (typeof ErrorConstructor.captureStackTrace === "function") ErrorConstructor.captureStackTrace(this, this.constructor);
|
|
53
52
|
}
|
|
54
53
|
};
|
|
55
54
|
/**
|
|
55
|
+
* Retryable errors - transient failures that may succeed on retry
|
|
56
|
+
* Examples: network timeouts, rate limiting, temporary service unavailability
|
|
57
|
+
*
|
|
58
|
+
* Use this error type when the operation might succeed if retried.
|
|
59
|
+
* The worker will apply exponential backoff and retry the message.
|
|
60
|
+
*/
|
|
61
|
+
var RetryableError = class extends HandlerError {
|
|
62
|
+
name = "RetryableError";
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
56
65
|
* Non-retryable errors - permanent failures that should not be retried
|
|
57
66
|
* Examples: invalid data, business rule violations, permanent external failures
|
|
58
67
|
*
|
|
59
68
|
* Use this error type when retrying would not help - the message will be
|
|
60
69
|
* immediately sent to the dead letter queue (DLQ) if configured.
|
|
61
70
|
*/
|
|
62
|
-
var NonRetryableError = class extends
|
|
63
|
-
|
|
64
|
-
super(message);
|
|
65
|
-
this.cause = cause;
|
|
66
|
-
this.name = "NonRetryableError";
|
|
67
|
-
const ErrorConstructor = Error;
|
|
68
|
-
if (typeof ErrorConstructor.captureStackTrace === "function") ErrorConstructor.captureStackTrace(this, this.constructor);
|
|
69
|
-
}
|
|
71
|
+
var NonRetryableError = class extends HandlerError {
|
|
72
|
+
name = "NonRetryableError";
|
|
70
73
|
};
|
|
71
74
|
/**
|
|
72
75
|
* Type guard to check if an error is a RetryableError.
|
|
@@ -137,7 +140,7 @@ function isNonRetryableError(error) {
|
|
|
137
140
|
* ```
|
|
138
141
|
*/
|
|
139
142
|
function isHandlerError(error) {
|
|
140
|
-
return
|
|
143
|
+
return error instanceof HandlerError;
|
|
141
144
|
}
|
|
142
145
|
/**
|
|
143
146
|
* Create a RetryableError with less verbosity.
|
|
@@ -152,15 +155,16 @@ function isHandlerError(error) {
|
|
|
152
155
|
* @example
|
|
153
156
|
* ```typescript
|
|
154
157
|
* import { retryable } from '@amqp-contract/worker';
|
|
155
|
-
* import {
|
|
158
|
+
* import { ResultAsync } from 'neverthrow';
|
|
156
159
|
*
|
|
157
160
|
* const handler = ({ payload }) =>
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
+
* ResultAsync.fromPromise(
|
|
162
|
+
* processPayment(payload),
|
|
163
|
+
* (e) => retryable('Payment service unavailable', e),
|
|
164
|
+
* ).map(() => undefined);
|
|
161
165
|
*
|
|
162
166
|
* // Equivalent to:
|
|
163
|
-
* // .
|
|
167
|
+
* // ResultAsync.fromPromise(processPayment(payload), (e) => new RetryableError('...', e))
|
|
164
168
|
* ```
|
|
165
169
|
*/
|
|
166
170
|
function retryable(message, cause) {
|
|
@@ -179,17 +183,17 @@ function retryable(message, cause) {
|
|
|
179
183
|
* @example
|
|
180
184
|
* ```typescript
|
|
181
185
|
* import { nonRetryable } from '@amqp-contract/worker';
|
|
182
|
-
* import {
|
|
186
|
+
* import { errAsync, okAsync } from 'neverthrow';
|
|
183
187
|
*
|
|
184
188
|
* const handler = ({ payload }) => {
|
|
185
189
|
* if (!isValidPayload(payload)) {
|
|
186
|
-
* return
|
|
190
|
+
* return errAsync(nonRetryable('Invalid payload format'));
|
|
187
191
|
* }
|
|
188
|
-
* return
|
|
192
|
+
* return okAsync(undefined);
|
|
189
193
|
* };
|
|
190
194
|
*
|
|
191
195
|
* // Equivalent to:
|
|
192
|
-
* // return
|
|
196
|
+
* // return errAsync(new NonRetryableError('Invalid payload format'));
|
|
193
197
|
* ```
|
|
194
198
|
*/
|
|
195
199
|
function nonRetryable(message, cause) {
|
|
@@ -217,23 +221,18 @@ function nonRetryable(message, cause) {
|
|
|
217
221
|
*/
|
|
218
222
|
function handleError(ctx, error, msg, consumerName, consumer) {
|
|
219
223
|
if (error instanceof NonRetryableError) {
|
|
220
|
-
ctx.logger?.error("Non-retryable error, sending to DLQ immediately", {
|
|
221
|
-
consumerName,
|
|
222
|
-
errorType: error.name,
|
|
223
|
-
error: error.message
|
|
224
|
-
});
|
|
225
224
|
sendToDLQ(ctx, msg, consumer);
|
|
226
|
-
return
|
|
225
|
+
return okAsync(void 0);
|
|
227
226
|
}
|
|
228
227
|
const config = extractQueue(consumer.queue).retry;
|
|
229
228
|
if (config.mode === "immediate-requeue") return handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, config);
|
|
230
229
|
if (config.mode === "ttl-backoff") return handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config);
|
|
231
|
-
ctx.logger?.
|
|
230
|
+
ctx.logger?.info("Retry disabled (none mode), sending to DLQ", {
|
|
232
231
|
consumerName,
|
|
233
|
-
|
|
232
|
+
queueName: extractQueue(consumer.queue).name
|
|
234
233
|
});
|
|
235
234
|
sendToDLQ(ctx, msg, consumer);
|
|
236
|
-
return
|
|
235
|
+
return okAsync(void 0);
|
|
237
236
|
}
|
|
238
237
|
/**
|
|
239
238
|
* Handle error by requeuing immediately.
|
|
@@ -249,26 +248,24 @@ function handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, co
|
|
|
249
248
|
const queueName = queue.name;
|
|
250
249
|
const retryCount = queue.type === "quorum" ? msg.properties.headers?.["x-delivery-count"] ?? 0 : msg.properties.headers?.["x-retry-count"] ?? 0;
|
|
251
250
|
if (retryCount >= config.maxRetries) {
|
|
252
|
-
ctx.logger?.
|
|
251
|
+
ctx.logger?.info("Max retries exceeded, sending to DLQ (immediate-requeue mode)", {
|
|
253
252
|
consumerName,
|
|
254
253
|
queueName,
|
|
255
254
|
retryCount,
|
|
256
|
-
maxRetries: config.maxRetries
|
|
257
|
-
error: error.message
|
|
255
|
+
maxRetries: config.maxRetries
|
|
258
256
|
});
|
|
259
257
|
sendToDLQ(ctx, msg, consumer);
|
|
260
|
-
return
|
|
258
|
+
return okAsync(void 0);
|
|
261
259
|
}
|
|
262
|
-
ctx.logger?.
|
|
260
|
+
ctx.logger?.info("Retrying message (immediate-requeue mode)", {
|
|
263
261
|
consumerName,
|
|
264
262
|
queueName,
|
|
265
263
|
retryCount,
|
|
266
|
-
maxRetries: config.maxRetries
|
|
267
|
-
error: error.message
|
|
264
|
+
maxRetries: config.maxRetries
|
|
268
265
|
});
|
|
269
266
|
if (queue.type === "quorum") {
|
|
270
267
|
ctx.amqpClient.nack(msg, false, true);
|
|
271
|
-
return
|
|
268
|
+
return okAsync(void 0);
|
|
272
269
|
} else return publishForRetry(ctx, {
|
|
273
270
|
msg,
|
|
274
271
|
exchange: msg.fields.exchange,
|
|
@@ -309,30 +306,28 @@ function handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config)
|
|
|
309
306
|
consumerName,
|
|
310
307
|
queueName: consumer.queue.name
|
|
311
308
|
});
|
|
312
|
-
return
|
|
309
|
+
return errAsync(new TechnicalError("Queue does not have TTL-backoff infrastructure"));
|
|
313
310
|
}
|
|
314
311
|
const queueEntry = consumer.queue;
|
|
315
312
|
const queueName = extractQueue(queueEntry).name;
|
|
316
313
|
const retryCount = msg.properties.headers?.["x-retry-count"] ?? 0;
|
|
317
314
|
if (retryCount >= config.maxRetries) {
|
|
318
|
-
ctx.logger?.
|
|
315
|
+
ctx.logger?.info("Max retries exceeded, sending to DLQ (ttl-backoff mode)", {
|
|
319
316
|
consumerName,
|
|
320
317
|
queueName,
|
|
321
318
|
retryCount,
|
|
322
|
-
maxRetries: config.maxRetries
|
|
323
|
-
error: error.message
|
|
319
|
+
maxRetries: config.maxRetries
|
|
324
320
|
});
|
|
325
321
|
sendToDLQ(ctx, msg, consumer);
|
|
326
|
-
return
|
|
322
|
+
return okAsync(void 0);
|
|
327
323
|
}
|
|
328
324
|
const delayMs = calculateRetryDelay(retryCount, config);
|
|
329
|
-
ctx.logger?.
|
|
325
|
+
ctx.logger?.info("Retrying message (ttl-backoff mode)", {
|
|
330
326
|
consumerName,
|
|
331
327
|
queueName,
|
|
332
328
|
retryCount: retryCount + 1,
|
|
333
329
|
maxRetries: config.maxRetries,
|
|
334
|
-
delayMs
|
|
335
|
-
error: error.message
|
|
330
|
+
delayMs
|
|
336
331
|
});
|
|
337
332
|
return publishForRetry(ctx, {
|
|
338
333
|
msg,
|
|
@@ -349,9 +344,9 @@ function handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config)
|
|
|
349
344
|
*/
|
|
350
345
|
function calculateRetryDelay(retryCount, config) {
|
|
351
346
|
const { initialDelayMs, maxDelayMs, backoffMultiplier, jitter } = config;
|
|
352
|
-
let delay =
|
|
353
|
-
if (jitter) delay = delay * (.5 + Math.random()
|
|
354
|
-
return Math.floor(delay);
|
|
347
|
+
let delay = initialDelayMs * Math.pow(backoffMultiplier, retryCount);
|
|
348
|
+
if (jitter) delay = delay * (.5 + Math.random());
|
|
349
|
+
return Math.floor(Math.min(delay, maxDelayMs));
|
|
355
350
|
}
|
|
356
351
|
/**
|
|
357
352
|
* Parse message content for republishing.
|
|
@@ -370,10 +365,10 @@ function parseMessageContentForRetry(ctx, msg, queueName) {
|
|
|
370
365
|
if (!(contentType === void 0 || contentType === "application/json" || contentType.startsWith("application/json;") || contentType.endsWith("+json"))) return msg.content;
|
|
371
366
|
try {
|
|
372
367
|
return JSON.parse(msg.content.toString());
|
|
373
|
-
} catch (
|
|
368
|
+
} catch (parseErr) {
|
|
374
369
|
ctx.logger?.warn("Failed to parse JSON message for retry, using original buffer", {
|
|
375
370
|
queueName,
|
|
376
|
-
error:
|
|
371
|
+
error: parseErr
|
|
377
372
|
});
|
|
378
373
|
return msg.content;
|
|
379
374
|
}
|
|
@@ -383,7 +378,6 @@ function parseMessageContentForRetry(ctx, msg, queueName) {
|
|
|
383
378
|
*/
|
|
384
379
|
function publishForRetry(ctx, { msg, exchange, routingKey, queueName, waitQueueName, delayMs, error }) {
|
|
385
380
|
const newRetryCount = (msg.properties.headers?.["x-retry-count"] ?? 0) + 1;
|
|
386
|
-
ctx.amqpClient.ack(msg);
|
|
387
381
|
const content = parseMessageContentForRetry(ctx, msg, queueName);
|
|
388
382
|
return ctx.amqpClient.publish(exchange, routingKey, content, {
|
|
389
383
|
...msg.properties,
|
|
@@ -398,21 +392,30 @@ function publishForRetry(ctx, { msg, exchange, routingKey, queueName, waitQueueN
|
|
|
398
392
|
"x-retry-queue": queueName
|
|
399
393
|
} : {}
|
|
400
394
|
}
|
|
401
|
-
}).
|
|
395
|
+
}).andThen((published) => {
|
|
402
396
|
if (!published) {
|
|
403
397
|
ctx.logger?.error("Failed to publish message for retry (write buffer full)", {
|
|
404
398
|
queueName,
|
|
405
399
|
retryCount: newRetryCount,
|
|
406
400
|
...delayMs !== void 0 ? { delayMs } : {}
|
|
407
401
|
});
|
|
408
|
-
return
|
|
402
|
+
return err(new TechnicalError("Failed to publish message for retry (write buffer full)"));
|
|
409
403
|
}
|
|
404
|
+
ctx.amqpClient.ack(msg);
|
|
410
405
|
ctx.logger?.info("Message published for retry", {
|
|
411
406
|
queueName,
|
|
412
407
|
retryCount: newRetryCount,
|
|
413
408
|
...delayMs !== void 0 ? { delayMs } : {}
|
|
414
409
|
});
|
|
415
|
-
return
|
|
410
|
+
return ok(void 0);
|
|
411
|
+
}).orElse((publishError) => {
|
|
412
|
+
ctx.logger?.error("Publish for retry failed; leaving original un-ack'd for redelivery", {
|
|
413
|
+
queueName,
|
|
414
|
+
retryCount: newRetryCount,
|
|
415
|
+
...delayMs !== void 0 ? { delayMs } : {},
|
|
416
|
+
error: publishError
|
|
417
|
+
});
|
|
418
|
+
return err(publishError);
|
|
416
419
|
});
|
|
417
420
|
}
|
|
418
421
|
/**
|
|
@@ -449,6 +452,7 @@ function isHandlerTuple(entry) {
|
|
|
449
452
|
* ```typescript
|
|
450
453
|
* import { TypedAmqpWorker } from '@amqp-contract/worker';
|
|
451
454
|
* import { defineQueue, defineMessage, defineContract, defineConsumer } from '@amqp-contract/contract';
|
|
455
|
+
* import { okAsync } from 'neverthrow';
|
|
452
456
|
* import { z } from 'zod';
|
|
453
457
|
*
|
|
454
458
|
* const orderQueue = defineQueue('order-processing');
|
|
@@ -463,19 +467,22 @@ function isHandlerTuple(entry) {
|
|
|
463
467
|
* }
|
|
464
468
|
* });
|
|
465
469
|
*
|
|
466
|
-
* const
|
|
470
|
+
* const result = await TypedAmqpWorker.create({
|
|
467
471
|
* contract,
|
|
468
472
|
* handlers: {
|
|
469
|
-
* processOrder:
|
|
470
|
-
* console.log('Processing order',
|
|
471
|
-
*
|
|
472
|
-
* }
|
|
473
|
+
* processOrder: ({ payload }) => {
|
|
474
|
+
* console.log('Processing order', payload.orderId);
|
|
475
|
+
* return okAsync(undefined);
|
|
476
|
+
* },
|
|
473
477
|
* },
|
|
474
|
-
* urls: ['amqp://localhost']
|
|
475
|
-
* })
|
|
478
|
+
* urls: ['amqp://localhost'],
|
|
479
|
+
* });
|
|
480
|
+
*
|
|
481
|
+
* if (result.isErr()) throw result.error;
|
|
482
|
+
* const worker = result.value;
|
|
476
483
|
*
|
|
477
484
|
* // Close when done
|
|
478
|
-
* await worker.close()
|
|
485
|
+
* await worker.close();
|
|
479
486
|
* ```
|
|
480
487
|
*/
|
|
481
488
|
var TypedAmqpWorker = class TypedAmqpWorker {
|
|
@@ -551,18 +558,17 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
551
558
|
* Connections are automatically shared across clients and workers with the same
|
|
552
559
|
* URLs and connection options, following RabbitMQ best practices.
|
|
553
560
|
*
|
|
554
|
-
* @
|
|
555
|
-
* @returns A Future that resolves to a Result containing the worker or an error
|
|
561
|
+
* @returns A ResultAsync that resolves to the worker or a TechnicalError.
|
|
556
562
|
*
|
|
557
563
|
* @example
|
|
558
564
|
* ```typescript
|
|
559
|
-
* const
|
|
565
|
+
* const result = await TypedAmqpWorker.create({
|
|
560
566
|
* contract: myContract,
|
|
561
567
|
* handlers: {
|
|
562
|
-
* processOrder:
|
|
568
|
+
* processOrder: ({ payload }) => okAsync(undefined),
|
|
563
569
|
* },
|
|
564
|
-
* urls: ['amqp://localhost']
|
|
565
|
-
* })
|
|
570
|
+
* urls: ['amqp://localhost'],
|
|
571
|
+
* });
|
|
566
572
|
* ```
|
|
567
573
|
*/
|
|
568
574
|
static create({ contract, handlers, urls, connectionOptions, defaultConsumerOptions, logger, telemetry, connectTimeoutMs }) {
|
|
@@ -571,12 +577,14 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
571
577
|
connectionOptions,
|
|
572
578
|
connectTimeoutMs
|
|
573
579
|
}), handlers, defaultConsumerOptions ?? {}, logger, telemetry);
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
+
const setup = worker.waitForConnectionReady().andThen(() => worker.consumeAll());
|
|
581
|
+
return new ResultAsync((async () => {
|
|
582
|
+
const setupResult = await setup;
|
|
583
|
+
if (setupResult.isOk()) return ok(worker);
|
|
584
|
+
const closeResult = await worker.close();
|
|
585
|
+
if (closeResult.isErr()) logger?.warn("Failed to close worker after setup failure", { error: closeResult.error });
|
|
586
|
+
return err(setupResult.error);
|
|
587
|
+
})());
|
|
580
588
|
}
|
|
581
589
|
/**
|
|
582
590
|
* Close the AMQP channel and connection.
|
|
@@ -584,26 +592,25 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
584
592
|
* This gracefully closes the connection to the AMQP broker,
|
|
585
593
|
* stopping all message consumption and cleaning up resources.
|
|
586
594
|
*
|
|
587
|
-
* @returns A Future that resolves to a Result indicating success or failure
|
|
588
|
-
*
|
|
589
595
|
* @example
|
|
590
596
|
* ```typescript
|
|
591
|
-
* const closeResult = await worker.close()
|
|
597
|
+
* const closeResult = await worker.close();
|
|
592
598
|
* if (closeResult.isOk()) {
|
|
593
599
|
* console.log('Worker closed successfully');
|
|
594
600
|
* }
|
|
595
601
|
* ```
|
|
596
602
|
*/
|
|
597
603
|
close() {
|
|
598
|
-
|
|
604
|
+
const cancellations = Array.from(this.consumerTags).map((consumerTag) => this.amqpClient.cancel(consumerTag).orElse((error) => {
|
|
599
605
|
this.logger?.warn("Failed to cancel consumer during close", {
|
|
600
606
|
consumerTag,
|
|
601
607
|
error
|
|
602
608
|
});
|
|
603
|
-
return
|
|
604
|
-
}))
|
|
609
|
+
return ok(void 0);
|
|
610
|
+
}));
|
|
611
|
+
return ResultAsync.combine(cancellations).andTee(() => {
|
|
605
612
|
this.consumerTags.clear();
|
|
606
|
-
}).
|
|
613
|
+
}).andThen(() => this.amqpClient.close()).map(() => void 0);
|
|
607
614
|
}
|
|
608
615
|
/**
|
|
609
616
|
* Start consuming for every entry in `contract.consumers` and `contract.rpcs`.
|
|
@@ -612,7 +619,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
612
619
|
const consumerNames = Object.keys(this.contract.consumers ?? {});
|
|
613
620
|
const rpcNames = Object.keys(this.contract.rpcs ?? {});
|
|
614
621
|
const allNames = [...consumerNames, ...rpcNames];
|
|
615
|
-
return
|
|
622
|
+
return ResultAsync.combine(allNames.map((name) => this.consume(name))).map(() => void 0);
|
|
616
623
|
}
|
|
617
624
|
waitForConnectionReady() {
|
|
618
625
|
return this.amqpClient.waitForConnect();
|
|
@@ -633,9 +640,9 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
633
640
|
validateSchema(schema, data, context) {
|
|
634
641
|
const rawValidation = schema["~standard"].validate(data);
|
|
635
642
|
const validationPromise = rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation);
|
|
636
|
-
return
|
|
637
|
-
if (result.issues) return
|
|
638
|
-
return
|
|
643
|
+
return ResultAsync.fromPromise(validationPromise, (error) => new TechnicalError(`Error validating ${context.field}`, error)).andThen((result) => {
|
|
644
|
+
if (result.issues) return err(new TechnicalError(`${context.field} validation failed`, new MessageValidationError(context.consumerName, result.issues)));
|
|
645
|
+
return ok(result.value);
|
|
639
646
|
});
|
|
640
647
|
}
|
|
641
648
|
/**
|
|
@@ -647,18 +654,18 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
647
654
|
*/
|
|
648
655
|
parseAndValidateMessage(msg, consumer, consumerName) {
|
|
649
656
|
const context = { consumerName: String(consumerName) };
|
|
650
|
-
const parsePayload = decompressBuffer(msg.content, msg.properties.contentEncoding).
|
|
657
|
+
const parsePayload = decompressBuffer(msg.content, msg.properties.contentEncoding).andThen((buffer) => safeJsonParse(buffer, (error) => new TechnicalError("Failed to parse JSON", error))).andThen((parsed) => this.validateSchema(consumer.message.payload, parsed, {
|
|
651
658
|
...context,
|
|
652
659
|
field: "payload"
|
|
653
660
|
}));
|
|
654
661
|
const parseHeaders = consumer.message.headers ? this.validateSchema(consumer.message.headers, msg.properties.headers ?? {}, {
|
|
655
662
|
...context,
|
|
656
663
|
field: "headers"
|
|
657
|
-
}) :
|
|
658
|
-
return
|
|
659
|
-
payload
|
|
660
|
-
headers
|
|
661
|
-
})
|
|
664
|
+
}) : okAsync(void 0);
|
|
665
|
+
return ResultAsync.combine([parsePayload, parseHeaders]).map(([payload, headers]) => ({
|
|
666
|
+
payload,
|
|
667
|
+
headers
|
|
668
|
+
}));
|
|
662
669
|
}
|
|
663
670
|
/**
|
|
664
671
|
* Validate an RPC handler's response and publish it back to the caller's reply
|
|
@@ -687,7 +694,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
687
694
|
rpcName: String(rpcName),
|
|
688
695
|
queueName
|
|
689
696
|
});
|
|
690
|
-
return
|
|
697
|
+
return errAsync(new NonRetryableError(`RPC "${String(rpcName)}" received a message without replyTo; cannot deliver response`));
|
|
691
698
|
}
|
|
692
699
|
if (typeof correlationId !== "string" || correlationId.length === 0) {
|
|
693
700
|
this.logger?.error("RPC handler returned a response but the incoming message has no correlationId", {
|
|
@@ -695,85 +702,127 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
695
702
|
queueName,
|
|
696
703
|
replyTo
|
|
697
704
|
});
|
|
698
|
-
return
|
|
705
|
+
return errAsync(new NonRetryableError(`RPC "${String(rpcName)}" received a message without correlationId; cannot deliver response`));
|
|
699
706
|
}
|
|
700
707
|
let rawValidation;
|
|
701
708
|
try {
|
|
702
709
|
rawValidation = responseSchema["~standard"].validate(response);
|
|
703
710
|
} catch (error) {
|
|
704
|
-
return
|
|
711
|
+
return errAsync(new NonRetryableError("RPC response schema validation threw", error));
|
|
705
712
|
}
|
|
706
713
|
const validationPromise = rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation);
|
|
707
|
-
return
|
|
708
|
-
if (validation.issues) return
|
|
709
|
-
return
|
|
710
|
-
}).
|
|
714
|
+
return ResultAsync.fromPromise(validationPromise, (error) => new NonRetryableError("RPC response schema validation threw", error)).andThen((validation) => {
|
|
715
|
+
if (validation.issues) return err(new NonRetryableError(`RPC response for "${String(rpcName)}" failed schema validation`, new MessageValidationError(String(rpcName), validation.issues)));
|
|
716
|
+
return ok(validation.value);
|
|
717
|
+
}).andThen((validatedResponse) => this.amqpClient.publish("", replyTo, validatedResponse, {
|
|
711
718
|
correlationId,
|
|
712
719
|
contentType: "application/json"
|
|
713
|
-
}).
|
|
720
|
+
}).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"))));
|
|
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 errAsync(parseError);
|
|
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 `okAsync(undefined)`.
|
|
747
|
+
*/
|
|
748
|
+
publishReplyIfRpc(msg, view, name, handlerResponse) {
|
|
749
|
+
if (!view.isRpc || !view.responseSchema) return okAsync(void 0);
|
|
750
|
+
const queueName = extractQueue(view.consumer.queue).name;
|
|
751
|
+
return this.publishRpcResponse(msg, queueName, name, view.responseSchema, handlerResponse);
|
|
714
752
|
}
|
|
715
753
|
/**
|
|
716
754
|
* Process a single consumed message: validate, invoke handler, optionally
|
|
717
|
-
* 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.
|
|
718
769
|
*/
|
|
719
|
-
processMessage(msg, view, name, handler) {
|
|
720
|
-
const { consumer
|
|
770
|
+
processMessage(msg, view, name, handler, state) {
|
|
771
|
+
const { consumer } = view;
|
|
721
772
|
const queueName = extractQueue(consumer.queue).name;
|
|
722
773
|
const startTime = Date.now();
|
|
723
774
|
const span = startConsumeSpan(this.telemetry, queueName, String(name), { "messaging.rabbitmq.message.delivery_tag": msg.fields.deliveryTag });
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
775
|
+
return this.parseAndValidateOrNack(msg, consumer, name).orTee((parseError) => {
|
|
776
|
+
this.logger?.error("Failed to parse/validate message; sending to DLQ", {
|
|
777
|
+
consumerName: String(name),
|
|
778
|
+
queueName,
|
|
779
|
+
error: parseError
|
|
780
|
+
});
|
|
781
|
+
state.messageHandled = true;
|
|
782
|
+
}).andThen((validatedMessage) => this.runHandler(handler, validatedMessage, msg).andThen((handlerResponse) => this.publishReplyIfRpc(msg, view, name, handlerResponse).andTee(() => {
|
|
783
|
+
this.logger?.info("Message consumed successfully", {
|
|
784
|
+
consumerName: String(name),
|
|
785
|
+
queueName
|
|
786
|
+
});
|
|
787
|
+
this.amqpClient.ack(msg);
|
|
788
|
+
state.messageHandled = true;
|
|
789
|
+
})).orElse((handlerError) => {
|
|
790
|
+
this.logger?.error("Error processing message", {
|
|
791
|
+
consumerName: String(name),
|
|
792
|
+
queueName,
|
|
793
|
+
errorType: handlerError.name,
|
|
794
|
+
retryCount: msg.properties.headers?.["x-delivery-count"] ?? msg.properties.headers?.["x-retry-count"] ?? 0,
|
|
795
|
+
error: handlerError.message
|
|
796
|
+
});
|
|
797
|
+
return handleError({
|
|
798
|
+
amqpClient: this.amqpClient,
|
|
799
|
+
logger: this.logger
|
|
800
|
+
}, handlerError, msg, String(name), consumer).andTee(() => {
|
|
801
|
+
state.messageHandled = true;
|
|
802
|
+
}).andThen(() => errAsync(new TechnicalError(`Handler "${String(name)}" failed: ${handlerError.message}`, handlerError)));
|
|
803
|
+
})).andTee(() => {
|
|
804
|
+
try {
|
|
805
|
+
endSpanSuccess(span);
|
|
806
|
+
recordConsumeMetric(this.telemetry, queueName, String(name), true, Date.now() - startTime);
|
|
807
|
+
} catch (telemetryError) {
|
|
808
|
+
this.logger?.warn("Telemetry recording threw; ignoring", {
|
|
746
809
|
consumerName: String(name),
|
|
747
810
|
queueName,
|
|
748
|
-
|
|
749
|
-
error: handlerError.message
|
|
811
|
+
error: telemetryError
|
|
750
812
|
});
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
this.logger?.error("Failed to parse/validate message; sending to DLQ", {
|
|
813
|
+
}
|
|
814
|
+
}).orTee((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", {
|
|
760
821
|
consumerName: String(name),
|
|
761
822
|
queueName,
|
|
762
|
-
error:
|
|
823
|
+
error: telemetryError
|
|
763
824
|
});
|
|
764
|
-
this.amqpClient.nack(msg, false, false);
|
|
765
|
-
return Future.value(Result.Error(parseError));
|
|
766
|
-
}
|
|
767
|
-
})).map((result) => {
|
|
768
|
-
const durationMs = Date.now() - startTime;
|
|
769
|
-
if (messageHandled) {
|
|
770
|
-
endSpanSuccess(span);
|
|
771
|
-
recordConsumeMetric(this.telemetry, queueName, String(name), true, durationMs);
|
|
772
|
-
} else {
|
|
773
|
-
endSpanError(span, result.isError() ? result.error : firstError ?? /* @__PURE__ */ new Error("Unknown error"));
|
|
774
|
-
recordConsumeMetric(this.telemetry, queueName, String(name), false, durationMs);
|
|
775
825
|
}
|
|
776
|
-
return result;
|
|
777
826
|
});
|
|
778
827
|
}
|
|
779
828
|
/**
|
|
@@ -789,9 +838,18 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
789
838
|
});
|
|
790
839
|
return;
|
|
791
840
|
}
|
|
841
|
+
const state = { messageHandled: false };
|
|
792
842
|
try {
|
|
793
|
-
await this.processMessage(msg, view, name, handler)
|
|
843
|
+
await this.processMessage(msg, view, name, handler, state);
|
|
794
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
|
+
}
|
|
795
853
|
this.logger?.error("Uncaught error in consume callback; nacking message", {
|
|
796
854
|
consumerName: String(name),
|
|
797
855
|
queueName,
|
|
@@ -799,64 +857,77 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
799
857
|
});
|
|
800
858
|
this.amqpClient.nack(msg, false, false);
|
|
801
859
|
}
|
|
802
|
-
}, this.consumerOptions[name]).
|
|
860
|
+
}, this.consumerOptions[name]).andTee((consumerTag) => {
|
|
803
861
|
this.consumerTags.add(consumerTag);
|
|
804
|
-
}).
|
|
862
|
+
}).map(() => void 0).mapErr((error) => new TechnicalError(`Failed to start consuming for "${String(name)}"`, error));
|
|
805
863
|
}
|
|
806
864
|
};
|
|
807
865
|
//#endregion
|
|
808
866
|
//#region src/handlers.ts
|
|
809
867
|
/**
|
|
810
|
-
*
|
|
868
|
+
* Build the list of available handler-target names — every key under
|
|
869
|
+
* `contract.consumers` plus every key under `contract.rpcs`.
|
|
811
870
|
*/
|
|
812
|
-
function
|
|
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.
|
|
882
|
+
*/
|
|
883
|
+
function validateHandlerTargetExists(contract, name) {
|
|
813
884
|
const consumers = contract.consumers;
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
const available =
|
|
817
|
-
throw new Error(`
|
|
885
|
+
const rpcs = contract.rpcs;
|
|
886
|
+
if (!(!!consumers && Object.hasOwn(consumers, name)) && !(!!rpcs && Object.hasOwn(rpcs, name))) {
|
|
887
|
+
const available = formatAvailable(availableHandlerNames(contract));
|
|
888
|
+
throw new Error(`Handler target "${name}" not found in contract. Available consumers and RPCs: ${available}`);
|
|
818
889
|
}
|
|
819
890
|
}
|
|
820
891
|
/**
|
|
821
|
-
* Validate that
|
|
892
|
+
* Validate that every key in `handlers` maps to a contract entry —
|
|
893
|
+
* either a `consumers` key or an `rpcs` key.
|
|
822
894
|
*/
|
|
823
895
|
function validateHandlers(contract, handlers) {
|
|
824
|
-
const
|
|
825
|
-
const availableConsumers = Object.keys(consumers ?? {});
|
|
826
|
-
const availableConsumerNames = availableConsumers.length > 0 ? availableConsumers.join(", ") : "none";
|
|
827
|
-
for (const handlerName of Object.keys(handlers)) if (!consumers || !(handlerName in consumers)) throw new Error(`Consumer "${handlerName}" not found in contract. Available consumers: ${availableConsumerNames}`);
|
|
896
|
+
for (const handlerName of Object.keys(handlers)) validateHandlerTargetExists(contract, handlerName);
|
|
828
897
|
}
|
|
829
|
-
function defineHandler(contract,
|
|
830
|
-
|
|
898
|
+
function defineHandler(contract, name, handler, options) {
|
|
899
|
+
validateHandlerTargetExists(contract, String(name));
|
|
831
900
|
if (options) return [handler, options];
|
|
832
901
|
return handler;
|
|
833
902
|
}
|
|
834
903
|
/**
|
|
835
|
-
* Define multiple type-safe handlers for consumers in a contract.
|
|
904
|
+
* Define multiple type-safe handlers for consumers and RPCs in a contract.
|
|
905
|
+
*
|
|
906
|
+
* **Recommended:** This function creates handlers that return
|
|
907
|
+
* `ResultAsync<void, HandlerError>` (consumers) or
|
|
908
|
+
* `ResultAsync<TResponse, HandlerError>` (RPCs), providing explicit error
|
|
909
|
+
* handling and better control over retry behavior.
|
|
836
910
|
*
|
|
837
|
-
*
|
|
838
|
-
*
|
|
911
|
+
* The handlers object must contain exactly one entry per `consumers` and
|
|
912
|
+
* `rpcs` key in the contract — see {@link WorkerInferHandlers}.
|
|
839
913
|
*
|
|
840
914
|
* @template TContract - The contract definition type
|
|
841
|
-
* @param contract - The contract definition containing the consumers
|
|
842
|
-
* @param handlers - An object with handler functions for each consumer
|
|
915
|
+
* @param contract - The contract definition containing the consumers and RPCs
|
|
916
|
+
* @param handlers - An object with handler functions for each consumer and RPC
|
|
843
917
|
* @returns A type-safe handlers object that can be used with TypedAmqpWorker
|
|
844
918
|
*
|
|
845
919
|
* @example
|
|
846
920
|
* ```typescript
|
|
847
921
|
* import { defineHandlers, RetryableError } from '@amqp-contract/worker';
|
|
848
|
-
* import {
|
|
849
|
-
* import { orderContract } from './contract';
|
|
922
|
+
* import { okAsync, ResultAsync } from 'neverthrow';
|
|
850
923
|
*
|
|
851
924
|
* const handlers = defineHandlers(orderContract, {
|
|
852
925
|
* processOrder: ({ payload }) =>
|
|
853
|
-
*
|
|
854
|
-
*
|
|
855
|
-
*
|
|
856
|
-
*
|
|
857
|
-
*
|
|
858
|
-
* .mapOk(() => undefined)
|
|
859
|
-
* .mapError((error) => new RetryableError('Notification failed', error)),
|
|
926
|
+
* ResultAsync.fromPromise(
|
|
927
|
+
* processPayment(payload),
|
|
928
|
+
* (error) => new RetryableError('Payment failed', error),
|
|
929
|
+
* ).map(() => undefined),
|
|
930
|
+
* calculate: ({ payload }) => okAsync({ sum: payload.a + payload.b }),
|
|
860
931
|
* });
|
|
861
932
|
* ```
|
|
862
933
|
*/
|
|
@@ -865,6 +936,6 @@ function defineHandlers(contract, handlers) {
|
|
|
865
936
|
return handlers;
|
|
866
937
|
}
|
|
867
938
|
//#endregion
|
|
868
|
-
export { MessageValidationError, NonRetryableError, RetryableError, TypedAmqpWorker, defineHandler, defineHandlers, isHandlerError, isNonRetryableError, isRetryableError, nonRetryable, retryable };
|
|
939
|
+
export { HandlerError, MessageValidationError, NonRetryableError, RetryableError, TypedAmqpWorker, defineHandler, defineHandlers, isHandlerError, isNonRetryableError, isRetryableError, nonRetryable, retryable };
|
|
869
940
|
|
|
870
941
|
//# sourceMappingURL=index.mjs.map
|