@vercel/sqs-consumer
Version:
Build SQS-based Node applications without the boilerplate
357 lines (356 loc) • 12.4 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Consumer = void 0;
const SQS = require("aws-sdk/clients/sqs");
const Debug = require("debug");
const events_1 = require("events");
const bind_1 = require("./bind");
const errors_1 = require("./errors");
const debug = Debug('sqs-consumer');
const requiredOptions = [
'queueUrl',
// only one of handleMessage / handleMessagesBatch is required
'handleMessage|handleMessageBatch'
];
function createTimeout(duration) {
let timeout;
const pending = new Promise((_, reject) => {
timeout = setTimeout(() => {
reject(new errors_1.TimeoutError());
}, duration);
});
return [timeout, pending];
}
function assertOptions(options) {
requiredOptions.forEach((option) => {
const possibilities = option.split('|');
if (!possibilities.find((p) => options[p])) {
throw new Error(`Missing SQS consumer option [ ${possibilities.join(' or ')} ].`);
}
});
if (options.batchSize > 10 || options.batchSize < 1) {
throw new Error('SQS batchSize option must be between 1 and 10.');
}
if (options.heartbeatInterval && !(options.heartbeatInterval < options.visibilityTimeout)) {
throw new Error('heartbeatInterval must be less than visibilityTimeout.');
}
}
function isConnectionError(err) {
if (err instanceof errors_1.SQSError) {
return (err.statusCode === 403 || err.code === 'CredentialsError' || err.code === 'UnknownEndpoint');
}
return false;
}
function toSQSError(err, message) {
const sqsError = new errors_1.SQSError(message);
sqsError.code = err.code;
sqsError.statusCode = err.statusCode;
sqsError.region = err.region;
sqsError.retryable = err.retryable;
sqsError.hostname = err.hostname;
sqsError.time = err.time;
return sqsError;
}
function hasMessages(response) {
return response.Messages && response.Messages.length > 0;
}
class Consumer extends events_1.EventEmitter {
constructor(options) {
super();
assertOptions(options);
this.queueUrl = options.queueUrl;
this.handleMessage = options.handleMessage;
this.handleMessageBatch = options.handleMessageBatch;
this.handleMessageTimeout = options.handleMessageTimeout;
this.attributeNames = options.attributeNames || [];
this.messageAttributeNames = options.messageAttributeNames || [];
this.stopped = true;
this.batchSize = options.batchSize || 1;
this.visibilityTimeout = options.visibilityTimeout;
this.terminateVisibilityTimeout = options.terminateVisibilityTimeout || false;
this.heartbeatInterval = options.heartbeatInterval;
this.waitTimeSeconds = options.waitTimeSeconds || 20;
this.authenticationErrorTimeout = options.authenticationErrorTimeout || 10000;
this.pollingWaitTimeMs = options.pollingWaitTimeMs || 0;
this.sqs = options.sqs || new SQS({
region: options.region || process.env.AWS_REGION || 'eu-west-1'
});
this.preReceiveMessageCallback = options.preReceiveMessageCallback;
this.postReceiveMessageCallback = options.postReceiveMessageCallback;
bind_1.autoBind(this);
}
emit(event, ...args) {
return super.emit(event, ...args);
}
on(event, listener) {
return super.on(event, listener);
}
once(event, listener) {
return super.once(event, listener);
}
get isRunning() {
return !this.stopped;
}
static create(options) {
return new Consumer(options);
}
start() {
if (this.stopped) {
debug('Starting consumer');
this.stopped = false;
this.poll();
}
}
stop() {
debug('Stopping consumer');
this.stopped = true;
}
async handleSqsResponse(response) {
debug('Received SQS response');
debug(response);
if (response) {
if (hasMessages(response)) {
if (this.handleMessageBatch) {
// prefer handling messages in batch when available
await this.processMessageBatch(response.Messages);
}
else {
await Promise.all(response.Messages.map(this.processMessage));
}
this.emit('response_processed');
}
else {
this.emit('empty');
}
}
}
async processMessage(message) {
this.emit('message_received', message);
let heartbeat;
try {
if (this.heartbeatInterval) {
heartbeat = this.startHeartbeat(async (elapsedSeconds) => {
return this.changeVisibilityTimeout(message, elapsedSeconds + this.visibilityTimeout);
});
}
await this.executeHandler(message);
await this.deleteMessage(message);
this.emit('message_processed', message);
}
catch (err) {
this.emitError(err, message);
if (this.terminateVisibilityTimeout) {
if (typeof this.terminateVisibilityTimeout === 'function') {
const visibilityTimeout = this.terminateVisibilityTimeout(message);
await this.changeVisibilityTimeout(message, visibilityTimeout);
}
else {
await this.changeVisibilityTimeout(message, 0);
}
}
}
finally {
clearInterval(heartbeat);
}
}
async receiveMessage(params) {
try {
if (this.preReceiveMessageCallback) {
await this.preReceiveMessageCallback();
}
const result = await this.sqs
.receiveMessage(params)
.promise();
if (this.postReceiveMessageCallback) {
await this.postReceiveMessageCallback();
}
return result;
}
catch (err) {
throw toSQSError(err, `SQS receive message failed: ${err.message}`);
}
}
async deleteMessage(message) {
debug('Deleting message %s', message.MessageId);
const deleteParams = {
QueueUrl: this.queueUrl,
ReceiptHandle: message.ReceiptHandle
};
try {
await this.sqs
.deleteMessage(deleteParams)
.promise();
}
catch (err) {
throw toSQSError(err, `SQS delete message failed: ${err.message}`);
}
}
async executeHandler(message) {
let timeout;
let pending;
try {
if (this.handleMessageTimeout) {
[timeout, pending] = createTimeout(this.handleMessageTimeout);
await Promise.race([
this.handleMessage(message),
pending
]);
}
else {
await this.handleMessage(message);
}
}
catch (err) {
if (err instanceof errors_1.TimeoutError) {
err.message = `Message handler timed out after ${this.handleMessageTimeout}ms: Operation timed out.`;
}
else {
err.message = `Unexpected message handler failure: ${err.message}`;
}
throw err;
}
finally {
clearTimeout(timeout);
}
}
async changeVisibilityTimeout(message, timeout) {
try {
return this.sqs
.changeMessageVisibility({
QueueUrl: this.queueUrl,
ReceiptHandle: message.ReceiptHandle,
VisibilityTimeout: timeout
})
.promise();
}
catch (err) {
this.emit('error', err, message);
}
}
emitError(err, message) {
if (err.name === errors_1.SQSError.name) {
this.emit('error', err, message);
}
else if (err instanceof errors_1.TimeoutError) {
this.emit('timeout_error', err, message);
}
else {
this.emit('processing_error', err, message);
}
}
poll() {
if (this.stopped) {
this.emit('stopped');
return;
}
debug('Polling for messages');
const receiveParams = {
QueueUrl: this.queueUrl,
AttributeNames: this.attributeNames,
MessageAttributeNames: this.messageAttributeNames,
MaxNumberOfMessages: this.batchSize,
WaitTimeSeconds: this.waitTimeSeconds,
VisibilityTimeout: this.visibilityTimeout
};
let currentPollingTimeout = this.pollingWaitTimeMs;
this.receiveMessage(receiveParams)
.then(this.handleSqsResponse)
.catch((err) => {
this.emit('error', err);
if (isConnectionError(err)) {
debug('There was an authentication error. Pausing before retrying.');
currentPollingTimeout = this.authenticationErrorTimeout;
}
return;
}).then(() => {
setTimeout(this.poll, currentPollingTimeout);
}).catch((err) => {
this.emit('error', err);
});
}
async processMessageBatch(messages) {
messages.forEach((message) => {
this.emit('message_received', message);
});
let heartbeat;
try {
if (this.heartbeatInterval) {
heartbeat = this.startHeartbeat(async (elapsedSeconds) => {
return this.changeVisibilityTimeoutBatch(messages, () => elapsedSeconds + this.visibilityTimeout);
});
}
await this.executeBatchHandler(messages);
await this.deleteMessageBatch(messages);
messages.forEach((message) => {
this.emit('message_processed', message);
});
}
catch (err) {
this.emit('error', err, messages);
if (this.terminateVisibilityTimeout) {
if (typeof this.terminateVisibilityTimeout === 'function') {
await this.changeVisibilityTimeoutBatch(messages, this.terminateVisibilityTimeout);
}
else {
await this.changeVisibilityTimeoutBatch(messages, () => 0);
}
}
}
finally {
clearInterval(heartbeat);
}
}
async deleteMessageBatch(messages) {
debug('Deleting messages %s', messages.map((msg) => msg.MessageId).join(' ,'));
const deleteParams = {
QueueUrl: this.queueUrl,
Entries: messages.map((message) => ({
Id: message.MessageId,
ReceiptHandle: message.ReceiptHandle
}))
};
try {
await this.sqs
.deleteMessageBatch(deleteParams)
.promise();
}
catch (err) {
throw toSQSError(err, `SQS delete message failed: ${err.message}`);
}
}
async executeBatchHandler(messages) {
try {
await this.handleMessageBatch(messages);
}
catch (err) {
err.message = `Unexpected message handler failure: ${err.message}`;
throw err;
}
}
async changeVisibilityTimeoutBatch(messages, getTimeout) {
const params = {
QueueUrl: this.queueUrl,
Entries: messages.map((message) => ({
Id: message.MessageId,
ReceiptHandle: message.ReceiptHandle,
VisibilityTimeout: getTimeout(message)
}))
};
try {
return this.sqs
.changeMessageVisibilityBatch(params)
.promise();
}
catch (err) {
this.emit('error', err, messages);
}
}
startHeartbeat(heartbeatFn) {
const startTime = Date.now();
return setInterval(() => {
const elapsedSeconds = Math.ceil((Date.now() - startTime) / 1000);
heartbeatFn(elapsedSeconds);
}, this.heartbeatInterval * 1000);
}
}
exports.Consumer = Consumer;