@amqp-contract/worker 0.21.0 → 0.23.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 +422 -307
- package/dist/index.d.cts +115 -137
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +115 -137
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +422 -307
- package/dist/index.mjs.map +1 -1
- package/docs/index.md +50 -86
- package/package.json +5 -5
package/dist/index.cjs
CHANGED
|
@@ -197,6 +197,240 @@ function nonRetryable(message, cause) {
|
|
|
197
197
|
return new NonRetryableError(message, cause);
|
|
198
198
|
}
|
|
199
199
|
//#endregion
|
|
200
|
+
//#region src/retry.ts
|
|
201
|
+
/**
|
|
202
|
+
* Handle error in message processing with retry logic.
|
|
203
|
+
*
|
|
204
|
+
* Flow depends on retry mode:
|
|
205
|
+
*
|
|
206
|
+
* **immediate-requeue mode:**
|
|
207
|
+
* 1. If NonRetryableError -> send directly to DLQ (no retry)
|
|
208
|
+
* 2. If max retries exceeded -> send to DLQ
|
|
209
|
+
* 3. Otherwise -> requeue immediately for retry
|
|
210
|
+
*
|
|
211
|
+
* **ttl-backoff mode:**
|
|
212
|
+
* 1. If NonRetryableError -> send directly to DLQ (no retry)
|
|
213
|
+
* 2. If max retries exceeded -> send to DLQ
|
|
214
|
+
* 3. Otherwise -> publish to wait queue with TTL for retry
|
|
215
|
+
*
|
|
216
|
+
* **none mode (no retry config):**
|
|
217
|
+
* 1. send directly to DLQ (no retry)
|
|
218
|
+
*/
|
|
219
|
+
function handleError(ctx, error, msg, consumerName, consumer) {
|
|
220
|
+
if (error instanceof NonRetryableError) {
|
|
221
|
+
ctx.logger?.error("Non-retryable error, sending to DLQ immediately", {
|
|
222
|
+
consumerName,
|
|
223
|
+
errorType: error.name,
|
|
224
|
+
error: error.message
|
|
225
|
+
});
|
|
226
|
+
sendToDLQ(ctx, msg, consumer);
|
|
227
|
+
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
|
|
228
|
+
}
|
|
229
|
+
const config = (0, _amqp_contract_contract.extractQueue)(consumer.queue).retry;
|
|
230
|
+
if (config.mode === "immediate-requeue") return handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, config);
|
|
231
|
+
if (config.mode === "ttl-backoff") return handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config);
|
|
232
|
+
ctx.logger?.warn("Retry disabled (none mode), sending to DLQ", {
|
|
233
|
+
consumerName,
|
|
234
|
+
error: error.message
|
|
235
|
+
});
|
|
236
|
+
sendToDLQ(ctx, msg, consumer);
|
|
237
|
+
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Handle error by requeuing immediately.
|
|
241
|
+
*
|
|
242
|
+
* For quorum queues, messages are requeued with `nack(requeue=true)`, and the worker tracks delivery count via the native RabbitMQ `x-delivery-count` header.
|
|
243
|
+
* For classic queues, messages are re-published on the same queue, and the worker tracks delivery count via a custom `x-retry-count` header.
|
|
244
|
+
* When the count exceeds `maxRetries`, the message is automatically dead-lettered (if DLX is configured) or dropped.
|
|
245
|
+
*
|
|
246
|
+
* This is simpler than TTL-based retry but provides immediate retries only.
|
|
247
|
+
*/
|
|
248
|
+
function handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, config) {
|
|
249
|
+
const queue = (0, _amqp_contract_contract.extractQueue)(consumer.queue);
|
|
250
|
+
const queueName = queue.name;
|
|
251
|
+
const retryCount = queue.type === "quorum" ? msg.properties.headers?.["x-delivery-count"] ?? 0 : msg.properties.headers?.["x-retry-count"] ?? 0;
|
|
252
|
+
if (retryCount >= config.maxRetries) {
|
|
253
|
+
ctx.logger?.error("Max retries exceeded, sending to DLQ (immediate-requeue mode)", {
|
|
254
|
+
consumerName,
|
|
255
|
+
queueName,
|
|
256
|
+
retryCount,
|
|
257
|
+
maxRetries: config.maxRetries,
|
|
258
|
+
error: error.message
|
|
259
|
+
});
|
|
260
|
+
sendToDLQ(ctx, msg, consumer);
|
|
261
|
+
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
|
|
262
|
+
}
|
|
263
|
+
ctx.logger?.warn("Retrying message (immediate-requeue mode)", {
|
|
264
|
+
consumerName,
|
|
265
|
+
queueName,
|
|
266
|
+
retryCount,
|
|
267
|
+
maxRetries: config.maxRetries,
|
|
268
|
+
error: error.message
|
|
269
|
+
});
|
|
270
|
+
if (queue.type === "quorum") {
|
|
271
|
+
ctx.amqpClient.nack(msg, false, true);
|
|
272
|
+
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
|
|
273
|
+
} else return publishForRetry(ctx, {
|
|
274
|
+
msg,
|
|
275
|
+
exchange: msg.fields.exchange,
|
|
276
|
+
routingKey: msg.fields.routingKey,
|
|
277
|
+
queueName,
|
|
278
|
+
error
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Handle error using TTL + wait queue pattern for exponential backoff.
|
|
283
|
+
*
|
|
284
|
+
* ┌─────────────────────────────────────────────────────────────────┐
|
|
285
|
+
* │ Retry Flow (Native RabbitMQ TTL + Wait queue pattern) │
|
|
286
|
+
* ├─────────────────────────────────────────────────────────────────┤
|
|
287
|
+
* │ │
|
|
288
|
+
* │ 1. Handler throws any Error │
|
|
289
|
+
* │ ↓ │
|
|
290
|
+
* │ 2. Worker publishes to wait exchange |
|
|
291
|
+
* | (with header `x-wait-queue` set to the wait queue name) │
|
|
292
|
+
* │ ↓ │
|
|
293
|
+
* │ 3. Wait exchange routes to wait queue │
|
|
294
|
+
* │ (with expiration: calculated backoff delay) │
|
|
295
|
+
* │ ↓ │
|
|
296
|
+
* │ 4. Message waits in queue until TTL expires │
|
|
297
|
+
* │ ↓ │
|
|
298
|
+
* │ 5. Expired message dead-lettered to retry exchange |
|
|
299
|
+
* | (with header `x-retry-queue` set to the main queue name) │
|
|
300
|
+
* │ ↓ │
|
|
301
|
+
* │ 6. Retry exchange routes back to main queue → RETRY │
|
|
302
|
+
* │ ↓ │
|
|
303
|
+
* │ 7. If retries exhausted: nack without requeue → DLQ │
|
|
304
|
+
* │ │
|
|
305
|
+
* └─────────────────────────────────────────────────────────────────┘
|
|
306
|
+
*/
|
|
307
|
+
function handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config) {
|
|
308
|
+
if (!(0, _amqp_contract_contract.isQueueWithTtlBackoffInfrastructure)(consumer.queue)) {
|
|
309
|
+
ctx.logger?.error("Queue does not have TTL-backoff infrastructure", {
|
|
310
|
+
consumerName,
|
|
311
|
+
queueName: consumer.queue.name
|
|
312
|
+
});
|
|
313
|
+
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError("Queue does not have TTL-backoff infrastructure")));
|
|
314
|
+
}
|
|
315
|
+
const queueEntry = consumer.queue;
|
|
316
|
+
const queueName = (0, _amqp_contract_contract.extractQueue)(queueEntry).name;
|
|
317
|
+
const retryCount = msg.properties.headers?.["x-retry-count"] ?? 0;
|
|
318
|
+
if (retryCount >= config.maxRetries) {
|
|
319
|
+
ctx.logger?.error("Max retries exceeded, sending to DLQ (ttl-backoff mode)", {
|
|
320
|
+
consumerName,
|
|
321
|
+
queueName,
|
|
322
|
+
retryCount,
|
|
323
|
+
maxRetries: config.maxRetries,
|
|
324
|
+
error: error.message
|
|
325
|
+
});
|
|
326
|
+
sendToDLQ(ctx, msg, consumer);
|
|
327
|
+
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
|
|
328
|
+
}
|
|
329
|
+
const delayMs = calculateRetryDelay(retryCount, config);
|
|
330
|
+
ctx.logger?.warn("Retrying message (ttl-backoff mode)", {
|
|
331
|
+
consumerName,
|
|
332
|
+
queueName,
|
|
333
|
+
retryCount: retryCount + 1,
|
|
334
|
+
maxRetries: config.maxRetries,
|
|
335
|
+
delayMs,
|
|
336
|
+
error: error.message
|
|
337
|
+
});
|
|
338
|
+
return publishForRetry(ctx, {
|
|
339
|
+
msg,
|
|
340
|
+
exchange: queueEntry.waitExchange.name,
|
|
341
|
+
routingKey: msg.fields.routingKey,
|
|
342
|
+
waitQueueName: queueEntry.waitQueue.name,
|
|
343
|
+
queueName,
|
|
344
|
+
delayMs,
|
|
345
|
+
error
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Calculate retry delay with exponential backoff and optional jitter.
|
|
350
|
+
*/
|
|
351
|
+
function calculateRetryDelay(retryCount, config) {
|
|
352
|
+
const { initialDelayMs, maxDelayMs, backoffMultiplier, jitter } = config;
|
|
353
|
+
let delay = Math.min(initialDelayMs * Math.pow(backoffMultiplier, retryCount), maxDelayMs);
|
|
354
|
+
if (jitter) delay = delay * (.5 + Math.random() * .5);
|
|
355
|
+
return Math.floor(delay);
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Parse message content for republishing.
|
|
359
|
+
*
|
|
360
|
+
* The channel is configured with `json: true`, so values published as plain
|
|
361
|
+
* objects are encoded once at publish time. Re-publishing the raw `Buffer`
|
|
362
|
+
* would then trigger a *second* JSON.stringify (turning the bytes into a
|
|
363
|
+
* stringified base64 blob), so for JSON payloads we must round-trip back to
|
|
364
|
+
* the parsed value. For any other content type — or when the message is
|
|
365
|
+
* compressed — we pass the bytes through untouched, since re-parsing would
|
|
366
|
+
* either fail or silently corrupt binary data.
|
|
367
|
+
*/
|
|
368
|
+
function parseMessageContentForRetry(ctx, msg, queueName) {
|
|
369
|
+
if (msg.properties.contentEncoding) return msg.content;
|
|
370
|
+
const contentType = msg.properties.contentType;
|
|
371
|
+
if (!(contentType === void 0 || contentType === "application/json" || contentType.startsWith("application/json;") || contentType.endsWith("+json"))) return msg.content;
|
|
372
|
+
try {
|
|
373
|
+
return JSON.parse(msg.content.toString());
|
|
374
|
+
} catch (err) {
|
|
375
|
+
ctx.logger?.warn("Failed to parse JSON message for retry, using original buffer", {
|
|
376
|
+
queueName,
|
|
377
|
+
error: err
|
|
378
|
+
});
|
|
379
|
+
return msg.content;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Publish message with an incremented x-retry-count header and optional TTL.
|
|
384
|
+
*/
|
|
385
|
+
function publishForRetry(ctx, { msg, exchange, routingKey, queueName, waitQueueName, delayMs, error }) {
|
|
386
|
+
const newRetryCount = (msg.properties.headers?.["x-retry-count"] ?? 0) + 1;
|
|
387
|
+
ctx.amqpClient.ack(msg);
|
|
388
|
+
const content = parseMessageContentForRetry(ctx, msg, queueName);
|
|
389
|
+
return ctx.amqpClient.publish(exchange, routingKey, content, {
|
|
390
|
+
...msg.properties,
|
|
391
|
+
...delayMs !== void 0 ? { expiration: delayMs.toString() } : {},
|
|
392
|
+
headers: {
|
|
393
|
+
...msg.properties.headers,
|
|
394
|
+
"x-retry-count": newRetryCount,
|
|
395
|
+
"x-last-error": error.message,
|
|
396
|
+
"x-first-failure-timestamp": msg.properties.headers?.["x-first-failure-timestamp"] ?? Date.now(),
|
|
397
|
+
...waitQueueName !== void 0 ? {
|
|
398
|
+
"x-wait-queue": waitQueueName,
|
|
399
|
+
"x-retry-queue": queueName
|
|
400
|
+
} : {}
|
|
401
|
+
}
|
|
402
|
+
}).mapOkToResult((published) => {
|
|
403
|
+
if (!published) {
|
|
404
|
+
ctx.logger?.error("Failed to publish message for retry (write buffer full)", {
|
|
405
|
+
queueName,
|
|
406
|
+
retryCount: newRetryCount,
|
|
407
|
+
...delayMs !== void 0 ? { delayMs } : {}
|
|
408
|
+
});
|
|
409
|
+
return _swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError("Failed to publish message for retry (write buffer full)"));
|
|
410
|
+
}
|
|
411
|
+
ctx.logger?.info("Message published for retry", {
|
|
412
|
+
queueName,
|
|
413
|
+
retryCount: newRetryCount,
|
|
414
|
+
...delayMs !== void 0 ? { delayMs } : {}
|
|
415
|
+
});
|
|
416
|
+
return _swan_io_boxed.Result.Ok(void 0);
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Send message to dead letter queue.
|
|
421
|
+
* Nacks the message without requeue, relying on DLX configuration.
|
|
422
|
+
*/
|
|
423
|
+
function sendToDLQ(ctx, msg, consumer) {
|
|
424
|
+
const queue = (0, _amqp_contract_contract.extractQueue)(consumer.queue);
|
|
425
|
+
const queueName = queue.name;
|
|
426
|
+
if (!(queue.deadLetter !== void 0)) ctx.logger?.warn("Queue does not have DLX configured - message will be lost on nack", { queueName });
|
|
427
|
+
ctx.logger?.info("Sending message to DLQ", {
|
|
428
|
+
queueName,
|
|
429
|
+
deliveryTag: msg.fields.deliveryTag
|
|
430
|
+
});
|
|
431
|
+
ctx.amqpClient.nack(msg, false, false);
|
|
432
|
+
}
|
|
433
|
+
//#endregion
|
|
200
434
|
//#region src/worker.ts
|
|
201
435
|
/**
|
|
202
436
|
* Type guard to check if a handler entry is a tuple format [handler, options].
|
|
@@ -247,7 +481,10 @@ function isHandlerTuple(entry) {
|
|
|
247
481
|
*/
|
|
248
482
|
var TypedAmqpWorker = class TypedAmqpWorker {
|
|
249
483
|
/**
|
|
250
|
-
* Internal handler storage
|
|
484
|
+
* Internal handler storage. Keyed by handler name (consumer or RPC); the
|
|
485
|
+
* stored function signature is widened so the dispatch loop can call it
|
|
486
|
+
* uniformly. The actual handler is type-checked at the worker's public API
|
|
487
|
+
* boundary via `WorkerInferHandlers<TContract>`.
|
|
251
488
|
*/
|
|
252
489
|
actualHandlers;
|
|
253
490
|
consumerOptions;
|
|
@@ -262,23 +499,49 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
262
499
|
this.actualHandlers = {};
|
|
263
500
|
this.consumerOptions = {};
|
|
264
501
|
const handlersRecord = handlers;
|
|
265
|
-
for (const
|
|
266
|
-
const handlerEntry = handlersRecord[
|
|
267
|
-
const
|
|
502
|
+
for (const handlerName of Object.keys(handlersRecord)) {
|
|
503
|
+
const handlerEntry = handlersRecord[handlerName];
|
|
504
|
+
const typedName = handlerName;
|
|
268
505
|
if (isHandlerTuple(handlerEntry)) {
|
|
269
506
|
const [handler, options] = handlerEntry;
|
|
270
|
-
this.actualHandlers[
|
|
271
|
-
this.consumerOptions[
|
|
507
|
+
this.actualHandlers[typedName] = handler;
|
|
508
|
+
this.consumerOptions[typedName] = {
|
|
272
509
|
...this.defaultConsumerOptions,
|
|
273
510
|
...options
|
|
274
511
|
};
|
|
275
512
|
} else {
|
|
276
|
-
this.actualHandlers[
|
|
277
|
-
this.consumerOptions[
|
|
513
|
+
this.actualHandlers[typedName] = handlerEntry;
|
|
514
|
+
this.consumerOptions[typedName] = this.defaultConsumerOptions;
|
|
278
515
|
}
|
|
279
516
|
}
|
|
280
517
|
}
|
|
281
518
|
/**
|
|
519
|
+
* Build a `ConsumerDefinition`-shaped view for a handler name, regardless
|
|
520
|
+
* of whether it came from `contract.consumers` or `contract.rpcs`. The
|
|
521
|
+
* dispatch path treats both uniformly; the returned `isRpc` flag (and the
|
|
522
|
+
* accompanying `responseSchema`) tells `processMessage` whether to validate
|
|
523
|
+
* the handler return value and publish a reply.
|
|
524
|
+
*/
|
|
525
|
+
resolveConsumerView(name) {
|
|
526
|
+
const rpcs = this.contract.rpcs;
|
|
527
|
+
if (rpcs && Object.hasOwn(rpcs, name)) {
|
|
528
|
+
const rpc = rpcs[name];
|
|
529
|
+
return {
|
|
530
|
+
consumer: {
|
|
531
|
+
queue: rpc.queue,
|
|
532
|
+
message: rpc.request
|
|
533
|
+
},
|
|
534
|
+
isRpc: true,
|
|
535
|
+
responseSchema: rpc.response.payload
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
const consumerEntry = this.contract.consumers[name];
|
|
539
|
+
return {
|
|
540
|
+
consumer: (0, _amqp_contract_contract.extractConsumer)(consumerEntry),
|
|
541
|
+
isRpc: false
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
282
545
|
* Create a type-safe AMQP worker from a contract.
|
|
283
546
|
*
|
|
284
547
|
* Connection management (including automatic reconnection) is handled internally
|
|
@@ -303,12 +566,18 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
303
566
|
* }).resultToPromise();
|
|
304
567
|
* ```
|
|
305
568
|
*/
|
|
306
|
-
static create({ contract, handlers, urls, connectionOptions, defaultConsumerOptions, logger, telemetry }) {
|
|
569
|
+
static create({ contract, handlers, urls, connectionOptions, defaultConsumerOptions, logger, telemetry, connectTimeoutMs }) {
|
|
307
570
|
const worker = new TypedAmqpWorker(contract, new _amqp_contract_core.AmqpClient(contract, {
|
|
308
571
|
urls,
|
|
309
|
-
connectionOptions
|
|
572
|
+
connectionOptions,
|
|
573
|
+
connectTimeoutMs
|
|
310
574
|
}), handlers, defaultConsumerOptions ?? {}, logger, telemetry);
|
|
311
|
-
return worker.waitForConnectionReady().flatMapOk(() => worker.consumeAll()).
|
|
575
|
+
return worker.waitForConnectionReady().flatMapOk(() => worker.consumeAll()).flatMap((result) => result.match({
|
|
576
|
+
Ok: () => _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(worker)),
|
|
577
|
+
Error: (error) => worker.close().tapError((closeError) => {
|
|
578
|
+
logger?.warn("Failed to close worker after setup failure", { error: closeError });
|
|
579
|
+
}).map(() => _swan_io_boxed.Result.Error(error))
|
|
580
|
+
}));
|
|
312
581
|
}
|
|
313
582
|
/**
|
|
314
583
|
* Close the AMQP channel and connection.
|
|
@@ -338,356 +607,202 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
338
607
|
}).flatMapOk(() => this.amqpClient.close()).mapOk(() => void 0);
|
|
339
608
|
}
|
|
340
609
|
/**
|
|
341
|
-
*
|
|
342
|
-
* Defaults are applied in the contract's defineQueue, so we just return the config.
|
|
343
|
-
*/
|
|
344
|
-
getRetryConfigForConsumer(consumer) {
|
|
345
|
-
return (0, _amqp_contract_contract.extractQueue)(consumer.queue).retry;
|
|
346
|
-
}
|
|
347
|
-
/**
|
|
348
|
-
* Start consuming messages for all consumers.
|
|
349
|
-
* TypeScript guarantees consumers exist (handlers require matching consumers).
|
|
610
|
+
* Start consuming for every entry in `contract.consumers` and `contract.rpcs`.
|
|
350
611
|
*/
|
|
351
612
|
consumeAll() {
|
|
352
|
-
const
|
|
353
|
-
const
|
|
354
|
-
|
|
613
|
+
const consumerNames = Object.keys(this.contract.consumers ?? {});
|
|
614
|
+
const rpcNames = Object.keys(this.contract.rpcs ?? {});
|
|
615
|
+
const allNames = [...consumerNames, ...rpcNames];
|
|
616
|
+
return _swan_io_boxed.Future.all(allNames.map((name) => this.consume(name))).map(_swan_io_boxed.Result.all).mapOk(() => void 0);
|
|
355
617
|
}
|
|
356
618
|
waitForConnectionReady() {
|
|
357
619
|
return this.amqpClient.waitForConnect();
|
|
358
620
|
}
|
|
359
621
|
/**
|
|
360
|
-
* Start consuming messages for a specific
|
|
361
|
-
*
|
|
622
|
+
* Start consuming messages for a specific handler — either a `consumers`
|
|
623
|
+
* entry (regular event/command consumer) or an `rpcs` entry (RPC server).
|
|
362
624
|
*/
|
|
363
|
-
consume(
|
|
364
|
-
const
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
return this.consumeSingle(consumerName, consumer, handler);
|
|
625
|
+
consume(name) {
|
|
626
|
+
const view = this.resolveConsumerView(name);
|
|
627
|
+
const handler = this.actualHandlers[name];
|
|
628
|
+
return this.consumeSingle(name, view, handler);
|
|
368
629
|
}
|
|
369
630
|
/**
|
|
370
|
-
* Validate data against a Standard Schema
|
|
631
|
+
* Validate data against a Standard Schema. No side effects; the caller is
|
|
632
|
+
* responsible for ack/nack based on the Result.
|
|
371
633
|
*/
|
|
372
|
-
validateSchema(schema, data, context
|
|
634
|
+
validateSchema(schema, data, context) {
|
|
373
635
|
const rawValidation = schema["~standard"].validate(data);
|
|
374
636
|
const validationPromise = rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation);
|
|
375
637
|
return _swan_io_boxed.Future.fromPromise(validationPromise).mapError((error) => new _amqp_contract_core.TechnicalError(`Error validating ${context.field}`, error)).mapOkToResult((result) => {
|
|
376
638
|
if (result.issues) return _swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError(`${context.field} validation failed`, new _amqp_contract_core.MessageValidationError(context.consumerName, result.issues)));
|
|
377
639
|
return _swan_io_boxed.Result.Ok(result.value);
|
|
378
|
-
}).tapError((error) => {
|
|
379
|
-
this.logger?.error(`${context.field} validation failed`, {
|
|
380
|
-
consumerName: context.consumerName,
|
|
381
|
-
queueName: context.queueName,
|
|
382
|
-
error
|
|
383
|
-
});
|
|
384
|
-
this.amqpClient.nack(msg, false, false);
|
|
385
640
|
});
|
|
386
641
|
}
|
|
387
642
|
/**
|
|
388
|
-
* Parse and validate a message from AMQP.
|
|
389
|
-
*
|
|
643
|
+
* Parse and validate a message from AMQP. Pure: returns the validated payload
|
|
644
|
+
* and headers, or an error. The dispatch path in {@link processMessage} routes
|
|
645
|
+
* validation/parse errors directly to the DLQ (single nack) — they never enter
|
|
646
|
+
* the retry pipeline because retrying an unparseable or schema-violating
|
|
647
|
+
* payload cannot succeed.
|
|
390
648
|
*/
|
|
391
649
|
parseAndValidateMessage(msg, consumer, consumerName) {
|
|
392
|
-
const
|
|
393
|
-
const
|
|
394
|
-
consumerName: String(consumerName),
|
|
395
|
-
queueName: queue.name
|
|
396
|
-
};
|
|
397
|
-
const nackAndError = (message, error) => {
|
|
398
|
-
this.logger?.error(message, {
|
|
399
|
-
...context,
|
|
400
|
-
error
|
|
401
|
-
});
|
|
402
|
-
this.amqpClient.nack(msg, false, false);
|
|
403
|
-
return new _amqp_contract_core.TechnicalError(message, error);
|
|
404
|
-
};
|
|
405
|
-
const parsePayload = decompressBuffer(msg.content, msg.properties.contentEncoding).tapError((error) => {
|
|
406
|
-
this.logger?.error("Failed to decompress message", {
|
|
407
|
-
...context,
|
|
408
|
-
error
|
|
409
|
-
});
|
|
410
|
-
this.amqpClient.nack(msg, false, false);
|
|
411
|
-
}).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, {
|
|
650
|
+
const context = { consumerName: String(consumerName) };
|
|
651
|
+
const parsePayload = decompressBuffer(msg.content, msg.properties.contentEncoding).mapErrorToResult((error) => _swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError("Failed to decompress message", error))).mapOkToResult((buffer) => _swan_io_boxed.Result.fromExecution(() => JSON.parse(buffer.toString())).mapError((error) => new _amqp_contract_core.TechnicalError("Failed to parse JSON", error))).flatMapOk((parsed) => this.validateSchema(consumer.message.payload, parsed, {
|
|
412
652
|
...context,
|
|
413
653
|
field: "payload"
|
|
414
|
-
}
|
|
654
|
+
}));
|
|
415
655
|
const parseHeaders = consumer.message.headers ? this.validateSchema(consumer.message.headers, msg.properties.headers ?? {}, {
|
|
416
656
|
...context,
|
|
417
657
|
field: "headers"
|
|
418
|
-
}
|
|
658
|
+
}) : _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
|
|
419
659
|
return _swan_io_boxed.Future.allFromDict({
|
|
420
660
|
payload: parsePayload,
|
|
421
661
|
headers: parseHeaders
|
|
422
662
|
}).map(_swan_io_boxed.Result.allFromDict);
|
|
423
663
|
}
|
|
424
664
|
/**
|
|
425
|
-
*
|
|
665
|
+
* Validate an RPC handler's response and publish it back to the caller's reply
|
|
666
|
+
* queue with the same `correlationId`. Published via the AMQP default exchange
|
|
667
|
+
* with `routingKey = msg.properties.replyTo`, which works for both
|
|
668
|
+
* `amq.rabbitmq.reply-to` and any anonymous queue declared by the caller.
|
|
669
|
+
*
|
|
670
|
+
* Failure semantics:
|
|
671
|
+
* - **Missing replyTo / correlationId**: NonRetryableError. The caller is
|
|
672
|
+
* already lost; retrying the original message cannot recover the reply
|
|
673
|
+
* path. The poison message lands in DLQ for inspection rather than being
|
|
674
|
+
* silently ack'd (which would mask a contract violation).
|
|
675
|
+
* - **Schema validation failure**: NonRetryableError — the handler returned
|
|
676
|
+
* the wrong shape; retrying the same input will not fix it.
|
|
677
|
+
* - **Publish failure**: NonRetryableError. The caller has already timed out
|
|
678
|
+
* (or will shortly), so retrying the message wastes the queue's retry
|
|
679
|
+
* budget on a reply that no one is waiting for. The message is logged and
|
|
680
|
+
* DLQ'd; the original work is treated as completed for the purpose of the
|
|
681
|
+
* inbox.
|
|
682
|
+
*/
|
|
683
|
+
publishRpcResponse(msg, queueName, rpcName, responseSchema, response) {
|
|
684
|
+
const replyTo = msg.properties.replyTo;
|
|
685
|
+
const correlationId = msg.properties.correlationId;
|
|
686
|
+
if (typeof replyTo !== "string" || replyTo.length === 0) {
|
|
687
|
+
this.logger?.error("RPC handler returned a response but the incoming message has no replyTo", {
|
|
688
|
+
rpcName: String(rpcName),
|
|
689
|
+
queueName
|
|
690
|
+
});
|
|
691
|
+
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new NonRetryableError(`RPC "${String(rpcName)}" received a message without replyTo; cannot deliver response`)));
|
|
692
|
+
}
|
|
693
|
+
if (typeof correlationId !== "string" || correlationId.length === 0) {
|
|
694
|
+
this.logger?.error("RPC handler returned a response but the incoming message has no correlationId", {
|
|
695
|
+
rpcName: String(rpcName),
|
|
696
|
+
queueName,
|
|
697
|
+
replyTo
|
|
698
|
+
});
|
|
699
|
+
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new NonRetryableError(`RPC "${String(rpcName)}" received a message without correlationId; cannot deliver response`)));
|
|
700
|
+
}
|
|
701
|
+
let rawValidation;
|
|
702
|
+
try {
|
|
703
|
+
rawValidation = responseSchema["~standard"].validate(response);
|
|
704
|
+
} catch (error) {
|
|
705
|
+
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new NonRetryableError("RPC response schema validation threw", error)));
|
|
706
|
+
}
|
|
707
|
+
const validationPromise = rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation);
|
|
708
|
+
return _swan_io_boxed.Future.fromPromise(validationPromise).mapError((error) => new NonRetryableError("RPC response schema validation threw", error)).mapOkToResult((validation) => {
|
|
709
|
+
if (validation.issues) return _swan_io_boxed.Result.Error(new NonRetryableError(`RPC response for "${String(rpcName)}" failed schema validation`, new _amqp_contract_core.MessageValidationError(String(rpcName), validation.issues)));
|
|
710
|
+
return _swan_io_boxed.Result.Ok(validation.value);
|
|
711
|
+
}).flatMapOk((validatedResponse) => this.amqpClient.publish("", replyTo, validatedResponse, {
|
|
712
|
+
correlationId,
|
|
713
|
+
contentType: "application/json"
|
|
714
|
+
}).mapErrorToResult((error) => _swan_io_boxed.Result.Error(new NonRetryableError("Failed to publish RPC response", error))).mapOkToResult((published) => published ? _swan_io_boxed.Result.Ok(void 0) : _swan_io_boxed.Result.Error(new NonRetryableError("Failed to publish RPC response: channel buffer full"))));
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Process a single consumed message: validate, invoke handler, optionally
|
|
718
|
+
* publish the RPC response, record telemetry, and handle errors.
|
|
426
719
|
*/
|
|
427
|
-
|
|
720
|
+
processMessage(msg, view, name, handler) {
|
|
721
|
+
const { consumer, isRpc, responseSchema } = view;
|
|
428
722
|
const queueName = (0, _amqp_contract_contract.extractQueue)(consumer.queue).name;
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
723
|
+
const startTime = Date.now();
|
|
724
|
+
const span = (0, _amqp_contract_core.startConsumeSpan)(this.telemetry, queueName, String(name), { "messaging.rabbitmq.message.delivery_tag": msg.fields.deliveryTag });
|
|
725
|
+
let messageHandled = false;
|
|
726
|
+
let firstError;
|
|
727
|
+
return this.parseAndValidateMessage(msg, consumer, name).flatMap((parseResult) => parseResult.match({
|
|
728
|
+
Ok: (validatedMessage) => handler(validatedMessage, msg).flatMapOk((handlerResponse) => {
|
|
729
|
+
if (isRpc && responseSchema) return this.publishRpcResponse(msg, queueName, name, responseSchema, handlerResponse).flatMapOk(() => {
|
|
730
|
+
this.logger?.info("Message consumed successfully", {
|
|
731
|
+
consumerName: String(name),
|
|
732
|
+
queueName
|
|
733
|
+
});
|
|
734
|
+
this.amqpClient.ack(msg);
|
|
735
|
+
messageHandled = true;
|
|
736
|
+
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
|
|
434
737
|
});
|
|
435
|
-
return;
|
|
436
|
-
}
|
|
437
|
-
const startTime = Date.now();
|
|
438
|
-
const span = (0, _amqp_contract_core.startConsumeSpan)(this.telemetry, queueName, String(consumerName), { "messaging.rabbitmq.message.delivery_tag": msg.fields.deliveryTag });
|
|
439
|
-
await this.parseAndValidateMessage(msg, consumer, consumerName).flatMapOk((validatedMessage) => handler(validatedMessage, msg).flatMapOk(() => {
|
|
440
738
|
this.logger?.info("Message consumed successfully", {
|
|
441
|
-
consumerName: String(
|
|
739
|
+
consumerName: String(name),
|
|
442
740
|
queueName
|
|
443
741
|
});
|
|
444
742
|
this.amqpClient.ack(msg);
|
|
445
|
-
|
|
446
|
-
(0, _amqp_contract_core.endSpanSuccess)(span);
|
|
447
|
-
(0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(consumerName), true, durationMs);
|
|
743
|
+
messageHandled = true;
|
|
448
744
|
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
|
|
449
745
|
}).flatMapError((handlerError) => {
|
|
450
746
|
this.logger?.error("Error processing message", {
|
|
451
|
-
consumerName: String(
|
|
747
|
+
consumerName: String(name),
|
|
452
748
|
queueName,
|
|
453
749
|
errorType: handlerError.name,
|
|
454
750
|
error: handlerError.message
|
|
455
751
|
});
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
* 3. Otherwise -> publish to wait queue with TTL for retry
|
|
483
|
-
*
|
|
484
|
-
* **none mode (no retry config):**
|
|
485
|
-
* 1. send directly to DLQ (no retry)
|
|
486
|
-
*/
|
|
487
|
-
handleError(error, msg, consumerName, consumer) {
|
|
488
|
-
if (error instanceof NonRetryableError) {
|
|
489
|
-
this.logger?.error("Non-retryable error, sending to DLQ immediately", {
|
|
490
|
-
consumerName,
|
|
491
|
-
errorType: error.name,
|
|
492
|
-
error: error.message
|
|
493
|
-
});
|
|
494
|
-
this.sendToDLQ(msg, consumer);
|
|
495
|
-
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
|
|
496
|
-
}
|
|
497
|
-
const config = this.getRetryConfigForConsumer(consumer);
|
|
498
|
-
if (config.mode === "immediate-requeue") return this.handleErrorImmediateRequeue(error, msg, consumerName, consumer, config);
|
|
499
|
-
if (config.mode === "ttl-backoff") return this.handleErrorTtlBackoff(error, msg, consumerName, consumer, config);
|
|
500
|
-
this.logger?.warn("Retry disabled (none mode), sending to DLQ", {
|
|
501
|
-
consumerName,
|
|
502
|
-
error: error.message
|
|
503
|
-
});
|
|
504
|
-
this.sendToDLQ(msg, consumer);
|
|
505
|
-
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
|
|
506
|
-
}
|
|
507
|
-
/**
|
|
508
|
-
* Handle error by requeuing immediately.
|
|
509
|
-
*
|
|
510
|
-
* For quorum queues, messages are requeued with `nack(requeue=true)`, and the worker tracks delivery count via the native RabbitMQ `x-delivery-count` header.
|
|
511
|
-
* For classic queues, messages are re-published on the same queue, and the worker tracks delivery count via a custom `x-retry-count` header.
|
|
512
|
-
* When the count exceeds `maxRetries`, the message is automatically dead-lettered (if DLX is configured) or dropped.
|
|
513
|
-
*
|
|
514
|
-
* This is simpler than TTL-based retry but provides immediate retries only.
|
|
515
|
-
*/
|
|
516
|
-
handleErrorImmediateRequeue(error, msg, consumerName, consumer, config) {
|
|
517
|
-
const queue = (0, _amqp_contract_contract.extractQueue)(consumer.queue);
|
|
518
|
-
const queueName = queue.name;
|
|
519
|
-
const retryCount = queue.type === "quorum" ? msg.properties.headers?.["x-delivery-count"] ?? 0 : msg.properties.headers?.["x-retry-count"] ?? 0;
|
|
520
|
-
if (retryCount >= config.maxRetries) {
|
|
521
|
-
this.logger?.error("Max retries exceeded, sending to DLQ (immediate-requeue mode)", {
|
|
522
|
-
consumerName,
|
|
523
|
-
queueName,
|
|
524
|
-
retryCount,
|
|
525
|
-
maxRetries: config.maxRetries,
|
|
526
|
-
error: error.message
|
|
527
|
-
});
|
|
528
|
-
this.sendToDLQ(msg, consumer);
|
|
529
|
-
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
|
|
530
|
-
}
|
|
531
|
-
this.logger?.warn("Retrying message (immediate-requeue mode)", {
|
|
532
|
-
consumerName,
|
|
533
|
-
queueName,
|
|
534
|
-
retryCount,
|
|
535
|
-
maxRetries: config.maxRetries,
|
|
536
|
-
error: error.message
|
|
537
|
-
});
|
|
538
|
-
if (queue.type === "quorum") {
|
|
539
|
-
this.amqpClient.nack(msg, false, true);
|
|
540
|
-
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
|
|
541
|
-
} else return this.publishForRetry({
|
|
542
|
-
msg,
|
|
543
|
-
exchange: msg.fields.exchange,
|
|
544
|
-
routingKey: msg.fields.routingKey,
|
|
545
|
-
queueName,
|
|
546
|
-
error
|
|
547
|
-
});
|
|
548
|
-
}
|
|
549
|
-
/**
|
|
550
|
-
* Handle error using TTL + wait queue pattern for exponential backoff.
|
|
551
|
-
*
|
|
552
|
-
* ┌─────────────────────────────────────────────────────────────────┐
|
|
553
|
-
* │ Retry Flow (Native RabbitMQ TTL + Wait queue pattern) │
|
|
554
|
-
* ├─────────────────────────────────────────────────────────────────┤
|
|
555
|
-
* │ │
|
|
556
|
-
* │ 1. Handler throws any Error │
|
|
557
|
-
* │ ↓ │
|
|
558
|
-
* │ 2. Worker publishes to wait exchange |
|
|
559
|
-
* | (with header `x-wait-queue` set to the wait queue name) │
|
|
560
|
-
* │ ↓ │
|
|
561
|
-
* │ 3. Wait exchange routes to wait queue │
|
|
562
|
-
* │ (with expiration: calculated backoff delay) │
|
|
563
|
-
* │ ↓ │
|
|
564
|
-
* │ 4. Message waits in queue until TTL expires │
|
|
565
|
-
* │ ↓ │
|
|
566
|
-
* │ 5. Expired message dead-lettered to retry exchange |
|
|
567
|
-
* | (with header `x-retry-queue` set to the main queue name) │
|
|
568
|
-
* │ ↓ │
|
|
569
|
-
* │ 6. Retry exchange routes back to main queue → RETRY │
|
|
570
|
-
* │ ↓ │
|
|
571
|
-
* │ 7. If retries exhausted: nack without requeue → DLQ │
|
|
572
|
-
* │ │
|
|
573
|
-
* └─────────────────────────────────────────────────────────────────┘
|
|
574
|
-
*/
|
|
575
|
-
handleErrorTtlBackoff(error, msg, consumerName, consumer, config) {
|
|
576
|
-
if (!(0, _amqp_contract_contract.isQueueWithTtlBackoffInfrastructure)(consumer.queue)) {
|
|
577
|
-
this.logger?.error("Queue does not have TTL-backoff infrastructure", {
|
|
578
|
-
consumerName,
|
|
579
|
-
queueName: consumer.queue.name
|
|
580
|
-
});
|
|
581
|
-
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError("Queue does not have TTL-backoff infrastructure")));
|
|
582
|
-
}
|
|
583
|
-
const queueEntry = consumer.queue;
|
|
584
|
-
const queueName = (0, _amqp_contract_contract.extractQueue)(queueEntry).name;
|
|
585
|
-
const retryCount = msg.properties.headers?.["x-retry-count"] ?? 0;
|
|
586
|
-
if (retryCount >= config.maxRetries) {
|
|
587
|
-
this.logger?.error("Max retries exceeded, sending to DLQ (ttl-backoff mode)", {
|
|
588
|
-
consumerName,
|
|
589
|
-
queueName,
|
|
590
|
-
retryCount,
|
|
591
|
-
maxRetries: config.maxRetries,
|
|
592
|
-
error: error.message
|
|
593
|
-
});
|
|
594
|
-
this.sendToDLQ(msg, consumer);
|
|
595
|
-
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
|
|
596
|
-
}
|
|
597
|
-
const delayMs = this.calculateRetryDelay(retryCount, config);
|
|
598
|
-
this.logger?.warn("Retrying message (ttl-backoff mode)", {
|
|
599
|
-
consumerName,
|
|
600
|
-
queueName,
|
|
601
|
-
retryCount: retryCount + 1,
|
|
602
|
-
maxRetries: config.maxRetries,
|
|
603
|
-
delayMs,
|
|
604
|
-
error: error.message
|
|
605
|
-
});
|
|
606
|
-
return this.publishForRetry({
|
|
607
|
-
msg,
|
|
608
|
-
exchange: queueEntry.waitExchange.name,
|
|
609
|
-
routingKey: msg.fields.routingKey,
|
|
610
|
-
waitQueueName: queueEntry.waitQueue.name,
|
|
611
|
-
queueName,
|
|
612
|
-
delayMs,
|
|
613
|
-
error
|
|
752
|
+
firstError = handlerError;
|
|
753
|
+
return handleError({
|
|
754
|
+
amqpClient: this.amqpClient,
|
|
755
|
+
logger: this.logger
|
|
756
|
+
}, handlerError, msg, String(name), consumer);
|
|
757
|
+
}),
|
|
758
|
+
Error: (parseError) => {
|
|
759
|
+
firstError = parseError;
|
|
760
|
+
this.logger?.error("Failed to parse/validate message; sending to DLQ", {
|
|
761
|
+
consumerName: String(name),
|
|
762
|
+
queueName,
|
|
763
|
+
error: parseError
|
|
764
|
+
});
|
|
765
|
+
this.amqpClient.nack(msg, false, false);
|
|
766
|
+
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(parseError));
|
|
767
|
+
}
|
|
768
|
+
})).map((result) => {
|
|
769
|
+
const durationMs = Date.now() - startTime;
|
|
770
|
+
if (messageHandled) {
|
|
771
|
+
(0, _amqp_contract_core.endSpanSuccess)(span);
|
|
772
|
+
(0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(name), true, durationMs);
|
|
773
|
+
} else {
|
|
774
|
+
(0, _amqp_contract_core.endSpanError)(span, result.isError() ? result.error : firstError ?? /* @__PURE__ */ new Error("Unknown error"));
|
|
775
|
+
(0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(name), false, durationMs);
|
|
776
|
+
}
|
|
777
|
+
return result;
|
|
614
778
|
});
|
|
615
779
|
}
|
|
616
780
|
/**
|
|
617
|
-
*
|
|
618
|
-
*/
|
|
619
|
-
calculateRetryDelay(retryCount, config) {
|
|
620
|
-
const { initialDelayMs, maxDelayMs, backoffMultiplier, jitter } = config;
|
|
621
|
-
let delay = Math.min(initialDelayMs * Math.pow(backoffMultiplier, retryCount), maxDelayMs);
|
|
622
|
-
if (jitter) delay = delay * (.5 + Math.random() * .5);
|
|
623
|
-
return Math.floor(delay);
|
|
624
|
-
}
|
|
625
|
-
/**
|
|
626
|
-
* Parse message content for republishing.
|
|
627
|
-
* Prevents double JSON serialization by converting Buffer to object when possible.
|
|
628
|
-
*/
|
|
629
|
-
parseMessageContentForRetry(msg, queueName) {
|
|
630
|
-
let content = msg.content;
|
|
631
|
-
if (!msg.properties.contentEncoding) try {
|
|
632
|
-
content = JSON.parse(msg.content.toString());
|
|
633
|
-
} catch (err) {
|
|
634
|
-
this.logger?.warn("Failed to parse message for retry, using original buffer", {
|
|
635
|
-
queueName,
|
|
636
|
-
error: err
|
|
637
|
-
});
|
|
638
|
-
}
|
|
639
|
-
return content;
|
|
640
|
-
}
|
|
641
|
-
/**
|
|
642
|
-
* Publish message with an incremented x-retry-count header and optional TTL.
|
|
781
|
+
* Consume messages one at a time.
|
|
643
782
|
*/
|
|
644
|
-
|
|
645
|
-
const
|
|
646
|
-
this.amqpClient.
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
"x-retry-count": newRetryCount,
|
|
654
|
-
"x-last-error": error.message,
|
|
655
|
-
"x-first-failure-timestamp": msg.properties.headers?.["x-first-failure-timestamp"] ?? Date.now(),
|
|
656
|
-
...waitQueueName !== void 0 ? {
|
|
657
|
-
"x-wait-queue": waitQueueName,
|
|
658
|
-
"x-retry-queue": queueName
|
|
659
|
-
} : {}
|
|
783
|
+
consumeSingle(name, view, handler) {
|
|
784
|
+
const queueName = (0, _amqp_contract_contract.extractQueue)(view.consumer.queue).name;
|
|
785
|
+
return this.amqpClient.consume(queueName, async (msg) => {
|
|
786
|
+
if (msg === null) {
|
|
787
|
+
this.logger?.warn("Consumer cancelled by server", {
|
|
788
|
+
consumerName: String(name),
|
|
789
|
+
queueName
|
|
790
|
+
});
|
|
791
|
+
return;
|
|
660
792
|
}
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
793
|
+
try {
|
|
794
|
+
await this.processMessage(msg, view, name, handler).toPromise();
|
|
795
|
+
} catch (error) {
|
|
796
|
+
this.logger?.error("Uncaught error in consume callback; nacking message", {
|
|
797
|
+
consumerName: String(name),
|
|
664
798
|
queueName,
|
|
665
|
-
|
|
666
|
-
...delayMs !== void 0 ? { delayMs } : {}
|
|
799
|
+
error
|
|
667
800
|
});
|
|
668
|
-
|
|
801
|
+
this.amqpClient.nack(msg, false, false);
|
|
669
802
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
...delayMs !== void 0 ? { delayMs } : {}
|
|
674
|
-
});
|
|
675
|
-
return _swan_io_boxed.Result.Ok(void 0);
|
|
676
|
-
});
|
|
677
|
-
}
|
|
678
|
-
/**
|
|
679
|
-
* Send message to dead letter queue.
|
|
680
|
-
* Nacks the message without requeue, relying on DLX configuration.
|
|
681
|
-
*/
|
|
682
|
-
sendToDLQ(msg, consumer) {
|
|
683
|
-
const queue = (0, _amqp_contract_contract.extractQueue)(consumer.queue);
|
|
684
|
-
const queueName = queue.name;
|
|
685
|
-
if (!(queue.deadLetter !== void 0)) this.logger?.warn("Queue does not have DLX configured - message will be lost on nack", { queueName });
|
|
686
|
-
this.logger?.info("Sending message to DLQ", {
|
|
687
|
-
queueName,
|
|
688
|
-
deliveryTag: msg.fields.deliveryTag
|
|
689
|
-
});
|
|
690
|
-
this.amqpClient.nack(msg, false, false);
|
|
803
|
+
}, this.consumerOptions[name]).tapOk((consumerTag) => {
|
|
804
|
+
this.consumerTags.add(consumerTag);
|
|
805
|
+
}).mapError((error) => new _amqp_contract_core.TechnicalError(`Failed to start consuming for "${String(name)}"`, error)).mapOk(() => void 0);
|
|
691
806
|
}
|
|
692
807
|
};
|
|
693
808
|
//#endregion
|