@linagora/rabbitmq-client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,161 @@
1
+ # @linagora/rabbitmq-client
2
+
3
+ An opinionated RabbitMQ client for Node.js that handles the boilerplate you'd otherwise copy-paste between services: confirm channels, dead letter queues, exponential backoff on publish, and automatic reconnection that restores your subscriptions.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @linagora/rabbitmq-client
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```typescript
14
+ import { RabbitMQClient } from '@linagora/rabbitmq-client'
15
+
16
+ const client = new RabbitMQClient({ url: 'amqp://localhost' })
17
+ await client.init()
18
+
19
+ await client.publish('auth', 'user.created', { userId: '123' })
20
+
21
+ await client.subscribe('auth', 'user.created', 'my-service.user-created', async (msg) => {
22
+ // your logic here
23
+ })
24
+
25
+ await client.close()
26
+ ```
27
+
28
+ ## Configuration
29
+
30
+ Only `url` is required. Everything else has defaults.
31
+
32
+ | Option | Default | Description |
33
+ |--------|---------|-------------|
34
+ | `url` | -- | AMQP connection string |
35
+ | `maxRetries` | `3` | How many times to retry a failed handler before sending the message to DLQ |
36
+ | `retryDelay` | `1000` | Milliseconds between handler retries |
37
+ | `connectionRetryDelay` | `5000` | Milliseconds between reconnection attempts |
38
+ | `initMaxAttempts` | `5` | Connection attempts on startup before giving up |
39
+ | `publishMaxAttempts` | `5` | Publish retries (exponential backoff, capped at 60s) |
40
+ | `prefetch` | `10` | Channel prefetch count |
41
+ | `closeTimeout` | `5000` | Milliseconds to wait for in-flight messages when closing |
42
+ | `logger` | -- | Parent [pino](https://github.com/pinojs/pino) instance (the library creates its own if omitted) |
43
+ | `hooks` | -- | Observability callbacks (see [Hooks](#hooks)) |
44
+
45
+ ## How it works
46
+
47
+ ### Publishing
48
+
49
+ Publishes go through a confirm channel, so you know the broker accepted the message. If something goes wrong, the client retries with exponential backoff and forces a new channel on each attempt to avoid retrying on a silently dead connection. Exchange assertions are cached per connection to avoid unnecessary AMQP round-trips on the hot path.
50
+
51
+ ### Subscribing
52
+
53
+ Each call to `subscribe` wires up the DLQ plumbing for you:
54
+
55
+ - A dead letter exchange (`<exchange>.dlx`)
56
+ - A dead letter queue (`<queue>.dlq`)
57
+ - A dead letter routing key (`<routingKey>.dead`)
58
+
59
+ The main queue is created as a quorum queue with `at-least-once` delivery and `reject-publish` overflow. Messages are manually acknowledged.
60
+
61
+ You can override the default queue arguments by passing a fifth argument:
62
+
63
+ ```typescript
64
+ await client.subscribe('events', 'order.placed', 'order-queue', handler, {
65
+ queueArguments: { 'x-queue-type': 'classic', 'x-max-length': 10_000 },
66
+ })
67
+ ```
68
+
69
+ Custom arguments are merged with the DLQ wiring defaults, so you can swap the queue type or add a max-length without losing dead-letter routing.
70
+
71
+ ### Unsubscribing
72
+
73
+ Cancel a consumer and remove it from the auto-restoration list:
74
+
75
+ ```typescript
76
+ await client.unsubscribe('order-queue')
77
+ ```
78
+
79
+ After unsubscribing, the queue will not be re-subscribed on reconnection.
80
+
81
+ ### Message handling
82
+
83
+ Incoming messages are JSON-parsed first. If that fails, the message goes straight to the DLQ (no point retrying garbage). Otherwise, your handler runs up to `maxRetries` times. Success means ack, final failure means nack to the DLQ.
84
+
85
+ ### Reconnection
86
+
87
+ If the connection or channel drops, the client reconnects and re-subscribes to everything automatically. Multiple reconnection triggers (e.g. connection close + channel close firing at the same time) are collapsed into a single attempt.
88
+
89
+ ### Graceful shutdown
90
+
91
+ `close()` waits for in-flight message handlers to finish before tearing down the channel, up to `closeTimeout` milliseconds. If handlers don't drain in time, the client closes anyway and logs a warning.
92
+
93
+ ```typescript
94
+ // wait for handlers to finish, then close
95
+ await client.close()
96
+
97
+ // close but keep subscriptions for a later init()
98
+ await client.close(false)
99
+ ```
100
+
101
+ ### Health check
102
+
103
+ `checkHealth()` creates and immediately deletes a temporary queue. Useful for Kubernetes readiness probes.
104
+
105
+ ```typescript
106
+ const healthy = await client.checkHealth()
107
+ ```
108
+
109
+ ## Hooks
110
+
111
+ Optional callbacks for wiring metrics, tracing, or alerting. Hook errors are swallowed so they never break message flow.
112
+
113
+ ```typescript
114
+ const client = new RabbitMQClient({
115
+ url: 'amqp://localhost',
116
+ hooks: {
117
+ onPublish({ exchange, routingKey, attempts }) {
118
+ metrics.increment('rabbitmq.publish', { exchange })
119
+ },
120
+ onMessageProcessed({ exchange, routingKey, duration, attempts }) {
121
+ metrics.histogram('rabbitmq.handler.duration', duration)
122
+ },
123
+ onMessageDlq({ exchange, routingKey, duration, reason }) {
124
+ alerting.warn(`Message sent to DLQ: ${reason}`)
125
+ },
126
+ onReconnect({ subscriptionsRestored, subscriptionsFailed }) {
127
+ metrics.increment('rabbitmq.reconnect')
128
+ },
129
+ },
130
+ })
131
+ ```
132
+
133
+ | Hook | Fires when |
134
+ |------|-----------|
135
+ | `onPublish` | A message is confirmed by the broker |
136
+ | `onMessageProcessed` | A handler completes successfully (includes duration and retry count) |
137
+ | `onMessageDlq` | A message is nacked to the DLQ — reason is `'invalid_json'` or `'max_retries_exhausted'` |
138
+ | `onReconnect` | The client reconnects and re-establishes subscriptions |
139
+
140
+ ## Test helpers
141
+
142
+ The `@linagora/rabbitmq-client/testing` entrypoint provides mocks that don't depend on any test framework.
143
+
144
+ ```typescript
145
+ import { createMockAmqplib } from '@linagora/rabbitmq-client/testing'
146
+
147
+ const { mockConnection, amqpMock } = createMockAmqplib()
148
+
149
+ // check what was published
150
+ mockConnection.channel.getPublishedMessages()
151
+
152
+ // feed a message into a consumer
153
+ mockConnection.channel.simulateMessage({ userId: '123' })
154
+
155
+ // simulate a broker going down
156
+ mockConnection.simulateClose()
157
+ ```
158
+
159
+ ## License
160
+
161
+ AGPL-3.0
package/dist/index.cjs ADDED
@@ -0,0 +1,509 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var src_exports = {};
32
+ __export(src_exports, {
33
+ RabbitMQClient: () => RabbitMQClient
34
+ });
35
+ module.exports = __toCommonJS(src_exports);
36
+
37
+ // src/client.ts
38
+ var import_amqplib = __toESM(require("amqplib"), 1);
39
+
40
+ // src/logger.ts
41
+ var import_pino = __toESM(require("pino"), 1);
42
+ var createLogger = (parent) => {
43
+ if (parent) {
44
+ return parent.child({ service: "rabbitmq-client" });
45
+ }
46
+ return (0, import_pino.default)({ name: "rabbitmq-client" });
47
+ };
48
+
49
+ // src/client.ts
50
+ var DEFAULT_PREFETCH = 10;
51
+ var MAX_PUBLISH_RETRY_DELAY_MS = 6e4;
52
+ var DEFAULTS = {
53
+ maxRetries: 3,
54
+ retryDelay: 1e3,
55
+ connectionRetryDelay: 5e3,
56
+ initMaxAttempts: 5,
57
+ publishMaxAttempts: 5,
58
+ prefetch: DEFAULT_PREFETCH,
59
+ closeTimeout: 5e3
60
+ };
61
+ var RabbitMQClient = class {
62
+ connection = null;
63
+ channel = null;
64
+ connected = false;
65
+ subscriptions = [];
66
+ options;
67
+ logger;
68
+ hooks;
69
+ initializationPromise = null;
70
+ reconnectionPromise = null;
71
+ assertedExchanges = /* @__PURE__ */ new Set();
72
+ consumerTags = /* @__PURE__ */ new Map();
73
+ inflightCount = 0;
74
+ drainResolve = null;
75
+ constructor(options) {
76
+ this.options = {
77
+ url: options.url,
78
+ maxRetries: options.maxRetries ?? DEFAULTS.maxRetries,
79
+ retryDelay: options.retryDelay ?? DEFAULTS.retryDelay,
80
+ connectionRetryDelay: options.connectionRetryDelay ?? DEFAULTS.connectionRetryDelay,
81
+ initMaxAttempts: options.initMaxAttempts ?? DEFAULTS.initMaxAttempts,
82
+ publishMaxAttempts: options.publishMaxAttempts ?? DEFAULTS.publishMaxAttempts,
83
+ prefetch: options.prefetch ?? DEFAULTS.prefetch,
84
+ closeTimeout: options.closeTimeout ?? DEFAULTS.closeTimeout
85
+ };
86
+ this.logger = createLogger(options.logger);
87
+ this.hooks = options.hooks ?? {};
88
+ }
89
+ /**
90
+ * Opens a connection and creates a confirm channel. Retries up to
91
+ * `initMaxAttempts` times, then throws. Idempotent and safe to call
92
+ * concurrently — duplicate calls share the same in-flight promise.
93
+ */
94
+ async init() {
95
+ if (this.connection && this.connected) {
96
+ return;
97
+ }
98
+ if (this.initializationPromise) {
99
+ return this.initializationPromise;
100
+ }
101
+ this.initializationPromise = this.doConnect(this.options.initMaxAttempts);
102
+ this.initializationPromise.catch(() => void 0);
103
+ try {
104
+ await this.initializationPromise;
105
+ } finally {
106
+ this.initializationPromise = null;
107
+ }
108
+ }
109
+ async doConnect(maxAttempts) {
110
+ this.assertedExchanges.clear();
111
+ this.consumerTags.clear();
112
+ let attempts = 0;
113
+ while (!this.connected) {
114
+ try {
115
+ this.connection = await import_amqplib.default.connect(this.options.url);
116
+ this.logger.info("Connected to server");
117
+ this.channel = await this.connection.createConfirmChannel();
118
+ this.logger.info("Confirm channel created");
119
+ await this.channel.prefetch(this.options.prefetch);
120
+ this.logger.info({ prefetch: this.options.prefetch }, "Channel prefetch set");
121
+ this.connection.on("error", (error) => {
122
+ this.connected = false;
123
+ this.logger.error({ error }, "Connection error");
124
+ });
125
+ this.connection.on("close", () => {
126
+ this.connected = false;
127
+ this.logger.warn("Connection closed");
128
+ this.reconnectWithRetry();
129
+ });
130
+ this.channel.on("error", (error) => {
131
+ this.logger.error({ error }, "Channel error");
132
+ this.handleChannelFailure();
133
+ });
134
+ this.channel.on("close", () => {
135
+ this.logger.warn("Channel closed");
136
+ this.handleChannelFailure();
137
+ });
138
+ this.connected = true;
139
+ this.logger.info("Client initialized successfully");
140
+ } catch (error) {
141
+ attempts++;
142
+ this.connected = false;
143
+ if (maxAttempts !== void 0 && attempts >= maxAttempts) {
144
+ this.logger.error({ error, attempts, maxAttempts }, "Connection failed after maximum attempts");
145
+ throw new Error(
146
+ `Failed to connect to RabbitMQ after ${attempts} attempts. Check RABBITMQ_URL configuration and RabbitMQ server availability.`
147
+ );
148
+ }
149
+ this.logger.warn(
150
+ {
151
+ error,
152
+ attempt: attempts,
153
+ maxAttempts: maxAttempts ?? "unlimited",
154
+ retryDelayMs: this.options.connectionRetryDelay
155
+ },
156
+ "Connection attempt failed, retrying..."
157
+ );
158
+ await this.sleep(this.options.connectionRetryDelay);
159
+ }
160
+ }
161
+ }
162
+ handleChannelFailure() {
163
+ if (this.connected) {
164
+ this.connected = false;
165
+ this.reconnectWithRetry();
166
+ }
167
+ }
168
+ /**
169
+ * Returns whether the client currently has an active connection and channel.
170
+ */
171
+ isConnected() {
172
+ return this.connected;
173
+ }
174
+ /**
175
+ * Publishes a JSON message to a topic exchange with publisher confirms.
176
+ * Retries with exponential backoff (capped at 60 s), forcing a reconnect
177
+ * on each failure. Throws after `publishMaxAttempts` exhausted.
178
+ */
179
+ async publish(exchange, routingKey, message, options) {
180
+ let attempts = 0;
181
+ const maxAttempts = options?.maxAttempts ?? this.options.publishMaxAttempts;
182
+ const baseDelay = this.options.connectionRetryDelay;
183
+ const content = Buffer.from(JSON.stringify(message));
184
+ while (attempts < maxAttempts) {
185
+ try {
186
+ if (!this.connected) {
187
+ await this.reconnectWithRetry();
188
+ }
189
+ if (!this.channel) {
190
+ throw new Error("Channel not available");
191
+ }
192
+ if (!this.assertedExchanges.has(exchange)) {
193
+ await this.channel.assertExchange(exchange, "topic", { durable: true });
194
+ this.assertedExchanges.add(exchange);
195
+ }
196
+ this.channel.publish(exchange, routingKey, content, {
197
+ persistent: true
198
+ });
199
+ await this.channel.waitForConfirms();
200
+ if (attempts > 0) {
201
+ this.logger.info({ exchange, routingKey, messageSize: content.length, attempts }, "Published message after retries");
202
+ } else {
203
+ this.logger.info({ exchange, routingKey, messageSize: content.length }, "Published message");
204
+ }
205
+ this.logger.debug({ exchange, routingKey, payload: message }, "Published message payload");
206
+ this.callHook(this.hooks.onPublish, { exchange, routingKey, attempts: attempts + 1 });
207
+ return;
208
+ } catch (error) {
209
+ attempts++;
210
+ this.connected = false;
211
+ if (attempts >= maxAttempts) {
212
+ this.logger.error(
213
+ { error, exchange, routingKey, attempts, maxAttempts },
214
+ "Publish failed after max attempts"
215
+ );
216
+ throw new Error(
217
+ `Failed to publish to ${exchange}/${routingKey} after ${attempts} attempts: ${error instanceof Error ? error.message : String(error)}`
218
+ );
219
+ }
220
+ const retryDelay = Math.min(
221
+ baseDelay * Math.pow(2, attempts - 1),
222
+ MAX_PUBLISH_RETRY_DELAY_MS
223
+ );
224
+ this.logger.warn(
225
+ { error, exchange, routingKey, attempt: attempts, maxAttempts, retryDelayMs: retryDelay },
226
+ "Publish attempt failed, retrying"
227
+ );
228
+ await this.sleep(retryDelay);
229
+ }
230
+ }
231
+ }
232
+ /**
233
+ * Gracefully shuts down the client. Waits up to `closeTimeout` ms for
234
+ * in-flight message handlers to finish before closing the channel and
235
+ * connection. Pass `false` to preserve subscriptions for a later
236
+ * `init()` / reconnect cycle.
237
+ */
238
+ async close(clearSubscriptions = true) {
239
+ try {
240
+ if (this.inflightCount > 0) {
241
+ this.logger.info({ inflightCount: this.inflightCount }, "Waiting for in-flight messages to drain");
242
+ await this.waitForDrain(this.options.closeTimeout);
243
+ }
244
+ await this.channel?.close();
245
+ await this.connection?.close();
246
+ this.connection = null;
247
+ this.channel = null;
248
+ this.connected = false;
249
+ this.initializationPromise = null;
250
+ this.reconnectionPromise = null;
251
+ this.assertedExchanges.clear();
252
+ this.consumerTags.clear();
253
+ if (clearSubscriptions) {
254
+ this.subscriptions = [];
255
+ }
256
+ this.logger.info("Connection closed");
257
+ } catch (error) {
258
+ this.logger.error({ error }, "Error closing connection");
259
+ throw error;
260
+ }
261
+ }
262
+ async reconnectWithRetry() {
263
+ if (this.reconnectionPromise) {
264
+ return this.reconnectionPromise;
265
+ }
266
+ this.connected = false;
267
+ this.logger.info("Starting reconnection...");
268
+ this.reconnectionPromise = this.doReconnect();
269
+ this.reconnectionPromise.catch(() => void 0);
270
+ try {
271
+ await this.reconnectionPromise;
272
+ } finally {
273
+ this.reconnectionPromise = null;
274
+ }
275
+ }
276
+ async doReconnect() {
277
+ await this.doConnect();
278
+ await this.resubscribeAll();
279
+ }
280
+ async resubscribeAll() {
281
+ if (this.subscriptions.length === 0) {
282
+ return;
283
+ }
284
+ this.logger.info({ count: this.subscriptions.length }, "Re-establishing subscriptions");
285
+ const subs = [...this.subscriptions];
286
+ const results = await Promise.allSettled(
287
+ subs.map(async (sub) => {
288
+ await this.setupSubscription(sub);
289
+ return sub.queue;
290
+ })
291
+ );
292
+ const succeeded = [];
293
+ const failed = [];
294
+ results.forEach((result, index) => {
295
+ if (result.status === "fulfilled") {
296
+ succeeded.push(result.value);
297
+ } else {
298
+ const queue = subs[index].queue;
299
+ failed.push(queue);
300
+ this.logger.error({ error: result.reason, queue }, "Failed to re-subscribe to queue");
301
+ }
302
+ });
303
+ if (succeeded.length > 0) {
304
+ this.logger.info({ count: succeeded.length, queues: succeeded }, "Successfully re-subscribed to queues");
305
+ }
306
+ if (failed.length > 0) {
307
+ this.logger.warn({ count: failed.length, queues: failed }, "Some subscriptions failed to restore");
308
+ }
309
+ this.callHook(this.hooks.onReconnect, { subscriptionsRestored: succeeded.length, subscriptionsFailed: failed.length });
310
+ }
311
+ /**
312
+ * Subscribes to a queue with automatic DLQ infrastructure setup.
313
+ *
314
+ * Pass `options.queueArguments` to override the default quorum-queue
315
+ * arguments (merged with the DLQ wiring defaults).
316
+ */
317
+ async subscribe(exchange, routingKey, queue, handler, options) {
318
+ if (!this.connected || !this.channel) {
319
+ throw new Error("RabbitMQ client not connected. Call init() first.");
320
+ }
321
+ const sub = { exchange, routingKey, queue, handler, options };
322
+ const existingIndex = this.subscriptions.findIndex((s) => s.queue === queue);
323
+ if (existingIndex === -1) {
324
+ this.subscriptions.push(sub);
325
+ } else {
326
+ this.subscriptions[existingIndex] = sub;
327
+ }
328
+ await this.setupSubscription(sub);
329
+ }
330
+ /**
331
+ * Cancels a queue subscription and removes it from the restoration list.
332
+ */
333
+ async unsubscribe(queue) {
334
+ const tag = this.consumerTags.get(queue);
335
+ if (tag && this.channel) {
336
+ await this.channel.cancel(tag);
337
+ }
338
+ this.consumerTags.delete(queue);
339
+ this.subscriptions = this.subscriptions.filter((s) => s.queue !== queue);
340
+ this.logger.info({ queue }, "Unsubscribed from queue");
341
+ }
342
+ async setupSubscription(sub) {
343
+ if (!this.channel) {
344
+ throw new Error("Channel not available");
345
+ }
346
+ const { exchange, routingKey, queue, handler, options } = sub;
347
+ const dlxExchange = `${exchange}.dlx`;
348
+ const dlqQueue = `${queue}.dlq`;
349
+ const dlqRoutingKey = `${routingKey}.dead`;
350
+ await this.channel.assertExchange(dlxExchange, "topic", { durable: true });
351
+ await this.channel.assertQueue(dlqQueue, { durable: true });
352
+ await this.channel.bindQueue(dlqQueue, dlxExchange, dlqRoutingKey);
353
+ if (!this.assertedExchanges.has(exchange)) {
354
+ await this.channel.assertExchange(exchange, "topic", { durable: true });
355
+ this.assertedExchanges.add(exchange);
356
+ }
357
+ await this.channel.assertQueue(queue, {
358
+ durable: true,
359
+ deadLetterExchange: dlxExchange,
360
+ deadLetterRoutingKey: dlqRoutingKey,
361
+ arguments: {
362
+ "x-dead-letter-strategy": "at-least-once",
363
+ "x-queue-type": "quorum",
364
+ "x-overflow": "reject-publish",
365
+ ...options?.queueArguments
366
+ }
367
+ });
368
+ await this.channel.bindQueue(queue, exchange, routingKey);
369
+ const { consumerTag } = await this.channel.consume(
370
+ queue,
371
+ (message) => {
372
+ if (message) {
373
+ this.handleWithRetry(message, handler);
374
+ }
375
+ },
376
+ { noAck: false }
377
+ );
378
+ this.consumerTags.set(queue, consumerTag);
379
+ this.logger.info({ exchange, routingKey, queue }, "Subscribed to queue");
380
+ }
381
+ async handleWithRetry(message, handler) {
382
+ const channel = this.channel;
383
+ if (!channel) {
384
+ this.logger.warn("Cannot process message -- channel unavailable");
385
+ return;
386
+ }
387
+ this.inflightCount++;
388
+ try {
389
+ const startTime = Date.now();
390
+ let attempts = 0;
391
+ const routingKey = message.fields.routingKey;
392
+ const exchange = message.fields.exchange;
393
+ let content;
394
+ try {
395
+ content = JSON.parse(message.content.toString());
396
+ } catch (parseError) {
397
+ const rawContent = message.content.toString();
398
+ const rawPreview = rawContent.substring(0, 100);
399
+ this.logger.error(
400
+ {
401
+ error: parseError,
402
+ exchange,
403
+ routingKey,
404
+ rawContentPreview: rawPreview + (rawContent.length > 100 ? "..." : "")
405
+ },
406
+ "Failed to parse message JSON, sending to DLQ"
407
+ );
408
+ channel.nack(message, false, false);
409
+ this.callHook(this.hooks.onMessageDlq, { exchange, routingKey, duration: 0, reason: "invalid_json" });
410
+ return;
411
+ }
412
+ this.logger.debug({ exchange, routingKey, payload: content }, "Message received, processing");
413
+ while (attempts < this.options.maxRetries) {
414
+ try {
415
+ await handler(content);
416
+ const duration2 = Date.now() - startTime;
417
+ this.logger.info(
418
+ { exchange, routingKey, duration: duration2, attempts: attempts + 1 },
419
+ "Message processed successfully"
420
+ );
421
+ channel.ack(message);
422
+ this.callHook(this.hooks.onMessageProcessed, { exchange, routingKey, duration: duration2, attempts: attempts + 1 });
423
+ return;
424
+ } catch (error) {
425
+ attempts++;
426
+ this.logger.error(
427
+ {
428
+ error: error instanceof Error ? error.message : error,
429
+ stack: error instanceof Error ? error.stack : void 0,
430
+ exchange,
431
+ routingKey,
432
+ attempt: attempts,
433
+ maxRetries: this.options.maxRetries
434
+ },
435
+ "Handler failed"
436
+ );
437
+ if (attempts < this.options.maxRetries) {
438
+ await this.sleep(this.options.retryDelay);
439
+ }
440
+ }
441
+ }
442
+ const duration = Date.now() - startTime;
443
+ this.logger.error(
444
+ { exchange, routingKey, maxRetries: this.options.maxRetries, duration },
445
+ "Message failed after max retries, sending to DLQ"
446
+ );
447
+ channel.nack(message, false, false);
448
+ this.callHook(this.hooks.onMessageDlq, { exchange, routingKey, duration, reason: "max_retries_exhausted" });
449
+ } finally {
450
+ this.inflightCount--;
451
+ if (this.inflightCount === 0 && this.drainResolve) {
452
+ this.drainResolve();
453
+ }
454
+ }
455
+ }
456
+ /**
457
+ * Lightweight liveness probe: creates and immediately deletes a temporary
458
+ * exclusive queue. Returns `false` when disconnected or on broker error.
459
+ */
460
+ async checkHealth() {
461
+ if (!this.connected || !this.channel) {
462
+ return false;
463
+ }
464
+ try {
465
+ const { queue } = await this.channel.assertQueue("", {
466
+ exclusive: true,
467
+ autoDelete: true
468
+ });
469
+ await this.channel.deleteQueue(queue);
470
+ return true;
471
+ } catch (error) {
472
+ this.logger.warn({ error }, "Health check failed");
473
+ return false;
474
+ }
475
+ }
476
+ waitForDrain(timeout) {
477
+ if (this.inflightCount === 0) return Promise.resolve();
478
+ return new Promise((resolve) => {
479
+ const timer = setTimeout(() => {
480
+ this.logger.warn(
481
+ { inflightCount: this.inflightCount },
482
+ "Close timeout reached with messages still in flight"
483
+ );
484
+ this.drainResolve = null;
485
+ resolve();
486
+ }, timeout);
487
+ this.drainResolve = () => {
488
+ clearTimeout(timer);
489
+ this.drainResolve = null;
490
+ resolve();
491
+ };
492
+ });
493
+ }
494
+ /** Invokes a hook callback, swallowing errors so hooks never break message flow. */
495
+ callHook(hook, info) {
496
+ try {
497
+ hook?.(info);
498
+ } catch {
499
+ }
500
+ }
501
+ sleep(ms) {
502
+ return new Promise((resolve) => setTimeout(resolve, ms));
503
+ }
504
+ };
505
+ // Annotate the CommonJS export names for ESM import in node:
506
+ 0 && (module.exports = {
507
+ RabbitMQClient
508
+ });
509
+ //# sourceMappingURL=index.cjs.map