@amqp-contract/worker 0.11.0 → 0.13.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/dist/index.cjs +237 -233
- package/dist/index.d.cts +172 -56
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +172 -56
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +231 -231
- package/dist/index.mjs.map +1 -1
- package/docs/index.md +391 -258
- package/package.json +4 -4
package/dist/index.mjs
CHANGED
|
@@ -1,40 +1,20 @@
|
|
|
1
|
-
import { AmqpClient, defaultTelemetryProvider, endSpanError, endSpanSuccess, recordConsumeMetric, startConsumeSpan } from "@amqp-contract/core";
|
|
1
|
+
import { AmqpClient, TechnicalError, defaultTelemetryProvider, endSpanError, endSpanSuccess, recordConsumeMetric, startConsumeSpan } from "@amqp-contract/core";
|
|
2
2
|
import { Future, Result } from "@swan-io/boxed";
|
|
3
3
|
import { gunzip, inflate } from "node:zlib";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
|
|
6
6
|
//#region src/errors.ts
|
|
7
7
|
/**
|
|
8
|
-
* Base error class for worker errors
|
|
9
|
-
*/
|
|
10
|
-
var WorkerError = class extends Error {
|
|
11
|
-
constructor(message) {
|
|
12
|
-
super(message);
|
|
13
|
-
this.name = "WorkerError";
|
|
14
|
-
const ErrorConstructor = Error;
|
|
15
|
-
if (typeof ErrorConstructor.captureStackTrace === "function") ErrorConstructor.captureStackTrace(this, this.constructor);
|
|
16
|
-
}
|
|
17
|
-
};
|
|
18
|
-
/**
|
|
19
|
-
* Error for technical/runtime failures in worker operations
|
|
20
|
-
* This includes validation failures, parsing failures, and processing failures
|
|
21
|
-
*/
|
|
22
|
-
var TechnicalError = class extends WorkerError {
|
|
23
|
-
constructor(message, cause) {
|
|
24
|
-
super(message);
|
|
25
|
-
this.cause = cause;
|
|
26
|
-
this.name = "TechnicalError";
|
|
27
|
-
}
|
|
28
|
-
};
|
|
29
|
-
/**
|
|
30
8
|
* Error thrown when message validation fails
|
|
31
9
|
*/
|
|
32
|
-
var MessageValidationError = class extends
|
|
10
|
+
var MessageValidationError = class extends Error {
|
|
33
11
|
constructor(consumerName, issues) {
|
|
34
12
|
super(`Message validation failed for consumer "${consumerName}"`);
|
|
35
13
|
this.consumerName = consumerName;
|
|
36
14
|
this.issues = issues;
|
|
37
15
|
this.name = "MessageValidationError";
|
|
16
|
+
const ErrorConstructor = Error;
|
|
17
|
+
if (typeof ErrorConstructor.captureStackTrace === "function") ErrorConstructor.captureStackTrace(this, this.constructor);
|
|
38
18
|
}
|
|
39
19
|
};
|
|
40
20
|
/**
|
|
@@ -44,11 +24,13 @@ var MessageValidationError = class extends WorkerError {
|
|
|
44
24
|
* Use this error type when the operation might succeed if retried.
|
|
45
25
|
* The worker will apply exponential backoff and retry the message.
|
|
46
26
|
*/
|
|
47
|
-
var RetryableError = class extends
|
|
27
|
+
var RetryableError = class extends Error {
|
|
48
28
|
constructor(message, cause) {
|
|
49
29
|
super(message);
|
|
50
30
|
this.cause = cause;
|
|
51
31
|
this.name = "RetryableError";
|
|
32
|
+
const ErrorConstructor = Error;
|
|
33
|
+
if (typeof ErrorConstructor.captureStackTrace === "function") ErrorConstructor.captureStackTrace(this, this.constructor);
|
|
52
34
|
}
|
|
53
35
|
};
|
|
54
36
|
/**
|
|
@@ -58,34 +40,173 @@ var RetryableError = class extends WorkerError {
|
|
|
58
40
|
* Use this error type when retrying would not help - the message will be
|
|
59
41
|
* immediately sent to the dead letter queue (DLQ) if configured.
|
|
60
42
|
*/
|
|
61
|
-
var NonRetryableError = class extends
|
|
43
|
+
var NonRetryableError = class extends Error {
|
|
62
44
|
constructor(message, cause) {
|
|
63
45
|
super(message);
|
|
64
46
|
this.cause = cause;
|
|
65
47
|
this.name = "NonRetryableError";
|
|
48
|
+
const ErrorConstructor = Error;
|
|
49
|
+
if (typeof ErrorConstructor.captureStackTrace === "function") ErrorConstructor.captureStackTrace(this, this.constructor);
|
|
66
50
|
}
|
|
67
51
|
};
|
|
52
|
+
/**
|
|
53
|
+
* Type guard to check if an error is a RetryableError.
|
|
54
|
+
*
|
|
55
|
+
* Use this to check error types in catch blocks or error handlers.
|
|
56
|
+
*
|
|
57
|
+
* @param error - The error to check
|
|
58
|
+
* @returns True if the error is a RetryableError
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```typescript
|
|
62
|
+
* import { isRetryableError } from '@amqp-contract/worker';
|
|
63
|
+
*
|
|
64
|
+
* try {
|
|
65
|
+
* await processMessage();
|
|
66
|
+
* } catch (error) {
|
|
67
|
+
* if (isRetryableError(error)) {
|
|
68
|
+
* console.log('Will retry:', error.message);
|
|
69
|
+
* } else {
|
|
70
|
+
* console.log('Permanent failure:', error);
|
|
71
|
+
* }
|
|
72
|
+
* }
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
function isRetryableError(error) {
|
|
76
|
+
return error instanceof RetryableError;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Type guard to check if an error is a NonRetryableError.
|
|
80
|
+
*
|
|
81
|
+
* Use this to check error types in catch blocks or error handlers.
|
|
82
|
+
*
|
|
83
|
+
* @param error - The error to check
|
|
84
|
+
* @returns True if the error is a NonRetryableError
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* ```typescript
|
|
88
|
+
* import { isNonRetryableError } from '@amqp-contract/worker';
|
|
89
|
+
*
|
|
90
|
+
* try {
|
|
91
|
+
* await processMessage();
|
|
92
|
+
* } catch (error) {
|
|
93
|
+
* if (isNonRetryableError(error)) {
|
|
94
|
+
* console.log('Will not retry:', error.message);
|
|
95
|
+
* }
|
|
96
|
+
* }
|
|
97
|
+
* ```
|
|
98
|
+
*/
|
|
99
|
+
function isNonRetryableError(error) {
|
|
100
|
+
return error instanceof NonRetryableError;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Type guard to check if an error is any HandlerError (RetryableError or NonRetryableError).
|
|
104
|
+
*
|
|
105
|
+
* @param error - The error to check
|
|
106
|
+
* @returns True if the error is a HandlerError
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* ```typescript
|
|
110
|
+
* import { isHandlerError } from '@amqp-contract/worker';
|
|
111
|
+
*
|
|
112
|
+
* function handleError(error: unknown) {
|
|
113
|
+
* if (isHandlerError(error)) {
|
|
114
|
+
* // error is RetryableError | NonRetryableError
|
|
115
|
+
* console.log('Handler error:', error.name, error.message);
|
|
116
|
+
* }
|
|
117
|
+
* }
|
|
118
|
+
* ```
|
|
119
|
+
*/
|
|
120
|
+
function isHandlerError(error) {
|
|
121
|
+
return isRetryableError(error) || isNonRetryableError(error);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Create a RetryableError with less verbosity.
|
|
125
|
+
*
|
|
126
|
+
* This is a shorthand factory function for creating RetryableError instances.
|
|
127
|
+
* Use it for cleaner error creation in handlers.
|
|
128
|
+
*
|
|
129
|
+
* @param message - Error message describing the failure
|
|
130
|
+
* @param cause - Optional underlying error that caused this failure
|
|
131
|
+
* @returns A new RetryableError instance
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* ```typescript
|
|
135
|
+
* import { retryable } from '@amqp-contract/worker';
|
|
136
|
+
* import { Future, Result } from '@swan-io/boxed';
|
|
137
|
+
*
|
|
138
|
+
* const handler = ({ payload }) =>
|
|
139
|
+
* Future.fromPromise(processPayment(payload))
|
|
140
|
+
* .mapOk(() => undefined)
|
|
141
|
+
* .mapError((e) => retryable('Payment service unavailable', e));
|
|
142
|
+
*
|
|
143
|
+
* // Equivalent to:
|
|
144
|
+
* // .mapError((e) => new RetryableError('Payment service unavailable', e));
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
function retryable(message, cause) {
|
|
148
|
+
return new RetryableError(message, cause);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Create a NonRetryableError with less verbosity.
|
|
152
|
+
*
|
|
153
|
+
* This is a shorthand factory function for creating NonRetryableError instances.
|
|
154
|
+
* Use it for cleaner error creation in handlers.
|
|
155
|
+
*
|
|
156
|
+
* @param message - Error message describing the failure
|
|
157
|
+
* @param cause - Optional underlying error that caused this failure
|
|
158
|
+
* @returns A new NonRetryableError instance
|
|
159
|
+
*
|
|
160
|
+
* @example
|
|
161
|
+
* ```typescript
|
|
162
|
+
* import { nonRetryable } from '@amqp-contract/worker';
|
|
163
|
+
* import { Future, Result } from '@swan-io/boxed';
|
|
164
|
+
*
|
|
165
|
+
* const handler = ({ payload }) => {
|
|
166
|
+
* if (!isValidPayload(payload)) {
|
|
167
|
+
* return Future.value(Result.Error(nonRetryable('Invalid payload format')));
|
|
168
|
+
* }
|
|
169
|
+
* return Future.value(Result.Ok(undefined));
|
|
170
|
+
* };
|
|
171
|
+
*
|
|
172
|
+
* // Equivalent to:
|
|
173
|
+
* // return Future.value(Result.Error(new NonRetryableError('Invalid payload format')));
|
|
174
|
+
* ```
|
|
175
|
+
*/
|
|
176
|
+
function nonRetryable(message, cause) {
|
|
177
|
+
return new NonRetryableError(message, cause);
|
|
178
|
+
}
|
|
68
179
|
|
|
69
180
|
//#endregion
|
|
70
181
|
//#region src/decompression.ts
|
|
71
182
|
const gunzipAsync = promisify(gunzip);
|
|
72
183
|
const inflateAsync = promisify(inflate);
|
|
73
184
|
/**
|
|
185
|
+
* Supported content encodings for message decompression.
|
|
186
|
+
*/
|
|
187
|
+
const SUPPORTED_ENCODINGS = ["gzip", "deflate"];
|
|
188
|
+
/**
|
|
189
|
+
* Type guard to check if a string is a supported encoding.
|
|
190
|
+
*/
|
|
191
|
+
function isSupportedEncoding(encoding) {
|
|
192
|
+
return SUPPORTED_ENCODINGS.includes(encoding.toLowerCase());
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
74
195
|
* Decompress a buffer based on the content-encoding header.
|
|
75
196
|
*
|
|
76
197
|
* @param buffer - The buffer to decompress
|
|
77
198
|
* @param contentEncoding - The content-encoding header value (e.g., 'gzip', 'deflate')
|
|
78
|
-
* @returns A
|
|
79
|
-
* @throws Error if decompression fails or if the encoding is unsupported
|
|
199
|
+
* @returns A Future with the decompressed buffer or a TechnicalError
|
|
80
200
|
*
|
|
81
201
|
* @internal
|
|
82
202
|
*/
|
|
83
|
-
|
|
84
|
-
if (!contentEncoding) return buffer;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
203
|
+
function decompressBuffer(buffer, contentEncoding) {
|
|
204
|
+
if (!contentEncoding) return Future.value(Result.Ok(buffer));
|
|
205
|
+
const normalizedEncoding = contentEncoding.toLowerCase();
|
|
206
|
+
if (!isSupportedEncoding(normalizedEncoding)) return Future.value(Result.Error(new TechnicalError(`Unsupported content-encoding: "${contentEncoding}". Supported encodings are: ${SUPPORTED_ENCODINGS.join(", ")}. Please check your publisher configuration.`)));
|
|
207
|
+
switch (normalizedEncoding) {
|
|
208
|
+
case "gzip": return Future.fromPromise(gunzipAsync(buffer)).mapError((error) => new TechnicalError("Failed to decompress gzip", error));
|
|
209
|
+
case "deflate": return Future.fromPromise(inflateAsync(buffer)).mapError((error) => new TechnicalError("Failed to decompress deflate", error));
|
|
89
210
|
}
|
|
90
211
|
}
|
|
91
212
|
|
|
@@ -98,17 +219,6 @@ function isHandlerTuple(entry) {
|
|
|
98
219
|
return Array.isArray(entry) && entry.length === 2;
|
|
99
220
|
}
|
|
100
221
|
/**
|
|
101
|
-
* Type guard to check if a value is a Standard Schema v1 compliant schema.
|
|
102
|
-
*/
|
|
103
|
-
function isStandardSchema(value) {
|
|
104
|
-
if (typeof value !== "object" || value === null) return false;
|
|
105
|
-
if (!("~standard" in value)) return false;
|
|
106
|
-
const standard = value["~standard"];
|
|
107
|
-
if (typeof standard !== "object" || standard === null) return false;
|
|
108
|
-
if (!("validate" in standard)) return false;
|
|
109
|
-
return typeof standard.validate === "function";
|
|
110
|
-
}
|
|
111
|
-
/**
|
|
112
222
|
* Type-safe AMQP worker for consuming messages from RabbitMQ.
|
|
113
223
|
*
|
|
114
224
|
* This class provides automatic message validation, connection management,
|
|
@@ -204,7 +314,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
204
314
|
urls,
|
|
205
315
|
connectionOptions
|
|
206
316
|
}), handlers, logger, telemetry);
|
|
207
|
-
return worker.waitForConnectionReady().flatMapOk(() => worker.
|
|
317
|
+
return worker.waitForConnectionReady().flatMapOk(() => worker.consumeAll()).mapOk(() => worker);
|
|
208
318
|
}
|
|
209
319
|
/**
|
|
210
320
|
* Close the AMQP channel and connection.
|
|
@@ -223,7 +333,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
223
333
|
* ```
|
|
224
334
|
*/
|
|
225
335
|
close() {
|
|
226
|
-
return Future.all(Array.from(this.consumerTags).map((consumerTag) =>
|
|
336
|
+
return Future.all(Array.from(this.consumerTags).map((consumerTag) => this.amqpClient.cancel(consumerTag).mapErrorToResult((error) => {
|
|
227
337
|
this.logger?.warn("Failed to cancel consumer during close", {
|
|
228
338
|
consumerTag,
|
|
229
339
|
error
|
|
@@ -231,213 +341,103 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
231
341
|
return Result.Ok(void 0);
|
|
232
342
|
}))).map(Result.all).tapOk(() => {
|
|
233
343
|
this.consumerTags.clear();
|
|
234
|
-
}).flatMapOk(() =>
|
|
235
|
-
}
|
|
236
|
-
/**
|
|
237
|
-
* Validate retry configuration for all consumers.
|
|
238
|
-
*
|
|
239
|
-
* For quorum-native mode, validates that the queue is properly configured.
|
|
240
|
-
* For TTL-backoff mode, wait queues are created by setupAmqpTopology in the core package.
|
|
241
|
-
*/
|
|
242
|
-
validateRetryConfiguration() {
|
|
243
|
-
if (!this.contract.consumers) return Future.value(Result.Ok(void 0));
|
|
244
|
-
for (const consumerName of Object.keys(this.contract.consumers)) {
|
|
245
|
-
const consumer = this.contract.consumers[consumerName];
|
|
246
|
-
if (!consumer) continue;
|
|
247
|
-
const queue = consumer.queue;
|
|
248
|
-
if ((queue.retry?.mode ?? "ttl-backoff") === "quorum-native") {
|
|
249
|
-
const validationError = this.validateQuorumNativeConfigForConsumer(String(consumerName), consumer);
|
|
250
|
-
if (validationError) return Future.value(Result.Error(validationError));
|
|
251
|
-
this.logger?.info("Using quorum-native retry mode", {
|
|
252
|
-
consumerName: String(consumerName),
|
|
253
|
-
queueName: queue.name
|
|
254
|
-
});
|
|
255
|
-
} else if (queue.deadLetter) this.logger?.info("Using TTL-backoff retry mode", {
|
|
256
|
-
consumerName: String(consumerName),
|
|
257
|
-
queueName: queue.name
|
|
258
|
-
});
|
|
259
|
-
else this.logger?.warn("Queue has no deadLetter configured - retries will use nack with requeue", {
|
|
260
|
-
consumerName: String(consumerName),
|
|
261
|
-
queueName: queue.name
|
|
262
|
-
});
|
|
263
|
-
}
|
|
264
|
-
return Future.value(Result.Ok(void 0));
|
|
344
|
+
}).flatMapOk(() => this.amqpClient.close()).mapOk(() => void 0);
|
|
265
345
|
}
|
|
266
346
|
/**
|
|
267
|
-
* Get the
|
|
268
|
-
*
|
|
347
|
+
* Get the retry configuration for a consumer's queue.
|
|
348
|
+
* Defaults are applied in the contract's defineQueue, so we just return the config.
|
|
269
349
|
*/
|
|
270
350
|
getRetryConfigForConsumer(consumer) {
|
|
271
|
-
|
|
272
|
-
if (retryOptions?.mode === "quorum-native") return {
|
|
273
|
-
mode: "quorum-native",
|
|
274
|
-
maxRetries: 0,
|
|
275
|
-
initialDelayMs: 0,
|
|
276
|
-
maxDelayMs: 0,
|
|
277
|
-
backoffMultiplier: 0,
|
|
278
|
-
jitter: false
|
|
279
|
-
};
|
|
280
|
-
if (retryOptions?.mode === "ttl-backoff") return {
|
|
281
|
-
mode: "ttl-backoff",
|
|
282
|
-
maxRetries: retryOptions.maxRetries ?? 3,
|
|
283
|
-
initialDelayMs: retryOptions.initialDelayMs ?? 1e3,
|
|
284
|
-
maxDelayMs: retryOptions.maxDelayMs ?? 3e4,
|
|
285
|
-
backoffMultiplier: retryOptions.backoffMultiplier ?? 2,
|
|
286
|
-
jitter: retryOptions.jitter ?? true
|
|
287
|
-
};
|
|
288
|
-
return {
|
|
289
|
-
mode: "ttl-backoff",
|
|
290
|
-
maxRetries: 3,
|
|
291
|
-
initialDelayMs: 1e3,
|
|
292
|
-
maxDelayMs: 3e4,
|
|
293
|
-
backoffMultiplier: 2,
|
|
294
|
-
jitter: true
|
|
295
|
-
};
|
|
296
|
-
}
|
|
297
|
-
/**
|
|
298
|
-
* Validate that quorum-native retry mode is properly configured for a specific consumer.
|
|
299
|
-
*
|
|
300
|
-
* Requirements for quorum-native mode:
|
|
301
|
-
* - Consumer queue must be a quorum queue
|
|
302
|
-
* - Consumer queue must have deliveryLimit configured
|
|
303
|
-
* - Consumer queue should have DLX configured (warning if not)
|
|
304
|
-
*
|
|
305
|
-
* @returns TechnicalError if validation fails, null if valid
|
|
306
|
-
*/
|
|
307
|
-
validateQuorumNativeConfigForConsumer(consumerName, consumer) {
|
|
308
|
-
const queue = consumer.queue;
|
|
309
|
-
if (queue.type !== "quorum") return new TechnicalError(`Consumer "${consumerName}" uses queue "${queue.name}" with type "${queue.type}". Quorum-native retry mode requires quorum queues.`);
|
|
310
|
-
if (queue.deliveryLimit === void 0) return new TechnicalError(`Consumer "${consumerName}" uses queue "${queue.name}" without deliveryLimit configured. Quorum-native retry mode requires deliveryLimit to be set on the queue definition.`);
|
|
311
|
-
if (!queue.deadLetter) this.logger?.warn(`Consumer "${consumerName}" uses queue "${queue.name}" without deadLetter configured. Messages exceeding deliveryLimit will be dropped instead of dead-lettered.`);
|
|
312
|
-
return null;
|
|
351
|
+
return consumer.queue.retry;
|
|
313
352
|
}
|
|
314
353
|
/**
|
|
315
|
-
* Start consuming messages for all consumers
|
|
354
|
+
* Start consuming messages for all consumers.
|
|
355
|
+
* TypeScript guarantees consumers exist (handlers require matching consumers).
|
|
316
356
|
*/
|
|
317
357
|
consumeAll() {
|
|
318
|
-
|
|
319
|
-
const consumerNames = Object.keys(
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
maxPrefetch = Math.max(maxPrefetch, options.prefetch);
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
if (maxPrefetch > 0) this.amqpClient.channel.addSetup(async (channel) => {
|
|
358
|
+
const consumers = this.contract.consumers;
|
|
359
|
+
const consumerNames = Object.keys(consumers);
|
|
360
|
+
const maxPrefetch = consumerNames.reduce((max, name) => {
|
|
361
|
+
const prefetch = this.consumerOptions[name]?.prefetch;
|
|
362
|
+
return prefetch ? Math.max(max, prefetch) : max;
|
|
363
|
+
}, 0);
|
|
364
|
+
if (maxPrefetch > 0) this.amqpClient.addSetup(async (channel) => {
|
|
329
365
|
await channel.prefetch(maxPrefetch);
|
|
330
366
|
});
|
|
331
|
-
return Future.all(consumerNames.map((
|
|
367
|
+
return Future.all(consumerNames.map((name) => this.consume(name))).map(Result.all).mapOk(() => void 0);
|
|
332
368
|
}
|
|
333
369
|
waitForConnectionReady() {
|
|
334
|
-
return
|
|
370
|
+
return this.amqpClient.waitForConnect();
|
|
335
371
|
}
|
|
336
372
|
/**
|
|
337
|
-
* Start consuming messages for a specific consumer
|
|
373
|
+
* Start consuming messages for a specific consumer.
|
|
374
|
+
* TypeScript guarantees consumer and handler exist for valid consumer names.
|
|
338
375
|
*/
|
|
339
376
|
consume(consumerName) {
|
|
340
|
-
const
|
|
341
|
-
if (!consumers) return Future.value(Result.Error(new TechnicalError("No consumers defined in contract")));
|
|
342
|
-
const consumer = consumers[consumerName];
|
|
343
|
-
if (!consumer) {
|
|
344
|
-
const availableConsumers = Object.keys(consumers);
|
|
345
|
-
const available = availableConsumers.length > 0 ? availableConsumers.join(", ") : "none";
|
|
346
|
-
return Future.value(Result.Error(new TechnicalError(`Consumer not found: "${String(consumerName)}". Available consumers: ${available}`)));
|
|
347
|
-
}
|
|
377
|
+
const consumer = this.contract.consumers[consumerName];
|
|
348
378
|
const handler = this.actualHandlers[consumerName];
|
|
349
|
-
if (!handler) return Future.value(Result.Error(new TechnicalError(`Handler for "${String(consumerName)}" not provided`)));
|
|
350
379
|
return this.consumeSingle(consumerName, consumer, handler);
|
|
351
380
|
}
|
|
352
381
|
/**
|
|
353
|
-
*
|
|
354
|
-
* @returns `Future<Result<consumed message, void>>` - Ok with validated consumed message (payload + headers), or Error (already handled with nack)
|
|
382
|
+
* Validate data against a Standard Schema and handle errors.
|
|
355
383
|
*/
|
|
356
|
-
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
384
|
+
validateSchema(schema, data, context, msg) {
|
|
385
|
+
const rawValidation = schema["~standard"].validate(data);
|
|
386
|
+
const validationPromise = rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation);
|
|
387
|
+
return Future.fromPromise(validationPromise).mapError((error) => new TechnicalError(`Error validating ${context.field}`, error)).mapOkToResult((result) => {
|
|
388
|
+
if (result.issues) return Result.Error(new TechnicalError(`${context.field} validation failed`, new MessageValidationError(context.consumerName, result.issues)));
|
|
389
|
+
return Result.Ok(result.value);
|
|
390
|
+
}).tapError((error) => {
|
|
391
|
+
this.logger?.error(`${context.field} validation failed`, {
|
|
392
|
+
consumerName: context.consumerName,
|
|
393
|
+
queueName: context.queueName,
|
|
362
394
|
error
|
|
363
395
|
});
|
|
364
|
-
this.amqpClient.
|
|
365
|
-
})
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
return Future.value(Result.Error(void 0));
|
|
376
|
-
}
|
|
377
|
-
return Future.value(Result.Ok(parseResult.value));
|
|
378
|
-
};
|
|
379
|
-
const validatePayload = (parsedMessage) => {
|
|
380
|
-
const rawValidation = consumer.message.payload["~standard"].validate(parsedMessage);
|
|
381
|
-
return Future.fromPromise(rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation)).mapError(() => void 0).mapOkToResult((validationResult) => {
|
|
382
|
-
if (validationResult.issues) {
|
|
383
|
-
const error = new MessageValidationError(String(consumerName), validationResult.issues);
|
|
384
|
-
this.logger?.error("Message payload validation failed", {
|
|
385
|
-
consumerName: String(consumerName),
|
|
386
|
-
queueName: consumer.queue.name,
|
|
387
|
-
error
|
|
388
|
-
});
|
|
389
|
-
this.amqpClient.channel.nack(msg, false, false);
|
|
390
|
-
return Result.Error(void 0);
|
|
391
|
-
}
|
|
392
|
-
return Result.Ok(validationResult.value);
|
|
393
|
-
});
|
|
396
|
+
this.amqpClient.nack(msg, false, false);
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Parse and validate a message from AMQP.
|
|
401
|
+
* @returns Ok with validated message (payload + headers), or Error (message already nacked)
|
|
402
|
+
*/
|
|
403
|
+
parseAndValidateMessage(msg, consumer, consumerName) {
|
|
404
|
+
const context = {
|
|
405
|
+
consumerName: String(consumerName),
|
|
406
|
+
queueName: consumer.queue.name
|
|
394
407
|
};
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
const error = new MessageValidationError(String(consumerName), "Invalid headers schema: not a Standard Schema v1 compliant schema");
|
|
400
|
-
this.logger?.error("Message headers validation failed", {
|
|
401
|
-
consumerName: String(consumerName),
|
|
402
|
-
queueName: consumer.queue.name,
|
|
403
|
-
error
|
|
404
|
-
});
|
|
405
|
-
this.amqpClient.channel.nack(msg, false, false);
|
|
406
|
-
return Future.value(Result.Error(void 0));
|
|
407
|
-
}
|
|
408
|
-
const validSchema = headersSchema;
|
|
409
|
-
const rawHeaders = msg.properties.headers ?? {};
|
|
410
|
-
const rawValidation = validSchema["~standard"].validate(rawHeaders);
|
|
411
|
-
return Future.fromPromise(rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation)).mapError(() => void 0).mapOkToResult((validationResult) => {
|
|
412
|
-
if (validationResult.issues) {
|
|
413
|
-
const error = new MessageValidationError(String(consumerName), validationResult.issues);
|
|
414
|
-
this.logger?.error("Message headers validation failed", {
|
|
415
|
-
consumerName: String(consumerName),
|
|
416
|
-
queueName: consumer.queue.name,
|
|
417
|
-
error
|
|
418
|
-
});
|
|
419
|
-
this.amqpClient.channel.nack(msg, false, false);
|
|
420
|
-
return Result.Error(void 0);
|
|
421
|
-
}
|
|
422
|
-
return Result.Ok(validationResult.value);
|
|
408
|
+
const nackAndError = (message, error) => {
|
|
409
|
+
this.logger?.error(message, {
|
|
410
|
+
...context,
|
|
411
|
+
error
|
|
423
412
|
});
|
|
413
|
+
this.amqpClient.nack(msg, false, false);
|
|
414
|
+
return new TechnicalError(message, error);
|
|
424
415
|
};
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
headers: validatedHeaders
|
|
430
|
-
};
|
|
416
|
+
const parsePayload = decompressBuffer(msg.content, msg.properties.contentEncoding).tapError((error) => {
|
|
417
|
+
this.logger?.error("Failed to decompress message", {
|
|
418
|
+
...context,
|
|
419
|
+
error
|
|
431
420
|
});
|
|
432
|
-
|
|
433
|
-
|
|
421
|
+
this.amqpClient.nack(msg, false, false);
|
|
422
|
+
}).mapOkToResult((buffer) => Result.fromExecution(() => JSON.parse(buffer.toString())).mapError((error) => nackAndError("Failed to parse JSON", error))).flatMapOk((parsed) => this.validateSchema(consumer.message.payload, parsed, {
|
|
423
|
+
...context,
|
|
424
|
+
field: "payload"
|
|
425
|
+
}, msg));
|
|
426
|
+
const parseHeaders = consumer.message.headers ? this.validateSchema(consumer.message.headers, msg.properties.headers ?? {}, {
|
|
427
|
+
...context,
|
|
428
|
+
field: "headers"
|
|
429
|
+
}, msg) : Future.value(Result.Ok(void 0));
|
|
430
|
+
return Future.allFromDict({
|
|
431
|
+
payload: parsePayload,
|
|
432
|
+
headers: parseHeaders
|
|
433
|
+
}).map(Result.allFromDict);
|
|
434
434
|
}
|
|
435
435
|
/**
|
|
436
436
|
* Consume messages one at a time
|
|
437
437
|
*/
|
|
438
438
|
consumeSingle(consumerName, consumer, handler) {
|
|
439
439
|
const queueName = consumer.queue.name;
|
|
440
|
-
return
|
|
440
|
+
return this.amqpClient.consume(queueName, async (msg) => {
|
|
441
441
|
if (msg === null) {
|
|
442
442
|
this.logger?.warn("Consumer cancelled by server", {
|
|
443
443
|
consumerName: String(consumerName),
|
|
@@ -452,7 +452,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
452
452
|
consumerName: String(consumerName),
|
|
453
453
|
queueName
|
|
454
454
|
});
|
|
455
|
-
this.amqpClient.
|
|
455
|
+
this.amqpClient.ack(msg);
|
|
456
456
|
const durationMs = Date.now() - startTime;
|
|
457
457
|
endSpanSuccess(span);
|
|
458
458
|
recordConsumeMetric(this.telemetry, queueName, String(consumerName), true, durationMs);
|
|
@@ -473,8 +473,8 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
473
473
|
endSpanError(span, /* @__PURE__ */ new Error("Message validation failed"));
|
|
474
474
|
recordConsumeMetric(this.telemetry, queueName, String(consumerName), false, durationMs);
|
|
475
475
|
}).toPromise();
|
|
476
|
-
})
|
|
477
|
-
this.consumerTags.add(
|
|
476
|
+
}).tapOk((consumerTag) => {
|
|
477
|
+
this.consumerTags.add(consumerTag);
|
|
478
478
|
}).mapError((error) => new TechnicalError(`Failed to start consuming for "${String(consumerName)}"`, error)).mapOk(() => void 0);
|
|
479
479
|
}
|
|
480
480
|
/**
|
|
@@ -540,7 +540,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
540
540
|
attemptsBeforeDeadLetter,
|
|
541
541
|
error: error.message
|
|
542
542
|
});
|
|
543
|
-
this.amqpClient.
|
|
543
|
+
this.amqpClient.nack(msg, false, true);
|
|
544
544
|
return Future.value(Result.Ok(void 0));
|
|
545
545
|
}
|
|
546
546
|
/**
|
|
@@ -622,14 +622,14 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
622
622
|
const deadLetter = consumer.queue.deadLetter;
|
|
623
623
|
if (!deadLetter) {
|
|
624
624
|
this.logger?.warn("Cannot retry: queue does not have DLX configured, falling back to nack with requeue", { queueName });
|
|
625
|
-
this.amqpClient.
|
|
625
|
+
this.amqpClient.nack(msg, false, true);
|
|
626
626
|
return Future.value(Result.Ok(void 0));
|
|
627
627
|
}
|
|
628
628
|
const dlxName = deadLetter.exchange.name;
|
|
629
629
|
const waitRoutingKey = `${queueName}-wait`;
|
|
630
|
-
this.amqpClient.
|
|
630
|
+
this.amqpClient.ack(msg);
|
|
631
631
|
const content = this.parseMessageContentForRetry(msg, queueName);
|
|
632
|
-
return
|
|
632
|
+
return this.amqpClient.publish(dlxName, waitRoutingKey, content, {
|
|
633
633
|
...msg.properties,
|
|
634
634
|
expiration: delayMs.toString(),
|
|
635
635
|
headers: {
|
|
@@ -638,7 +638,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
638
638
|
"x-last-error": error.message,
|
|
639
639
|
"x-first-failure-timestamp": msg.properties.headers?.["x-first-failure-timestamp"] ?? Date.now()
|
|
640
640
|
}
|
|
641
|
-
})
|
|
641
|
+
}).mapOkToResult((published) => {
|
|
642
642
|
if (!published) {
|
|
643
643
|
this.logger?.error("Failed to publish message for retry (write buffer full)", {
|
|
644
644
|
queueName,
|
|
@@ -667,7 +667,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
667
667
|
queueName,
|
|
668
668
|
deliveryTag: msg.fields.deliveryTag
|
|
669
669
|
});
|
|
670
|
-
this.amqpClient.
|
|
670
|
+
this.amqpClient.nack(msg, false, false);
|
|
671
671
|
}
|
|
672
672
|
};
|
|
673
673
|
|
|
@@ -733,5 +733,5 @@ function defineHandlers(contract, handlers) {
|
|
|
733
733
|
}
|
|
734
734
|
|
|
735
735
|
//#endregion
|
|
736
|
-
export { MessageValidationError, NonRetryableError, RetryableError,
|
|
736
|
+
export { MessageValidationError, NonRetryableError, RetryableError, TypedAmqpWorker, defineHandler, defineHandlers, isHandlerError, isNonRetryableError, isRetryableError, nonRetryable, retryable };
|
|
737
737
|
//# sourceMappingURL=index.mjs.map
|