@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.cjs
CHANGED
|
@@ -5,36 +5,16 @@ let node_util = require("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 = (0, node_util.promisify)(node_zlib.gunzip);
|
|
72
183
|
const inflateAsync = (0, node_util.promisify)(node_zlib.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 _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(buffer));
|
|
205
|
+
const normalizedEncoding = contentEncoding.toLowerCase();
|
|
206
|
+
if (!isSupportedEncoding(normalizedEncoding)) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError(`Unsupported content-encoding: "${contentEncoding}". Supported encodings are: ${SUPPORTED_ENCODINGS.join(", ")}. Please check your publisher configuration.`)));
|
|
207
|
+
switch (normalizedEncoding) {
|
|
208
|
+
case "gzip": return _swan_io_boxed.Future.fromPromise(gunzipAsync(buffer)).mapError((error) => new _amqp_contract_core.TechnicalError("Failed to decompress gzip", error));
|
|
209
|
+
case "deflate": return _swan_io_boxed.Future.fromPromise(inflateAsync(buffer)).mapError((error) => new _amqp_contract_core.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 _swan_io_boxed.Future.all(Array.from(this.consumerTags).map((consumerTag) =>
|
|
336
|
+
return _swan_io_boxed.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 _swan_io_boxed.Result.Ok(void 0);
|
|
232
342
|
}))).map(_swan_io_boxed.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 _swan_io_boxed.Future.value(_swan_io_boxed.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 _swan_io_boxed.Future.value(_swan_io_boxed.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 _swan_io_boxed.Future.value(_swan_io_boxed.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 _swan_io_boxed.Future.all(consumerNames.map((
|
|
367
|
+
return _swan_io_boxed.Future.all(consumerNames.map((name) => this.consume(name))).map(_swan_io_boxed.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 _swan_io_boxed.Future.value(_swan_io_boxed.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 _swan_io_boxed.Future.value(_swan_io_boxed.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 _swan_io_boxed.Future.value(_swan_io_boxed.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 _swan_io_boxed.Future.fromPromise(validationPromise).mapError((error) => new _amqp_contract_core.TechnicalError(`Error validating ${context.field}`, error)).mapOkToResult((result) => {
|
|
388
|
+
if (result.issues) return _swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError(`${context.field} validation failed`, new MessageValidationError(context.consumerName, result.issues)));
|
|
389
|
+
return _swan_io_boxed.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 _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(void 0));
|
|
376
|
-
}
|
|
377
|
-
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(parseResult.value));
|
|
378
|
-
};
|
|
379
|
-
const validatePayload = (parsedMessage) => {
|
|
380
|
-
const rawValidation = consumer.message.payload["~standard"].validate(parsedMessage);
|
|
381
|
-
return _swan_io_boxed.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 _swan_io_boxed.Result.Error(void 0);
|
|
391
|
-
}
|
|
392
|
-
return _swan_io_boxed.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 _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(void 0));
|
|
407
|
-
}
|
|
408
|
-
const validSchema = headersSchema;
|
|
409
|
-
const rawHeaders = msg.properties.headers ?? {};
|
|
410
|
-
const rawValidation = validSchema["~standard"].validate(rawHeaders);
|
|
411
|
-
return _swan_io_boxed.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 _swan_io_boxed.Result.Error(void 0);
|
|
421
|
-
}
|
|
422
|
-
return _swan_io_boxed.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 _amqp_contract_core.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) => _swan_io_boxed.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) : _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
|
|
430
|
+
return _swan_io_boxed.Future.allFromDict({
|
|
431
|
+
payload: parsePayload,
|
|
432
|
+
headers: parseHeaders
|
|
433
|
+
}).map(_swan_io_boxed.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
|
(0, _amqp_contract_core.endSpanSuccess)(span);
|
|
458
458
|
(0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(consumerName), true, durationMs);
|
|
@@ -473,9 +473,9 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
473
473
|
(0, _amqp_contract_core.endSpanError)(span, /* @__PURE__ */ new Error("Message validation failed"));
|
|
474
474
|
(0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(consumerName), false, durationMs);
|
|
475
475
|
}).toPromise();
|
|
476
|
-
})
|
|
477
|
-
this.consumerTags.add(
|
|
478
|
-
}).mapError((error) => new TechnicalError(`Failed to start consuming for "${String(consumerName)}"`, error)).mapOk(() => void 0);
|
|
476
|
+
}).tapOk((consumerTag) => {
|
|
477
|
+
this.consumerTags.add(consumerTag);
|
|
478
|
+
}).mapError((error) => new _amqp_contract_core.TechnicalError(`Failed to start consuming for "${String(consumerName)}"`, error)).mapOk(() => void 0);
|
|
479
479
|
}
|
|
480
480
|
/**
|
|
481
481
|
* Handle error in message processing with retry logic.
|
|
@@ -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 _swan_io_boxed.Future.value(_swan_io_boxed.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 _swan_io_boxed.Future.value(_swan_io_boxed.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,14 +638,14 @@ 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,
|
|
645
645
|
waitRoutingKey,
|
|
646
646
|
retryCount: newRetryCount
|
|
647
647
|
});
|
|
648
|
-
return _swan_io_boxed.Result.Error(new TechnicalError("Failed to publish message for retry (write buffer full)"));
|
|
648
|
+
return _swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError("Failed to publish message for retry (write buffer full)"));
|
|
649
649
|
}
|
|
650
650
|
this.logger?.info("Message published for retry", {
|
|
651
651
|
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
|
|
|
@@ -736,7 +736,11 @@ function defineHandlers(contract, handlers) {
|
|
|
736
736
|
exports.MessageValidationError = MessageValidationError;
|
|
737
737
|
exports.NonRetryableError = NonRetryableError;
|
|
738
738
|
exports.RetryableError = RetryableError;
|
|
739
|
-
exports.TechnicalError = TechnicalError;
|
|
740
739
|
exports.TypedAmqpWorker = TypedAmqpWorker;
|
|
741
740
|
exports.defineHandler = defineHandler;
|
|
742
|
-
exports.defineHandlers = defineHandlers;
|
|
741
|
+
exports.defineHandlers = defineHandlers;
|
|
742
|
+
exports.isHandlerError = isHandlerError;
|
|
743
|
+
exports.isNonRetryableError = isNonRetryableError;
|
|
744
|
+
exports.isRetryableError = isRetryableError;
|
|
745
|
+
exports.nonRetryable = nonRetryable;
|
|
746
|
+
exports.retryable = retryable;
|