sqs-consumer
Version:
Build SQS-based Node applications without the boilerplate
732 lines (665 loc) • 21.6 kB
text/typescript
import {
SQSClient,
Message,
ChangeMessageVisibilityCommand,
ChangeMessageVisibilityCommandInput,
ChangeMessageVisibilityCommandOutput,
ChangeMessageVisibilityBatchCommand,
ChangeMessageVisibilityBatchCommandInput,
ChangeMessageVisibilityBatchCommandOutput,
DeleteMessageCommand,
DeleteMessageCommandInput,
DeleteMessageBatchCommand,
DeleteMessageBatchCommandInput,
ReceiveMessageCommand,
ReceiveMessageCommandInput,
ReceiveMessageCommandOutput,
QueueAttributeName,
MessageSystemAttributeName,
} from "@aws-sdk/client-sqs";
import type {
ConsumerOptions,
StopOptions,
UpdatableOptions,
} from "./types.js";
import { TypedEventEmitter } from "./emitter.js";
import {
SQSError,
TimeoutError,
toStandardError,
toTimeoutError,
toSQSError,
isConnectionError,
} from "./errors.js";
import { validateOption, assertOptions, hasMessages } from "./validation.js";
import { logger } from "./logger.js";
/**
* [Usage](https://bbc.github.io/sqs-consumer/index.html#usage)
*/
export class Consumer extends TypedEventEmitter {
private pollingTimeoutId: NodeJS.Timeout | undefined = undefined;
private stopped = true;
protected queueUrl: string;
private isFifoQueue: boolean;
private suppressFifoWarning: boolean;
private handleMessage: (message: Message) => Promise<Message | undefined>;
private handleMessageBatch: (
messages: Message[],
) => Promise<Message[] | undefined>;
private preReceiveMessageCallback?: () => Promise<void>;
private postReceiveMessageCallback?: () => Promise<void>;
private sqs: SQSClient;
private handleMessageTimeout: number;
private attributeNames: QueueAttributeName[];
private messageAttributeNames: string[];
private messageSystemAttributeNames: MessageSystemAttributeName[];
private shouldDeleteMessages: boolean;
private alwaysAcknowledge: boolean;
private batchSize: number;
private visibilityTimeout: number;
private terminateVisibilityTimeout:
| boolean
| number
| ((message: Message[]) => number);
private waitTimeSeconds: number;
private authenticationErrorTimeout: number;
private pollingWaitTimeMs: number;
private pollingCompleteWaitTimeMs: number;
private heartbeatInterval: number;
private isPolling = false;
private stopRequestedAtTimestamp: number;
public abortController: AbortController;
private extendedAWSErrors: boolean;
private strictReturn: boolean;
constructor(options: ConsumerOptions) {
super(options.queueUrl);
assertOptions(options);
this.queueUrl = options.queueUrl;
this.isFifoQueue = this.queueUrl.endsWith(".fifo");
this.suppressFifoWarning = options.suppressFifoWarning ?? false;
this.handleMessage = options.handleMessage;
this.handleMessageBatch = options.handleMessageBatch;
this.preReceiveMessageCallback = options.preReceiveMessageCallback;
this.postReceiveMessageCallback = options.postReceiveMessageCallback;
this.handleMessageTimeout = options.handleMessageTimeout;
this.attributeNames = options.attributeNames || [];
this.messageAttributeNames = options.messageAttributeNames || [];
this.messageSystemAttributeNames =
options.messageSystemAttributeNames || [];
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.pollingCompleteWaitTimeMs = options.pollingCompleteWaitTimeMs ?? 0;
this.shouldDeleteMessages = options.shouldDeleteMessages ?? true;
this.alwaysAcknowledge = options.alwaysAcknowledge ?? false;
this.extendedAWSErrors = options.extendedAWSErrors ?? false;
this.strictReturn = options.strictReturn ?? false;
this.sqs =
options.sqs ||
new SQSClient({
useQueueUrlAsEndpoint: options.useQueueUrlAsEndpoint ?? true,
region: options.region || process.env.AWS_REGION || "eu-west-1",
});
}
/**
* Creates a new SQS consumer.
*/
public static create(options: ConsumerOptions): Consumer {
return new Consumer(options);
}
/**
* Start polling the queue for messages.
*/
public start(): void {
if (this.stopped) {
if (this.isFifoQueue && !this.suppressFifoWarning) {
logger.warn(
"WARNING: A FIFO queue was detected. SQS Consumer does not guarantee FIFO queues will work as expected. Set 'suppressFifoWarning: true' to disable this warning.",
);
}
// Create a new abort controller each time the consumer is started
this.abortController = new AbortController();
logger.debug("starting");
this.stopped = false;
this.emit("started");
this.poll();
}
}
/**
* A reusable options object for sqs.send that's used to avoid duplication.
*/
private get sqsSendOptions(): { abortSignal: AbortSignal } {
return {
// return the current abortController signal or a fresh signal that has not been aborted.
// This effectively defaults the signal sent to the AWS SDK to not aborted
abortSignal: this.abortController?.signal || new AbortController().signal,
};
}
/**
* Stop polling the queue for messages (pre existing requests will still be made until concluded).
*/
public stop(options?: StopOptions): void {
if (this.stopped) {
logger.debug("already_stopped");
return;
}
logger.debug("stopping");
this.stopped = true;
if (this.pollingTimeoutId) {
clearTimeout(this.pollingTimeoutId);
this.pollingTimeoutId = undefined;
}
if (options?.abort) {
logger.debug("aborting");
this.abortController.abort();
this.emit("aborted");
}
this.stopRequestedAtTimestamp = Date.now();
this.waitForPollingToComplete();
}
/**
* Wait for final poll and in flight messages to complete.
* @private
*/
private waitForPollingToComplete(): void {
if (!this.isPolling || !(this.pollingCompleteWaitTimeMs > 0)) {
this.emit("stopped");
return;
}
const exceededTimeout: boolean =
Date.now() - this.stopRequestedAtTimestamp >
this.pollingCompleteWaitTimeMs;
if (exceededTimeout) {
this.emit("waiting_for_polling_to_complete_timeout_exceeded");
this.emit("stopped");
return;
}
this.emit("waiting_for_polling_to_complete");
setTimeout(() => this.waitForPollingToComplete(), 1000);
}
/**
* Returns the current status of the consumer.
* This includes whether it is running or currently polling.
*/
public get status(): {
isRunning: boolean;
isPolling: boolean;
} {
return {
isRunning: !this.stopped,
isPolling: this.isPolling,
};
}
/**
* Validates and then updates the provided option to the provided value.
* @param option The option to validate and then update
* @param value The value to set the provided option to
*/
public updateOption(
option: UpdatableOptions,
value: ConsumerOptions[UpdatableOptions],
): void {
validateOption(option, value, this, true);
this[option] = value;
this.emit("option_updated", option, value);
}
/**
* Emit one of the consumer's error events depending on the error received.
* @param err The error object to forward on
* @param message The message that the error occurred on
*/
private emitError(err: Error, message?: Message): void {
if (!message) {
this.emit("error", err, undefined);
} else 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);
}
}
/**
* Poll for new messages from SQS
*/
private poll(): void {
if (this.stopped) {
logger.debug("cancelling_poll", {
detail:
"Poll was called while consumer was stopped, cancelling poll...",
});
return;
}
logger.debug("polling");
this.isPolling = true;
let currentPollingTimeout: number = this.pollingWaitTimeMs;
this.receiveMessage({
QueueUrl: this.queueUrl,
AttributeNames: this.attributeNames,
MessageAttributeNames: this.messageAttributeNames,
MessageSystemAttributeNames: this.messageSystemAttributeNames,
MaxNumberOfMessages: this.batchSize,
WaitTimeSeconds: this.waitTimeSeconds,
VisibilityTimeout: this.visibilityTimeout,
})
.then((output: ReceiveMessageCommandOutput) =>
this.handleSqsResponse(output),
)
.catch((err): void => {
this.emitError(err);
if (isConnectionError(err)) {
logger.debug("authentication_error", {
code: err.code || "Unknown",
detail:
"There was an authentication error. Pausing before retrying.",
});
currentPollingTimeout = this.authenticationErrorTimeout;
}
return;
})
.then((): void => {
if (this.pollingTimeoutId) {
clearTimeout(this.pollingTimeoutId);
}
this.pollingTimeoutId = setTimeout(
() => this.poll(),
currentPollingTimeout,
);
})
.catch((err): void => {
this.emitError(err);
})
.finally((): void => {
this.isPolling = false;
});
}
/**
* Send a request to SQS to retrieve messages
* @param params The required params to receive messages from SQS
*/
private async receiveMessage(
params: ReceiveMessageCommandInput,
): Promise<ReceiveMessageCommandOutput> {
try {
if (this.preReceiveMessageCallback) {
await this.preReceiveMessageCallback();
}
const result: ReceiveMessageCommandOutput = await this.sqs.send(
new ReceiveMessageCommand(params),
this.sqsSendOptions,
);
if (this.postReceiveMessageCallback) {
await this.postReceiveMessageCallback();
}
return result;
} catch (err) {
throw toSQSError(
err,
`SQS receive message failed: ${err.message}`,
this.extendedAWSErrors,
this.queueUrl,
);
}
}
/**
* Handles the response from AWS SQS, determining if we should proceed to
* the message handler.
* @param response The output from AWS SQS
*/
private async handleSqsResponse(
response: ReceiveMessageCommandOutput,
): Promise<void> {
if (hasMessages(response)) {
if (this.handleMessageBatch) {
await this.processMessageBatch(response.Messages);
} else {
await Promise.all(
response.Messages.map((message: Message) =>
this.processMessage(message),
),
);
}
this.emit("response_processed");
} else if (response) {
this.emit("empty");
}
}
/**
* Process a message that has been received from SQS. This will execute the message
* handler and delete the message once complete.
* @param message The message that was delivered from SQS
*/
private async processMessage(message: Message): Promise<void> {
let heartbeatTimeoutId: NodeJS.Timeout | undefined = undefined;
try {
this.emit("message_received", message);
if (this.heartbeatInterval) {
heartbeatTimeoutId = this.startHeartbeat(message);
}
const ackedMessage: Message = await this.executeHandler(message);
if (ackedMessage?.MessageId === message.MessageId) {
await this.deleteMessage(message);
this.emit("message_processed", message);
}
} catch (err) {
this.emitError(err, message);
if (this.terminateVisibilityTimeout !== false) {
if (typeof this.terminateVisibilityTimeout === "function") {
const timeout = this.terminateVisibilityTimeout([message]);
await this.changeVisibilityTimeout(message, timeout);
} else {
const timeout =
this.terminateVisibilityTimeout === true
? 0
: this.terminateVisibilityTimeout;
await this.changeVisibilityTimeout(message, timeout);
}
}
} finally {
if (this.heartbeatInterval) {
clearInterval(heartbeatTimeoutId);
}
}
}
/**
* Process a batch of messages from the SQS queue.
* @param messages The messages that were delivered from SQS
*/
private async processMessageBatch(messages: Message[]): Promise<void> {
let heartbeatTimeoutId: NodeJS.Timeout | undefined = undefined;
try {
messages.forEach((message: Message): void => {
this.emit("message_received", message);
});
if (this.heartbeatInterval) {
heartbeatTimeoutId = this.startHeartbeat(null, messages);
}
const ackedMessages: Message[] = await this.executeBatchHandler(messages);
if (ackedMessages?.length > 0) {
await this.deleteMessageBatch(ackedMessages);
ackedMessages.forEach((message: Message): void => {
this.emit("message_processed", message);
});
}
} catch (err) {
this.emit("error", err, messages);
if (this.terminateVisibilityTimeout !== false) {
if (typeof this.terminateVisibilityTimeout === "function") {
const timeout = this.terminateVisibilityTimeout(messages);
await this.changeVisibilityTimeoutBatch(messages, timeout);
} else {
const timeout =
this.terminateVisibilityTimeout === true
? 0
: this.terminateVisibilityTimeout;
await this.changeVisibilityTimeoutBatch(messages, timeout);
}
}
} finally {
clearInterval(heartbeatTimeoutId);
}
}
/**
* Trigger a function on a set interval
* @param heartbeatFn The function that should be triggered
*/
private startHeartbeat(
message?: Message,
messages?: Message[],
): NodeJS.Timeout {
return setInterval(() => {
if (this.handleMessageBatch) {
return this.changeVisibilityTimeoutBatch(
messages,
this.visibilityTimeout,
);
}
return this.changeVisibilityTimeout(message, this.visibilityTimeout);
}, this.heartbeatInterval * 1000);
}
/**
* Change the visibility timeout on a message
* @param message The message to change the value of
* @param timeout The new timeout that should be set
*/
private async changeVisibilityTimeout(
message: Message,
timeout: number,
): Promise<ChangeMessageVisibilityCommandOutput> {
try {
const input: ChangeMessageVisibilityCommandInput = {
QueueUrl: this.queueUrl,
ReceiptHandle: message.ReceiptHandle,
VisibilityTimeout: timeout,
};
return await this.sqs.send(
new ChangeMessageVisibilityCommand(input),
this.sqsSendOptions,
);
} catch (err) {
this.emit(
"error",
toSQSError(
err,
`Error changing visibility timeout: ${err.message}`,
this.extendedAWSErrors,
this.queueUrl,
message,
),
message,
);
}
}
/**
* Change the visibility timeout on a batch of messages
* @param messages The messages to change the value of
* @param timeout The new timeout that should be set
*/
private async changeVisibilityTimeoutBatch(
messages: Message[],
timeout: number,
): Promise<ChangeMessageVisibilityBatchCommandOutput> {
const params: ChangeMessageVisibilityBatchCommandInput = {
QueueUrl: this.queueUrl,
Entries: messages.map((message: Message) => ({
Id: message.MessageId,
ReceiptHandle: message.ReceiptHandle,
VisibilityTimeout: timeout,
})),
};
try {
return await this.sqs.send(
new ChangeMessageVisibilityBatchCommand(params),
this.sqsSendOptions,
);
} catch (err) {
this.emit(
"error",
toSQSError(
err,
`Error changing visibility timeout: ${err.message}`,
this.extendedAWSErrors,
this.queueUrl,
messages,
),
messages,
);
}
}
/**
* Trigger the applications handleMessage function
* @param message The message that was received from SQS
*/
private async executeHandler(message: Message): Promise<Message> {
let handleMessageTimeoutId: NodeJS.Timeout | undefined = undefined;
try {
let result: Message | undefined | null;
if (this.handleMessageTimeout) {
const pending: Promise<never> = new Promise<never>((_, reject) => {
handleMessageTimeoutId = setTimeout(() => {
reject(new TimeoutError());
}, this.handleMessageTimeout);
});
result = await Promise.race([this.handleMessage(message), pending]);
} else {
result = await this.handleMessage(message);
}
if (this.alwaysAcknowledge) {
return message;
}
if (result instanceof Object) {
return result;
}
if (result === undefined) {
return null;
}
if (result === null) {
if (this.strictReturn) {
throw new Error(
"strictReturn is enabled: handleMessage must return a Message object or an object with the same MessageId. Returning null is not allowed.",
);
}
console.warn(
"[DEPRECATION] Future versions will throw on void/null returns. Enable `strictReturn` now to prepare.",
);
return null;
}
return null;
} catch (err) {
if (err instanceof TimeoutError) {
throw toTimeoutError(
err,
`Message handler timed out after ${this.handleMessageTimeout}ms: Operation timed out.`,
message,
);
}
if (err instanceof Error) {
throw toStandardError(
err,
`Unexpected message handler failure: ${err.message}`,
message,
);
}
throw err;
} finally {
if (handleMessageTimeoutId) {
clearTimeout(handleMessageTimeoutId);
}
}
}
/**
* Execute the application's message batch handler
* @param messages The messages that should be forwarded from the SQS queue
*/
private async executeBatchHandler(messages: Message[]): Promise<Message[]> {
try {
const result: Message[] | undefined | null =
await this.handleMessageBatch(messages);
if (this.alwaysAcknowledge) {
return messages;
}
if (Array.isArray(result)) {
return result;
}
if (result === undefined) {
return [];
}
if (result === null) {
if (this.strictReturn) {
throw new Error(
"strictReturn is enabled: handleMessageBatch must return an array of Message objects. Returning null is not allowed.",
);
}
console.warn(
"[DEPRECATION] Future versions will throw on void/null returns. Enable `strictReturn` now to prepare.",
);
return [];
}
return [];
} catch (err) {
if (err instanceof Error) {
throw toStandardError(
err,
`Unexpected message batch handler failure: ${err.message}`,
messages,
);
}
throw err;
}
}
/**
* Delete a single message from SQS
* @param message The message to delete from the SQS queue
*/
private async deleteMessage(message: Message): Promise<void> {
if (!this.shouldDeleteMessages) {
logger.debug("skipping_delete", {
detail:
"Skipping message delete since shouldDeleteMessages is set to false",
});
return;
}
logger.debug("deleting_message", { messageId: message.MessageId });
const deleteParams: DeleteMessageCommandInput = {
QueueUrl: this.queueUrl,
ReceiptHandle: message.ReceiptHandle,
};
try {
await this.sqs.send(
new DeleteMessageCommand(deleteParams),
this.sqsSendOptions,
);
} catch (err) {
throw toSQSError(
err,
`SQS delete message failed: ${err.message}`,
this.extendedAWSErrors,
this.queueUrl,
message,
);
}
}
/**
* Delete a batch of messages from the SQS queue.
* @param messages The messages that should be deleted from SQS
*/
private async deleteMessageBatch(messages: Message[]): Promise<void> {
if (!this.shouldDeleteMessages) {
logger.debug("skipping_delete", {
detail:
"Skipping message delete since shouldDeleteMessages is set to false",
});
return;
}
logger.debug("deleting_messages", {
messageIds: messages.map((msg: Message) => msg.MessageId),
});
const deleteParams: DeleteMessageBatchCommandInput = {
QueueUrl: this.queueUrl,
Entries: messages.map((message: Message) => ({
Id: message.MessageId,
ReceiptHandle: message.ReceiptHandle,
})),
};
try {
await this.sqs.send(
new DeleteMessageBatchCommand(deleteParams),
this.sqsSendOptions,
);
} catch (err) {
throw toSQSError(
err,
`SQS delete message failed: ${err.message}`,
this.extendedAWSErrors,
this.queueUrl,
messages,
);
}
}
}