@amqp-contract/worker 0.10.0 → 0.12.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.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 WorkerError {
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 WorkerError {
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,11 +40,13 @@ 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 WorkerError {
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
  };
68
52
 
@@ -71,27 +55,43 @@ var NonRetryableError = class extends WorkerError {
71
55
  const gunzipAsync = promisify(gunzip);
72
56
  const inflateAsync = promisify(inflate);
73
57
  /**
58
+ * Supported content encodings for message decompression.
59
+ */
60
+ const SUPPORTED_ENCODINGS = ["gzip", "deflate"];
61
+ /**
62
+ * Type guard to check if a string is a supported encoding.
63
+ */
64
+ function isSupportedEncoding(encoding) {
65
+ return SUPPORTED_ENCODINGS.includes(encoding.toLowerCase());
66
+ }
67
+ /**
74
68
  * Decompress a buffer based on the content-encoding header.
75
69
  *
76
70
  * @param buffer - The buffer to decompress
77
71
  * @param contentEncoding - The content-encoding header value (e.g., 'gzip', 'deflate')
78
- * @returns A promise that resolves to the decompressed buffer
79
- * @throws Error if decompression fails or if the encoding is unsupported
72
+ * @returns A Future with the decompressed buffer or a TechnicalError
80
73
  *
81
74
  * @internal
82
75
  */
83
- async function decompressBuffer(buffer, contentEncoding) {
84
- if (!contentEncoding) return buffer;
85
- switch (contentEncoding.toLowerCase()) {
86
- case "gzip": return gunzipAsync(buffer);
87
- case "deflate": return inflateAsync(buffer);
88
- default: throw new Error(`Unsupported content-encoding: ${contentEncoding}`);
76
+ function decompressBuffer(buffer, contentEncoding) {
77
+ if (!contentEncoding) return Future.value(Result.Ok(buffer));
78
+ const normalizedEncoding = contentEncoding.toLowerCase();
79
+ 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.`)));
80
+ switch (normalizedEncoding) {
81
+ case "gzip": return Future.fromPromise(gunzipAsync(buffer)).mapError((error) => new TechnicalError("Failed to decompress gzip", error));
82
+ case "deflate": return Future.fromPromise(inflateAsync(buffer)).mapError((error) => new TechnicalError("Failed to decompress deflate", error));
89
83
  }
90
84
  }
91
85
 
92
86
  //#endregion
93
87
  //#region src/worker.ts
94
88
  /**
89
+ * Type guard to check if a handler entry is a tuple format [handler, options].
90
+ */
91
+ function isHandlerTuple(entry) {
92
+ return Array.isArray(entry) && entry.length === 2;
93
+ }
94
+ /**
95
95
  * Type-safe AMQP worker for consuming messages from RabbitMQ.
96
96
  *
97
97
  * This class provides automatic message validation, connection management,
@@ -133,16 +133,13 @@ async function decompressBuffer(buffer, contentEncoding) {
133
133
  */
134
134
  var TypedAmqpWorker = class TypedAmqpWorker {
135
135
  /**
136
- * Internal handler type - always safe handlers (`Future<Result>`).
137
- * Unsafe handlers are wrapped into safe handlers by defineUnsafeHandler/defineUnsafeHandlers.
136
+ * Internal handler storage - handlers returning `Future<Result>`.
138
137
  */
139
138
  actualHandlers;
140
139
  consumerOptions;
141
- batchTimers = /* @__PURE__ */ new Map();
142
140
  consumerTags = /* @__PURE__ */ new Set();
143
- retryConfig;
144
141
  telemetry;
145
- constructor(contract, amqpClient, handlers, logger, retryOptions, telemetry) {
142
+ constructor(contract, amqpClient, handlers, logger, telemetry) {
146
143
  this.contract = contract;
147
144
  this.amqpClient = amqpClient;
148
145
  this.logger = logger;
@@ -153,19 +150,12 @@ var TypedAmqpWorker = class TypedAmqpWorker {
153
150
  for (const consumerName of Object.keys(handlersRecord)) {
154
151
  const handlerEntry = handlersRecord[consumerName];
155
152
  const typedConsumerName = consumerName;
156
- if (Array.isArray(handlerEntry)) {
157
- this.actualHandlers[typedConsumerName] = handlerEntry[0];
158
- this.consumerOptions[typedConsumerName] = handlerEntry[1];
153
+ if (isHandlerTuple(handlerEntry)) {
154
+ const [handler, options] = handlerEntry;
155
+ this.actualHandlers[typedConsumerName] = handler;
156
+ this.consumerOptions[typedConsumerName] = options;
159
157
  } else this.actualHandlers[typedConsumerName] = handlerEntry;
160
158
  }
161
- if (retryOptions === void 0) this.retryConfig = null;
162
- else this.retryConfig = {
163
- maxRetries: retryOptions.maxRetries ?? 3,
164
- initialDelayMs: retryOptions.initialDelayMs ?? 1e3,
165
- maxDelayMs: retryOptions.maxDelayMs ?? 3e4,
166
- backoffMultiplier: retryOptions.backoffMultiplier ?? 2,
167
- jitter: retryOptions.jitter ?? true
168
- };
169
159
  }
170
160
  /**
171
161
  * Create a type-safe AMQP worker from a contract.
@@ -186,18 +176,18 @@ var TypedAmqpWorker = class TypedAmqpWorker {
186
176
  * const worker = await TypedAmqpWorker.create({
187
177
  * contract: myContract,
188
178
  * handlers: {
189
- * processOrder: async (msg) => console.log('Order:', msg.orderId)
179
+ * processOrder: async ({ payload }) => console.log('Order:', payload.orderId)
190
180
  * },
191
181
  * urls: ['amqp://localhost']
192
182
  * }).resultToPromise();
193
183
  * ```
194
184
  */
195
- static create({ contract, handlers, urls, connectionOptions, logger, retry, telemetry }) {
185
+ static create({ contract, handlers, urls, connectionOptions, logger, telemetry }) {
196
186
  const worker = new TypedAmqpWorker(contract, new AmqpClient(contract, {
197
187
  urls,
198
188
  connectionOptions
199
- }), handlers, logger, retry, telemetry);
200
- return worker.waitForConnectionReady().flatMapOk(() => worker.setupWaitQueues()).flatMapOk(() => worker.consumeAll()).mapOk(() => worker);
189
+ }), handlers, logger, telemetry);
190
+ return worker.waitForConnectionReady().flatMapOk(() => worker.consumeAll()).mapOk(() => worker);
201
191
  }
202
192
  /**
203
193
  * Close the AMQP channel and connection.
@@ -216,9 +206,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
216
206
  * ```
217
207
  */
218
208
  close() {
219
- for (const timer of this.batchTimers.values()) clearTimeout(timer);
220
- this.batchTimers.clear();
221
- return Future.all(Array.from(this.consumerTags).map((consumerTag) => Future.fromPromise(this.amqpClient.channel.cancel(consumerTag)).mapErrorToResult((error) => {
209
+ return Future.all(Array.from(this.consumerTags).map((consumerTag) => this.amqpClient.cancel(consumerTag).mapErrorToResult((error) => {
222
210
  this.logger?.warn("Failed to cancel consumer during close", {
223
211
  consumerTag,
224
212
  error
@@ -226,146 +214,103 @@ var TypedAmqpWorker = class TypedAmqpWorker {
226
214
  return Result.Ok(void 0);
227
215
  }))).map(Result.all).tapOk(() => {
228
216
  this.consumerTags.clear();
229
- }).flatMapOk(() => Future.fromPromise(this.amqpClient.close())).mapError((error) => new TechnicalError("Failed to close AMQP connection", error)).mapOk(() => void 0);
217
+ }).flatMapOk(() => this.amqpClient.close()).mapOk(() => void 0);
230
218
  }
231
219
  /**
232
- * Set up wait queues for retry mechanism.
233
- * Creates and binds wait queues for each consumer queue that has DLX configuration.
220
+ * Get the retry configuration for a consumer's queue.
221
+ * Defaults are applied in the contract's defineQueue, so we just return the config.
234
222
  */
235
- setupWaitQueues() {
236
- if (this.retryConfig === null) return Future.value(Result.Ok(void 0));
237
- if (!this.contract.consumers || !this.contract.queues) return Future.value(Result.Ok(void 0));
238
- const setupTasks = [];
239
- for (const consumerName of Object.keys(this.contract.consumers)) {
240
- const consumer = this.contract.consumers[consumerName];
241
- if (!consumer) continue;
242
- const queue = consumer.queue;
243
- const deadLetter = queue.deadLetter;
244
- if (!deadLetter) continue;
245
- const queueName = queue.name;
246
- const waitQueueName = `${queueName}-wait`;
247
- const dlxName = deadLetter.exchange.name;
248
- const setupTask = Future.fromPromise(this.amqpClient.channel.addSetup(async (channel) => {
249
- await channel.assertQueue(waitQueueName, {
250
- durable: queue.durable ?? false,
251
- deadLetterExchange: dlxName,
252
- deadLetterRoutingKey: queueName
253
- });
254
- await channel.bindQueue(waitQueueName, dlxName, `${queueName}-wait`);
255
- await channel.bindQueue(queueName, dlxName, queueName);
256
- this.logger?.info("Wait queue created and bound", {
257
- consumerName: String(consumerName),
258
- queueName,
259
- waitQueueName,
260
- dlxName
261
- });
262
- })).mapError((error) => new TechnicalError(`Failed to setup wait queue for "${String(consumerName)}"`, error));
263
- setupTasks.push(setupTask);
264
- }
265
- if (setupTasks.length === 0) return Future.value(Result.Ok(void 0));
266
- return Future.all(setupTasks).map(Result.all).mapOk(() => void 0);
223
+ getRetryConfigForConsumer(consumer) {
224
+ return consumer.queue.retry;
267
225
  }
268
226
  /**
269
- * Start consuming messages for all consumers
227
+ * Start consuming messages for all consumers.
228
+ * TypeScript guarantees consumers exist (handlers require matching consumers).
270
229
  */
271
230
  consumeAll() {
272
- if (!this.contract.consumers) return Future.value(Result.Error(new TechnicalError("No consumers defined in contract")));
273
- const consumerNames = Object.keys(this.contract.consumers);
274
- let maxPrefetch = 0;
275
- for (const consumerName of consumerNames) {
276
- const options = this.consumerOptions[consumerName];
277
- if (options?.prefetch !== void 0) {
278
- if (options.prefetch <= 0 || !Number.isInteger(options.prefetch)) return Future.value(Result.Error(new TechnicalError(`Invalid prefetch value for "${String(consumerName)}": must be a positive integer`)));
279
- maxPrefetch = Math.max(maxPrefetch, options.prefetch);
280
- }
281
- if (options?.batchSize !== void 0) {
282
- const effectivePrefetch = options.prefetch ?? options.batchSize;
283
- maxPrefetch = Math.max(maxPrefetch, effectivePrefetch);
284
- }
285
- }
286
- if (maxPrefetch > 0) this.amqpClient.channel.addSetup(async (channel) => {
231
+ const consumers = this.contract.consumers;
232
+ const consumerNames = Object.keys(consumers);
233
+ const maxPrefetch = consumerNames.reduce((max, name) => {
234
+ const prefetch = this.consumerOptions[name]?.prefetch;
235
+ return prefetch ? Math.max(max, prefetch) : max;
236
+ }, 0);
237
+ if (maxPrefetch > 0) this.amqpClient.addSetup(async (channel) => {
287
238
  await channel.prefetch(maxPrefetch);
288
239
  });
289
- return Future.all(consumerNames.map((consumerName) => this.consume(consumerName))).map(Result.all).mapOk(() => void 0);
240
+ return Future.all(consumerNames.map((name) => this.consume(name))).map(Result.all).mapOk(() => void 0);
290
241
  }
291
242
  waitForConnectionReady() {
292
- return Future.fromPromise(this.amqpClient.channel.waitForConnect()).mapError((error) => new TechnicalError("Failed to wait for connection ready", error));
243
+ return this.amqpClient.waitForConnect();
293
244
  }
294
245
  /**
295
- * Start consuming messages for a specific consumer
246
+ * Start consuming messages for a specific consumer.
247
+ * TypeScript guarantees consumer and handler exist for valid consumer names.
296
248
  */
297
249
  consume(consumerName) {
298
- const consumers = this.contract.consumers;
299
- if (!consumers) return Future.value(Result.Error(new TechnicalError("No consumers defined in contract")));
300
- const consumer = consumers[consumerName];
301
- if (!consumer) {
302
- const availableConsumers = Object.keys(consumers);
303
- const available = availableConsumers.length > 0 ? availableConsumers.join(", ") : "none";
304
- return Future.value(Result.Error(new TechnicalError(`Consumer not found: "${String(consumerName)}". Available consumers: ${available}`)));
305
- }
250
+ const consumer = this.contract.consumers[consumerName];
306
251
  const handler = this.actualHandlers[consumerName];
307
- if (!handler) return Future.value(Result.Error(new TechnicalError(`Handler for "${String(consumerName)}" not provided`)));
308
- const options = this.consumerOptions[consumerName] ?? {};
309
- if (options.batchSize !== void 0) {
310
- if (options.batchSize <= 0 || !Number.isInteger(options.batchSize)) return Future.value(Result.Error(new TechnicalError(`Invalid batchSize for "${String(consumerName)}": must be a positive integer`)));
311
- }
312
- if (options.batchTimeout !== void 0) {
313
- if (typeof options.batchTimeout !== "number" || !Number.isFinite(options.batchTimeout) || options.batchTimeout <= 0) return Future.value(Result.Error(new TechnicalError(`Invalid batchTimeout for "${String(consumerName)}": must be a positive number`)));
314
- }
315
- if (options.batchSize !== void 0 && options.batchSize > 0) return this.consumeBatch(consumerName, consumer, options, handler);
316
- else return this.consumeSingle(consumerName, consumer, handler);
252
+ return this.consumeSingle(consumerName, consumer, handler);
317
253
  }
318
254
  /**
319
- * Parse and validate a message from AMQP
320
- * @returns `Future<Result<validated message, void>>` - Ok with validated message, or Error (already handled with nack)
255
+ * Validate data against a Standard Schema and handle errors.
321
256
  */
322
- parseAndValidateMessage(msg, consumer, consumerName) {
323
- const decompressMessage = Future.fromPromise(decompressBuffer(msg.content, msg.properties.contentEncoding)).tapError((error) => {
324
- this.logger?.error("Error decompressing message", {
325
- consumerName: String(consumerName),
326
- queueName: consumer.queue.name,
327
- contentEncoding: msg.properties.contentEncoding,
257
+ validateSchema(schema, data, context, msg) {
258
+ const rawValidation = schema["~standard"].validate(data);
259
+ const validationPromise = rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation);
260
+ return Future.fromPromise(validationPromise).mapError((error) => new TechnicalError(`Error validating ${context.field}`, error)).mapOkToResult((result) => {
261
+ if (result.issues) return Result.Error(new TechnicalError(`${context.field} validation failed`, new MessageValidationError(context.consumerName, result.issues)));
262
+ return Result.Ok(result.value);
263
+ }).tapError((error) => {
264
+ this.logger?.error(`${context.field} validation failed`, {
265
+ consumerName: context.consumerName,
266
+ queueName: context.queueName,
328
267
  error
329
268
  });
330
- this.amqpClient.channel.nack(msg, false, false);
269
+ this.amqpClient.nack(msg, false, false);
331
270
  });
332
- const parseMessage = (buffer) => {
333
- const parseResult = Result.fromExecution(() => JSON.parse(buffer.toString()));
334
- if (parseResult.isError()) {
335
- this.logger?.error("Error parsing message", {
336
- consumerName: String(consumerName),
337
- queueName: consumer.queue.name,
338
- error: parseResult.error
339
- });
340
- this.amqpClient.channel.nack(msg, false, false);
341
- return Future.value(Result.Error(void 0));
342
- }
343
- return Future.value(Result.Ok(parseResult.value));
271
+ }
272
+ /**
273
+ * Parse and validate a message from AMQP.
274
+ * @returns Ok with validated message (payload + headers), or Error (message already nacked)
275
+ */
276
+ parseAndValidateMessage(msg, consumer, consumerName) {
277
+ const context = {
278
+ consumerName: String(consumerName),
279
+ queueName: consumer.queue.name
344
280
  };
345
- const validateMessage = (parsedMessage) => {
346
- const rawValidation = consumer.message.payload["~standard"].validate(parsedMessage);
347
- return Future.fromPromise(rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation)).mapOkToResult((validationResult) => {
348
- if (validationResult.issues) {
349
- const error = new MessageValidationError(String(consumerName), validationResult.issues);
350
- this.logger?.error("Message validation failed", {
351
- consumerName: String(consumerName),
352
- queueName: consumer.queue.name,
353
- error
354
- });
355
- this.amqpClient.channel.nack(msg, false, false);
356
- return Result.Error(void 0);
357
- }
358
- return Result.Ok(validationResult.value);
281
+ const nackAndError = (message, error) => {
282
+ this.logger?.error(message, {
283
+ ...context,
284
+ error
359
285
  });
286
+ this.amqpClient.nack(msg, false, false);
287
+ return new TechnicalError(message, error);
360
288
  };
361
- return decompressMessage.flatMapOk(parseMessage).flatMapOk(validateMessage);
289
+ const parsePayload = decompressBuffer(msg.content, msg.properties.contentEncoding).tapError((error) => {
290
+ this.logger?.error("Failed to decompress message", {
291
+ ...context,
292
+ error
293
+ });
294
+ this.amqpClient.nack(msg, false, false);
295
+ }).mapOkToResult((buffer) => Result.fromExecution(() => JSON.parse(buffer.toString())).mapError((error) => nackAndError("Failed to parse JSON", error))).flatMapOk((parsed) => this.validateSchema(consumer.message.payload, parsed, {
296
+ ...context,
297
+ field: "payload"
298
+ }, msg));
299
+ const parseHeaders = consumer.message.headers ? this.validateSchema(consumer.message.headers, msg.properties.headers ?? {}, {
300
+ ...context,
301
+ field: "headers"
302
+ }, msg) : Future.value(Result.Ok(void 0));
303
+ return Future.allFromDict({
304
+ payload: parsePayload,
305
+ headers: parseHeaders
306
+ }).map(Result.allFromDict);
362
307
  }
363
308
  /**
364
309
  * Consume messages one at a time
365
310
  */
366
311
  consumeSingle(consumerName, consumer, handler) {
367
312
  const queueName = consumer.queue.name;
368
- return Future.fromPromise(this.amqpClient.channel.consume(queueName, async (msg) => {
313
+ return this.amqpClient.consume(queueName, async (msg) => {
369
314
  if (msg === null) {
370
315
  this.logger?.warn("Consumer cancelled by server", {
371
316
  consumerName: String(consumerName),
@@ -375,12 +320,12 @@ var TypedAmqpWorker = class TypedAmqpWorker {
375
320
  }
376
321
  const startTime = Date.now();
377
322
  const span = startConsumeSpan(this.telemetry, queueName, String(consumerName), { "messaging.rabbitmq.message.delivery_tag": msg.fields.deliveryTag });
378
- await this.parseAndValidateMessage(msg, consumer, consumerName).flatMapOk((validatedMessage) => handler(validatedMessage).flatMapOk(() => {
323
+ await this.parseAndValidateMessage(msg, consumer, consumerName).flatMapOk((validatedMessage) => handler(validatedMessage, msg).flatMapOk(() => {
379
324
  this.logger?.info("Message consumed successfully", {
380
325
  consumerName: String(consumerName),
381
326
  queueName
382
327
  });
383
- this.amqpClient.channel.ack(msg);
328
+ this.amqpClient.ack(msg);
384
329
  const durationMs = Date.now() - startTime;
385
330
  endSpanSuccess(span);
386
331
  recordConsumeMetric(this.telemetry, queueName, String(consumerName), true, durationMs);
@@ -401,112 +346,26 @@ var TypedAmqpWorker = class TypedAmqpWorker {
401
346
  endSpanError(span, /* @__PURE__ */ new Error("Message validation failed"));
402
347
  recordConsumeMetric(this.telemetry, queueName, String(consumerName), false, durationMs);
403
348
  }).toPromise();
404
- })).tapOk((reply) => {
405
- this.consumerTags.add(reply.consumerTag);
406
- }).mapError((error) => new TechnicalError(`Failed to start consuming for "${String(consumerName)}"`, error)).mapOk(() => void 0);
407
- }
408
- /**
409
- * Handle batch processing error by applying error handling to all messages.
410
- */
411
- handleBatchError(error, currentBatch, consumerName, consumer) {
412
- return Future.all(currentBatch.map((item) => this.handleError(error, item.amqpMessage, consumerName, consumer))).map(Result.all).mapOk(() => void 0);
413
- }
414
- /**
415
- * Consume messages in batches
416
- */
417
- consumeBatch(consumerName, consumer, options, handler) {
418
- const batchSize = options.batchSize;
419
- const batchTimeout = options.batchTimeout ?? 1e3;
420
- const timerKey = String(consumerName);
421
- const queueName = consumer.queue.name;
422
- let batch = [];
423
- let isProcessing = false;
424
- const processBatch = () => {
425
- if (isProcessing || batch.length === 0) return Future.value(Result.Ok(void 0));
426
- isProcessing = true;
427
- const currentBatch = batch;
428
- batch = [];
429
- const timer = this.batchTimers.get(timerKey);
430
- if (timer) {
431
- clearTimeout(timer);
432
- this.batchTimers.delete(timerKey);
433
- }
434
- const messages = currentBatch.map((item) => item.message);
435
- const batchCount = currentBatch.length;
436
- const startTime = Date.now();
437
- const span = startConsumeSpan(this.telemetry, queueName, String(consumerName), { "amqp.batch.size": batchCount });
438
- this.logger?.info("Processing batch", {
439
- consumerName: String(consumerName),
440
- queueName,
441
- batchSize: batchCount
442
- });
443
- return handler(messages).flatMapOk(() => {
444
- for (const item of currentBatch) this.amqpClient.channel.ack(item.amqpMessage);
445
- this.logger?.info("Batch processed successfully", {
446
- consumerName: String(consumerName),
447
- queueName,
448
- batchSize: batchCount
449
- });
450
- const durationMs = Date.now() - startTime;
451
- endSpanSuccess(span);
452
- recordConsumeMetric(this.telemetry, queueName, String(consumerName), true, durationMs);
453
- return Future.value(Result.Ok(void 0));
454
- }).flatMapError((handlerError) => {
455
- this.logger?.error("Error processing batch", {
456
- consumerName: String(consumerName),
457
- queueName,
458
- batchSize: batchCount,
459
- errorType: handlerError.name,
460
- error: handlerError.message
461
- });
462
- const durationMs = Date.now() - startTime;
463
- endSpanError(span, handlerError);
464
- recordConsumeMetric(this.telemetry, queueName, String(consumerName), false, durationMs);
465
- return this.handleBatchError(handlerError, currentBatch, String(consumerName), consumer);
466
- }).tap(() => {
467
- isProcessing = false;
468
- });
469
- };
470
- const scheduleBatchProcessing = () => {
471
- if (isProcessing) return;
472
- const existingTimer = this.batchTimers.get(timerKey);
473
- if (existingTimer) clearTimeout(existingTimer);
474
- const timer = setTimeout(() => {
475
- processBatch().toPromise();
476
- }, batchTimeout);
477
- this.batchTimers.set(timerKey, timer);
478
- };
479
- return Future.fromPromise(this.amqpClient.channel.consume(queueName, async (msg) => {
480
- if (msg === null) {
481
- this.logger?.warn("Consumer cancelled by server", {
482
- consumerName: String(consumerName),
483
- queueName
484
- });
485
- await processBatch().toPromise();
486
- return;
487
- }
488
- const validationResult = await this.parseAndValidateMessage(msg, consumer, consumerName).toPromise();
489
- if (validationResult.isError()) return;
490
- batch.push({
491
- message: validationResult.value,
492
- amqpMessage: msg
493
- });
494
- if (batch.length >= batchSize) {
495
- await processBatch().toPromise();
496
- if (batch.length > 0 && !this.batchTimers.has(timerKey)) scheduleBatchProcessing();
497
- } else if (!this.batchTimers.has(timerKey)) scheduleBatchProcessing();
498
- })).tapOk((reply) => {
499
- this.consumerTags.add(reply.consumerTag);
349
+ }).tapOk((consumerTag) => {
350
+ this.consumerTags.add(consumerTag);
500
351
  }).mapError((error) => new TechnicalError(`Failed to start consuming for "${String(consumerName)}"`, error)).mapOk(() => void 0);
501
352
  }
502
353
  /**
503
354
  * Handle error in message processing with retry logic.
504
355
  *
505
- * Flow:
356
+ * Flow depends on retry mode:
357
+ *
358
+ * **quorum-native mode:**
506
359
  * 1. If NonRetryableError -> send directly to DLQ (no retry)
507
- * 2. If no retry config -> legacy behavior (immediate requeue)
508
- * 3. If max retries exceeded -> send to DLQ
509
- * 4. Otherwise -> publish to wait queue with TTL for retry
360
+ * 2. Otherwise -> nack with requeue=true (RabbitMQ handles delivery count)
361
+ *
362
+ * **ttl-backoff mode:**
363
+ * 1. If NonRetryableError -> send directly to DLQ (no retry)
364
+ * 2. If max retries exceeded -> send to DLQ
365
+ * 3. Otherwise -> publish to wait queue with TTL for retry
366
+ *
367
+ * **Legacy mode (no retry config):**
368
+ * 1. nack with requeue=true (immediate requeue)
510
369
  */
511
370
  handleError(error, msg, consumerName, consumer) {
512
371
  if (error instanceof NonRetryableError) {
@@ -518,16 +377,50 @@ var TypedAmqpWorker = class TypedAmqpWorker {
518
377
  this.sendToDLQ(msg, consumer);
519
378
  return Future.value(Result.Ok(void 0));
520
379
  }
521
- if (this.retryConfig === null) {
522
- this.logger?.warn("Error in handler (legacy mode: immediate requeue)", {
523
- consumerName,
524
- error: error.message
525
- });
526
- this.amqpClient.channel.nack(msg, false, true);
527
- return Future.value(Result.Ok(void 0));
528
- }
380
+ const config = this.getRetryConfigForConsumer(consumer);
381
+ if (config.mode === "quorum-native") return this.handleErrorQuorumNative(error, msg, consumerName, consumer);
382
+ return this.handleErrorTtlBackoff(error, msg, consumerName, consumer, config);
383
+ }
384
+ /**
385
+ * Handle error using quorum queue's native delivery limit feature.
386
+ *
387
+ * Simply requeues the message with nack(requeue=true). RabbitMQ automatically:
388
+ * - Increments x-delivery-count header
389
+ * - Dead-letters the message when count exceeds x-delivery-limit
390
+ *
391
+ * This is simpler than TTL-based retry but provides immediate retries only.
392
+ */
393
+ handleErrorQuorumNative(error, msg, consumerName, consumer) {
394
+ const queue = consumer.queue;
395
+ const queueName = queue.name;
396
+ const deliveryCount = msg.properties.headers?.["x-delivery-count"] ?? 0;
397
+ const deliveryLimit = queue.type === "quorum" ? queue.deliveryLimit : void 0;
398
+ const attemptsBeforeDeadLetter = deliveryLimit !== void 0 ? Math.max(0, deliveryLimit - deliveryCount - 1) : "unknown";
399
+ if (deliveryLimit !== void 0 && deliveryCount >= deliveryLimit - 1) this.logger?.warn("Message at final delivery attempt (quorum-native mode)", {
400
+ consumerName,
401
+ queueName,
402
+ deliveryCount,
403
+ deliveryLimit,
404
+ willDeadLetterOnNextFailure: deliveryCount === deliveryLimit - 1,
405
+ alreadyExceededLimit: deliveryCount >= deliveryLimit,
406
+ error: error.message
407
+ });
408
+ else this.logger?.warn("Retrying message (quorum-native mode)", {
409
+ consumerName,
410
+ queueName,
411
+ deliveryCount,
412
+ deliveryLimit,
413
+ attemptsBeforeDeadLetter,
414
+ error: error.message
415
+ });
416
+ this.amqpClient.nack(msg, false, true);
417
+ return Future.value(Result.Ok(void 0));
418
+ }
419
+ /**
420
+ * Handle error using TTL + wait queue pattern for exponential backoff.
421
+ */
422
+ handleErrorTtlBackoff(error, msg, consumerName, consumer, config) {
529
423
  const retryCount = msg.properties.headers?.["x-retry-count"] ?? 0;
530
- const config = this.retryConfig;
531
424
  if (retryCount >= config.maxRetries) {
532
425
  this.logger?.error("Max retries exceeded, sending to DLQ", {
533
426
  consumerName,
@@ -538,8 +431,8 @@ var TypedAmqpWorker = class TypedAmqpWorker {
538
431
  this.sendToDLQ(msg, consumer);
539
432
  return Future.value(Result.Ok(void 0));
540
433
  }
541
- const delayMs = this.calculateRetryDelay(retryCount);
542
- this.logger?.warn("Retrying message", {
434
+ const delayMs = this.calculateRetryDelay(retryCount, config);
435
+ this.logger?.warn("Retrying message (ttl-backoff mode)", {
543
436
  consumerName,
544
437
  retryCount: retryCount + 1,
545
438
  delayMs,
@@ -550,8 +443,8 @@ var TypedAmqpWorker = class TypedAmqpWorker {
550
443
  /**
551
444
  * Calculate retry delay with exponential backoff and optional jitter.
552
445
  */
553
- calculateRetryDelay(retryCount) {
554
- const { initialDelayMs, maxDelayMs, backoffMultiplier, jitter } = this.retryConfig;
446
+ calculateRetryDelay(retryCount, config) {
447
+ const { initialDelayMs, maxDelayMs, backoffMultiplier, jitter } = config;
555
448
  let delay = Math.min(initialDelayMs * Math.pow(backoffMultiplier, retryCount), maxDelayMs);
556
449
  if (jitter) delay = delay * (.5 + Math.random() * .5);
557
450
  return Math.floor(delay);
@@ -602,14 +495,14 @@ var TypedAmqpWorker = class TypedAmqpWorker {
602
495
  const deadLetter = consumer.queue.deadLetter;
603
496
  if (!deadLetter) {
604
497
  this.logger?.warn("Cannot retry: queue does not have DLX configured, falling back to nack with requeue", { queueName });
605
- this.amqpClient.channel.nack(msg, false, true);
498
+ this.amqpClient.nack(msg, false, true);
606
499
  return Future.value(Result.Ok(void 0));
607
500
  }
608
501
  const dlxName = deadLetter.exchange.name;
609
502
  const waitRoutingKey = `${queueName}-wait`;
610
- this.amqpClient.channel.ack(msg);
503
+ this.amqpClient.ack(msg);
611
504
  const content = this.parseMessageContentForRetry(msg, queueName);
612
- return Future.fromPromise(this.amqpClient.channel.publish(dlxName, waitRoutingKey, content, {
505
+ return this.amqpClient.publish(dlxName, waitRoutingKey, content, {
613
506
  ...msg.properties,
614
507
  expiration: delayMs.toString(),
615
508
  headers: {
@@ -618,7 +511,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
618
511
  "x-last-error": error.message,
619
512
  "x-first-failure-timestamp": msg.properties.headers?.["x-first-failure-timestamp"] ?? Date.now()
620
513
  }
621
- })).mapError((error$1) => new TechnicalError("Failed to publish message for retry", error$1)).mapOkToResult((published) => {
514
+ }).mapOkToResult((published) => {
622
515
  if (!published) {
623
516
  this.logger?.error("Failed to publish message for retry (write buffer full)", {
624
517
  queueName,
@@ -647,7 +540,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
647
540
  queueName,
648
541
  deliveryTag: msg.fields.deliveryTag
649
542
  });
650
- this.amqpClient.channel.nack(msg, false, false);
543
+ this.amqpClient.nack(msg, false, false);
651
544
  }
652
545
  };
653
546
 
@@ -673,19 +566,6 @@ function validateHandlers(contract, handlers) {
673
566
  const availableConsumerNames = availableConsumers.length > 0 ? availableConsumers.join(", ") : "none";
674
567
  for (const handlerName of Object.keys(handlers)) if (!consumers || !(handlerName in consumers)) throw new Error(`Consumer "${handlerName}" not found in contract. Available consumers: ${availableConsumerNames}`);
675
568
  }
676
- /**
677
- * Wrap a Promise-based handler into a Future-based safe handler.
678
- * This is used internally by defineUnsafeHandler to convert Promise handlers to Future handlers.
679
- */
680
- function wrapUnsafeHandler(handler) {
681
- return (input) => {
682
- return Future.fromPromise(handler(input)).mapOkToResult(() => Result.Ok(void 0)).flatMapError((error) => {
683
- if (error instanceof NonRetryableError || error instanceof RetryableError) return Future.value(Result.Error(error));
684
- const retryableError = new RetryableError(error instanceof Error ? error.message : String(error), error);
685
- return Future.value(Result.Error(retryableError));
686
- });
687
- };
688
- }
689
569
  function defineHandler(contract, consumerName, handler, options) {
690
570
  validateConsumerExists(contract, String(consumerName));
691
571
  if (options) return [handler, options];
@@ -709,12 +589,12 @@ function defineHandler(contract, consumerName, handler, options) {
709
589
  * import { orderContract } from './contract';
710
590
  *
711
591
  * const handlers = defineHandlers(orderContract, {
712
- * processOrder: (message) =>
713
- * Future.fromPromise(processPayment(message))
592
+ * processOrder: ({ payload }) =>
593
+ * Future.fromPromise(processPayment(payload))
714
594
  * .mapOk(() => undefined)
715
595
  * .mapError((error) => new RetryableError('Payment failed', error)),
716
- * notifyOrder: (message) =>
717
- * Future.fromPromise(sendNotification(message))
596
+ * notifyOrder: ({ payload }) =>
597
+ * Future.fromPromise(sendNotification(payload))
718
598
  * .mapOk(() => undefined)
719
599
  * .mapError((error) => new RetryableError('Notification failed', error)),
720
600
  * });
@@ -724,53 +604,7 @@ function defineHandlers(contract, handlers) {
724
604
  validateHandlers(contract, handlers);
725
605
  return handlers;
726
606
  }
727
- function defineUnsafeHandler(contract, consumerName, handler, options) {
728
- validateConsumerExists(contract, String(consumerName));
729
- const wrappedHandler = wrapUnsafeHandler(handler);
730
- if (options) return [wrappedHandler, options];
731
- return wrappedHandler;
732
- }
733
- /**
734
- * Define multiple unsafe handlers for consumers in a contract.
735
- *
736
- * @deprecated Use `defineHandlers` instead for explicit error handling with `Future<Result>`.
737
- *
738
- * **Warning:** Unsafe handlers use exception-based error handling.
739
- * Consider migrating to safe handlers for better error control.
740
- *
741
- * **Note:** Internally, this function wraps all Promise-based handlers into Future-based
742
- * safe handlers for consistent processing in the worker.
743
- *
744
- * @template TContract - The contract definition type
745
- * @param contract - The contract definition containing the consumers
746
- * @param handlers - An object with async handler functions for each consumer
747
- * @returns A type-safe handlers object that can be used with TypedAmqpWorker
748
- *
749
- * @example
750
- * ```typescript
751
- * import { defineUnsafeHandlers } from '@amqp-contract/worker';
752
- *
753
- * // ⚠️ Consider using defineHandlers for better error handling
754
- * const handlers = defineUnsafeHandlers(orderContract, {
755
- * processOrder: async (message) => {
756
- * await processPayment(message);
757
- * },
758
- * notifyOrder: async (message) => {
759
- * await sendNotification(message);
760
- * },
761
- * });
762
- * ```
763
- */
764
- function defineUnsafeHandlers(contract, handlers) {
765
- validateHandlers(contract, handlers);
766
- const result = {};
767
- for (const [name, entry] of Object.entries(handlers)) if (Array.isArray(entry)) {
768
- const [handler, options] = entry;
769
- result[name] = [wrapUnsafeHandler(handler), options];
770
- } else result[name] = wrapUnsafeHandler(entry);
771
- return result;
772
- }
773
607
 
774
608
  //#endregion
775
- export { MessageValidationError, NonRetryableError, RetryableError, TechnicalError, TypedAmqpWorker, defineHandler, defineHandlers, defineUnsafeHandler, defineUnsafeHandlers };
609
+ export { MessageValidationError, NonRetryableError, RetryableError, TypedAmqpWorker, defineHandler, defineHandlers };
776
610
  //# sourceMappingURL=index.mjs.map