UNPKG

@qbatch/sqs-consumer

Version:

Build SQS-based Node applications without the boilerplate

287 lines (249 loc) 8.5 kB
const debug = require('debug')('sqs-consumer'); import { AWSError } from 'aws-sdk'; import * as SQS from 'aws-sdk/clients/sqs'; import { PromiseResult } from 'aws-sdk/lib/request'; import { EventEmitter } from 'events'; import { autoBind } from './bind'; import { SQSError, TimeoutError } from './errors'; type ReceieveMessageResponse = PromiseResult<SQS.Types.ReceiveMessageResult, AWSError>; type SQSMessage = SQS.Types.Message; type ReceiveMessageRequest = SQS.Types.ReceiveMessageRequest; const requiredOptions = [ 'queueUrl', 'handleMessage' ]; interface TimeoutResonse { timeout: NodeJS.Timeout; pending: Promise<void>; } function createTimeout(duration: number): TimeoutResonse[] { let timeout; const pending = new Promise((_, reject) => { timeout = setTimeout((): void => { reject(new TimeoutError()); }, duration); }); return [timeout, pending]; } function assertOptions(options: ConsumerOptions): void { requiredOptions.forEach((option) => { if (!options[option]) { throw new Error(`Missing SQS consumer option ['${option}'].`); } }); if (options.batchSize > 10 || options.batchSize < 1) { throw new Error('SQS batchSize option must be between 1 and 10.'); } } function isConnectionError(err: Error): Boolean { if (err instanceof SQSError) { return (err.statusCode === 403 || err.code === 'CredentialsError' || err.code === 'UnknownEndpoint'); } return false; } function toSQSError(err: AWSError, message: string): SQSError { const sqsError = new 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: ReceieveMessageResponse): boolean { return response.Messages && response.Messages.length > 0; } export interface ConsumerOptions { queueUrl?: string; attributeNames?: string[]; messageAttributeNames?: string[]; stopped?: boolean; batchSize?: number; visibilityTimeout?: number; waitTimeSeconds?: number; authenticationErrorTimeout?: number; terminateVisibilityTimeout?: boolean; sqs?: SQS; region?: string; handleMessageTimeout?: number; handleMessage(message: object): Promise<void>; preProcessMessages(messages: SQSMessage[]): object[]; } export class Consumer extends EventEmitter { private queueUrl: string; private handleMessage: (message: object) => Promise<void>; private handleMessageTimeout: number; private attributeNames: string[]; private messageAttributeNames: string[]; private stopped: boolean; private batchSize: number; private visibilityTimeout: number; private waitTimeSeconds: number; private authenticationErrorTimeout: number; private terminateVisibilityTimeout: boolean; private sqs: SQS; private preProcessMessages: (messages: SQSMessage[]) => object[]; constructor(options: ConsumerOptions) { super(); assertOptions(options); this.queueUrl = options.queueUrl; this.handleMessage = options.handleMessage; 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.waitTimeSeconds = options.waitTimeSeconds || 20; this.authenticationErrorTimeout = options.authenticationErrorTimeout || 10000; this.sqs = options.sqs || new SQS({ region: options.region || process.env.AWS_REGION || 'eu-west-1' }); this.preProcessMessages = options.preProcessMessages; autoBind(this); } public get isRunning(): boolean { return !this.stopped; } public static create(options: ConsumerOptions): Consumer { return new Consumer(options); } public start(): void { if (this.stopped) { debug('Starting consumer'); this.stopped = false; this.poll(); } } public stop(): void { debug('Stopping consumer'); this.stopped = true; } private async handleSqsResponse(response: ReceieveMessageResponse): Promise<void> { debug('Received SQS response'); debug(response); if (response) { if (hasMessages(response)) { const messages = this.preProcessMessages(response.Messages); await Promise.all(messages.map(this.processMessage)); this.emit('response_processed'); } else { this.emit('empty'); } } } private async processMessage(message: SQSMessage): Promise<void> { this.emit('message_received', message); try { await this.executeHandler(message); await this.deleteMessage(message); this.emit('message_processed', message); } catch (err) { this.emitError(err, message); if (this.terminateVisibilityTimeout) { try { await this.terminateVisabilityTimeout(message); } catch (err) { this.emit('error', err, message); } } } } private async receiveMessage(params: ReceiveMessageRequest): Promise<ReceieveMessageResponse> { try { return await this.sqs .receiveMessage(params) .promise(); } catch (err) { throw toSQSError(err, `SQS receive message failed: ${err.message}`); } } private async deleteMessage(message: SQSMessage): Promise<void> { 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}`); } } private async executeHandler(message: SQSMessage): Promise<void> { 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 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); } } private async terminateVisabilityTimeout(message: SQSMessage): Promise<PromiseResult<any, AWSError>> { return this.sqs .changeMessageVisibility({ QueueUrl: this.queueUrl, ReceiptHandle: message.ReceiptHandle, VisibilityTimeout: 0 }) .promise(); } private emitError(err: Error, message: SQSMessage): void { if (err.name === SQSError.name) { this.emit('error', err, message); } else if (err instanceof TimeoutError) { this.emit('timeout_error', err, message); } else { this.emit('processing_error', err, message); } } private poll(): void { 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 pollingTimeout = 0; this.receiveMessage(receiveParams) .then(this.handleSqsResponse) .catch((err) => { this.emit('error', err); if (isConnectionError(err)) { debug('There was an authentication error. Pausing before retrying.'); pollingTimeout = this.authenticationErrorTimeout; } return; }).then(() => { setTimeout(this.poll, pollingTimeout); }).catch((err) => { this.emit('error', err); }); } }