@pixellot/pxlt-rabbit-handler
Version:
A generic class that handles RabbitMQ connection, consume and produce functionality.
937 lines (770 loc) • 39.2 kB
JavaScript
const EventEmitter = require('events');
const url = require('url');
const amqp = require('amqplib');
const winston = require('winston');
const _ = require('lodash');
const utils = require('./utils');
const config = require('../config/default');
/**
* Class that handles ReabbitMQ connect, consume and produce functionalities. Emits different events to notify Rabbit related occurrences
*/
class RabbitHandler extends EventEmitter {
/**
* @param {string} connString containing connection information (username, password, endpoint, virtual host)
* @param {Object} [options={}] an object of any amqplib supported parameters and custom parameters "maxBackoffIntervalMs", "maxBackoffCount", "backoffFactor"
* @param {Number} [options.maxBackoffIntervalMs] - maximum interval in ms of the exponential backoff retry mechanism for the connect function. Defaults to 60000
* @param {Number} [options.backoffFactor] - multiplication factor of the base-2 exponent. Defaults to 50
* @param {Number} [options.maxBackoffCount] - maximum connect retry count. Defaults to Infinity
* @param {String} [options.logLevel] - log level from which logging to console is enabled. Defaults to 'info'
*/
constructor(connString, options = {}) {
super();
this.connString = connString;
this.options = options;
this.maxBackoffIntervalMs = this.options.maxBackoffIntervalMs || config.maxBackoffIntervalMs;
this.backoffFactor = this.options.backoffFactor || config.backoffFactor;
this.maxBackoffCount = this.options.maxBackoffCount == null ? config.maxBackoffCount : this.options.maxBackoffCount;
this.connRetryCount = -1;
this.connectionRetry = false;
this.connection = null;
this.hostname = url.parse(this.connString).hostname;
this.connectOnce = utils.runOnce(this._connect, this);
this.consumers = {};
this.defaultChannel = null;
this.channels = {};
this.connectionState = 'closed';
this.logger = winston.createLogger({
level: this.options.logLevel || config.logLevel,
transports: [
new winston.transports.Console()
],
format: winston.format.printf((msg) => winston.format.colorize().colorize(msg.level, `${msg.level}: ${msg.message}`))
});
}
/**
* Connects to Rabbit with reconnect functionality
* @return {Promise}
* @private
*/
_createConnection() {
const self = this;
self.logger.debug(`[AMQP] connecting to ${self.hostname}`);
return amqp.connect(self.connString, {
servername: self.hostname,
...self.options
})
.then((conn) => {
self.connection = conn;
self.connectionState = 'active';
// Catch the cases when the server lose the connection , and re-connect
self.connection.on('close', () => {
self.connectionState = 'closed';
if (!self.connectionRetry) {
self.logger.error(`[AMQP] connection to Rabbit closed, attempting to reconnect in ${self.connRetryCount} retry at: ${self.hostname}`);
self.connectionRetry = true;
self.connection = null;
return setTimeout(() => self._connect().catch((err) => {
// this point is reached in 2 cases: connection not established after max retries, or consume after reconnect failed
self.logger.error(`[AMQP] no more reconnect/reconsume attempts will be made. ${err}`);
if (self.getConnectionState() === 'active') {
self.connection.close();
self.connectionState = 'closed';
self.connectionRetry = true;
}
self.emit('connectionClosed', err);
}), utils.exponentialBackoff(self.connRetryCount, self.backoffFactor, self.maxBackoffIntervalMs));
}
});
self.connection.on('error', (err) => {
self.connectionState = 'error';
self.logger.error(`[AMQP] error in connection to Rabbit at ${self.hostname}. ${err}`);
self.emit('connectionError');
});
self.connection.on('blocked', (reason) => {
self.connectionState = 'blocked';
self.logger.info(`[AMQP] Rabbit server decided to block the connection because ${reason}`);
self.emit('connectionBlocked');
});
self.connection.on('unblocked', () => {
self.connectionState = 'active';
self.logger.info('[AMQP] Rabbit server unblocked the connection');
self.emit('connectionUnblocked');
});
}).catch((err) => {
throw new Error(`[AMQP] failed to connect to Rabbit. ${err}`);
});
}
/**
* Created a channel on a connection
* @param {boolean} [confirm=true] - boolean denoting whether created channel should be a confirm channel or not. Defaults to true.
* @return {Promise}
* @private
*/
_createChannel(confirm = true) {
const self = this;
let channelPromise;
self.logger.debug('[AMQP] attempting to create a channel on connection');
if (!self.connection) {
return Promise.reject(new Error('[AMQP] Cannot create channel as no connection is established'));
}
if (confirm) {
channelPromise = self.connection.createConfirmChannel();
} else {
channelPromise = self.connection.createChannel();
}
return channelPromise
.catch((err) => {
throw new Error(`[AMQP] failed to create a Rabbit channel. ${err}`);
});
}
/**
* Checks whether the queue exists. If it doesn't exist, the channel will be closed with an error
* @param {string} queueName - the queue name to check
* @return {*|{durable: boolean, ticket: number, autoDelete: boolean, exclusive: boolean, passive: boolean, queue: *, nowait: boolean}}
* @private
*/
_checkQueue(queueName) {
return this.consumers[queueName].channel.checkQueue(queueName);
}
_assertQueue(queueName, options, channel = null) {
this.logger.info(`[AMQP] asserting queue ${queueName} with options ${JSON.stringify(options)}`);
const channelToUse = channel ?? this.consumers[queueName].channel;
return channelToUse.assertQueue(queueName, options);
}
checkExchange(exchangeName) {
return this._getDefaultChannel()
.then((ch) => ch.checkExchange(exchangeName))
.catch((err) => {
throw new Error(`[AMQP] check exchange ${exchangeName} failed: error = ${err}`);
});
}
assertExchange(exchangeName, exchangeType, exchangeOptions) {
this.logger.info(`[AMQP] asserting exchange ${exchangeName} ${exchangeType} ${JSON.stringify(exchangeName)}`);
return this._getDefaultChannel()
.then((ch) => ch.assertExchange(exchangeName, exchangeType, exchangeOptions))
.catch((err) => {
throw new Error(`[AMQP] check exchange ${exchangeName} failed: error = ${err}`);
});
}
/**
* @param {Object} retryConfig - An object that defines configuration for the retry process
*
* @param {Object} retryConfig.queue - The queue object in the retry configuration
* @param {string} retryConfig.queue.exchangeToBindTo - The name of the exchange to which the queue will be bound
* @param {string} retryConfig.queue.nameTemplate - The template for the name of the queue, used in retry mechanism
* @param {string} retryConfig.queue.routingKey - The routing key template for the queue, used in retry mechanism. This should contain 'DELAY' which will be replaced with each retry delay time.
* @param {Object} retryConfig.queue.options - Additional options for the queue
* @param {boolean} retryConfig.queue.options.durable - If true, the queue will survive broker restarts, modulo the effects of exclusive and autoDelete; this defaults to true if not supplied
* @param {Object} retryConfig.queue.options.arguments - Arguments for the queue. 'x-dead-letter-exchange' for specifying the exchange where the message will be sent after its TTL is expired and 'x-message-ttl' for setting the message's TTL in milliseconds.
* @param {string} retryConfig.queue.options.arguments['x-dead-letter-exchange'] - An exchange where the message from the queue will be sent when it's dead-lettered
* @param {number} retryConfig.queue.options.arguments['x-message-ttl'] - Time-to-live for a message in the queue in milliseconds
* @param {Array<number>} retryConfig.queue.retryDelaysInSeconds - An array of delay times (in seconds) that the retry mechanism should use. The nth retry will use the nth delay time. If there are more retries than delay times, the last delay time in the array will be used for all further retries.
*/
async setupRetry(retryConfig) {
try {
const channel = await this._getDefaultChannel();
// Queue creation
const { queue } = retryConfig;
for (const delay of queue.retryDelaysInSeconds) {
const queueName = queue.nameTemplate.replace('DELAY', delay);
const routingKey = queue.routingKey.replace('DELAY', delay);
const queueOptions = {
durable: queue.options.durable,
arguments: {
...queue.options.arguments,
'x-message-ttl': delay * 1000 // convert delay from seconds to milliseconds
}
};
await this._assertQueue(queueName, queueOptions, channel);
// Binding queue to the exchange
this.logger.info(`[AMQP] binding retry queue: ${queueName} to ${queue.exchangeToBindTo} with routing key: ${routingKey}`);
await channel.bindQueue(queueName, queue.exchangeToBindTo, routingKey);
}
} catch (err) {
this.logger.error('Failed setup retry', err);
throw new Error('Failed setup retry', err);
}
}
/**
* @param {Object} [queueSettings] - an obcject the following properties:
* @param {string} [queueSettings.queueName]
* @param {Object} [queueSettings.queueOptions]
* @param {string} [queueSettings.bindToExchange]
* @param {string} [queueSettings.bindToExchange.routingKey]
* @param {string} [queueSettings.bindToExchange.exchangeName]
* @param {Object} [queueSettings.deadLetter=null]
* @param {string} [queueSettings.deadLetter.exchangeName]
* @param {string} [queueSettings.deadLetter.exchangeType]
* @param {Object} [queueSettings.deadLetter.exchangeOptions]
* @param {string} [queueSettings.deadLetter.queueName]
* @param {Object} [queueSettings.deadLetter.queueOptions]
* @param {string} [queueSettings.deadLetter.routingKey='*']
*/
async queueSetup(queueSettings) {
try {
const channel = await this._getDefaultChannel();
await this._addDeadLetterItems(queueSettings?.deadLetter, channel);
const q = await this._assertQueue(queueSettings.queueName, queueSettings.queueOptions);
if (queueSettings.bindToExchange) {
this.logger.info(`[AMQP] binding queue to ${queueSettings.bindToExchange.exchangeName} with routing key: ${queueSettings.bindToExchange.exchangeName}`);
await channel.bindQueue(queueSettings.queueName, queueSettings.bindToExchange.exchangeName, queueSettings.bindToExchange.routingKey);
}
return q;
} catch (err) {
this.logger.error('[AMQP] failed to setup queue: queueSettings.queueName', err);
}
}
/**
* Get a channel if there is already one, or create one
* @param {string} queueName
* @param {boolean} confirm
* @returns
*/
async getChannel(queueName, confirm = true) {
const self = this;
try {
if (Object.prototype.hasOwnProperty.call(self.consumers, queueName)) {
return self.consumers[queueName].channel;
}
const ch = await self._createChannel(confirm);
self.consumers[queueName].channel = ch;
self.channels[ch.ch] = ch;
return ch;
} catch (err) {
const errMsg = `[AMQP] consume error: ${err}`;
self.logger.error(errMsg);
throw new Error(errMsg);
}
}
/**
* Consumes messages from provided queue and emits according to defined rate and batch size
* @param {string} queueName - name of queue to consume
* @param {function} clientMessageHandler - the message processing callback to trigger upon message arrival
* @param {Object} [consumeOptions] - all rabbit supported consume options and pxlt-rabbit-handler consume options
* @param {boolean}[consumeOptions.confirm=true] - boolean denoting whether created channel should be a confirm channel or not. Defaults to true
* @param {Number} [consumeOptions.prefetch=1] - Number denoting the consumer prefetch value. Defaults to 1
* @param {Object} [consumeOptions.retryConfig] - exchange details to assertExchange
* @param {Array} [consumeOptions.retryConfig.retryDelaysInSeconds] - array of numbers represent the delay in second for each Q
* @param {string} [consumeOptions.retryConfig.exchange] - exchange name
* @param {Object} [consumeOptions.queueSettings=null] - an obcject the following properties:
* @param {string} [consumeOptions.queueSettings.queueName]
* @param {Object} [consumeOptions.queueSettings.queueOptions]
* @param {string} [consumeOptions.queueSettings.bindToExchange]
* @param {string} [consumeOptions.queueSettings.bindToExchange.routingKey]
* @param {string} [consumeOptions.queueSettings.bindToExchange.exchangeName]
* @param {Object} [consumeOptions.queueSettings.deadLetter=null]
* @param {string} [consumeOptions.queueSettings.deadLetter.exchangeName]
* @param {string} [consumeOptions.queueSettings.deadLetter.exchangeType]
* @param {Object} [consumeOptions.queueSettings.deadLetter.exchangeOptions]
* @param {string} [consumeOptions.queueSettings.deadLetter.queueName]
* @param {Object} [consumeOptions.queueSettings.deadLetter.queueOptions]
* @param {string} [consumeOptions.queueSettings.deadLetter.routingKey='*']
* @return {Promise} - a promise
*/
async consume(queueName, clientMessageHandler, consumeOptions, retry = false, attempt = 1) {
if (!clientMessageHandler) {
return Promise.reject('No valid message handling callback was provided');
}
const self = this;
let channel;
// if consume was already called with specific queue, and it's not after a reconnect, reject.
if (self.consumers[queueName] && !self.connectionRetry) {
return Promise.reject(`[AMQP] consume error: Already consuming from queue ${queueName}`);
}
self.consumers[queueName] = { options: consumeOptions, callback: clientMessageHandler };
const retryConfig = (consumeOptions && consumeOptions.retryConfig) || null;
return self._createChannel(consumeOptions && consumeOptions.confirm)
.then((ch) => {
channel = ch;
if (retryConfig) {
ch.retryConfig = retryConfig;
}
self.consumers[queueName].channel = ch;
self.channels[ch.ch] = ch;
ch.on('close', () => {
self.logger.error('[AMQP] channel closed.');
// if channel is not closed as a result of client actively asking
if (self.consumers[queueName]) {
self.consumers[queueName].channel = null;
delete self.channels[ch.ch];
}
// Reconnection is not needed here as connection close event will trigger it
// As we do not close channels without closing the connection, we assume all close events are caused by connection closed
});
ch.on('error', (err) => {
self.logger.error(`[AMQP] error on channel ${ch.ch}. ${err}`);
});
return ch.prefetch(parseInt((consumeOptions && consumeOptions.prefetch) || config.channelPrefetch, 10));
})
.then(() => self._addDeadLetterItems(consumeOptions?.queueSettings?.deadLetter, channel))
.then(() => {
if (consumeOptions?.queueSettings) {
return self._assertQueue(consumeOptions.queueSettings.queueName, consumeOptions.queueSettings.queueOptions);
}
return self._checkQueue(queueName);
})
.then((q) => {
if (consumeOptions?.queueSettings?.bindToExchange) {
this.logger.info(`[AMQP] starting to bind queue to ${consumeOptions.queueSettings.bindToExchange.exchangeName}`);
// Check if routingKey is an array
if (Array.isArray(consumeOptions.queueSettings.bindToExchange.routingKey)) {
const bindings = consumeOptions.queueSettings.bindToExchange.routingKey.map((rk) => {
this.logger.info(`[AMQP] binding queue with routing key: ${rk}`);
return channel.bindQueue(queueName, consumeOptions.queueSettings.bindToExchange.exchangeName, rk);
});
// Use Promise.all to wait for all bindings to complete
return Promise.all(bindings).then(() => q);
}
this.logger.info(`[AMQP] binding queue with routing key: ${consumeOptions.queueSettings.bindToExchange.routingKey}`);
return channel.bindQueue(queueName, consumeOptions.queueSettings.bindToExchange.exchangeName, consumeOptions.queueSettings.bindToExchange.routingKey).then(() => q);
}
return q;
})
.then((q) => {
self.logger.info(`[AMQP] Waiting for messages queue: ${queueName}`);
return self.consumers[queueName].channel.consume(q.queue, (msg) => {
self.logger.debug(`[AMQP] received message ${msg.fields.deliveryTag} on queue ${queueName}`);
if (!msg) {
return;
}
Promise.resolve()
.then(() => clientMessageHandler({ deliveryIdentifier: channel.ch, message: msg }))
.catch((err) => {
const error = `Unhandled rejection was received from callback: ${err}`;
self.logger.error(`[AMQP] ${error}`);
throw error;
});
}, consumeOptions);
})
.then((tag) => {
self.logger.debug(`[AMQP] consumer tag ${JSON.stringify(tag)}`);
self.consumers[queueName].consumerTag = tag.consumerTag;
})
.catch(async (err) => {
const errMsg = `[AMQP] consume error: ${err}`;
self.logger.error(errMsg);
delete self.consumers[queueName];
delete self.channels[channel.ch];
if (!retry) throw new Error(errMsg);
const delay = Math.min(2 ** attempt * 1000, config.maxBackoffIntervalMs); // Cap the delay at 60 seconds
await utils.sleep(delay);
await this.consume(queueName, clientMessageHandler, consumeOptions, retry, attempt + 1);
});
}
/**
* gets a message from a specific queue
* @param {String} queueName - queue
* @param {Object} [options] - amqplib supported options
* @param {boolean} [options.noAck] - denoting whether server expects acknowledge
* @return {Promise<msgOrFalse>} - a promise
*/
getMessage(queueName, options) {
const self = this;
let channel;
return self._getDefaultChannel()
.then((ch) => {
channel = ch;
return ch.get(queueName, options);
})
.then((msg) => {
if (msg) {
return { deliveryIdentifier: channel.ch, message: msg };
}
return msg;
})
.catch((err) => {
throw new Error(`[AMQP] get message from queue ${queueName} failed: error = ${err}`);
});
}
_connect() {
const self = this;
self.connRetryCount++;
return self._createConnection()
.then(() => {
const msg = self.connectionRetry ? 'after a connection closed event' : '';
self.logger.debug(`[AMQP] successfully initiated connection ${msg}`);
self.connRetryCount = -1;
self.emit('connectionCreated', self.connectionRetry);
if (self.connectionRetry) {
return Promise.all(Object.keys(self.consumers).map((queue) => self.consume(queue, self.consumers[queue].callback, self.consumers[queue].options)))
.then(() => {
self.connectionRetry = false;
});
}
})
.catch((err) => {
self.logger.error(`[AMQP] connect failure: ${err}\n`);
if (!self.connection) {
if (self.connRetryCount >= self.maxBackoffCount) {
throw new Error(err);
} else {
return new Promise((resolve) => {
setTimeout(() => {
self.logger.info(`[AMQP] attempting to reconnect for the ${self.connRetryCount + 1}th time out of ${self.maxBackoffCount}`);
resolve(self._connect());
}, utils.exponentialBackoff(self.connRetryCount, self.backoffFactor, self.maxBackoffIntervalMs));
});
}
}
// if failure to re-consume occurs, throw
throw new Error(err);
});
}
/**
* Initializes a connection and a channel to Rabbit according to defined instance parameters
* @return {Promise}
*/
connect() {
const self = this;
if (self.listeners('connectionClosed').length === 0) {
self.logger.warn("No 'connectionClosed' event handler defined, please be aware that in case reconnect fails, no transactions will be forthcoming");
}
return new Promise((resolve, reject) => {
try {
resolve(self.connectOnce());
return;
} catch (e) {
reject(`[AMQP] connect failure: ${e}`);
}
});
}
/**
* setting up the dead letter exchange Q and bind them together
* @param {Object} [deadLetterSettings=null]
* @param {string} [deadLetterSettings.exchangeName]
* @param {string} [deadLetterSettings.exchangeType]
* @param {Object} [deadLetterSettings.exchangeOptions]
* @param {Array} [deadLetterSettings.queues] // array of queues {name:string, routingKey:string ,options:Object}
* @param {Channel} channel
*/
async _addDeadLetterItems(deadLetterSettings, channel) {
if (!deadLetterSettings) return;
const self = this;
this.logger.info(`[AMQP] adding dead letter: ${JSON.stringify(deadLetterSettings)}`);
try {
await channel.assertExchange(deadLetterSettings.exchangeName, deadLetterSettings.exchangeType, deadLetterSettings.exchangeOptions);
if (deadLetterSettings.queues) {
for (const key in deadLetterSettings.queues) {
if (Object.hasOwn(deadLetterSettings.queues, key)) {
const q = deadLetterSettings.queues[key];
await self._assertQueue(q.name, q.options, channel);
this.logger.info(`[AMQP] binding queue ${q.name} to ${deadLetterSettings.exchangeName} with routing key: ${q.routingKey}`);
await channel.bindQueue(q.name, deadLetterSettings.exchangeName, q.routingKey);
}
}
}
} catch (ex) {
throw new Error('Failed to setup dead letter', ex);
}
}
_getDefaultChannel() {
const self = this;
if (!self.defaultChannel) {
return self._createChannel()
.then((ch) => {
self.channels[ch.ch] = ch;
self.defaultChannel = ch;
ch.on('close', () => {
self.logger.error('[AMQP] channel closed.');
if (self.defaultChannel) {
self.defaultChannel = null;
}
delete self.channels[ch.ch];
// Reconnection is not needed here as connection close event will trigger it
// As we do not close channels without closing the connection, we assume all close events are caused by connection closed
});
ch.on('error', (err) => {
self.logger.error(`[AMQP] error on channel ${ch.ch}. ${err}`);
});
return ch;
});
}
return Promise.resolve(self.defaultChannel);
}
_publish(exchangeName, routingKey, buffer, options = {}) {
const self = this;
// Create a new options object, setting persistent to true by default.
// If 'persistent' is already specified in options, it will use that value.
const optionsToPublish = { persistent: true, ...options };
return new Promise((resolve, reject) => {
self._getDefaultChannel()
.then(() => self.defaultChannel.publish(exchangeName, routingKey, buffer, optionsToPublish, (error) => {
if (error) {
return reject(error);
}
return resolve();
}))
.catch((err) => reject(`[AMQP] can't publish to ${exchangeName}: error = ${err}`));
});
}
/**
* Publish message to the required exchange via routing key
* @param {string} exchangeName - name of exchange to publish to
* @param {string} routingKey - routing key that will determine where the message goes
* @param {buffer} buffer - a buffer containing the message content
* @param {Object} [options={}] - object containing any rabbit supported/ignored options
* @param {Number} [options.timeout] - timeout in ms until rejecting, in case server doesn't send acknowledgment the published message was dealt with. If not provided, no timeout is applied
* @return {Promise<boolean>} - a promise the resolves to publish result
*/
publish(exchangeName, routingKey, buffer, options = {}) {
const self = this;
let publishPromise;
if (options.timeout) {
publishPromise = utils.promiseTimeout(options.timeout, self._publish(exchangeName, routingKey, buffer, options));
} else {
publishPromise = self._publish(exchangeName, routingKey, buffer, options);
}
return publishPromise
.then((publishRes) => {
self.logger.debug(`[AMQP] Successfully published msg to exchange ${exchangeName}`);
return Promise.resolve(publishRes);
})
.catch((err) => Promise.reject(`[AMQP] publish to exchange ${exchangeName} failed. err: ${err}`));
}
/**
* Sends a message to a specific queue
* @param {string} queueName - queue
* @param {buffer} buffer - the buffer to publish
* @return {Promise} - a promise
*/
sendMessage(queueName, buffer, options) {
const self = this;
const persistentOptions = _.merge({ persistent: true }, options);
return new Promise((resolve, reject) => {
self._getDefaultChannel()
.then((ch) => {
try {
const res = ch.sendToQueue(queueName, buffer, persistentOptions);
self.logger.debug(`[AMQP] Successfully sent msg to queue ${queueName}`);
resolve(res);
} catch (err) {
const errStr = `[AMQP] failed to send message to queue ${queueName}. ${err}`;
self.logger.debug(errStr);
reject(errStr);
}
})
.catch((err) => reject(`[AMQP] can't send message to queue ${queueName}: error = ${err}`));
});
}
/**
* acks provided message
* @param {Object} message - message object to ack
* @param {boolean} [allUpTo=false] - if true, all outstanding messages prior to and including the given message shall be considered acknowledged. Defaults to false.
* @return {Promise} a promise
*/
ack(message, allUpTo = false) {
const self = this;
const channel = self.channels[message.deliveryIdentifier];
if (!channel) {
return Promise.reject('[AMQP] No channel to ack on');
}
self.logger.debug(`[AMQP] acking message with delivery tag ${message.message.fields.deliveryTag}`);
channel.ack(message.message, allUpTo);
return Promise.resolve();
}
_deathCount(headers) {
if (!headers || !headers['x-death']) {
return 0;
}
let sum = 0;
headers['x-death'].forEach((element) => {
sum += element.count;
});
return sum;
}
/**
* retries provided message
* @param {{ deliveryIdentifier: String, message: amqp.ConsumeMessage }} message - message object to ack
* @return {Promise} a promise
*/
retry(message) {
const self = this;
const channel = self.channels[message.deliveryIdentifier];
if (!channel) {
return Promise.reject('[AMQP] No channel to ack on');
}
if (!channel.retryConfig) {
return Promise.reject('[AMQP] No channel retry config');
}
const attemptNumber = self._deathCount(message.message.properties.headers);
if (attemptNumber < channel.retryConfig.retryDelaysInSeconds.length) {
const delayInSeconds = channel.retryConfig.retryDelaysInSeconds[attemptNumber];
self.logger.info(`[AMQP] retrying delay=${delayInSeconds} count=${attemptNumber}`);
const routingKeySegments = message.message.fields.routingKey.split('.');
if (routingKeySegments.length > 0) {
if (Number.parseInt(routingKeySegments[routingKeySegments.length - 1], 10) >= 0) {
routingKeySegments.pop();
}
}
routingKeySegments.push(delayInSeconds);
const newRoutingKey = routingKeySegments.join('.');
channel.publish(channel.retryConfig.exchange, newRoutingKey, message.message.content, { headers: message.message.properties.headers, persistent: true });
channel.ack(message.message, false);
} else {
self.logger.error(`[AMQP] failed to handle message ${message.message.fields.deliveryTag} for ${attemptNumber} times. stopping retry attempts...`);
channel.nack(message.message, false, false);
}
return Promise.resolve();
}
/**
* nacks provided message
* @param {Object} options - nack options
* @param {Object} message - message object to nack
* @param {boolean[]} [options=[false, false]] - [allUpTo, [requeue]], If allUpTo is truthy, all outstanding messages prior to and including the given message are rejected. Defaults to false. If requeue is truthy, the server will try to put the messages back on the queue from which they came. Defaults to false.
* @return {Promise} a promise
*/
nack(message, options = [false, false]) {
const self = this;
const channel = self.channels[message.deliveryIdentifier];
if (!channel) {
return Promise.reject('[AMQP] No channel to nack on');
}
self.logger.debug(`[AMQP] nacking message with delivery tag ${message.message.fields.deliveryTag}`);
channel.nack(message.message, ...options);
return Promise.resolve();
}
/**
* requeu message once
* @param {Object} message - message object to nack
* @return {Promise} a promise
*/
requeueOnce(message) {
const self = this;
self.logger.debug(`[AMQP] requeueOnce message with delivery tag ${message.message.fields.deliveryTag}`);
self.nack(message, [false, !message.message.fields.redelivered]);
return Promise.resolve();
}
/**
* acks all messages that were prefetched
* @param {Number} identifier - delivery identifier received on message
* @return {Promise} a promise
*/
ackAll(identifier) {
const self = this;
const channel = self.channels[identifier];
if (!channel) {
return Promise.reject('[AMQP] No channel to ack on');
}
self.logger.debug(`[AMQP] acking all on channel ${channel.ch}`);
channel.ackAll();
return Promise.resolve();
}
/**
* nacks all messages that were prefetched
* @param {Number} identifier - delivery identifier received on message
* @param {boolean} [requeue=false] - if truthy, the server will try to put the messages back on the queue from which they came. Defaults to false
* @return {Promise} a promise
*/
nackAll(identifier, requeue = false) {
const self = this;
const channel = self.channels[identifier];
if (!channel) {
return Promise.reject('[AMQP] No channel to nack on');
}
self.logger.debug(`[AMQP] nacking all on channel ${channel.ch}`);
channel.nackAll(requeue);
return Promise.resolve();
}
/**
* Stop consuming from a queue. This will close the queue's channel
* @param {String} queueName - Name of queue to nack on
* @return {Promise}
*/
cancelConsumer(queueName) {
const self = this;
const channel = self.consumers[queueName] && self.consumers[queueName].channel;
const consumerTag = self.consumers[queueName] && self.consumers[queueName].consumerTag;
return new Promise((resolve) => {
if (!channel) {
self.logger.info(`[AMQP] no open channel for queue ${queueName} to cancel`);
resolve();
return;
}
if (!consumerTag) {
self.logger.info(`[AMQP] no consumerTag for queue ${queueName} to cancel`);
resolve();
return;
}
channel.cancel(consumerTag)
.then(() => channel.close())
.finally(() => {
delete self.consumers[queueName];
delete self.channels[channel.ch];
self.logger.info(`Cancelled consumer for queue ${queueName}`);
resolve();
});
});
}
/**
* Stop consuming from a queue only
* @param {String} queueName - Name of queue to stop consuming from
* @return {Promise}
*/
stopConsumer(queueName) {
const self = this;
const channel = self.consumers[queueName] && self.consumers[queueName].channel;
const consumerTag = self.consumers[queueName] && self.consumers[queueName].consumerTag;
return new Promise((resolve) => {
if (!channel) {
self.logger.info(`[AMQP] no open channel for queue ${queueName} to cancel`);
resolve();
return;
}
if (!consumerTag) {
self.logger.info(`[AMQP] no consumerTag for queue ${queueName} to cancel`);
resolve();
return;
}
channel.cancel(consumerTag)
.finally(() => {
self.logger.info(`Stopped consuming for queue ${queueName}`);
resolve();
});
});
}
/**
* Gracefully closing connection and cancelling consumers if exist
* @return {Promise} a promise
*/
gracefullyDisconnect() {
const self = this;
return new Promise((resolve, reject) => {
if (!self.connection) {
self.logger.debug('[AMQP] no open connection to close');
resolve();
return;
}
Promise.all(Object.keys(self.consumers).map(((queueName) => self.cancelConsumer(queueName).catch((err) => {
self.logger.error(`[AMQP] failed to cancel consumer for queue ${queueName}. Error: ${err}`);
}))))
.then(() => Promise.all(Object.values(self.channels).map((channel) => {
if (!channel) {
return;
}
return channel.close().catch((err) => {
self.logger.error(`[AMQP] failed to close channel ${channel.ch}. Error: ${err}`);
});
})))
.then(() => {
self.connectionRetry = true;
return self.connection.close();
})
.then(() => {
self.logger.debug('[AMQP] connection to rabbit closed');
return resolve();
})
.catch((err) => {
self.logger.error(`[AMQP] failed to close connection to rabbit. ${err}`);
return reject(err);
});
});
}
/**
* Returns connection state: 'active', 'closed', 'error', 'blocked'
* @returns {String}
*/
getConnectionState() {
return this.connectionState;
}
}
module.exports = RabbitHandler;