UNPKG

@vercel/sqs-consumer

Version:

Build SQS-based Node applications without the boilerplate

823 lines (681 loc) 24.4 kB
import { assert } from 'chai'; import * as pEvent from 'p-event'; import * as sinon from 'sinon'; import { Consumer, SQSMessage } from '../src/index'; const sandbox = sinon.createSandbox(); const AUTHENTICATION_ERROR_TIMEOUT = 20; const POLLING_TIMEOUT = 100; function stubResolve(value?: any): any { return sandbox .stub() .returns({ promise: sandbox.stub().resolves(value) }); } function stubReject(value?: any): any { return sandbox .stub() .returns({ promise: sandbox.stub().rejects(value) }); } class MockSQSError extends Error { code: string; statusCode: number; region: string; hostname: string; time: Date; retryable: boolean; constructor(message: string) { super(message); this.message = message; } } // tslint:disable:no-unused-expression describe('Consumer', () => { let consumer; let clock; let handleMessage; let handleMessageBatch; let sqs; const response = { Messages: [{ ReceiptHandle: 'receipt-handle', MessageId: '123', Body: 'body', Attributes: { } }] }; beforeEach(() => { clock = sinon.useFakeTimers(); handleMessage = sandbox.stub().resolves(null); handleMessageBatch = sandbox.stub().resolves(null); sqs = sandbox.mock(); sqs.receiveMessage = stubResolve(response); sqs.deleteMessage = stubResolve(); sqs.deleteMessageBatch = stubResolve(); sqs.changeMessageVisibility = stubResolve(); sqs.changeMessageVisibilityBatch = stubResolve(); consumer = new Consumer({ queueUrl: 'some-queue-url', region: 'some-region', handleMessage, sqs, authenticationErrorTimeout: 20 }); }); afterEach(() => { sandbox.restore(); }); it('requires a queueUrl to be set', () => { assert.throws(() => { Consumer.create({ region: 'some-region', handleMessage }); }); }); it('requires a handleMessage or handleMessagesBatch function to be set', () => { assert.throws(() => { new Consumer({ handleMessage: undefined, region: 'some-region', queueUrl: 'some-queue-url' }); }); }); it('requires the batchSize option to be no greater than 10', () => { assert.throws(() => { new Consumer({ region: 'some-region', queueUrl: 'some-queue-url', handleMessage, batchSize: 11 }); }); }); it('requires the batchSize option to be greater than 0', () => { assert.throws(() => { new Consumer({ region: 'some-region', queueUrl: 'some-queue-url', handleMessage, batchSize: -1 }); }); }); it('requires visibilityTimeout to be set with heartbeatInterval', () => { assert.throws(() => { new Consumer({ region: 'some-region', queueUrl: 'some-queue-url', handleMessage, heartbeatInterval: 30 }); }); }); it('requires heartbeatInterval to be less than visibilityTimeout', () => { assert.throws(() => { new Consumer({ region: 'some-region', queueUrl: 'some-queue-url', handleMessage, heartbeatInterval: 30, visibilityTimeout: 30 }); }); }); describe('.create', () => { it('creates a new instance of a Consumer object', () => { const instance = Consumer.create({ region: 'some-region', queueUrl: 'some-queue-url', batchSize: 1, visibilityTimeout: 10, waitTimeSeconds: 10, handleMessage }); assert.instanceOf(instance, Consumer); }); }); describe('.start', () => { it('fires an error event when an error occurs receiving a message', async () => { const receiveErr = new Error('Receive error'); sqs.receiveMessage = stubReject(receiveErr); consumer.start(); const err: any = await pEvent(consumer, 'error'); consumer.stop(); assert.ok(err); assert.equal(err.message, 'SQS receive message failed: Receive error'); }); it('retains sqs error information', async () => { const receiveErr = new MockSQSError('Receive error'); receiveErr.code = 'short code'; receiveErr.retryable = false; receiveErr.statusCode = 403; receiveErr.time = new Date(); receiveErr.hostname = 'hostname'; receiveErr.region = 'eu-west-1'; sqs.receiveMessage = stubReject(receiveErr); consumer.start(); const err: any = await pEvent(consumer, 'error'); consumer.stop(); assert.ok(err); assert.equal(err.message, 'SQS receive message failed: Receive error'); assert.equal(err.code, receiveErr.code); assert.equal(err.retryable, receiveErr.retryable); assert.equal(err.statusCode, receiveErr.statusCode); assert.equal(err.time, receiveErr.time); assert.equal(err.hostname, receiveErr.hostname); assert.equal(err.region, receiveErr.region); }); it('fires a timeout event if handler function takes too long', async () => { const handleMessageTimeout = 500; consumer = new Consumer({ queueUrl: 'some-queue-url', region: 'some-region', handleMessage: () => new Promise((resolve) => setTimeout(resolve, 1000)), handleMessageTimeout, sqs, authenticationErrorTimeout: 20 }); consumer.start(); const [err]: any = await Promise.all([pEvent(consumer, 'timeout_error'), clock.tickAsync(handleMessageTimeout)]); consumer.stop(); assert.ok(err); assert.equal(err.message, `Message handler timed out after ${handleMessageTimeout}ms: Operation timed out.`); }); it('handles unexpected exceptions thrown by the handler function', async () => { consumer = new Consumer({ queueUrl: 'some-queue-url', region: 'some-region', handleMessage: () => { throw new Error('unexpected parsing error'); }, sqs, authenticationErrorTimeout: 20 }); consumer.start(); const err: any = await pEvent(consumer, 'processing_error'); consumer.stop(); assert.ok(err); assert.equal(err.message, 'Unexpected message handler failure: unexpected parsing error'); }); it('fires an error event when an error occurs deleting a message', async () => { const deleteErr = new Error('Delete error'); handleMessage.resolves(null); sqs.deleteMessage = stubReject(deleteErr); consumer.start(); const err: any = await pEvent(consumer, 'error'); consumer.stop(); assert.ok(err); assert.equal(err.message, 'SQS delete message failed: Delete error'); }); it('fires a `processing_error` event when a non-`SQSError` error occurs processing a message', async () => { const processingErr = new Error('Processing error'); handleMessage.rejects(processingErr); consumer.start(); const [err, message] = await pEvent(consumer, 'processing_error', { multiArgs: true }); consumer.stop(); assert.equal(err.message, 'Unexpected message handler failure: Processing error'); assert.equal(message.MessageId, '123'); }); it('fires an `error` event when an `SQSError` occurs processing a message', async () => { const sqsError = new Error('Processing error'); sqsError.name = 'SQSError'; handleMessage.resolves(sqsError); sqs.deleteMessage = stubReject(sqsError); consumer.start(); const [err, message] = await pEvent(consumer, 'error', { multiArgs: true }); consumer.stop(); assert.equal(err.message, 'SQS delete message failed: Processing error'); assert.equal(message.MessageId, '123'); }); it('waits before repolling when a credentials error occurs', async () => { const credentialsErr = { code: 'CredentialsError', message: 'Missing credentials in config' }; sqs.receiveMessage = stubReject(credentialsErr); const errorListener = sandbox.stub(); consumer.on('error', errorListener); consumer.start(); await clock.tickAsync(AUTHENTICATION_ERROR_TIMEOUT); consumer.stop(); sandbox.assert.calledTwice(errorListener); sandbox.assert.calledTwice(sqs.receiveMessage); }); it('waits before repolling when a 403 error occurs', async () => { const invalidSignatureErr = { statusCode: 403, message: 'The security token included in the request is invalid' }; sqs.receiveMessage = stubReject(invalidSignatureErr); const errorListener = sandbox.stub(); consumer.on('error', errorListener); consumer.start(); await clock.tickAsync(AUTHENTICATION_ERROR_TIMEOUT); consumer.stop(); sandbox.assert.calledTwice(errorListener); sandbox.assert.calledTwice(sqs.receiveMessage); }); it('waits before repolling when a UnknownEndpoint error occurs', async () => { const unknownEndpointErr = { code: 'UnknownEndpoint', message: 'Inaccessible host: `sqs.eu-west-1.amazonaws.com`. This service may not be available in the `eu-west-1` region.' }; sqs.receiveMessage = stubReject(unknownEndpointErr); const errorListener = sandbox.stub(); consumer.on('error', errorListener); consumer.start(); await clock.tickAsync(AUTHENTICATION_ERROR_TIMEOUT); consumer.stop(); sandbox.assert.calledTwice(errorListener); sandbox.assert.calledTwice(sqs.receiveMessage); }); it('waits before repolling when a polling timeout is set', async () => { consumer = new Consumer({ queueUrl: 'some-queue-url', region: 'some-region', handleMessage, sqs, authenticationErrorTimeout: 20, pollingWaitTimeMs: 100 }); consumer.start(); await clock.tickAsync(POLLING_TIMEOUT); consumer.stop(); sandbox.assert.calledTwice(sqs.receiveMessage); }); it('fires a message_received event when a message is received', async () => { consumer.start(); const message = await pEvent(consumer, 'message_received'); consumer.stop(); assert.equal(message, response.Messages[0]); }); it('fires a message_processed event when a message is successfully deleted', async () => { handleMessage.resolves(); consumer.start(); const message = await pEvent(consumer, 'message_received'); consumer.stop(); assert.equal(message, response.Messages[0]); }); it('calls the handleMessage function when a message is received', async () => { consumer.start(); await pEvent(consumer, 'message_processed'); consumer.stop(); sandbox.assert.calledWith(handleMessage, response.Messages[0]); }); it('calls the preReceiveMessageCallback and postReceiveMessageCallback function before receiving a message', async () => { let callbackCalls = 0; consumer = new Consumer({ queueUrl: 'some-queue-url', region: 'some-region', handleMessage, sqs, authenticationErrorTimeout: AUTHENTICATION_ERROR_TIMEOUT, preReceiveMessageCallback: async () => { callbackCalls++; }, postReceiveMessageCallback: async () => { callbackCalls++; } }); consumer.start(); await pEvent(consumer, 'message_processed'); consumer.stop(); assert.equal(callbackCalls, 2); }); it('deletes the message when the handleMessage function is called', async () => { handleMessage.resolves(); consumer.start(); await pEvent(consumer, 'message_processed'); consumer.stop(); sandbox.assert.calledWith(sqs.deleteMessage, { QueueUrl: 'some-queue-url', ReceiptHandle: 'receipt-handle' }); }); it('doesn\'t delete the message when a processing error is reported', async () => { handleMessage.rejects(new Error('Processing error')); consumer.start(); await pEvent(consumer, 'processing_error'); consumer.stop(); sandbox.assert.notCalled(sqs.deleteMessage); }); it('consumes another message once one is processed', async () => { handleMessage.resolves(); consumer.start(); await clock.runToLastAsync(); consumer.stop(); sandbox.assert.calledTwice(handleMessage); }); it('doesn\'t consume more messages when called multiple times', () => { sqs.receiveMessage = stubResolve(new Promise((res) => setTimeout(res, 100))); consumer.start(); consumer.start(); consumer.start(); consumer.start(); consumer.start(); consumer.stop(); sandbox.assert.calledOnce(sqs.receiveMessage); }); it('consumes multiple messages when the batchSize is greater than 1', async () => { sqs.receiveMessage = stubResolve({ Messages: [ { ReceiptHandle: 'receipt-handle-1', MessageId: '1', Body: 'body-1' }, { ReceiptHandle: 'receipt-handle-2', MessageId: '2', Body: 'body-2' }, { ReceiptHandle: 'receipt-handle-3', MessageId: '3', Body: 'body-3' } ] }); consumer = new Consumer({ queueUrl: 'some-queue-url', messageAttributeNames: ['attribute-1', 'attribute-2'], region: 'some-region', handleMessage, batchSize: 3, sqs }); consumer.start(); return new Promise((resolve) => { handleMessage.onThirdCall().callsFake(() => { sandbox.assert.calledWith(sqs.receiveMessage, { QueueUrl: 'some-queue-url', AttributeNames: [], MessageAttributeNames: ['attribute-1', 'attribute-2'], MaxNumberOfMessages: 3, WaitTimeSeconds: 20, VisibilityTimeout: undefined }); sandbox.assert.callCount(handleMessage, 3); consumer.stop(); resolve(); }); }); }); it('consumes messages with message attribute \'ApproximateReceiveCount\'', async () => { const messageWithAttr = { ReceiptHandle: 'receipt-handle-1', MessageId: '1', Body: 'body-1', Attributes: { ApproximateReceiveCount: 1 } }; sqs.receiveMessage = stubResolve({ Messages: [messageWithAttr] }); consumer = new Consumer({ queueUrl: 'some-queue-url', attributeNames: ['ApproximateReceiveCount'], region: 'some-region', handleMessage, sqs }); consumer.start(); const message = await pEvent(consumer, 'message_received'); consumer.stop(); sandbox.assert.calledWith(sqs.receiveMessage, { QueueUrl: 'some-queue-url', AttributeNames: ['ApproximateReceiveCount'], MessageAttributeNames: [], MaxNumberOfMessages: 1, WaitTimeSeconds: 20, VisibilityTimeout: undefined }); assert.equal(message, messageWithAttr); }); it('fires an emptyQueue event when all messages have been consumed', async () => { sqs.receiveMessage = stubResolve({}); consumer.start(); await pEvent(consumer, 'empty'); consumer.stop(); }); it('terminate message visibility timeout on processing error', async () => { handleMessage.rejects(new Error('Processing error')); consumer.terminateVisibilityTimeout = true; consumer.start(); await pEvent(consumer, 'processing_error'); consumer.stop(); sandbox.assert.calledWith(sqs.changeMessageVisibility, { QueueUrl: 'some-queue-url', ReceiptHandle: 'receipt-handle', VisibilityTimeout: 0 }); }); it('does not terminate visibility timeout when `terminateVisibilityTimeout` option is false', async () => { handleMessage.rejects(new Error('Processing error')); consumer.terminateVisibilityTimeout = false; consumer.start(); await pEvent(consumer, 'processing_error'); consumer.stop(); sandbox.assert.notCalled(sqs.changeMessageVisibility); }); it('fires error event when failed to terminate visibility timeout on processing error', async () => { handleMessage.rejects(new Error('Processing error')); const sqsError = new Error('Processing error'); sqsError.name = 'SQSError'; sqs.changeMessageVisibility = stubReject(sqsError); consumer.terminateVisibilityTimeout = true; consumer.start(); await pEvent(consumer, 'error'); consumer.stop(); sandbox.assert.calledWith(sqs.changeMessageVisibility, { QueueUrl: 'some-queue-url', ReceiptHandle: 'receipt-handle', VisibilityTimeout: 0 }); }); it('terminate message visibility timeout with a function to calculate timeout on processing error', async () => { const messageWithAttr = { ReceiptHandle: 'receipt-handle-2', MessageId: '1', Body: 'body-2', Attributes: { ApproximateReceiveCount: 2 } }; sqs.receiveMessage = stubResolve({ Messages: [messageWithAttr] }); consumer = new Consumer({ queueUrl: 'some-queue-url', attributeNames: ['ApproximateReceiveCount'], region: 'some-region', handleMessage, sqs, terminateVisibilityTimeout: (message: SQSMessage) => { const receiveCount = Number.parseInt(message.Attributes?.ApproximateReceiveCount || '1') || 1; // Add visibility timeout to (10 * receiveCount) seconds return receiveCount * 10; } }); handleMessage.rejects(new Error('Processing error')); consumer.start(); await pEvent(consumer, 'processing_error'); consumer.stop(); sandbox.assert.calledWith(sqs.changeMessageVisibility, { QueueUrl: 'some-queue-url', ReceiptHandle: 'receipt-handle-2', VisibilityTimeout: 20 }); }); it('fires response_processed event for each batch', async () => { sqs.receiveMessage = stubResolve({ Messages: [ { ReceiptHandle: 'receipt-handle-1', MessageId: '1', Body: 'body-1' }, { ReceiptHandle: 'receipt-handle-2', MessageId: '2', Body: 'body-2' } ] }); handleMessage.resolves(null); consumer = new Consumer({ queueUrl: 'some-queue-url', messageAttributeNames: ['attribute-1', 'attribute-2'], region: 'some-region', handleMessage, batchSize: 2, sqs }); consumer.start(); await pEvent(consumer, 'response_processed'); consumer.stop(); sandbox.assert.callCount(handleMessage, 2); }); it('calls the handleMessagesBatch function when a batch of messages is received', async () => { consumer = new Consumer({ queueUrl: 'some-queue-url', messageAttributeNames: ['attribute-1', 'attribute-2'], region: 'some-region', handleMessageBatch, batchSize: 2, sqs }); consumer.start(); await pEvent(consumer, 'response_processed'); consumer.stop(); sandbox.assert.callCount(handleMessageBatch, 1); }); it('prefers handleMessagesBatch over handleMessage when both are set', async () => { consumer = new Consumer({ queueUrl: 'some-queue-url', messageAttributeNames: ['attribute-1', 'attribute-2'], region: 'some-region', handleMessageBatch, handleMessage, batchSize: 2, sqs }); consumer.start(); await pEvent(consumer, 'response_processed'); consumer.stop(); sandbox.assert.callCount(handleMessageBatch, 1); sandbox.assert.callCount(handleMessage, 0); }); it('extends visibility timeout for long running handler functions', async () => { consumer = new Consumer({ queueUrl: 'some-queue-url', region: 'some-region', handleMessage: () => new Promise((resolve) => setTimeout(resolve, 75000)), sqs, visibilityTimeout: 40, heartbeatInterval: 30 }); const clearIntervalSpy = sinon.spy(global, 'clearInterval'); consumer.start(); await Promise.all([pEvent(consumer, 'response_processed'), clock.tickAsync(75000)]); consumer.stop(); sandbox.assert.calledWith(sqs.changeMessageVisibility, { QueueUrl: 'some-queue-url', ReceiptHandle: 'receipt-handle', VisibilityTimeout: 70 }); sandbox.assert.calledWith(sqs.changeMessageVisibility, { QueueUrl: 'some-queue-url', ReceiptHandle: 'receipt-handle', VisibilityTimeout: 100 }); sandbox.assert.calledOnce(clearIntervalSpy); }); it('extends visibility timeout for long running batch handler functions', async () => { sqs.receiveMessage = stubResolve({ Messages: [ { MessageId: '1', ReceiptHandle: 'receipt-handle-1', Body: 'body-1' }, { MessageId: '2', ReceiptHandle: 'receipt-handle-2', Body: 'body-2' }, { MessageId: '3', ReceiptHandle: 'receipt-handle-3', Body: 'body-3' } ] }); consumer = new Consumer({ queueUrl: 'some-queue-url', region: 'some-region', handleMessageBatch: () => new Promise((resolve) => setTimeout(resolve, 75000)), batchSize: 3, sqs, visibilityTimeout: 40, heartbeatInterval: 30 }); const clearIntervalSpy = sinon.spy(global, 'clearInterval'); consumer.start(); await Promise.all([pEvent(consumer, 'response_processed'), clock.tickAsync(75000)]); consumer.stop(); sandbox.assert.calledWith(sqs.changeMessageVisibilityBatch, { QueueUrl: 'some-queue-url', Entries: [ { Id: '1', ReceiptHandle: 'receipt-handle-1', VisibilityTimeout: 70 }, { Id: '2', ReceiptHandle: 'receipt-handle-2', VisibilityTimeout: 70 }, { Id: '3', ReceiptHandle: 'receipt-handle-3', VisibilityTimeout: 70 } ] }); sandbox.assert.calledWith(sqs.changeMessageVisibilityBatch, { QueueUrl: 'some-queue-url', Entries: [ { Id: '1', ReceiptHandle: 'receipt-handle-1', VisibilityTimeout: 100 }, { Id: '2', ReceiptHandle: 'receipt-handle-2', VisibilityTimeout: 100 }, { Id: '3', ReceiptHandle: 'receipt-handle-3', VisibilityTimeout: 100 } ] }); sandbox.assert.calledOnce(clearIntervalSpy); }); }); describe('.stop', () => { it('stops the consumer polling for messages', async () => { consumer.start(); consumer.stop(); await Promise.all([pEvent(consumer, 'stopped'), clock.runAllAsync()]); sandbox.assert.calledOnce(handleMessage); }); it('fires a stopped event when last poll occurs after stopping', async () => { consumer.start(); consumer.stop(); await Promise.all([pEvent(consumer, 'stopped'), clock.runAllAsync()]); }); it('fires a stopped event only once when stopped multiple times', async () => { const handleStop = sandbox.stub().returns(null); consumer.on('stopped', handleStop); consumer.start(); consumer.stop(); consumer.stop(); consumer.stop(); await clock.runAllAsync(); sandbox.assert.calledOnce(handleStop); }); it('fires a stopped event a second time if started and stopped twice', async () => { const handleStop = sandbox.stub().returns(null); consumer.on('stopped', handleStop); consumer.start(); consumer.stop(); consumer.start(); consumer.stop(); await clock.runAllAsync(); sandbox.assert.calledTwice(handleStop); }); }); describe('isRunning', async () => { it('returns true if the consumer is polling', () => { consumer.start(); assert.isTrue(consumer.isRunning); consumer.stop(); }); it('returns false if the consumer is not polling', () => { consumer.start(); consumer.stop(); assert.isFalse(consumer.isRunning); }); }); });