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