diff --git a/.github/DISCUSSION_TEMPLATE/help.yml b/.github/DISCUSSION_TEMPLATE/help.yml index c67468dc..d9e506b1 100644 --- a/.github/DISCUSSION_TEMPLATE/help.yml +++ b/.github/DISCUSSION_TEMPLATE/help.yml @@ -8,6 +8,6 @@ body: - type: input attributes: label: Example - description: A link to a minimal reproduction is helpful for debugging! You can fork [this stackblitz setup](https://stackblitz.com/edit/sqs-consumer-starter) to get a reproduction setup quickly. + description: A link to a minimal reproduction is helpful for debugging! You can use one of our [existing examples](https://github.com/bbc/sqs-consumer-starter/tree/main/examples) to get a reproduction setup quickly. validations: required: false diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 6311af63..0fb60c60 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -32,7 +32,7 @@ body: Please add a link to a minimal reproduction. Note: - Please keep your example as simple and reproduceable as possible, try leaving out dependencies that are not required for reproduction. - - To create a shareable code example for web, you can use Stackblitz (https://stackblitz.com/edit/sqs-consumer-starter). + - To create a shareable code example for web, you can use one of our existing examples: (https://github.com/bbc/sqs-consumer-starter/tree/main/examples). - Please make sure the example is complete and runnable - e.g. avoid localhost URLs. placeholder: | e.g. Code Sandbox, Stackblitz, Expo Snack or TypeScript playground diff --git a/.github/workflows/annotate-test-reports.yml b/.github/workflows/annotate-test-reports.yml new file mode 100644 index 00000000..1431ffb7 --- /dev/null +++ b/.github/workflows/annotate-test-reports.yml @@ -0,0 +1,32 @@ +name: Annotate CI run with test results +on: + workflow_run: + workflows: + - "Run Tests" + types: + - completed +permissions: + actions: read + contents: read + checks: write + pull-requests: write + +jobs: + annotate: + name: Annotate CI run with test results + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion != 'cancelled' }} + strategy: + fail-fast: false + matrix: + node-version: [18.x, 20.x, 22.x] + timeout-minutes: 5 + steps: + - name: Annotate CI run with test results + uses: dorny/test-reporter@v1 + with: + artifact: test-reports-${{ matrix.node-version }} + name: Test Results (${{matrix.node-version}} + path: "test-results.json" + reporter: mocha-json + token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 485e0f66..f8b26cc3 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [18.x] + node-version: [20.x] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 723a94ef..54c7073d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -34,7 +34,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 18.x + node-version: 20.x cache: 'npm' - name: NPM Audit diff --git a/.github/workflows/pr-coverage.yml b/.github/workflows/pr-coverage.yml new file mode 100644 index 00000000..2e0b858e --- /dev/null +++ b/.github/workflows/pr-coverage.yml @@ -0,0 +1,42 @@ +name: Comment PR Coverage +on: + pull_request: + branches: + - 'main' +permissions: + contents: read + pull-requests: write + +jobs: + coverage_report: + name: Generate coverage report + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20.x] + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - name: Install Node Modules + run: npm ci + + - name: Run Coverage Check + run: npm run lcov + + - name: Setup LCOV + uses: hrishikesh-kadam/setup-lcov@v1 + + - name: Report code coverage + uses: zgosalvez/github-actions-report-lcov@v3 + with: + coverage-files: coverage/lcov.info + minimum-coverage: 90 + artifact-name: code-coverage-report + github-token: ${{ secrets.GITHUB_TOKEN }} + update-comment: true \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 219cd48d..f9565d05 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,13 +8,15 @@ on: - main permissions: contents: read + checks: write + pull-requests: write jobs: build: runs-on: ubuntu-latest strategy: matrix: - node-version: [18.x, 20.x] + node-version: [18.x, 20.x, 22.x] steps: - uses: actions/checkout@v4 @@ -34,7 +36,7 @@ jobs: - name: Run Tests and Linting run: npm run test - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 with: name: test-reports-${{ matrix.node-version }} path: test/reports/ diff --git a/package-lock.json b/package-lock.json index 0a4186a0..7de7e744 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sqs-consumer", - "version": "10.0.0-canary.2", + "version": "10.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sqs-consumer", - "version": "10.0.0-canary.2", + "version": "10.1.0", "license": "Apache-2.0", "dependencies": { "@aws-sdk/client-sqs": "^3.529.1", diff --git a/package.json b/package.json index 50bd3c3a..c7a67183 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,20 @@ { "name": "sqs-consumer", - "version": "10.0.0-canary.2", + "version": "10.1.0", "description": "Build SQS-based Node applications without the boilerplate", "type": "module", + "main": "dist/cjs/index.js", + "types": "dist/cjs/index.d.ts", "exports": { ".": { - "types": "./dist/types/index.d.ts", - "require": "./dist/cjs/index.js", - "import": "./dist/esm/index.js", - "default": "./dist/esm/index.js" + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.ts", + "default": "./dist/cjs/index.js" + } } }, "engines": { @@ -21,7 +27,7 @@ "build": "npm run clean && npm run compile && npm run add-package-jsons", "watch": "tsc --watch", "prepublishOnly": "npm run build", - "test:unit": "mocha --recursive --full-trace --exit", + "test:unit": "mocha --recursive --full-trace --exit --reporter json > test/reports/test-results.json", "pretest:integration:init": "npm run build", "test:integration:init": "sh ./test/scripts/initIntTests.sh", "test:integration": "npm run test:integration:init && cucumber-js --config ./test/config/cucumber.mjs", diff --git a/scripts/addPackageJsons.js b/scripts/addPackageJsons.js index 3a0e76a1..2b2c30db 100644 --- a/scripts/addPackageJsons.js +++ b/scripts/addPackageJsons.js @@ -8,6 +8,10 @@ function buildPackageJson() { throw err; } dirs.forEach((dir) => { + if (dir === "types") { + return; + } + const packageJsonFile = join(buildDir, dir, "/package.json"); if (!existsSync(packageJsonFile)) { diff --git a/src/bind.ts b/src/bind.ts deleted file mode 100644 index 2fd897ab..00000000 --- a/src/bind.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Determines if the property is a method - * @param propertyName the name of the property - * @param value the value of the property - */ -function isMethod(propertyName: string, value: any): boolean { - return propertyName !== "constructor" && typeof value === "function"; -} - -/** - * Auto binds the provided properties - * @param obj an object containing the available properties - */ -export function autoBind(obj: object): void { - const propertyNames = Object.getOwnPropertyNames(obj.constructor.prototype); - propertyNames.forEach((propertyName) => { - const value = obj[propertyName]; - if (isMethod(propertyName, value)) { - obj[propertyName] = value.bind(obj); - } - }); -} diff --git a/src/consumer.ts b/src/consumer.ts index 772fdd6f..4fce2675 100644 --- a/src/consumer.ts +++ b/src/consumer.ts @@ -19,7 +19,6 @@ import { import { ConsumerOptions, StopOptions, UpdatableOptions } from "./types.js"; import { TypedEventEmitter } from "./emitter.js"; -import { autoBind } from "./bind.js"; import { SQSError, TimeoutError, @@ -59,6 +58,7 @@ export class Consumer extends TypedEventEmitter { private isPolling = false; private stopRequestedAtTimestamp: number; public abortController: AbortController; + private extendedAWSErrors: boolean; constructor(options: ConsumerOptions) { super(); @@ -83,13 +83,13 @@ export class Consumer extends TypedEventEmitter { this.pollingCompleteWaitTimeMs = options.pollingCompleteWaitTimeMs ?? 0; this.shouldDeleteMessages = options.shouldDeleteMessages ?? true; this.alwaysAcknowledge = options.alwaysAcknowledge ?? false; + this.extendedAWSErrors = options.extendedAWSErrors ?? false; this.sqs = options.sqs || new SQSClient({ useQueueUrlAsEndpoint: options.useQueueUrlAsEndpoint ?? true, region: options.region || process.env.AWS_REGION || "eu-west-1", }); - autoBind(this); } /** @@ -161,7 +161,7 @@ export class Consumer extends TypedEventEmitter { return; } - const exceededTimeout = + const exceededTimeout: boolean = Date.now() - this.stopRequestedAtTimestamp > this.pollingCompleteWaitTimeMs; if (exceededTimeout) { @@ -171,7 +171,7 @@ export class Consumer extends TypedEventEmitter { } this.emit("waiting_for_polling_to_complete"); - setTimeout(this.waitForPollingToComplete, 1000); + setTimeout(() => this.waitForPollingToComplete(), 1000); } /** @@ -196,7 +196,7 @@ export class Consumer extends TypedEventEmitter { public updateOption( option: UpdatableOptions, value: ConsumerOptions[UpdatableOptions], - ) { + ): void { validateOption(option, value, this, true); this[option] = value; @@ -237,7 +237,7 @@ export class Consumer extends TypedEventEmitter { this.isPolling = true; - let currentPollingTimeout = this.pollingWaitTimeMs; + let currentPollingTimeout: number = this.pollingWaitTimeMs; this.receiveMessage({ QueueUrl: this.queueUrl, AttributeNames: this.attributeNames, @@ -246,11 +246,14 @@ export class Consumer extends TypedEventEmitter { WaitTimeSeconds: this.waitTimeSeconds, VisibilityTimeout: this.visibilityTimeout, }) - .then(this.handleSqsResponse) - .catch((err) => { + .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.", }); @@ -258,16 +261,19 @@ export class Consumer extends TypedEventEmitter { } return; }) - .then(() => { + .then((): void => { if (this.pollingTimeoutId) { clearTimeout(this.pollingTimeoutId); } - this.pollingTimeoutId = setTimeout(this.poll, currentPollingTimeout); + this.pollingTimeoutId = setTimeout( + () => this.poll(), + currentPollingTimeout, + ); }) - .catch((err) => { + .catch((err): void => { this.emitError(err); }) - .finally(() => { + .finally((): void => { this.isPolling = false; }); } @@ -283,7 +289,7 @@ export class Consumer extends TypedEventEmitter { if (this.preReceiveMessageCallback) { await this.preReceiveMessageCallback(); } - const result = await this.sqs.send( + const result: ReceiveMessageCommandOutput = await this.sqs.send( new ReceiveMessageCommand(params), this.sqsSendOptions, ); @@ -293,7 +299,11 @@ export class Consumer extends TypedEventEmitter { return result; } catch (err) { - throw toSQSError(err, `SQS receive message failed: ${err.message}`); + throw toSQSError( + err, + `SQS receive message failed: ${err.message}`, + this.extendedAWSErrors, + ); } } @@ -309,7 +319,11 @@ export class Consumer extends TypedEventEmitter { if (this.handleMessageBatch) { await this.processMessageBatch(response.Messages); } else { - await Promise.all(response.Messages.map(this.processMessage)); + await Promise.all( + response.Messages.map((message: Message) => + this.processMessage(message), + ), + ); } this.emit("response_processed"); @@ -333,7 +347,7 @@ export class Consumer extends TypedEventEmitter { heartbeatTimeoutId = this.startHeartbeat(message); } - const ackedMessage = await this.executeHandler(message); + const ackedMessage: Message = await this.executeHandler(message); if (ackedMessage?.MessageId === message.MessageId) { await this.deleteMessage(message); @@ -361,7 +375,7 @@ export class Consumer extends TypedEventEmitter { let heartbeatTimeoutId: NodeJS.Timeout | undefined = undefined; try { - messages.forEach((message) => { + messages.forEach((message: Message): void => { this.emit("message_received", message); }); @@ -369,12 +383,12 @@ export class Consumer extends TypedEventEmitter { heartbeatTimeoutId = this.startHeartbeat(null, messages); } - const ackedMessages = await this.executeBatchHandler(messages); + const ackedMessages: Message[] = await this.executeBatchHandler(messages); if (ackedMessages?.length > 0) { await this.deleteMessageBatch(ackedMessages); - ackedMessages.forEach((message) => { + ackedMessages.forEach((message: Message): void => { this.emit("message_processed", message); }); } @@ -431,7 +445,11 @@ export class Consumer extends TypedEventEmitter { } catch (err) { this.emit( "error", - toSQSError(err, `Error changing visibility timeout: ${err.message}`), + toSQSError( + err, + `Error changing visibility timeout: ${err.message}`, + this.extendedAWSErrors, + ), message, ); } @@ -448,7 +466,7 @@ export class Consumer extends TypedEventEmitter { ): Promise { const params: ChangeMessageVisibilityBatchCommandInput = { QueueUrl: this.queueUrl, - Entries: messages.map((message) => ({ + Entries: messages.map((message: Message) => ({ Id: message.MessageId, ReceiptHandle: message.ReceiptHandle, VisibilityTimeout: timeout, @@ -462,7 +480,11 @@ export class Consumer extends TypedEventEmitter { } catch (err) { this.emit( "error", - toSQSError(err, `Error changing visibility timeout: ${err.message}`), + toSQSError( + err, + `Error changing visibility timeout: ${err.message}`, + this.extendedAWSErrors, + ), messages, ); } @@ -479,7 +501,7 @@ export class Consumer extends TypedEventEmitter { let result; if (this.handleMessageTimeout) { - const pending = new Promise((_, reject) => { + const pending: Promise = new Promise((_, reject): void => { handleMessageTimeoutId = setTimeout((): void => { reject(new TimeoutError()); }, this.handleMessageTimeout); @@ -518,7 +540,7 @@ export class Consumer extends TypedEventEmitter { */ private async executeBatchHandler(messages: Message[]): Promise { try { - const result = await this.handleMessageBatch(messages); + const result: void | Message[] = await this.handleMessageBatch(messages); return !this.alwaysAcknowledge && result instanceof Object ? result @@ -559,7 +581,11 @@ export class Consumer extends TypedEventEmitter { this.sqsSendOptions, ); } catch (err) { - throw toSQSError(err, `SQS delete message failed: ${err.message}`); + throw toSQSError( + err, + `SQS delete message failed: ${err.message}`, + this.extendedAWSErrors, + ); } } @@ -576,12 +602,12 @@ export class Consumer extends TypedEventEmitter { return; } logger.debug("deleting_messages", { - messageIds: messages.map((msg) => msg.MessageId), + messageIds: messages.map((msg: Message) => msg.MessageId), }); const deleteParams: DeleteMessageBatchCommandInput = { QueueUrl: this.queueUrl, - Entries: messages.map((message) => ({ + Entries: messages.map((message: Message) => ({ Id: message.MessageId, ReceiptHandle: message.ReceiptHandle, })), @@ -593,7 +619,11 @@ export class Consumer extends TypedEventEmitter { this.sqsSendOptions, ); } catch (err) { - throw toSQSError(err, `SQS delete message failed: ${err.message}`); + throw toSQSError( + err, + `SQS delete message failed: ${err.message}`, + this.extendedAWSErrors, + ); } } } diff --git a/src/errors.ts b/src/errors.ts index 71a25809..5fbcb858 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -6,7 +6,9 @@ class SQSError extends Error { service: string; time: Date; retryable: boolean; - fault: "client" | "server"; + fault: AWSError["$fault"]; + response?: AWSError["$response"]; + metadata?: AWSError["$metadata"]; constructor(message: string) { super(message); @@ -36,19 +38,28 @@ class StandardError extends Error { } } +/** + * List of SQS error codes that are considered connection errors. + */ +const CONNECTION_ERRORS = [ + "CredentialsError", + "UnknownEndpoint", + "AWS.SimpleQueueService.NonExistentQueue", + "CredentialsProviderError", + "InvalidAddress", + "InvalidSecurity", + "QueueDoesNotExist", + "RequestThrottled", + "OverLimit", +]; + /** * Checks if the error provided should be treated as a connection error. * @param err The error that was received. */ function isConnectionError(err: Error): boolean { if (err instanceof SQSError) { - return ( - err.statusCode === 403 || - err.code === "CredentialsError" || - err.code === "UnknownEndpoint" || - err.code === "AWS.SimpleQueueService.NonExistentQueue" || - err.code === "CredentialsProviderError" - ); + return err.statusCode === 403 || CONNECTION_ERRORS.includes(err.code); } return false; } @@ -58,7 +69,11 @@ function isConnectionError(err: Error): boolean { * @param err The error object that was received. * @param message The message to send with the error. */ -function toSQSError(err: AWSError, message: string): SQSError { +function toSQSError( + err: AWSError, + message: string, + extendedAWSErrors: boolean, +): SQSError { const sqsError = new SQSError(message); sqsError.code = err.name; sqsError.statusCode = err.$metadata?.httpStatusCode; @@ -67,6 +82,11 @@ function toSQSError(err: AWSError, message: string): SQSError { sqsError.fault = err.$fault; sqsError.time = new Date(); + if (extendedAWSErrors) { + sqsError.response = err.$response; + sqsError.metadata = err.$metadata; + } + return sqsError; } diff --git a/src/types.ts b/src/types.ts index cedff0d1..075407ee 100644 --- a/src/types.ts +++ b/src/types.ts @@ -141,6 +141,11 @@ export interface ConsumerOptions { * example to add middlewares. */ postReceiveMessageCallback?(): Promise; + /** + * Set this to `true` if you want to receive additional information about the error + * that occurred from AWS, such as the response and metadata. + */ + extendedAWSErrors?: boolean; } /** @@ -232,7 +237,7 @@ export type AWSError = { /** * Name, eg. ConditionalCheckFailedException */ - name: string; + readonly name: string; /** * Human-readable error response message @@ -247,7 +252,26 @@ export type AWSError = { /** * Whether the client or server are at fault. */ - readonly $fault?: "client" | "server"; + readonly $fault: "client" | "server"; + + /** + * Represents an HTTP message as received in reply to a request + */ + readonly $response?: { + /** + * The status code of the HTTP response. + */ + statusCode?: number; + /** + * The headers of the HTTP message. + */ + headers: Record; + /** + * The body of the HTTP message. + * Can be: ArrayBuffer | ArrayBufferView | string | Uint8Array | Readable | ReadableStream + */ + body?: any; + }; /** * The service that encountered the exception. @@ -264,37 +288,37 @@ export type AWSError = { readonly throttling?: boolean; }; - $metadata?: { + readonly $metadata: { /** * The status code of the last HTTP response received for this operation. */ - httpStatusCode?: number; + readonly httpStatusCode?: number; /** * A unique identifier for the last request sent for this operation. Often * requested by AWS service teams to aid in debugging. */ - requestId?: string; + readonly requestId?: string; /** * A secondary identifier for the last request sent. Used for debugging. */ - extendedRequestId?: string; + readonly extendedRequestId?: string; /** * A tertiary identifier for the last request sent. Used for debugging. */ - cfId?: string; + readonly cfId?: string; /** * The number of times this operation was attempted. */ - attempts?: number; + readonly attempts?: number; /** * The total amount of time (in milliseconds) that was spent waiting between * retry attempts. */ - totalRetryDelay?: number; + readonly totalRetryDelay?: number; }; }; diff --git a/test/config/cucumber.mjs b/test/config/cucumber.mjs index b160c04e..6f3704e9 100644 --- a/test/config/cucumber.mjs +++ b/test/config/cucumber.mjs @@ -1,6 +1,6 @@ export default { parallel: 0, - format: ['html:test/reports/cucumber-report.html'], - paths: ['test/features'], - forceExit: true + format: ["json:test/reports/cucumber-report.json"], + paths: ["test/features"], + forceExit: true, }; diff --git a/test/tests/consumer.test.ts b/test/tests/consumer.test.ts index ab5300a6..d70219e8 100644 --- a/test/tests/consumer.test.ts +++ b/test/tests/consumer.test.ts @@ -36,14 +36,17 @@ const mockChangeMessageVisibilityBatch = sinon.match.instanceOf( class MockSQSError extends Error implements AWSError { name: string; - $metadata: { - httpStatusCode: number; - }; + $metadata: AWSError["$metadata"]; $service: string; - $retryable: { - throttling: boolean; - }; - $fault: "client" | "server"; + $retryable: AWSError["$retryable"]; + $fault: AWSError["$fault"]; + $response?: + | { + statusCode?: number | undefined; + headers: Record; + body?: any; + } + | undefined; time: Date; constructor(message: string) { @@ -245,6 +248,82 @@ describe("Consumer", () => { assert.equal(err.time.toString(), receiveErr.time.toString()); assert.equal(err.service, receiveErr.$service); assert.equal(err.fault, receiveErr.$fault); + assert.isUndefined(err.response); + assert.isUndefined(err.metadata); + }); + + it('includes the response and metadata in the error when "extendedAWSErrors" is true', async () => { + const receiveErr = new MockSQSError("Receive error"); + receiveErr.name = "short code"; + receiveErr.$retryable = { + throttling: false, + }; + receiveErr.$metadata = { + httpStatusCode: 403, + }; + receiveErr.time = new Date(); + receiveErr.$service = "service"; + receiveErr.$response = { + statusCode: 200, + headers: {}, + body: "body", + }; + + sqs.send.withArgs(mockReceiveMessage).rejects(receiveErr); + + consumer = new Consumer({ + queueUrl: QUEUE_URL, + region: REGION, + handleMessage, + sqs, + authenticationErrorTimeout: AUTHENTICATION_ERROR_TIMEOUT, + extendedAWSErrors: true, + }); + + consumer.start(); + const err: any = await pEvent(consumer, "error"); + consumer.stop(); + + assert.ok(err); + assert.equal(err.response, receiveErr.$response); + assert.equal(err.metadata, receiveErr.$metadata); + }); + + it("does not include the response and metadata in the error when extendedAWSErrors is false", async () => { + const receiveErr = new MockSQSError("Receive error"); + receiveErr.name = "short code"; + receiveErr.$retryable = { + throttling: false, + }; + receiveErr.$metadata = { + httpStatusCode: 403, + }; + receiveErr.time = new Date(); + receiveErr.$service = "service"; + receiveErr.$response = { + statusCode: 200, + headers: {}, + body: "body", + }; + + sqs.send.withArgs(mockReceiveMessage).rejects(receiveErr); + + consumer = new Consumer({ + queueUrl: QUEUE_URL, + region: REGION, + handleMessage, + sqs, + authenticationErrorTimeout: AUTHENTICATION_ERROR_TIMEOUT, + extendedAWSErrors: false, + }); + + consumer.start(); + const err: any = await pEvent(consumer, "error"); + consumer.stop(); + + assert.ok(err); + assert.isUndefined(err.response); + assert.isUndefined(err.metadata); }); it("fires a timeout event if handler function takes too long", async () => { @@ -409,6 +488,8 @@ describe("Consumer", () => { }); it("waits before repolling when a credentials error occurs", async () => { + const loggerDebug = sandbox.stub(logger, "debug"); + const credentialsErr = { name: "CredentialsError", message: "Missing credentials in config", @@ -424,9 +505,16 @@ describe("Consumer", () => { sandbox.assert.calledTwice(errorListener); sandbox.assert.calledWithMatch(sqs.send.firstCall, mockReceiveMessage); sandbox.assert.calledWithMatch(sqs.send.secondCall, mockReceiveMessage); + + sandbox.assert.calledWith(loggerDebug, "authentication_error", { + code: "CredentialsError", + detail: "There was an authentication error. Pausing before retrying.", + }); }); it("waits before repolling when a 403 error occurs", async () => { + const loggerDebug = sandbox.stub(logger, "debug"); + const invalidSignatureErr = { $metadata: { httpStatusCode: 403, @@ -444,9 +532,16 @@ describe("Consumer", () => { sandbox.assert.calledTwice(errorListener); sandbox.assert.calledWithMatch(sqs.send.firstCall, mockReceiveMessage); sandbox.assert.calledWithMatch(sqs.send.secondCall, mockReceiveMessage); + + sandbox.assert.calledWith(loggerDebug, "authentication_error", { + code: "Unknown", + detail: "There was an authentication error. Pausing before retrying.", + }); }); it("waits before repolling when a UnknownEndpoint error occurs", async () => { + const loggerDebug = sandbox.stub(logger, "debug"); + const unknownEndpointErr = { name: "UnknownEndpoint", message: @@ -464,9 +559,16 @@ describe("Consumer", () => { sandbox.assert.calledTwice(sqs.send); sandbox.assert.calledWithMatch(sqs.send.firstCall, mockReceiveMessage); sandbox.assert.calledWithMatch(sqs.send.secondCall, mockReceiveMessage); + + sandbox.assert.calledWith(loggerDebug, "authentication_error", { + code: "UnknownEndpoint", + detail: "There was an authentication error. Pausing before retrying.", + }); }); it("waits before repolling when a NonExistentQueue error occurs", async () => { + const loggerDebug = sandbox.stub(logger, "debug"); + const nonExistentQueueErr = { name: "AWS.SimpleQueueService.NonExistentQueue", message: "The specified queue does not exist for this wsdl version.", @@ -483,9 +585,16 @@ describe("Consumer", () => { sandbox.assert.calledTwice(sqs.send); sandbox.assert.calledWithMatch(sqs.send.firstCall, mockReceiveMessage); sandbox.assert.calledWithMatch(sqs.send.secondCall, mockReceiveMessage); + + sandbox.assert.calledWith(loggerDebug, "authentication_error", { + code: "AWS.SimpleQueueService.NonExistentQueue", + detail: "There was an authentication error. Pausing before retrying.", + }); }); it("waits before repolling when a CredentialsProviderError error occurs", async () => { + const loggerDebug = sandbox.stub(logger, "debug"); + const credentialsProviderErr = { name: "CredentialsProviderError", message: "Could not load credentials from any providers.", @@ -502,6 +611,141 @@ describe("Consumer", () => { sandbox.assert.calledTwice(sqs.send); sandbox.assert.calledWithMatch(sqs.send.firstCall, mockReceiveMessage); sandbox.assert.calledWithMatch(sqs.send.secondCall, mockReceiveMessage); + + sandbox.assert.calledWith(loggerDebug, "authentication_error", { + code: "CredentialsProviderError", + detail: "There was an authentication error. Pausing before retrying.", + }); + }); + + it("waits before repolling when a InvalidAddress error occurs", async () => { + const loggerDebug = sandbox.stub(logger, "debug"); + + const credentialsProviderErr = { + name: "InvalidAddress", + message: "The address some-queue-url is not valid for this endpoint.", + }; + sqs.send.withArgs(mockReceiveMessage).rejects(credentialsProviderErr); + 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.send); + sandbox.assert.calledWithMatch(sqs.send.firstCall, mockReceiveMessage); + sandbox.assert.calledWithMatch(sqs.send.secondCall, mockReceiveMessage); + + sandbox.assert.calledWith(loggerDebug, "authentication_error", { + code: "InvalidAddress", + detail: "There was an authentication error. Pausing before retrying.", + }); + }); + + it("waits before repolling when a InvalidSecurity error occurs", async () => { + const loggerDebug = sandbox.stub(logger, "debug"); + + const credentialsProviderErr = { + name: "InvalidSecurity", + message: "The queue is not is not HTTPS and SigV4.", + }; + sqs.send.withArgs(mockReceiveMessage).rejects(credentialsProviderErr); + 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.send); + sandbox.assert.calledWithMatch(sqs.send.firstCall, mockReceiveMessage); + sandbox.assert.calledWithMatch(sqs.send.secondCall, mockReceiveMessage); + + sandbox.assert.calledWith(loggerDebug, "authentication_error", { + code: "InvalidSecurity", + detail: "There was an authentication error. Pausing before retrying.", + }); + }); + + it("waits before repolling when a QueueDoesNotExist error occurs", async () => { + const loggerDebug = sandbox.stub(logger, "debug"); + + const credentialsProviderErr = { + name: "QueueDoesNotExist", + message: "The queue does not exist.", + }; + sqs.send.withArgs(mockReceiveMessage).rejects(credentialsProviderErr); + 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.send); + sandbox.assert.calledWithMatch(sqs.send.firstCall, mockReceiveMessage); + sandbox.assert.calledWithMatch(sqs.send.secondCall, mockReceiveMessage); + + sandbox.assert.calledWith(loggerDebug, "authentication_error", { + code: "QueueDoesNotExist", + detail: "There was an authentication error. Pausing before retrying.", + }); + }); + + it("waits before repolling when a RequestThrottled error occurs", async () => { + const loggerDebug = sandbox.stub(logger, "debug"); + + const credentialsProviderErr = { + name: "RequestThrottled", + message: "Requests have been throttled.", + }; + sqs.send.withArgs(mockReceiveMessage).rejects(credentialsProviderErr); + 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.send); + sandbox.assert.calledWithMatch(sqs.send.firstCall, mockReceiveMessage); + sandbox.assert.calledWithMatch(sqs.send.secondCall, mockReceiveMessage); + + sandbox.assert.calledWith(loggerDebug, "authentication_error", { + code: "RequestThrottled", + detail: "There was an authentication error. Pausing before retrying.", + }); + }); + + it("waits before repolling when a RequestThrottled error occurs", async () => { + const loggerDebug = sandbox.stub(logger, "debug"); + + const credentialsProviderErr = { + name: "OverLimit", + message: "An over limit error.", + }; + sqs.send.withArgs(mockReceiveMessage).rejects(credentialsProviderErr); + 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.send); + sandbox.assert.calledWithMatch(sqs.send.firstCall, mockReceiveMessage); + sandbox.assert.calledWithMatch(sqs.send.secondCall, mockReceiveMessage); + + sandbox.assert.calledWith(loggerDebug, "authentication_error", { + code: "OverLimit", + detail: "There was an authentication error. Pausing before retrying.", + }); }); it("waits before repolling when a polling timeout is set", async () => { diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json index dc967eaf..89805c68 100644 --- a/tsconfig.cjs.json +++ b/tsconfig.cjs.json @@ -4,6 +4,7 @@ "outDir": "./dist/cjs", "module": "commonjs", "moduleResolution": "node", - "noEmit": false + "noEmit": false, + "declaration": true } } diff --git a/tsconfig.esm.json b/tsconfig.esm.json index 4279f9a3..3d2b5a25 100644 --- a/tsconfig.esm.json +++ b/tsconfig.esm.json @@ -4,6 +4,7 @@ "outDir": "./dist/esm", "module": "Node16", "moduleResolution": "Node16", - "noEmit": false + "noEmit": false, + "declaration": true } } diff --git a/tsconfig.json b/tsconfig.json index 195ee945..de95f414 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,8 +8,7 @@ "sourceMap": false, "allowJs": false, "noUnusedLocals": true, - "declaration": true, - "declarationDir": "dist/types" + "declaration": false }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"]