├── .github ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── DISCUSSION_TEMPLATE │ ├── feature-request.yml │ └── help.yml ├── ISSUE_TEMPLATE │ ├── bug-report.yml │ └── config.yml ├── SECURITY.md ├── pull_request_template.md ├── renovate.json └── workflows │ ├── annotate-test-reports.yml │ ├── cla.yml │ ├── codeql.yml │ ├── coverage.yml │ ├── dependency-review.yml │ ├── docs.yml │ ├── issues-to-projects.yml │ ├── lock-threads.yml │ ├── pr-coverage.yml │ ├── publish.yml │ ├── stale.yml │ └── test.yml ├── .gitignore ├── .npmignore ├── .prettierignore ├── .prettierrc.js ├── LICENSE ├── README.md ├── eslint.config.js ├── jsr.json ├── package-lock.json ├── package.json ├── scripts └── addPackageJsons.js ├── src ├── consumer.ts ├── emitter.ts ├── errors.ts ├── index.ts ├── logger.ts ├── types.ts └── validation.ts ├── test ├── config │ └── cucumber.mjs ├── features │ ├── events.feature │ ├── gracefulShutdown.feature │ ├── handleMessage.feature │ ├── handleMessageBatch.feature │ ├── step_definitions │ │ ├── events.js │ │ ├── gracefulShutdown.js │ │ ├── handleMessage.js │ │ └── handleMessageBatch.js │ └── utils │ │ ├── consumer │ │ ├── gracefulShutdown.js │ │ ├── handleMessage.js │ │ └── handleMessageBatch.js │ │ ├── delay.js │ │ ├── producer.js │ │ └── sqs.js ├── reports │ └── .keep ├── scripts │ ├── docker-compose.yml │ ├── initIntTests.sh │ └── localstack │ │ └── init.sh └── tests │ └── consumer.test.ts ├── tsconfig.cjs.json ├── tsconfig.esm.json ├── tsconfig.json └── typedoc.json /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # All changes should be reviewed by codeowners 2 | * @bbc/ibl @bbc/detl-engineers -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct (CoC) 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our Standards 10 | 11 | Examples of behaviour that contributes to a positive environment for our community include: 12 | 13 | - Respect different opinions, perspectives, and experiences 14 | - Giving and appreciating constructive feedback 15 | - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 16 | - Focusing on what is best for us as individuals and the overall community 17 | - Demonstrating kindness toward other people 18 | 19 | Examples of unacceptable behaviour include: 20 | 21 | - The use of sexualised language or imagery, and sexual attention or advances of any kind 22 | - Trolling, insulting or derogatory comments, and personal or political attacks 23 | - Public or private harassment 24 | - Publishing others’ private information, such as a physical or email address, without their explicit permission 25 | - Other conduct which could reasonably be considered inappropriate in a professional setting 26 | 27 | ## Enforcement Responsibilities 28 | 29 | Project maintainers are responsible for clarifying and enforcing our standards of acceptable behaviour and will take appropriate and fair corrective action in response to any behaviour that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 32 | 33 | ## Scope 34 | 35 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 36 | 37 | ## Enforcement 38 | 39 | Instances of abusive, harassing, or otherwise unacceptable behaviour may be reported to the community leaders responsible for enforcement. All complaints will be reviewed and investigated promptly and fairly. 40 | 41 | [Project maintainers](https://github.com/bbc/sqs-consumber/blob/main/.github/CODEOWNERS) are obligated to respect the privacy and security of the reporter of any incident. 42 | 43 | ## Enforcement Guidelines 44 | 45 | ### 1. Correction 46 | 47 | Community Impact: Use of inappropriate language or other behaviour deemed unprofessional or unwelcome in the community. 48 | 49 | Consequence: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behaviour was inappropriate. A public apology may be requested. 50 | 51 | ### 2. Warning 52 | 53 | Community Impact: A violation through a single incident or series of actions. 54 | 55 | Consequence: A warning with consequences for continued behaviour. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 56 | 57 | ### 3. Temporary Ban 58 | 59 | Community Impact: A serious violation of community standards, including sustained inappropriate behaviour. 60 | 61 | Consequence: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 62 | 63 | ### 4. Permanent Ban 64 | 65 | Community Impact: Demonstrating a pattern of violation of community standards, including sustained inappropriate behaviour, harassment of an individual, or aggression toward or disparagement of classes of individuals. 66 | 67 | Consequence: A permanent ban from any sort of public interaction within the community. 68 | 69 | ## Attribution 70 | 71 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][version] 72 | 73 | [homepage]: https://contributor-covenant.org 74 | [version]: https://www.contributor-covenant.org/version/2/0/code_of_conduct/ 75 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for your interest in contributing to the sqs-consumer. 4 | 5 | - If you're unsure if a feature would make a good addition, you can always [create an issue](https://github.com/bbc/sqs-consumer/issues/new) first. Raising an issue before creating a pull request is recommended. 6 | - We aim for 100% test coverage. Please write tests for any new functionality or changes. 7 | - Any API changes should be fully documented. 8 | - Make sure your code meets our linting standards. Run `npm run lint` to check your code. 9 | - Maintain the existing coding style. 10 | - Be mindful of others when making suggestions and/or code reviewing. 11 | 12 | ## Reporting Issues 13 | 14 | Before opening a new issue, first check that there is not already an [open issue or Pull Request](https://github.com/bbc/sqs-consumer/issues?utf8=%E2%9C%93&q=is%3Aopen) that addresses it. 15 | 16 | If there is, make relevant comments and add your reaction. Use a reaction in place of a "+1" comment: 17 | 18 | - 👍 - upvote 19 | - 👎 - downvote 20 | 21 | If you cannot find an existing issue that describes your bug or feature, create a new issue using the guidelines below. 22 | 23 | 1. Pick an appropriate template for the type of issue [from here](https://github.com/bbc/sqs-consumer/issues/choose) 24 | 2. Provide as much detail as possible 25 | 3. Follow your issue in the issue tracking workflow 26 | 27 | ## Contributing Code 28 | 29 | If you do not have push access to the repository, please [fork it](https://help.github.com/en/articles/fork-a-repo). You should then work on your own `main` branch. 30 | 31 | Otherwise, you may clone this repository and create a working branch with a _kebab-case_ name reflecting what you are working on (e.g. `fix-the-thing`). 32 | 33 | Follow the setup instructions in the [README](../README.md). 34 | 35 | Ensure all your code is thoroughly tested and that this testing is detailed in the pull request. 36 | 37 | ## Contributors Licence Agreement 38 | 39 | In order to accept contributions, we need all contributors grant Us a licence to the intellectual 40 | property rights in their Contributions. This Agreement (“Agreement”) is intended to protect your 41 | rights as a contributor, and to help ensure that the intellectual property contained 42 | within is available to the whole community, to use and build on. 43 | 44 | When you raise a pull request and you haven't previously signed a CLA, the bot will automatically 45 | ask you to do this. You must complete this step in order for your PR to be merged. 46 | 47 | ## Pull Request Process 48 | 49 | 1. Make sure you have opened an issue and it was approved by a project maintainer before working on a PR 50 | 2. Read and complete all relevant sections of the PR template 51 | 3. Wait for the PR get approved 52 | -------------------------------------------------------------------------------- /.github/DISCUSSION_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | body: 2 | - type: textarea 3 | attributes: 4 | label: Background 5 | description: Why do you think this feature is needed? Are there current alternatives? 6 | validations: 7 | required: true 8 | - type: textarea 9 | attributes: 10 | label: Objectives 11 | description: What should this feature request aim to address? 12 | value: | 13 | 1. 14 | 2. 15 | 3. 16 | validations: 17 | required: true -------------------------------------------------------------------------------- /.github/DISCUSSION_TEMPLATE/help.yml: -------------------------------------------------------------------------------- 1 | body: 2 | - type: textarea 3 | attributes: 4 | label: Summary 5 | description: What do you need help with? 6 | validations: 7 | required: true 8 | - type: input 9 | attributes: 10 | label: Example 11 | 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. 12 | validations: 13 | required: false 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: 'Bug report' 2 | title: "[Bug]: " 3 | labels: ["bug", "triage"] 4 | description: Report a reproducible bug or regression 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thank you for reporting an issue! 10 | 11 | This issue tracker is for reporting reproducible bugs or regression's found in this package, if you have a question or feature request, please report it within the [Discussions tab](https://github.com/bbc/sqs-consumer/discussions) instead. 12 | 13 | Before submitting a new bug/issue, please check the links below to see if there is a solution or question posted there already: 14 | 15 | - [Discussions](https://github.com/bbc/sqs-consumer/discussions) 16 | - [Open Issues](https://github.com/bbc/sqs-consumer/issues?q=is%3Aopen+is%3Aissue) 17 | - [Closed Issues](https://github.com/bbc/sqs-consumer/issues?q=is%3Aissue+is%3Aclosed) 18 | 19 | The more information you fill in, the better the community can help you. 20 | - type: textarea 21 | id: description 22 | attributes: 23 | label: Describe the bug 24 | description: Provide a clear and concise description of what the bug is. 25 | validations: 26 | required: true 27 | - type: input 28 | id: link 29 | attributes: 30 | label: Your minimal, reproducible example 31 | description: | 32 | Please add a link to a minimal reproduction. 33 | Note: 34 | - Please keep your example as simple and reproduceable as possible, try leaving out dependencies that are not required for reproduction. 35 | - 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). 36 | - Please make sure the example is complete and runnable - e.g. avoid localhost URLs. 37 | placeholder: | 38 | e.g. Code Sandbox, Stackblitz, Expo Snack or TypeScript playground 39 | validations: 40 | required: true 41 | - type: textarea 42 | id: steps 43 | attributes: 44 | label: Steps to reproduce 45 | description: Describe the steps we have to take to reproduce the behavior. 46 | placeholder: | 47 | 1. Go to '...' 48 | 2. Click on '....' 49 | 3. Scroll down to '....' 50 | 4. See error 51 | validations: 52 | required: true 53 | - type: textarea 54 | id: expected 55 | attributes: 56 | label: Expected behavior 57 | description: Provide a clear and concise description of what you expected to happen. 58 | placeholder: | 59 | As a user, I expected ___ behavior but i am seeing ___ 60 | validations: 61 | required: true 62 | - type: dropdown 63 | attributes: 64 | label: How often does this bug happen? 65 | description: | 66 | Following the repro steps above, how easily are you able to reproduce this bug? 67 | options: 68 | - Every time 69 | - Often 70 | - Sometimes 71 | - Only once 72 | - type: textarea 73 | id: screenshots_or_videos 74 | attributes: 75 | label: Screenshots or Videos 76 | description: | 77 | If applicable, add screenshots or a video to help explain your problem. 78 | For more information on the supported file image/file types and the file size limits, please refer 79 | to the following link: https://docs.github.com/en/github/writing-on-github/working-with-advanced-formatting/attaching-files 80 | placeholder: | 81 | You can drag your video or image files inside of this editor ↓ 82 | - type: textarea 83 | id: platform 84 | attributes: 85 | label: Platform 86 | description: | 87 | Please let us know which Operating System and Node version you were using when the issue occurred. 88 | placeholder: | 89 | - OS: [e.g. macOS, Windows, Linux, iOS, Android] 90 | - Node Version: [e.g. 16.6.0] 91 | validations: 92 | required: true 93 | - type: input 94 | id: package-version 95 | attributes: 96 | label: Package version 97 | description: | 98 | Please let us know the exact version of the package you were using when the issue occurred. Please don't just put in "latest", as this is subject to change. 99 | placeholder: | 100 | e.g. v6.0.0 101 | validations: 102 | required: true 103 | - type: input 104 | id: ts-version 105 | attributes: 106 | label: AWS SDK version 107 | description: | 108 | Please include what version of the AWS SDK you are using 109 | placeholder: | 110 | e.g. v3.226.0 111 | - type: textarea 112 | id: additional 113 | attributes: 114 | label: Additional context 115 | description: Add any other context about the problem here. 116 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Feature Requests & Questions 4 | url: https://github.com/bbc/sqs-consumer/discussions 5 | about: Please ask and answer questions here. -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | Our full security policy and vulnerability reporting procedure is documented on [this external website](https://www.bbc.com/backstage/security-disclosure-policy/#reportingavulnerability). 6 | 7 | Please note that this is a general BBC process. Communication will not be direct with the team responsible for this repo. 8 | 9 | If you would like to, you can also open an issue in this repo regarding your disclosure, but please never share any details of the vulnerability in the GitHub issue. 10 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | Resolves #NUMBER 2 | 3 | **Description:** 4 | _A very high-level summary of easily-reproducible changes that can be understood by non-devs._ 5 | 6 | **Type of change:** 7 | 8 | - [ ] Bug fix (non-breaking change which fixes an issue) 9 | - [ ] New feature (non-breaking change which adds functionality) 10 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 11 | 12 | **Why is this change required?:** 13 | _A simple explanation of what the problem is and how this PR solves it_ 14 | 15 | **Code changes:** 16 | 17 | - _A bullet point list of key code changes that have been made._ 18 | - _When describing code changes, try to communicate **how** and **why** you implemented something a specific way, not just **what** has changed._ 19 | 20 | --- 21 | 22 | **Checklist:** 23 | 24 | - [ ] My code follows the code style of this project. 25 | - [ ] My change requires a change to the documentation. 26 | - [ ] I have updated the documentation accordingly. 27 | - [ ] I have read the **CONTRIBUTING** document. 28 | - [ ] I have added tests to cover my changes. 29 | - [ ] All new and existing tests passed. 30 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | ":combinePatchMinorReleases", 6 | ":enableVulnerabilityAlertsWithLabel(vulnerability)", 7 | ":prConcurrentLimit10", 8 | ":prHourlyLimit4", 9 | ":prNotPending", 10 | ":preserveSemverRanges", 11 | ":rebaseStalePrs", 12 | ":semanticCommits", 13 | ":semanticPrefixFixDepsChoreOthers", 14 | ":label(dependencies)", 15 | ":timezone(Europe/London)", 16 | "docker:enableMajor", 17 | "docker:pinDigests", 18 | "group:postcss", 19 | "group:linters", 20 | "group:monorepos", 21 | "npm:unpublishSafe", 22 | "customManagers:dockerfileVersions", 23 | "replacements:all" 24 | ], 25 | "rangeStrategy": "update-lockfile", 26 | "dependencyDashboardAutoclose": true, 27 | "platformAutomerge": true, 28 | "vulnerabilityAlerts": { 29 | "labels": ["security"], 30 | "automerge": true 31 | }, 32 | "minimumReleaseAge": "3 days" 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/annotate-test-reports.yml: -------------------------------------------------------------------------------- 1 | name: Annotate CI run with test results 2 | on: 3 | workflow_run: 4 | workflows: 5 | - "Run Tests" 6 | types: 7 | - completed 8 | permissions: 9 | actions: read 10 | contents: read 11 | checks: write 12 | pull-requests: write 13 | 14 | jobs: 15 | annotate: 16 | name: Annotate CI run with test results 17 | runs-on: ubuntu-latest 18 | if: ${{ github.event.workflow_run.conclusion != 'cancelled' }} 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | node-version: [20.x, 22.x] 23 | timeout-minutes: 5 24 | steps: 25 | - name: Annotate CI run with test results 26 | uses: dorny/test-reporter@v1 27 | with: 28 | artifact: test-reports-${{ matrix.node-version }} 29 | name: Test Results (${{matrix.node-version}} 30 | path: "test-results.json" 31 | reporter: mocha-json 32 | token: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/cla.yml: -------------------------------------------------------------------------------- 1 | name: "CLA Check" 2 | on: 3 | issue_comment: 4 | types: [created] 5 | pull_request_target: 6 | types: [opened,closed,synchronize] 7 | 8 | permissions: 9 | contents: write 10 | issues: write 11 | pull-requests: write 12 | 13 | jobs: 14 | CLAAssistant: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: "CLA Check" 18 | if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' 19 | uses: contributor-assistant/github-action@v2.6.1 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 23 | with: 24 | path-to-signatures: 'sqs-consumer/v1/cla.json' 25 | remote-organization-name: 'bbc' 26 | remote-repository-name: 'cla-signatures' 27 | path-to-document: 'https://bbc.github.io/cla-signatures/cla/v1/cla.html' 28 | branch: 'main' 29 | allowlist: bot* 30 | custom-allsigned-prcomment: '**CLA CHECK** All Contributors have signed the CLA' 31 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main", "canary", "*.x" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main", "canary", "*.x" ] 20 | schedule: 21 | - cron: '43 21 * * 2' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Use only 'java' to analyze code written in Java, Kotlin or both 38 | # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both 39 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 40 | 41 | steps: 42 | - name: Checkout repository 43 | uses: actions/checkout@v4 44 | 45 | # Initializes the CodeQL tools for scanning. 46 | - name: Initialize CodeQL 47 | uses: github/codeql-action/init@v3 48 | with: 49 | languages: ${{ matrix.language }} 50 | # If you wish to specify custom queries, you can do so here or in a config file. 51 | # By default, queries listed here will override any specified in a config file. 52 | # Prefix the list here with "+" to use these queries and those in the config file. 53 | 54 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 55 | # queries: security-extended,security-and-quality 56 | 57 | 58 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 59 | # If this step fails, then you should remove it and run the build manually (see below) 60 | - name: Autobuild 61 | uses: github/codeql-action/autobuild@v3 62 | 63 | # ℹ️ Command-line programs to run using the OS shell. 64 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 65 | 66 | # If the Autobuild fails above, remove it and uncomment the following three lines. 67 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 68 | 69 | # - run: | 70 | # echo "Run, Build Application using script" 71 | # ./location_of_script_within_repo/buildscript.sh 72 | 73 | - name: Perform CodeQL Analysis 74 | uses: github/codeql-action/analyze@v3 75 | with: 76 | category: "/language:${{matrix.language}}" 77 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: Report Coverage 2 | on: 3 | pull_request: 4 | branches: 5 | - canary 6 | - "*.x" 7 | - 'main' 8 | push: 9 | branches: 10 | - canary 11 | - "*.x" 12 | - main 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | build: 18 | runs-on: ubuntu-latest 19 | strategy: 20 | matrix: 21 | node-version: [22.x] 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | 26 | - name: Use Node.js ${{ matrix.node-version }} 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: ${{ matrix.node-version }} 30 | cache: 'npm' 31 | 32 | - name: NPM Audit 33 | run: npx audit-ci 34 | 35 | - name: Install Node Modules 36 | run: npm ci 37 | 38 | - name: Report Coverage 39 | uses: paambaati/codeclimate-action@v9.0.0 40 | env: 41 | CC_TEST_REPORTER_ID: 760097cb88b4c685dce427cf94a8e12a5f082774d06b4f4f5daef839ffc07821 42 | with: 43 | coverageCommand: npm run lcov 44 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | name: 'Dependency Review' 2 | on: [pull_request] 3 | 4 | permissions: 5 | contents: read 6 | 7 | jobs: 8 | dependency-review: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: 'Checkout Repository' 12 | uses: actions/checkout@v4 13 | - name: 'Dependency Review' 14 | uses: actions/dependency-review-action@v4 15 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy static content to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["main"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow one concurrent deployment 19 | concurrency: 20 | group: "pages" 21 | cancel-in-progress: true 22 | 23 | jobs: 24 | # Single deploy job since we're just deploying 25 | deploy: 26 | environment: 27 | name: github-pages 28 | url: ${{ steps.deployment.outputs.page_url }} 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v4 33 | 34 | - name: Setup Node.js 35 | uses: actions/setup-node@v4 36 | with: 37 | node-version: 22.x 38 | cache: 'npm' 39 | 40 | - name: NPM Audit 41 | run: npx audit-ci 42 | 43 | - name: Install Node Modules 44 | run: npm ci 45 | 46 | - name: Build Docs 47 | run: npm run generate-docs 48 | 49 | - name: Setup Pages 50 | uses: actions/configure-pages@v5 51 | 52 | - name: Upload artifact 53 | uses: actions/upload-pages-artifact@v3 54 | with: 55 | path: './public' 56 | 57 | - name: Deploy to GitHub Pages 58 | id: deployment 59 | uses: actions/deploy-pages@v4 60 | -------------------------------------------------------------------------------- /.github/workflows/issues-to-projects.yml: -------------------------------------------------------------------------------- 1 | name: Add bugs to bugs project 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | - labeled 8 | 9 | jobs: 10 | add-to-project: 11 | name: Add issue to project 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/add-to-project@v1.0.2 15 | with: 16 | project-url: https://github.com/orgs/bbc/projects/170 17 | github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} 18 | -------------------------------------------------------------------------------- /.github/workflows/lock-threads.yml: -------------------------------------------------------------------------------- 1 | name: "Lock Threads" 2 | 3 | on: 4 | schedule: 5 | - cron: "0 * * * *" # Once a day, at midnight UTC 6 | workflow_dispatch: 7 | 8 | permissions: 9 | issues: write 10 | pull-requests: write 11 | 12 | concurrency: 13 | group: lock 14 | 15 | jobs: 16 | action: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: dessant/lock-threads@v5 20 | with: 21 | github-token: ${{ secrets.GITHUB_TOKEN }} 22 | issue-inactive-days: "30" # Lock issues after 30 days of being closed 23 | pr-inactive-days: "5" # Lock closed PRs after 5 days. This ensures that issues that stem from a PR are opened as issues, rather than comments on the recently merged PR. 24 | add-issue-labels: "outdated" 25 | exclude-issue-created-before: "2023-01-01" 26 | issue-comment: > 27 | This issue has been closed for more than 30 days. If this issue is still occuring, please open a new issue with more recent context. 28 | pr-comment: > 29 | This pull request has already been merged/closed. If you experience issues related to these changes, please open a new issue referencing this pull request. 30 | -------------------------------------------------------------------------------- /.github/workflows/pr-coverage.yml: -------------------------------------------------------------------------------- 1 | name: Comment PR Coverage 2 | on: 3 | pull_request: 4 | branches: 5 | - canary 6 | - "*.x" 7 | - 'main' 8 | permissions: 9 | contents: read 10 | pull-requests: write 11 | 12 | jobs: 13 | coverage_report: 14 | name: Generate coverage report 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | node-version: [22.x] 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | cache: 'npm' 27 | 28 | - name: Install Node Modules 29 | run: npm ci 30 | 31 | - name: Run Coverage Check 32 | run: npm run lcov 33 | 34 | - name: Setup LCOV 35 | uses: hrishikesh-kadam/setup-lcov@v1 36 | 37 | - name: Report code coverage 38 | uses: zgosalvez/github-actions-report-lcov@v4 39 | with: 40 | coverage-files: coverage/lcov.info 41 | minimum-coverage: 90 42 | artifact-name: code-coverage-report 43 | github-token: ${{ secrets.GITHUB_TOKEN }} 44 | update-comment: true -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package to npmjs 2 | on: 3 | workflow_run: 4 | workflows: 5 | - 'Run Tests' 6 | branches: ["canary", "*.x"] 7 | types: 8 | - completed 9 | workflow_dispatch: 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: write 15 | issues: write 16 | pull-requests: write 17 | id-token: write 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version: '22.x' 23 | cache: 'npm' 24 | registry-url: 'https://registry.npmjs.org' 25 | - run: npm ci 26 | - name: Release 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 30 | run: npx semantic-release 31 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Close stale issues and PRs' 2 | on: 3 | schedule: 4 | - cron: '30 1 * * *' 5 | 6 | jobs: 7 | stale: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/stale@v9 11 | with: 12 | stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.' 13 | stale-pr-message: 'This PR is stale because it has been open 45 days with no activity. Remove stale label or comment or this will be closed in 10 days.' 14 | close-issue-message: 'This issue was closed because it has been stalled for 5 days with no activity.' 15 | close-pr-message: 'This PR was closed because it has been stalled for 10 days with no activity.' 16 | days-before-issue-stale: 30 17 | days-before-pr-stale: 45 18 | days-before-issue-close: 5 19 | days-before-pr-close: 10 20 | operations-per-run: 90 21 | exempt-issue-labels: keep 22 | exempt-pr-labels: keep 23 | exempt-all-assignees: true 24 | exempt-all-milestones: true 25 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | on: 3 | pull_request: 4 | branches: 5 | - canary 6 | - "*.x" 7 | - 'main' 8 | push: 9 | branches: 10 | - canary 11 | - "*.x" 12 | - main 13 | permissions: 14 | contents: read 15 | checks: write 16 | pull-requests: write 17 | 18 | jobs: 19 | test-node: 20 | runs-on: ubuntu-latest 21 | strategy: 22 | matrix: 23 | node-version: [20.x, 22.x] 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | 28 | - name: Use Node.js ${{ matrix.node-version }} 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: ${{ matrix.node-version }} 32 | cache: 'npm' 33 | 34 | - name: NPM Audit 35 | run: npx audit-ci 36 | 37 | - name: Install Node Modules 38 | run: npm ci 39 | 40 | - name: Run Tests and Linting 41 | run: npm run test 42 | 43 | - uses: actions/upload-artifact@v4 44 | with: 45 | name: test-reports-${{ matrix.node-version }} 46 | path: test/reports/ 47 | 48 | test-bun: 49 | runs-on: ubuntu-latest 50 | 51 | steps: 52 | - uses: actions/checkout@v4 53 | 54 | - name: Setup Bun 55 | uses: oven-sh/setup-bun@v2 56 | 57 | - name: Install Bun Packages 58 | run: bun install --no-save 59 | 60 | - name: Run Bun Tests 61 | run: bun run test 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | test.js 3 | coverage 4 | dist 5 | .nyc_output 6 | public 7 | .DS_Store 8 | test/reports/* 9 | !test/reports/.keep -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | *.log 4 | .vscode 5 | examples 6 | test 7 | public 8 | .nyc_output 9 | scripts 10 | .github 11 | .prettierignore 12 | .prettierrc.js 13 | jsr.json 14 | typdoc.json 15 | eslint.config.js -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | bake-scripts 4 | dist 5 | public 6 | test/reports -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | /** @type {import("prettier").Config} */ 2 | 3 | export const config = { 4 | singleQuote: true, 5 | arrowParens: "always", 6 | trailingComma: "none", 7 | }; 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017-present British Broadcasting Corporation 2 | 3 | All rights reserved 4 | 5 | (http://www.bbc.co.uk) and sqs-consumer Contributors 6 | 7 | Licensed under the Apache License, Version 2.0 (the "License"); 8 | you may not use this file except in compliance with the License. 9 | You may obtain a copy of the License at 10 | 11 | http://www.apache.org/licenses/LICENSE-2.0 12 | 13 | Please contact us for an alternative licence 14 | 15 | Unless required by applicable law or agreed to in writing, software 16 | distributed under the License is distributed on an "AS IS" BASIS, 17 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | See the License for the specific language governing permissions and 19 | limitations under the License. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sqs-consumer 2 | 3 | [![NPM downloads](https://img.shields.io/npm/dm/sqs-consumer.svg?style=flat)](https://npmjs.org/package/sqs-consumer) 4 | [![Build Status](https://github.com/bbc/sqs-consumer/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/bbc/sqs-consumer/actions/workflows/test.yml) 5 | [![Maintainability](https://api.codeclimate.com/v1/badges/16ec3f59e73bc898b7ff/maintainability)](https://codeclimate.com/github/bbc/sqs-consumer/maintainability) 6 | [![Test Coverage](https://api.codeclimate.com/v1/badges/16ec3f59e73bc898b7ff/test_coverage)](https://codeclimate.com/github/bbc/sqs-consumer/test_coverage) 7 | 8 | Build SQS-based applications without the boilerplate. Just define an async function that handles the SQS message processing. 9 | 10 | ## Installation 11 | 12 | To install this package, simply enter the following command into your terminal (or the variant of whatever package manager you are using): 13 | 14 | ```bash 15 | npm install sqs-consumer 16 | ``` 17 | 18 | If you would like to use JSR instead, you can find the package [here](https://jsr.io/@bbc/sqs-consumer). 19 | 20 | ### Node version 21 | 22 | We will only support Node versions that are actively or security supported by the Node team. You can find the list of versions that are actively supported [here](https://nodejs.org/en/about/releases/). 23 | 24 | ## Documentation 25 | 26 | Visit [https://bbc.github.io/sqs-consumer/](https://bbc.github.io/sqs-consumer/) for the full API documentation. 27 | 28 | ## Usage 29 | 30 | ```js 31 | import { Consumer } from "sqs-consumer"; 32 | 33 | const app = Consumer.create({ 34 | queueUrl: "https://sqs.eu-west-1.amazonaws.com/account-id/queue-name", 35 | handleMessage: async (message) => { 36 | // do some work with `message` 37 | }, 38 | }); 39 | 40 | app.on("error", (err) => { 41 | console.error(err.message); 42 | }); 43 | 44 | app.on("processing_error", (err) => { 45 | console.error(err.message); 46 | }); 47 | 48 | app.start(); 49 | ``` 50 | 51 | - The queue is polled continuously for messages using [long polling](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-long-polling.html). 52 | - Throwing an error (or returning a rejected promise) from the handler function will cause the message to be left on the queue. An [SQS redrive policy](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/SQSDeadLetterQueue.html) can be used to move messages that cannot be processed to a dead letter queue. 53 | - By default messages are processed one at a time – a new message won't be received until the first one has been processed. To process messages in parallel, use the `batchSize` option [detailed here](https://bbc.github.io/sqs-consumer/interfaces/ConsumerOptions.html#batchSize). 54 | - It's also important to await any processing that you are doing to ensure that messages are processed one at a time. 55 | - By default, messages that are sent to the `handleMessage` and `handleMessageBatch` functions will be considered as processed if they return without an error. 56 | - To acknowledge individual messages, please return the message that you want to acknowledge if you are using `handleMessage` or the messages for `handleMessageBatch`. 57 | - To note, returning an empty object or an empty array will be considered an acknowledgement of no message(s) and will result in no messages being deleted. If you would like to change this behaviour, please use the `alwaysAcknowledge` option [detailed here](https://bbc.github.io/sqs-consumer/interfaces/ConsumerOptions.html). 58 | - By default, if an object or an array is not returned, all messages will be acknowledged. 59 | - Messages are deleted from the queue once the handler function has completed successfully (the above items should also be taken into account). 60 | 61 | ### FIFO Queue Support 62 | 63 | When using SQS Consumer with FIFO (First-In-First-Out) queues, you might see a warning message in your logs. 64 | 65 | As mentioned in the warning, we do not explicitly test SQS Consumer with FIFO queues, this means that we cannot guarantee that the library will work as expected, however, with the correct configuration, it should. If you have done that and believe FIFO to be working as expected, you can suppress the warning by setting `suppressFifoWarning: true`. 66 | 67 | To note: In order to maintain FIFO ordering, you should always use the `handleMessageBatch` method instead of `handleMessage`. 68 | 69 | ### Credentials 70 | 71 | By default the consumer will look for AWS credentials in the places [specified by the AWS SDK](https://docs.aws.amazon.com/AWSJavaScriptSDK/guide/node-configuring.html#Setting_AWS_Credentials). The simplest option is to export your credentials as environment variables: 72 | 73 | ```bash 74 | export AWS_SECRET_ACCESS_KEY=... 75 | export AWS_ACCESS_KEY_ID=... 76 | ``` 77 | 78 | If you need to specify your credentials manually, you can use a pre-configured instance of the [SQS Client](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-sqs/classes/sqsclient.html) client. 79 | 80 | ```js 81 | import { Consumer } from "sqs-consumer"; 82 | import { SQSClient } from "@aws-sdk/client-sqs"; 83 | 84 | const app = Consumer.create({ 85 | queueUrl: "https://sqs.eu-west-1.amazonaws.com/account-id/queue-name", 86 | handleMessage: async (message) => { 87 | // ... 88 | }, 89 | sqs: new SQSClient({ 90 | region: "my-region", 91 | credentials: { 92 | accessKeyId: "yourAccessKey", 93 | secretAccessKey: "yourSecret", 94 | }, 95 | }), 96 | }); 97 | 98 | app.on("error", (err) => { 99 | console.error(err.message); 100 | }); 101 | 102 | app.on("processing_error", (err) => { 103 | console.error(err.message); 104 | }); 105 | 106 | app.on("timeout_error", (err) => { 107 | console.error(err.message); 108 | }); 109 | 110 | app.start(); 111 | ``` 112 | 113 | ### AWS IAM Permissions 114 | 115 | Consumer will receive and delete messages from the SQS queue. Ensure `sqs:ReceiveMessage`, `sqs:DeleteMessage`, `sqs:DeleteMessageBatch`, `sqs:ChangeMessageVisibility` and `sqs:ChangeMessageVisibilityBatch` access is granted on the queue being consumed. 116 | 117 | ## API 118 | 119 | ### `Consumer.create(options)` 120 | 121 | Creates a new SQS consumer using the [defined options](https://bbc.github.io/sqs-consumer/interfaces/ConsumerOptions.html). 122 | 123 | ### `consumer.start()` 124 | 125 | Start polling the queue for messages. 126 | 127 | ### `consumer.stop(options)` 128 | 129 | Stop polling the queue for messages. [You can find the options definition here](https://bbc.github.io/sqs-consumer/interfaces/StopOptions.html). 130 | 131 | By default, the value of `abort` is set to `false` which means pre existing requests to AWS SQS will still be made until they have concluded. If you would like to abort these requests instead, pass the abort value as `true`, like so: 132 | 133 | `consumer.stop({ abort: true })` 134 | 135 | ### `consumer.status` 136 | 137 | Returns the current status of the consumer. 138 | 139 | - `isRunning` - `true` if the consumer has been started and not stopped, `false` if was not started or if it was stopped. 140 | - `isPolling` - `true` if the consumer is actively polling, `false` if it is not. 141 | 142 | > **Note:** 143 | > This method is not available in versions before v9.0.0 and replaced the method `isRunning` to supply both running and polling states. 144 | 145 | ### `consumer.updateOption(option, value)` 146 | 147 | Updates the provided option with the provided value. 148 | 149 | Please note that any update of the option `pollingWaitTimeMs` will take effect only on next polling cycle. 150 | 151 | You can [find out more about this here](https://bbc.github.io/sqs-consumer/classes/Consumer.html#updateOption). 152 | 153 | ### Events 154 | 155 | Each consumer is an [`EventEmitter`](https://nodejs.org/api/events.html) and [emits these events](https://bbc.github.io/sqs-consumer/interfaces/Events.html). 156 | 157 | ## Contributing 158 | 159 | We welcome and appreciate contributions for anyone who would like to take the time to fix a bug or implement a new feature. 160 | 161 | But before you get started, [please read the contributing guidelines](https://github.com/bbc/sqs-consumer/blob/main/.github/CONTRIBUTING.md) and [code of conduct](https://github.com/bbc/sqs-consumer/blob/main/.github/CODE_OF_CONDUCT.md). 162 | 163 | ## License 164 | 165 | SQS Consumer is distributed under the Apache License, Version 2.0, see [LICENSE](https://github.com/bbc/sqs-consumer/blob/main/LICENSE) for more information. 166 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslintConfigESLint from "eslint-config-eslint"; 2 | 3 | export default [ 4 | ...eslintConfigESLint, 5 | { 6 | ignores: ["node_modules", "coverage", "bake-scripts", "dist"], 7 | }, 8 | { 9 | rules: { 10 | "new-cap": "off", 11 | }, 12 | }, 13 | ]; 14 | -------------------------------------------------------------------------------- /jsr.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bbc/sqs-consumer", 3 | "version": "11.0.0-canary.1", 4 | "exports": { 5 | ".": "./src/index.ts" 6 | }, 7 | "publish": { 8 | "include": ["src", "package.json", "README.md", "LICENSE"], 9 | "exclude": ["dist", "test", "scripts"] 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sqs-consumer", 3 | "version": "10.1.0", 4 | "description": "Build SQS-based Node applications without the boilerplate", 5 | "type": "module", 6 | "main": "dist/cjs/index.js", 7 | "types": "dist/cjs/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "import": { 11 | "types": "./dist/esm/index.d.ts", 12 | "default": "./dist/esm/index.js" 13 | }, 14 | "require": { 15 | "types": "./dist/cjs/index.d.ts", 16 | "default": "./dist/cjs/index.js" 17 | } 18 | } 19 | }, 20 | "engines": { 21 | "node": ">=20.0.0" 22 | }, 23 | "scripts": { 24 | "clean": "rm -fr dist/*", 25 | "compile": "tsc -b ./tsconfig.cjs.json ./tsconfig.esm.json", 26 | "add-package-jsons": "node ./scripts/addPackageJsons.js", 27 | "build": "npm run clean && npm run compile && npm run add-package-jsons", 28 | "watch": "tsc --watch", 29 | "prepublishOnly": "npm run build", 30 | "test:unit": "mocha --recursive --full-trace --exit", 31 | "test:unit:report": "mocha --recursive --full-trace --exit --reporter json > test/reports/test-results.json", 32 | "pretest:integration:init": "npm run build", 33 | "test:integration:init": "sh ./test/scripts/initIntTests.sh", 34 | "test:integration": "npm run test:integration:init && cucumber-js --config ./test/config/cucumber.mjs", 35 | "test": "npm run test:unit:report && npm run test:integration", 36 | "coverage": "c8 mocha && c8 report --reporter=html && c8 report --reporter=json-summary", 37 | "lcov": "c8 mocha && c8 report --reporter=lcov", 38 | "lint": "eslint .", 39 | "lint:fix": "eslint . --fix", 40 | "format": "prettier --log-level warn --write \"**/*.{js,json,jsx,md,ts,tsx,html}\"", 41 | "format:check": "prettier --check \"**/*.{js,json,jsx,md,ts,tsx,html}\"", 42 | "posttest": "npm run lint && npm run format:check", 43 | "generate-docs": "typedoc" 44 | }, 45 | "repository": { 46 | "type": "git", 47 | "url": "git+https://github.com/bbc/sqs-consumer.git" 48 | }, 49 | "bugs": { 50 | "url": "https://github.com/bbc/sqs-consumer/issues" 51 | }, 52 | "homepage": "https://bbc.github.io/sqs-consumer/", 53 | "keywords": [ 54 | "sqs", 55 | "queue", 56 | "consumer" 57 | ], 58 | "license": "Apache-2.0", 59 | "publishConfig": { 60 | "provenance": true 61 | }, 62 | "release": { 63 | "branches": [ 64 | "main", 65 | "*.x", 66 | { 67 | "name": "canary", 68 | "prerelease": true 69 | } 70 | ], 71 | "plugins": [ 72 | [ 73 | "@semantic-release/commit-analyzer", 74 | { 75 | "preset": "conventionalcommits", 76 | "releaseRules": [ 77 | { 78 | "type": "breaking", 79 | "release": "major" 80 | }, 81 | { 82 | "type": "feat", 83 | "release": "minor" 84 | }, 85 | { 86 | "type": "chore", 87 | "release": "patch" 88 | }, 89 | { 90 | "type": "fix", 91 | "release": "patch" 92 | }, 93 | { 94 | "type": "docs", 95 | "release": "patch" 96 | }, 97 | { 98 | "type": "refactor", 99 | "release": "patch" 100 | }, 101 | { 102 | "type": "test", 103 | "release": "patch" 104 | } 105 | ] 106 | } 107 | ], 108 | [ 109 | "@semantic-release/release-notes-generator", 110 | { 111 | "preset": "conventionalcommits", 112 | "presetConfig": { 113 | "types": [ 114 | { 115 | "type": "feat", 116 | "section": "Features" 117 | }, 118 | { 119 | "type": "fix", 120 | "section": "Bug Fixes" 121 | }, 122 | { 123 | "type": "chore", 124 | "section": "Chores" 125 | }, 126 | { 127 | "type": "docs", 128 | "section": "Documentation" 129 | }, 130 | { 131 | "type": "refactor", 132 | "section": "Refactors" 133 | }, 134 | { 135 | "type": "test", 136 | "section": "Tests" 137 | } 138 | ] 139 | } 140 | } 141 | ], 142 | "@semantic-release/changelog", 143 | "@semantic-release/github", 144 | "@semantic-release/npm", 145 | "@sebbo2002/semantic-release-jsr" 146 | ] 147 | }, 148 | "overrides": { 149 | "cross-spawn": "^7.0.3" 150 | }, 151 | "devDependencies": { 152 | "@cucumber/cucumber": "^11.2.0", 153 | "@sebbo2002/semantic-release-jsr": "^2.0.5", 154 | "@semantic-release/changelog": "^6.0.3", 155 | "@semantic-release/commit-analyzer": "^13.0.1", 156 | "@semantic-release/git": "^10.0.1", 157 | "@semantic-release/github": "^11.0.2", 158 | "@semantic-release/npm": "^12.0.1", 159 | "@semantic-release/release-notes-generator": "^14.0.3", 160 | "@types/chai": "^5.2.2", 161 | "@types/mocha": "^10.0.10", 162 | "@types/node": "^22.15.17", 163 | "@types/sinon": "^17.0.4", 164 | "@typescript-eslint/eslint-plugin": "^8.32.0", 165 | "@typescript-eslint/parser": "^8.32.0", 166 | "c8": "^10.1.3", 167 | "chai": "^5.2.0", 168 | "conventional-changelog-conventionalcommits": "^8.0.0", 169 | "eslint": "^9.26.0", 170 | "eslint-config-eslint": "^11.0.0", 171 | "jsr": "^0.13.4", 172 | "mocha": "^11.2.2", 173 | "p-event": "^6.0.1", 174 | "prettier": "^3.5.3", 175 | "semantic-release": "^24.2.3", 176 | "sinon": "^20.0.0", 177 | "sqs-producer": "^6.0.1", 178 | "ts-node": "^10.9.2", 179 | "typedoc": "^0.28.4", 180 | "typescript": "^5.8.3" 181 | }, 182 | "dependencies": { 183 | "@aws-sdk/client-sqs": "^3.806.0", 184 | "debug": "^4.4.0" 185 | }, 186 | "peerDependencies": { 187 | "@aws-sdk/client-sqs": "^3.806.0" 188 | }, 189 | "mocha": { 190 | "extensions": [ 191 | "ts" 192 | ], 193 | "spec": "test/tests/**/**/*.test.ts", 194 | "node-option": [ 195 | "loader=ts-node/esm" 196 | ] 197 | }, 198 | "c8": { 199 | "include": [ 200 | "src/**/*.ts" 201 | ], 202 | "extension": [ 203 | ".ts" 204 | ], 205 | "sourceMap": true, 206 | "instrument": true 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /scripts/addPackageJsons.js: -------------------------------------------------------------------------------- 1 | import { readdir, existsSync, writeFile } from "node:fs"; 2 | import { join } from "node:path"; 3 | 4 | const buildDir = "./dist"; 5 | /** 6 | * Adds package.json files to the build directory. 7 | * @returns {void} 8 | */ 9 | function buildPackageJson() { 10 | readdir(buildDir, (err, dirs) => { 11 | if (err) { 12 | throw err; 13 | } 14 | dirs.forEach((dir) => { 15 | if (dir === "types") { 16 | return; 17 | } 18 | 19 | const packageJsonFile = join(buildDir, dir, "/package.json"); 20 | 21 | if (!existsSync(packageJsonFile)) { 22 | const value = 23 | dir === "esm" ? '{"type": "module"}' : '{"type": "commonjs"}'; 24 | 25 | writeFile( 26 | packageJsonFile, 27 | new Uint8Array(Buffer.from(value)), 28 | (writeErr) => { 29 | if (writeErr) { 30 | throw writeErr; 31 | } 32 | }, 33 | ); 34 | } 35 | }); 36 | }); 37 | } 38 | 39 | buildPackageJson(); 40 | -------------------------------------------------------------------------------- /src/consumer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SQSClient, 3 | Message, 4 | ChangeMessageVisibilityCommand, 5 | ChangeMessageVisibilityCommandInput, 6 | ChangeMessageVisibilityCommandOutput, 7 | ChangeMessageVisibilityBatchCommand, 8 | ChangeMessageVisibilityBatchCommandInput, 9 | ChangeMessageVisibilityBatchCommandOutput, 10 | DeleteMessageCommand, 11 | DeleteMessageCommandInput, 12 | DeleteMessageBatchCommand, 13 | DeleteMessageBatchCommandInput, 14 | ReceiveMessageCommand, 15 | ReceiveMessageCommandInput, 16 | ReceiveMessageCommandOutput, 17 | QueueAttributeName, 18 | MessageSystemAttributeName, 19 | } from "@aws-sdk/client-sqs"; 20 | 21 | import type { 22 | ConsumerOptions, 23 | StopOptions, 24 | UpdatableOptions, 25 | } from "./types.js"; 26 | import { TypedEventEmitter } from "./emitter.js"; 27 | import { 28 | SQSError, 29 | TimeoutError, 30 | toStandardError, 31 | toTimeoutError, 32 | toSQSError, 33 | isConnectionError, 34 | } from "./errors.js"; 35 | import { validateOption, assertOptions, hasMessages } from "./validation.js"; 36 | import { logger } from "./logger.js"; 37 | 38 | /** 39 | * [Usage](https://bbc.github.io/sqs-consumer/index.html#usage) 40 | */ 41 | export class Consumer extends TypedEventEmitter { 42 | private pollingTimeoutId: NodeJS.Timeout | undefined = undefined; 43 | private stopped = true; 44 | protected queueUrl: string; 45 | private isFifoQueue: boolean; 46 | private suppressFifoWarning: boolean; 47 | private handleMessage: (message: Message) => Promise; 48 | private handleMessageBatch: (message: Message[]) => Promise; 49 | private preReceiveMessageCallback?: () => Promise; 50 | private postReceiveMessageCallback?: () => Promise; 51 | private sqs: SQSClient; 52 | private handleMessageTimeout: number; 53 | private attributeNames: QueueAttributeName[]; 54 | private messageAttributeNames: string[]; 55 | private messageSystemAttributeNames: MessageSystemAttributeName[]; 56 | private shouldDeleteMessages: boolean; 57 | private alwaysAcknowledge: boolean; 58 | private batchSize: number; 59 | private visibilityTimeout: number; 60 | private terminateVisibilityTimeout: 61 | | boolean 62 | | number 63 | | ((message: Message[]) => number); 64 | private waitTimeSeconds: number; 65 | private authenticationErrorTimeout: number; 66 | private pollingWaitTimeMs: number; 67 | private pollingCompleteWaitTimeMs: number; 68 | private heartbeatInterval: number; 69 | private isPolling = false; 70 | private stopRequestedAtTimestamp: number; 71 | public abortController: AbortController; 72 | private extendedAWSErrors: boolean; 73 | 74 | constructor(options: ConsumerOptions) { 75 | super(options.queueUrl); 76 | assertOptions(options); 77 | this.queueUrl = options.queueUrl; 78 | this.isFifoQueue = this.queueUrl.endsWith(".fifo"); 79 | this.suppressFifoWarning = options.suppressFifoWarning ?? false; 80 | this.handleMessage = options.handleMessage; 81 | this.handleMessageBatch = options.handleMessageBatch; 82 | this.preReceiveMessageCallback = options.preReceiveMessageCallback; 83 | this.postReceiveMessageCallback = options.postReceiveMessageCallback; 84 | this.handleMessageTimeout = options.handleMessageTimeout; 85 | this.attributeNames = options.attributeNames || []; 86 | this.messageAttributeNames = options.messageAttributeNames || []; 87 | this.messageSystemAttributeNames = 88 | options.messageSystemAttributeNames || []; 89 | this.batchSize = options.batchSize || 1; 90 | this.visibilityTimeout = options.visibilityTimeout; 91 | this.terminateVisibilityTimeout = 92 | options.terminateVisibilityTimeout || false; 93 | this.heartbeatInterval = options.heartbeatInterval; 94 | this.waitTimeSeconds = options.waitTimeSeconds ?? 20; 95 | this.authenticationErrorTimeout = 96 | options.authenticationErrorTimeout ?? 10000; 97 | this.pollingWaitTimeMs = options.pollingWaitTimeMs ?? 0; 98 | this.pollingCompleteWaitTimeMs = options.pollingCompleteWaitTimeMs ?? 0; 99 | this.shouldDeleteMessages = options.shouldDeleteMessages ?? true; 100 | this.alwaysAcknowledge = options.alwaysAcknowledge ?? false; 101 | this.extendedAWSErrors = options.extendedAWSErrors ?? false; 102 | this.sqs = 103 | options.sqs || 104 | new SQSClient({ 105 | useQueueUrlAsEndpoint: options.useQueueUrlAsEndpoint ?? true, 106 | region: options.region || process.env.AWS_REGION || "eu-west-1", 107 | }); 108 | } 109 | 110 | /** 111 | * Creates a new SQS consumer. 112 | */ 113 | public static create(options: ConsumerOptions): Consumer { 114 | return new Consumer(options); 115 | } 116 | 117 | /** 118 | * Start polling the queue for messages. 119 | */ 120 | public start(): void { 121 | if (this.stopped) { 122 | if (this.isFifoQueue && !this.suppressFifoWarning) { 123 | logger.warn( 124 | "WARNING: A FIFO queue was detected. SQS Consumer does not guarantee FIFO queues will work as expected. Set 'suppressFifoWarning: true' to disable this warning.", 125 | ); 126 | } 127 | // Create a new abort controller each time the consumer is started 128 | this.abortController = new AbortController(); 129 | logger.debug("starting"); 130 | this.stopped = false; 131 | this.emit("started"); 132 | this.poll(); 133 | } 134 | } 135 | 136 | /** 137 | * A reusable options object for sqs.send that's used to avoid duplication. 138 | */ 139 | private get sqsSendOptions(): { abortSignal: AbortSignal } { 140 | return { 141 | // return the current abortController signal or a fresh signal that has not been aborted. 142 | // This effectively defaults the signal sent to the AWS SDK to not aborted 143 | abortSignal: this.abortController?.signal || new AbortController().signal, 144 | }; 145 | } 146 | 147 | /** 148 | * Stop polling the queue for messages (pre existing requests will still be made until concluded). 149 | */ 150 | public stop(options?: StopOptions): void { 151 | if (this.stopped) { 152 | logger.debug("already_stopped"); 153 | return; 154 | } 155 | 156 | logger.debug("stopping"); 157 | this.stopped = true; 158 | 159 | if (this.pollingTimeoutId) { 160 | clearTimeout(this.pollingTimeoutId); 161 | this.pollingTimeoutId = undefined; 162 | } 163 | 164 | if (options?.abort) { 165 | logger.debug("aborting"); 166 | this.abortController.abort(); 167 | this.emit("aborted"); 168 | } 169 | 170 | this.stopRequestedAtTimestamp = Date.now(); 171 | this.waitForPollingToComplete(); 172 | } 173 | 174 | /** 175 | * Wait for final poll and in flight messages to complete. 176 | * @private 177 | */ 178 | private waitForPollingToComplete(): void { 179 | if (!this.isPolling || !(this.pollingCompleteWaitTimeMs > 0)) { 180 | this.emit("stopped"); 181 | return; 182 | } 183 | 184 | const exceededTimeout: boolean = 185 | Date.now() - this.stopRequestedAtTimestamp > 186 | this.pollingCompleteWaitTimeMs; 187 | if (exceededTimeout) { 188 | this.emit("waiting_for_polling_to_complete_timeout_exceeded"); 189 | this.emit("stopped"); 190 | return; 191 | } 192 | 193 | this.emit("waiting_for_polling_to_complete"); 194 | setTimeout(() => this.waitForPollingToComplete(), 1000); 195 | } 196 | 197 | /** 198 | * Returns the current status of the consumer. 199 | * This includes whether it is running or currently polling. 200 | */ 201 | public get status(): { 202 | isRunning: boolean; 203 | isPolling: boolean; 204 | } { 205 | return { 206 | isRunning: !this.stopped, 207 | isPolling: this.isPolling, 208 | }; 209 | } 210 | 211 | /** 212 | * Validates and then updates the provided option to the provided value. 213 | * @param option The option to validate and then update 214 | * @param value The value to set the provided option to 215 | */ 216 | public updateOption( 217 | option: UpdatableOptions, 218 | value: ConsumerOptions[UpdatableOptions], 219 | ): void { 220 | validateOption(option, value, this, true); 221 | 222 | this[option] = value; 223 | 224 | this.emit("option_updated", option, value); 225 | } 226 | 227 | /** 228 | * Emit one of the consumer's error events depending on the error received. 229 | * @param err The error object to forward on 230 | * @param message The message that the error occurred on 231 | */ 232 | private emitError(err: Error, message?: Message): void { 233 | if (!message) { 234 | this.emit("error", err, undefined); 235 | } else if (err.name === SQSError.name) { 236 | this.emit("error", err, message); 237 | } else if (err instanceof TimeoutError) { 238 | this.emit("timeout_error", err, message); 239 | } else { 240 | this.emit("processing_error", err, message); 241 | } 242 | } 243 | 244 | /** 245 | * Poll for new messages from SQS 246 | */ 247 | private poll(): void { 248 | if (this.stopped) { 249 | logger.debug("cancelling_poll", { 250 | detail: 251 | "Poll was called while consumer was stopped, cancelling poll...", 252 | }); 253 | return; 254 | } 255 | 256 | logger.debug("polling"); 257 | 258 | this.isPolling = true; 259 | 260 | let currentPollingTimeout: number = this.pollingWaitTimeMs; 261 | this.receiveMessage({ 262 | QueueUrl: this.queueUrl, 263 | AttributeNames: this.attributeNames, 264 | MessageAttributeNames: this.messageAttributeNames, 265 | MessageSystemAttributeNames: this.messageSystemAttributeNames, 266 | MaxNumberOfMessages: this.batchSize, 267 | WaitTimeSeconds: this.waitTimeSeconds, 268 | VisibilityTimeout: this.visibilityTimeout, 269 | }) 270 | .then((output: ReceiveMessageCommandOutput) => 271 | this.handleSqsResponse(output), 272 | ) 273 | .catch((err): void => { 274 | this.emitError(err); 275 | if (isConnectionError(err)) { 276 | logger.debug("authentication_error", { 277 | code: err.code || "Unknown", 278 | detail: 279 | "There was an authentication error. Pausing before retrying.", 280 | }); 281 | currentPollingTimeout = this.authenticationErrorTimeout; 282 | } 283 | return; 284 | }) 285 | .then((): void => { 286 | if (this.pollingTimeoutId) { 287 | clearTimeout(this.pollingTimeoutId); 288 | } 289 | this.pollingTimeoutId = setTimeout( 290 | () => this.poll(), 291 | currentPollingTimeout, 292 | ); 293 | }) 294 | .catch((err): void => { 295 | this.emitError(err); 296 | }) 297 | .finally((): void => { 298 | this.isPolling = false; 299 | }); 300 | } 301 | 302 | /** 303 | * Send a request to SQS to retrieve messages 304 | * @param params The required params to receive messages from SQS 305 | */ 306 | private async receiveMessage( 307 | params: ReceiveMessageCommandInput, 308 | ): Promise { 309 | try { 310 | if (this.preReceiveMessageCallback) { 311 | await this.preReceiveMessageCallback(); 312 | } 313 | const result: ReceiveMessageCommandOutput = await this.sqs.send( 314 | new ReceiveMessageCommand(params), 315 | this.sqsSendOptions, 316 | ); 317 | if (this.postReceiveMessageCallback) { 318 | await this.postReceiveMessageCallback(); 319 | } 320 | 321 | return result; 322 | } catch (err) { 323 | throw toSQSError( 324 | err, 325 | `SQS receive message failed: ${err.message}`, 326 | this.extendedAWSErrors, 327 | this.queueUrl, 328 | ); 329 | } 330 | } 331 | 332 | /** 333 | * Handles the response from AWS SQS, determining if we should proceed to 334 | * the message handler. 335 | * @param response The output from AWS SQS 336 | */ 337 | private async handleSqsResponse( 338 | response: ReceiveMessageCommandOutput, 339 | ): Promise { 340 | if (hasMessages(response)) { 341 | if (this.handleMessageBatch) { 342 | await this.processMessageBatch(response.Messages); 343 | } else { 344 | await Promise.all( 345 | response.Messages.map((message: Message) => 346 | this.processMessage(message), 347 | ), 348 | ); 349 | } 350 | 351 | this.emit("response_processed"); 352 | } else if (response) { 353 | this.emit("empty"); 354 | } 355 | } 356 | 357 | /** 358 | * Process a message that has been received from SQS. This will execute the message 359 | * handler and delete the message once complete. 360 | * @param message The message that was delivered from SQS 361 | */ 362 | private async processMessage(message: Message): Promise { 363 | let heartbeatTimeoutId: NodeJS.Timeout | undefined = undefined; 364 | 365 | try { 366 | this.emit("message_received", message); 367 | 368 | if (this.heartbeatInterval) { 369 | heartbeatTimeoutId = this.startHeartbeat(message); 370 | } 371 | 372 | const ackedMessage: Message = await this.executeHandler(message); 373 | 374 | if (ackedMessage?.MessageId === message.MessageId) { 375 | await this.deleteMessage(message); 376 | 377 | this.emit("message_processed", message); 378 | } 379 | } catch (err) { 380 | this.emitError(err, message); 381 | 382 | if (this.terminateVisibilityTimeout !== false) { 383 | if (typeof this.terminateVisibilityTimeout === "function") { 384 | const timeout = this.terminateVisibilityTimeout([message]); 385 | await this.changeVisibilityTimeout(message, timeout); 386 | } else { 387 | const timeout = 388 | this.terminateVisibilityTimeout === true 389 | ? 0 390 | : this.terminateVisibilityTimeout; 391 | await this.changeVisibilityTimeout(message, timeout); 392 | } 393 | } 394 | } finally { 395 | if (this.heartbeatInterval) { 396 | clearInterval(heartbeatTimeoutId); 397 | } 398 | } 399 | } 400 | 401 | /** 402 | * Process a batch of messages from the SQS queue. 403 | * @param messages The messages that were delivered from SQS 404 | */ 405 | private async processMessageBatch(messages: Message[]): Promise { 406 | let heartbeatTimeoutId: NodeJS.Timeout | undefined = undefined; 407 | 408 | try { 409 | messages.forEach((message: Message): void => { 410 | this.emit("message_received", message); 411 | }); 412 | 413 | if (this.heartbeatInterval) { 414 | heartbeatTimeoutId = this.startHeartbeat(null, messages); 415 | } 416 | 417 | const ackedMessages: Message[] = await this.executeBatchHandler(messages); 418 | 419 | if (ackedMessages?.length > 0) { 420 | await this.deleteMessageBatch(ackedMessages); 421 | 422 | ackedMessages.forEach((message: Message): void => { 423 | this.emit("message_processed", message); 424 | }); 425 | } 426 | } catch (err) { 427 | this.emit("error", err, messages); 428 | 429 | if (this.terminateVisibilityTimeout !== false) { 430 | if (typeof this.terminateVisibilityTimeout === "function") { 431 | const timeout = this.terminateVisibilityTimeout(messages); 432 | await this.changeVisibilityTimeoutBatch(messages, timeout); 433 | } else { 434 | const timeout = 435 | this.terminateVisibilityTimeout === true 436 | ? 0 437 | : this.terminateVisibilityTimeout; 438 | await this.changeVisibilityTimeoutBatch(messages, timeout); 439 | } 440 | } 441 | } finally { 442 | clearInterval(heartbeatTimeoutId); 443 | } 444 | } 445 | 446 | /** 447 | * Trigger a function on a set interval 448 | * @param heartbeatFn The function that should be triggered 449 | */ 450 | private startHeartbeat( 451 | message?: Message, 452 | messages?: Message[], 453 | ): NodeJS.Timeout { 454 | return setInterval(() => { 455 | if (this.handleMessageBatch) { 456 | return this.changeVisibilityTimeoutBatch( 457 | messages, 458 | this.visibilityTimeout, 459 | ); 460 | } 461 | 462 | return this.changeVisibilityTimeout(message, this.visibilityTimeout); 463 | }, this.heartbeatInterval * 1000); 464 | } 465 | 466 | /** 467 | * Change the visibility timeout on a message 468 | * @param message The message to change the value of 469 | * @param timeout The new timeout that should be set 470 | */ 471 | private async changeVisibilityTimeout( 472 | message: Message, 473 | timeout: number, 474 | ): Promise { 475 | try { 476 | const input: ChangeMessageVisibilityCommandInput = { 477 | QueueUrl: this.queueUrl, 478 | ReceiptHandle: message.ReceiptHandle, 479 | VisibilityTimeout: timeout, 480 | }; 481 | return await this.sqs.send( 482 | new ChangeMessageVisibilityCommand(input), 483 | this.sqsSendOptions, 484 | ); 485 | } catch (err) { 486 | this.emit( 487 | "error", 488 | toSQSError( 489 | err, 490 | `Error changing visibility timeout: ${err.message}`, 491 | this.extendedAWSErrors, 492 | this.queueUrl, 493 | message, 494 | ), 495 | message, 496 | ); 497 | } 498 | } 499 | 500 | /** 501 | * Change the visibility timeout on a batch of messages 502 | * @param messages The messages to change the value of 503 | * @param timeout The new timeout that should be set 504 | */ 505 | private async changeVisibilityTimeoutBatch( 506 | messages: Message[], 507 | timeout: number, 508 | ): Promise { 509 | const params: ChangeMessageVisibilityBatchCommandInput = { 510 | QueueUrl: this.queueUrl, 511 | Entries: messages.map((message: Message) => ({ 512 | Id: message.MessageId, 513 | ReceiptHandle: message.ReceiptHandle, 514 | VisibilityTimeout: timeout, 515 | })), 516 | }; 517 | try { 518 | return await this.sqs.send( 519 | new ChangeMessageVisibilityBatchCommand(params), 520 | this.sqsSendOptions, 521 | ); 522 | } catch (err) { 523 | this.emit( 524 | "error", 525 | toSQSError( 526 | err, 527 | `Error changing visibility timeout: ${err.message}`, 528 | this.extendedAWSErrors, 529 | this.queueUrl, 530 | messages, 531 | ), 532 | messages, 533 | ); 534 | } 535 | } 536 | 537 | /** 538 | * Trigger the applications handleMessage function 539 | * @param message The message that was received from SQS 540 | */ 541 | private async executeHandler(message: Message): Promise { 542 | let handleMessageTimeoutId: NodeJS.Timeout | undefined = undefined; 543 | 544 | try { 545 | let result: Message | void; 546 | 547 | if (this.handleMessageTimeout) { 548 | const pending: Promise = new Promise((_, reject): void => { 549 | handleMessageTimeoutId = setTimeout((): void => { 550 | reject(new TimeoutError()); 551 | }, this.handleMessageTimeout); 552 | }); 553 | result = await Promise.race([this.handleMessage(message), pending]); 554 | } else { 555 | result = await this.handleMessage(message); 556 | } 557 | 558 | return !this.alwaysAcknowledge && result instanceof Object 559 | ? result 560 | : message; 561 | } catch (err) { 562 | if (err instanceof TimeoutError) { 563 | throw toTimeoutError( 564 | err, 565 | `Message handler timed out after ${this.handleMessageTimeout}ms: Operation timed out.`, 566 | message, 567 | ); 568 | } 569 | if (err instanceof Error) { 570 | throw toStandardError( 571 | err, 572 | `Unexpected message handler failure: ${err.message}`, 573 | message, 574 | ); 575 | } 576 | throw err; 577 | } finally { 578 | if (handleMessageTimeoutId) { 579 | clearTimeout(handleMessageTimeoutId); 580 | } 581 | } 582 | } 583 | 584 | /** 585 | * Execute the application's message batch handler 586 | * @param messages The messages that should be forwarded from the SQS queue 587 | */ 588 | private async executeBatchHandler(messages: Message[]): Promise { 589 | try { 590 | const result: void | Message[] = await this.handleMessageBatch(messages); 591 | 592 | return !this.alwaysAcknowledge && result instanceof Object 593 | ? result 594 | : messages; 595 | } catch (err) { 596 | if (err instanceof Error) { 597 | throw toStandardError( 598 | err, 599 | `Unexpected message handler failure: ${err.message}`, 600 | messages, 601 | ); 602 | } 603 | throw err; 604 | } 605 | } 606 | 607 | /** 608 | * Delete a single message from SQS 609 | * @param message The message to delete from the SQS queue 610 | */ 611 | private async deleteMessage(message: Message): Promise { 612 | if (!this.shouldDeleteMessages) { 613 | logger.debug("skipping_delete", { 614 | detail: 615 | "Skipping message delete since shouldDeleteMessages is set to false", 616 | }); 617 | return; 618 | } 619 | logger.debug("deleting_message", { messageId: message.MessageId }); 620 | 621 | const deleteParams: DeleteMessageCommandInput = { 622 | QueueUrl: this.queueUrl, 623 | ReceiptHandle: message.ReceiptHandle, 624 | }; 625 | 626 | try { 627 | await this.sqs.send( 628 | new DeleteMessageCommand(deleteParams), 629 | this.sqsSendOptions, 630 | ); 631 | } catch (err) { 632 | throw toSQSError( 633 | err, 634 | `SQS delete message failed: ${err.message}`, 635 | this.extendedAWSErrors, 636 | this.queueUrl, 637 | message, 638 | ); 639 | } 640 | } 641 | 642 | /** 643 | * Delete a batch of messages from the SQS queue. 644 | * @param messages The messages that should be deleted from SQS 645 | */ 646 | private async deleteMessageBatch(messages: Message[]): Promise { 647 | if (!this.shouldDeleteMessages) { 648 | logger.debug("skipping_delete", { 649 | detail: 650 | "Skipping message delete since shouldDeleteMessages is set to false", 651 | }); 652 | return; 653 | } 654 | logger.debug("deleting_messages", { 655 | messageIds: messages.map((msg: Message) => msg.MessageId), 656 | }); 657 | 658 | const deleteParams: DeleteMessageBatchCommandInput = { 659 | QueueUrl: this.queueUrl, 660 | Entries: messages.map((message: Message) => ({ 661 | Id: message.MessageId, 662 | ReceiptHandle: message.ReceiptHandle, 663 | })), 664 | }; 665 | 666 | try { 667 | await this.sqs.send( 668 | new DeleteMessageBatchCommand(deleteParams), 669 | this.sqsSendOptions, 670 | ); 671 | } catch (err) { 672 | throw toSQSError( 673 | err, 674 | `SQS delete message failed: ${err.message}`, 675 | this.extendedAWSErrors, 676 | this.queueUrl, 677 | messages, 678 | ); 679 | } 680 | } 681 | } 682 | -------------------------------------------------------------------------------- /src/emitter.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "node:events"; 2 | 3 | import { logger } from "./logger.js"; 4 | import { Events, QueueMetadata } from "./types.js"; 5 | 6 | export class TypedEventEmitter extends EventEmitter { 7 | protected queueUrl?: string; 8 | 9 | /** 10 | * @param queueUrl - The URL of the SQS queue this emitter is associated with 11 | */ 12 | constructor(queueUrl?: string) { 13 | super(); 14 | this.queueUrl = queueUrl; 15 | } 16 | 17 | /** 18 | * Trigger a listener on all emitted events 19 | * @param event The name of the event to listen to 20 | * @param listener A function to trigger when the event is emitted 21 | */ 22 | on( 23 | event: E, 24 | listener: (...args: [...Events[E], QueueMetadata]) => void, 25 | ): this { 26 | return super.on(event, listener); 27 | } 28 | 29 | /** 30 | * Trigger a listener only once for an emitted event 31 | * @param event The name of the event to listen to 32 | * @param listener A function to trigger when the event is emitted 33 | */ 34 | once( 35 | event: E, 36 | listener: (...args: [...Events[E], QueueMetadata]) => void, 37 | ): this { 38 | return super.once(event, listener); 39 | } 40 | 41 | /** 42 | * Emits an event with the provided arguments and adds queue metadata 43 | * @param event The name of the event to emit 44 | * @param args The arguments to pass to the event listeners 45 | * @returns {boolean} Returns true if the event had listeners, false otherwise 46 | * @example 47 | * // Inside a method: 48 | * this.emit('message_received', message); 49 | */ 50 | emit(event: E, ...args: Events[E]): boolean { 51 | const metadata: QueueMetadata = { queueUrl: this.queueUrl }; 52 | logger.debug(event, ...args, metadata); 53 | return super.emit(event, ...args, metadata); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | import { Message } from "@aws-sdk/client-sqs"; 2 | 3 | import { AWSError } from "./types.js"; 4 | 5 | class SQSError extends Error { 6 | code: string; 7 | statusCode: number; 8 | service: string; 9 | time: Date; 10 | retryable: boolean; 11 | fault: AWSError["$fault"]; 12 | response?: AWSError["$response"]; 13 | metadata?: AWSError["$metadata"]; 14 | queueUrl?: string; 15 | messageIds?: string[]; 16 | 17 | constructor(message: string) { 18 | super(message); 19 | this.name = this.constructor.name; 20 | } 21 | } 22 | 23 | class TimeoutError extends Error { 24 | messageIds: string[]; 25 | cause: Error; 26 | time: Date; 27 | 28 | constructor(message = "Operation timed out.") { 29 | super(message); 30 | this.message = message; 31 | this.name = "TimeoutError"; 32 | this.messageIds = []; 33 | } 34 | } 35 | 36 | class StandardError extends Error { 37 | messageIds: string[]; 38 | cause: Error; 39 | time: Date; 40 | 41 | constructor(message = "An unexpected error occurred:") { 42 | super(message); 43 | this.message = message; 44 | this.name = "StandardError"; 45 | this.messageIds = []; 46 | } 47 | } 48 | 49 | /** 50 | * List of SQS error codes that are considered connection errors. 51 | */ 52 | const CONNECTION_ERRORS = [ 53 | "CredentialsError", 54 | "UnknownEndpoint", 55 | "AWS.SimpleQueueService.NonExistentQueue", 56 | "CredentialsProviderError", 57 | "InvalidAddress", 58 | "InvalidSecurity", 59 | "QueueDoesNotExist", 60 | "RequestThrottled", 61 | "OverLimit", 62 | ]; 63 | 64 | /** 65 | * Checks if the error provided should be treated as a connection error. 66 | * @param err The error that was received. 67 | */ 68 | function isConnectionError(err: Error): boolean { 69 | if (err instanceof SQSError) { 70 | return err.statusCode === 403 || CONNECTION_ERRORS.includes(err.code); 71 | } 72 | return false; 73 | } 74 | 75 | /** 76 | * Gets the message IDs from the message. 77 | * @param message The message that was received from SQS. 78 | */ 79 | function getMessageIds(message: Message | Message[]): string[] { 80 | if (Array.isArray(message)) { 81 | return message.map((m) => m.MessageId); 82 | } 83 | return [message.MessageId]; 84 | } 85 | 86 | /** 87 | * Formats an AWSError the the SQSError type. 88 | * @param err The error object that was received. 89 | * @param message The message to send with the error. 90 | */ 91 | function toSQSError( 92 | err: AWSError, 93 | message: string, 94 | extendedAWSErrors: boolean, 95 | queueUrl?: string, 96 | sqsMessage?: Message | Message[], 97 | ): SQSError { 98 | const sqsError = new SQSError(message); 99 | sqsError.code = err.name; 100 | sqsError.statusCode = err.$metadata?.httpStatusCode; 101 | sqsError.retryable = err.$retryable?.throttling; 102 | sqsError.service = err.$service; 103 | sqsError.fault = err.$fault; 104 | sqsError.time = new Date(); 105 | 106 | if (extendedAWSErrors) { 107 | sqsError.response = err.$response; 108 | sqsError.metadata = err.$metadata; 109 | } 110 | 111 | if (queueUrl) { 112 | sqsError.queueUrl = queueUrl; 113 | } 114 | 115 | if (sqsMessage) { 116 | sqsError.messageIds = getMessageIds(sqsMessage); 117 | } 118 | 119 | return sqsError; 120 | } 121 | 122 | /** 123 | * Formats an Error to the StandardError type. 124 | * @param err The error object that was received. 125 | * @param message The message to send with the error. 126 | * @param sqsMessage The message that was received from SQS. 127 | */ 128 | function toStandardError( 129 | err: Error, 130 | message: string, 131 | sqsMessage: Message | Message[], 132 | ): StandardError { 133 | const error = new StandardError(message); 134 | error.cause = err; 135 | error.time = new Date(); 136 | error.messageIds = getMessageIds(sqsMessage); 137 | 138 | return error; 139 | } 140 | 141 | /** 142 | * Formats an Error to the TimeoutError type. 143 | * @param err The error object that was received. 144 | * @param message The message to send with the error. 145 | * @param sqsMessage The message that was received from SQS. 146 | */ 147 | function toTimeoutError( 148 | err: TimeoutError, 149 | message: string, 150 | sqsMessage: Message | Message[], 151 | ): TimeoutError { 152 | const error = new TimeoutError(message); 153 | error.cause = err; 154 | error.time = new Date(); 155 | error.messageIds = getMessageIds(sqsMessage); 156 | 157 | return error; 158 | } 159 | 160 | export { 161 | SQSError, 162 | StandardError, 163 | TimeoutError, 164 | isConnectionError, 165 | toSQSError, 166 | toStandardError, 167 | toTimeoutError, 168 | }; 169 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { Consumer } from "./consumer.js"; 2 | export { SQSError, StandardError, TimeoutError } from "./errors.js"; 3 | export * from "./types.js"; 4 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import createDebug from "debug"; 2 | const debug = createDebug("sqs-consumer"); 3 | 4 | export const logger = { 5 | debug, 6 | warn: (message: string) => { 7 | console.log(message); 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SQSClient, 3 | Message, 4 | QueueAttributeName, 5 | MessageSystemAttributeName, 6 | } from "@aws-sdk/client-sqs"; 7 | 8 | /** 9 | * The options for the consumer. 10 | */ 11 | export interface ConsumerOptions { 12 | /** 13 | * The SQS queue URL. 14 | */ 15 | queueUrl: string; 16 | /** 17 | * List of queue attributes to retrieve, see [AWS docs](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/Package/-aws-sdk-client-sqs/Variable/QueueAttributeName/). 18 | * @defaultvalue `[]` 19 | */ 20 | attributeNames?: QueueAttributeName[]; 21 | /** 22 | * List of message attributes to retrieve (i.e. `['name', 'address']`). 23 | * @defaultvalue `[]` 24 | */ 25 | messageAttributeNames?: string[]; 26 | /** 27 | * A list of attributes that need to be returned along with each message. 28 | * @defaultvalue `[]` 29 | */ 30 | messageSystemAttributeNames?: MessageSystemAttributeName[]; 31 | /** @hidden */ 32 | stopped?: boolean; 33 | /** 34 | * The number of messages to request from SQS when polling (default `1`). 35 | * 36 | * This cannot be higher than the 37 | * [AWS limit of 10](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/quotas-messages.html). 38 | * @defaultvalue `1` 39 | */ 40 | batchSize?: number; 41 | /** 42 | * The duration (in seconds) that the received messages are hidden from subsequent 43 | * retrieve requests after being retrieved by a ReceiveMessage request. 44 | */ 45 | visibilityTimeout?: number; 46 | /** 47 | * The duration (in seconds) for which the call will wait for a message to arrive in 48 | * the queue before returning. 49 | * @defaultvalue `20` 50 | */ 51 | waitTimeSeconds?: number; 52 | /** 53 | * The duration (in milliseconds) to wait before retrying after an authentication error. 54 | * @defaultvalue `10000` 55 | */ 56 | authenticationErrorTimeout?: number; 57 | /** 58 | * The duration (in milliseconds) to wait before repolling the queue. 59 | * @defaultvalue `0` 60 | */ 61 | pollingWaitTimeMs?: number; 62 | /** 63 | * If you want the stop action to wait for the final poll to complete and in-flight messages 64 | * to be processed before emitting 'stopped' set this to the max amount of time to wait. 65 | * @defaultvalue `0` 66 | */ 67 | pollingCompleteWaitTimeMs?: number; 68 | /** 69 | * If true, sets the message visibility timeout to 0 after a `processing_error`. You can 70 | * also specify a different timeout using a number. 71 | * If you would like to use exponential backoff, you can pass a function that returns 72 | * a number and it will use that as the value for the timeout. 73 | * @defaultvalue `false` 74 | */ 75 | terminateVisibilityTimeout?: 76 | | boolean 77 | | number 78 | | ((messages: Message[]) => number); 79 | /** 80 | * The interval (in seconds) between requests to extend the message visibility timeout. 81 | * 82 | * On each heartbeat the visibility is extended by adding `visibilityTimeout` to 83 | * the number of seconds since the start of the handler function. 84 | * 85 | * This value must less than `visibilityTimeout`. 86 | */ 87 | heartbeatInterval?: number; 88 | /** 89 | * An optional [SQS Client](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-sqs/classes/sqsclient.html) 90 | * object to use if you need to configure the client manually. 91 | */ 92 | sqs?: SQSClient; 93 | /** 94 | * The AWS region. 95 | * @defaultValue process.env.AWS_REGION || `eu-west-1` 96 | */ 97 | region?: string; 98 | /** 99 | * Set this value to false to ignore the `queueUrl` and use the 100 | * client's resolved endpoint, which may be a custom endpoint. 101 | * @defaultValue `true` 102 | */ 103 | useQueueUrlAsEndpoint?: boolean; 104 | /** 105 | * Time in ms to wait for `handleMessage` to process a message before timing out. 106 | * 107 | * Emits `timeout_error` on timeout. By default, if `handleMessage` times out, 108 | * the unprocessed message returns to the end of the queue. 109 | */ 110 | handleMessageTimeout?: number; 111 | /** 112 | * Default to `true`, if you don't want the package to delete messages from sqs 113 | * set this to `false`. 114 | * @defaultvalue `true` 115 | */ 116 | shouldDeleteMessages?: boolean; 117 | /** 118 | * By default, the consumer will treat an empty object or array from either of the 119 | * handlers as a acknowledgement of no messages and will not delete those messages as 120 | * a result. Set this to `true` to always acknowledge all messages no matter the returned 121 | * value. 122 | * @defaultvalue `false` 123 | */ 124 | alwaysAcknowledge?: boolean; 125 | /** 126 | * An `async` function (or function that returns a `Promise`) to be called whenever 127 | * a message is received. 128 | * 129 | * In the case that you need to acknowledge the message, return an object containing 130 | * the MessageId that you'd like to acknowledge. 131 | */ 132 | handleMessage?(message: Message): Promise; 133 | /** 134 | * An `async` function (or function that returns a `Promise`) to be called whenever 135 | * a batch of messages is received. Similar to `handleMessage` but will receive the 136 | * list of messages, not each message individually. 137 | * 138 | * **If both are set, `handleMessageBatch` overrides `handleMessage`**. 139 | * 140 | * In the case that you need to ack only some of the messages, return an array with 141 | * the successful messages only. 142 | */ 143 | handleMessageBatch?(messages: Message[]): Promise; 144 | /** 145 | * An `async` function (or function that returns a `Promise`) to be called right 146 | * before the SQS Client sends a receive message command. 147 | * 148 | * This function is usefull if SQS Client module exports have been modified, for 149 | * example to add middlewares. 150 | */ 151 | preReceiveMessageCallback?(): Promise; 152 | /** 153 | * An `async` function (or function that returns a `Promise`) to be called right 154 | * after the SQS Client sends a receive message command. 155 | * 156 | * This function is usefull if SQS Client module exports have been modified, for 157 | * example to add middlewares. 158 | */ 159 | postReceiveMessageCallback?(): Promise; 160 | /** 161 | * Set this to `true` if you want to receive additional information about the error 162 | * that occurred from AWS, such as the response and metadata. 163 | */ 164 | extendedAWSErrors?: boolean; 165 | /** 166 | * Set this to `true` if you want to suppress the warning about FIFO queues. 167 | * @defaultvalue `false` 168 | */ 169 | suppressFifoWarning?: boolean; 170 | } 171 | 172 | /** 173 | * A subset of the ConsumerOptions that can be updated at runtime. 174 | */ 175 | export type UpdatableOptions = 176 | | "visibilityTimeout" 177 | | "batchSize" 178 | | "waitTimeSeconds" 179 | | "pollingWaitTimeMs"; 180 | 181 | /** 182 | * The options for the stop method. 183 | */ 184 | export interface StopOptions { 185 | /** 186 | * Default to `false`, if you want the stop action to also abort requests to SQS 187 | * set this to `true`. 188 | * @defaultvalue `false` 189 | */ 190 | abort?: boolean; 191 | } 192 | 193 | /** 194 | * Metadata about the queue that is added to every event 195 | */ 196 | export interface QueueMetadata { 197 | queueUrl?: string; 198 | } 199 | 200 | /** 201 | * These are the events that the consumer emits. 202 | * Each event will receive QueueMetadata as the last argument, which is added automatically by the emitter. 203 | * @example 204 | * consumer.on('message_received', (message, metadata) => { 205 | * console.log(`Received message from queue: ${metadata.queueUrl}`); 206 | * console.log(message); 207 | * }); 208 | */ 209 | export interface Events { 210 | /** 211 | * Fired after one batch of items (up to `batchSize`) has been successfully processed. 212 | */ 213 | response_processed: []; 214 | /** 215 | * Fired when the queue is empty (All messages have been consumed). 216 | */ 217 | empty: []; 218 | /** 219 | * Fired when a message is received. 220 | */ 221 | message_received: [Message]; 222 | /** 223 | * Fired when a message is successfully processed and removed from the queue. 224 | */ 225 | message_processed: [Message]; 226 | /** 227 | * Fired when an error occurs interacting with the queue. 228 | * 229 | * If the error correlates to a message, that message is included in Params 230 | */ 231 | error: [Error, Message | Message[] | undefined]; 232 | /** 233 | * Fired when `handleMessageTimeout` is supplied as an option and if 234 | * `handleMessage` times out. 235 | */ 236 | timeout_error: [Error, Message]; 237 | /** 238 | * Fired when an error occurs processing the message. 239 | */ 240 | processing_error: [Error, Message]; 241 | /** 242 | * Fired when requests to SQS were aborted. 243 | */ 244 | aborted: []; 245 | /** 246 | * Fired when the consumer starts its work.. 247 | */ 248 | started: []; 249 | /** 250 | * Fired when the consumer finally stops its work. 251 | */ 252 | stopped: []; 253 | /** 254 | * Fired when an option is updated 255 | */ 256 | option_updated: [UpdatableOptions, ConsumerOptions[UpdatableOptions]]; 257 | /** 258 | * Fired when the Consumer is waiting for polling to complete before stopping. 259 | */ 260 | waiting_for_polling_to_complete: []; 261 | /** 262 | * Fired when the Consumer has waited for polling to complete and is stopping due to a timeout. 263 | */ 264 | waiting_for_polling_to_complete_timeout_exceeded: []; 265 | } 266 | 267 | /** 268 | * The error object that is emitted with error events from AWS. 269 | */ 270 | export type AWSError = { 271 | /** 272 | * Name, eg. ConditionalCheckFailedException 273 | */ 274 | readonly name: string; 275 | 276 | /** 277 | * Human-readable error response message 278 | */ 279 | message: string; 280 | 281 | /** 282 | * Non-standard stacktrace 283 | */ 284 | stack?: string; 285 | 286 | /** 287 | * Whether the client or server are at fault. 288 | */ 289 | readonly $fault: "client" | "server"; 290 | 291 | /** 292 | * Represents an HTTP message as received in reply to a request 293 | */ 294 | readonly $response?: { 295 | /** 296 | * The status code of the HTTP response. 297 | */ 298 | statusCode?: number; 299 | /** 300 | * The headers of the HTTP message. 301 | */ 302 | headers: Record; 303 | /** 304 | * The body of the HTTP message. 305 | * Can be: ArrayBuffer | ArrayBufferView | string | Uint8Array | Readable | ReadableStream 306 | */ 307 | body?: any; 308 | }; 309 | 310 | /** 311 | * The service that encountered the exception. 312 | */ 313 | readonly $service?: string; 314 | 315 | /** 316 | * Indicates that an error MAY be retried by the client. 317 | */ 318 | readonly $retryable?: { 319 | /** 320 | * Indicates that the error is a retryable throttling error. 321 | */ 322 | readonly throttling?: boolean; 323 | }; 324 | 325 | readonly $metadata: { 326 | /** 327 | * The status code of the last HTTP response received for this operation. 328 | */ 329 | readonly httpStatusCode?: number; 330 | 331 | /** 332 | * A unique identifier for the last request sent for this operation. Often 333 | * requested by AWS service teams to aid in debugging. 334 | */ 335 | readonly requestId?: string; 336 | 337 | /** 338 | * A secondary identifier for the last request sent. Used for debugging. 339 | */ 340 | readonly extendedRequestId?: string; 341 | 342 | /** 343 | * A tertiary identifier for the last request sent. Used for debugging. 344 | */ 345 | readonly cfId?: string; 346 | 347 | /** 348 | * The number of times this operation was attempted. 349 | */ 350 | readonly attempts?: number; 351 | 352 | /** 353 | * The total amount of time (in milliseconds) that was spent waiting between 354 | * retry attempts. 355 | */ 356 | readonly totalRetryDelay?: number; 357 | }; 358 | }; 359 | -------------------------------------------------------------------------------- /src/validation.ts: -------------------------------------------------------------------------------- 1 | import { ReceiveMessageCommandOutput } from "@aws-sdk/client-sqs"; 2 | 3 | import { ConsumerOptions } from "./types.js"; 4 | 5 | const requiredOptions = [ 6 | "queueUrl", 7 | // only one of handleMessage / handleMessagesBatch is required 8 | "handleMessage|handleMessageBatch", 9 | ]; 10 | 11 | function validateOption( 12 | option: string, 13 | value: any, 14 | allOptions: { [key: string]: any }, 15 | strict?: boolean, 16 | ): void { 17 | switch (option) { 18 | case "batchSize": 19 | if (value > 10 || value < 1) { 20 | throw new Error("batchSize must be between 1 and 10."); 21 | } 22 | break; 23 | case "heartbeatInterval": 24 | if ( 25 | !allOptions.visibilityTimeout || 26 | value >= allOptions.visibilityTimeout 27 | ) { 28 | throw new Error( 29 | "heartbeatInterval must be less than visibilityTimeout.", 30 | ); 31 | } 32 | break; 33 | case "visibilityTimeout": 34 | if ( 35 | allOptions.heartbeatInterval && 36 | value <= allOptions.heartbeatInterval 37 | ) { 38 | throw new Error( 39 | "heartbeatInterval must be less than visibilityTimeout.", 40 | ); 41 | } 42 | break; 43 | case "waitTimeSeconds": 44 | if (value < 1 || value > 20) { 45 | throw new Error("waitTimeSeconds must be between 0 and 20."); 46 | } 47 | break; 48 | case "pollingWaitTimeMs": 49 | if (value < 0) { 50 | throw new Error("pollingWaitTimeMs must be greater than 0."); 51 | } 52 | break; 53 | default: 54 | if (strict) { 55 | throw new Error(`The update ${option} cannot be updated`); 56 | } 57 | break; 58 | } 59 | } 60 | 61 | /** 62 | * Ensure that the required options have been set. 63 | * @param options The options that have been set by the application. 64 | */ 65 | function assertOptions(options: ConsumerOptions): void { 66 | requiredOptions.forEach((option) => { 67 | const possibilities = option.split("|"); 68 | if (!possibilities.find((p) => options[p])) { 69 | throw new Error( 70 | `Missing SQS consumer option [ ${possibilities.join(" or ")} ].`, 71 | ); 72 | } 73 | }); 74 | 75 | if (options.batchSize) { 76 | validateOption("batchSize", options.batchSize, options); 77 | } 78 | if (options.heartbeatInterval) { 79 | validateOption("heartbeatInterval", options.heartbeatInterval, options); 80 | } 81 | } 82 | 83 | /** 84 | * Determine if the response from SQS has messages in it. 85 | * @param response The response from SQS. 86 | */ 87 | function hasMessages(response: ReceiveMessageCommandOutput): boolean { 88 | return response.Messages && response.Messages.length > 0; 89 | } 90 | 91 | export { hasMessages, assertOptions, validateOption }; 92 | -------------------------------------------------------------------------------- /test/config/cucumber.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | parallel: 0, 3 | format: ["json:test/reports/cucumber-report.json"], 4 | paths: ["test/features"], 5 | forceExit: true, 6 | }; 7 | -------------------------------------------------------------------------------- /test/features/events.feature: -------------------------------------------------------------------------------- 1 | Feature: Events are emitted with the correct parameters and metadata 2 | 3 | Scenario: Error event without a message is emitted with undefined as the second parameter 4 | Given a consumer that will encounter an error without a message 5 | When the consumer starts 6 | Then an error event should be emitted with undefined as the second parameter 7 | And the event should include metadata with queueUrl 8 | 9 | Scenario: Error event with a message is emitted with the message as the second parameter 10 | Given a test message is sent to the SQS queue for events test 11 | And a consumer that will encounter an error with a message 12 | When the consumer starts 13 | Then an error event should be emitted with the message as the second parameter 14 | And the event should include metadata with queueUrl 15 | 16 | Scenario: Message events are emitted with metadata 17 | Given a test message is sent to the SQS queue for events test 18 | And a consumer that processes messages normally 19 | When the consumer starts 20 | Then message_received event should be emitted with metadata 21 | And message_processed event should be emitted with metadata 22 | 23 | Scenario: Empty queue event is emitted with metadata 24 | Given an empty SQS queue 25 | And a consumer that processes messages normally 26 | When the consumer polls an empty queue 27 | Then empty event should be emitted with metadata 28 | 29 | Scenario: Started and stopped events are emitted with metadata 30 | Given a consumer that processes messages normally 31 | When the consumer starts and stops 32 | Then started event should be emitted with metadata 33 | And stopped event should be emitted with metadata -------------------------------------------------------------------------------- /test/features/gracefulShutdown.feature: -------------------------------------------------------------------------------- 1 | Feature: Graceful shutdown 2 | 3 | Scenario: Several messages in flight 4 | Given Several messages are sent to the SQS queue 5 | Then the application is stopped while messages are in flight 6 | Then the in-flight messages should be processed before stopped is emitted -------------------------------------------------------------------------------- /test/features/handleMessage.feature: -------------------------------------------------------------------------------- 1 | Feature: When handleMessage is used, messages are consumed without error 2 | 3 | Scenario: A message is consumed from SQS 4 | Given a message is sent to the SQS queue 5 | Then the message should be consumed without error 6 | 7 | Scenario: Multiple messages are consumed from SQS 8 | Given messages are sent to the SQS queue 9 | Then the messages should be consumed without error -------------------------------------------------------------------------------- /test/features/handleMessageBatch.feature: -------------------------------------------------------------------------------- 1 | Feature: When handleMessageBatch is used, messages are consumed without error 2 | 3 | Scenario: A message batch is consumed from SQS 4 | Given a message batch is sent to the SQS queue 5 | Then the message batch should be consumed without error 6 | 7 | Scenario: Multiple message batches are consumed from SQS 8 | Given message batches are sent to the SQS queue 9 | Then the message batches should be consumed without error -------------------------------------------------------------------------------- /test/features/step_definitions/events.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undefined -- This is a test file */ 2 | 3 | import { Given, When, Then, After } from "@cucumber/cucumber"; 4 | import { strictEqual, ok, deepStrictEqual } from "node:assert"; 5 | import { PurgeQueueCommand } from "@aws-sdk/client-sqs"; 6 | import { pEvent } from "p-event"; 7 | 8 | import { Consumer } from "../../../dist/esm/consumer.js"; 9 | import { producer } from "../utils/producer.js"; 10 | import { sqs, QUEUE_URL } from "../utils/sqs.js"; 11 | 12 | let consumer; 13 | let errorEvent; 14 | let messageParam; 15 | let metadataParam; 16 | let receivedEvents = {}; 17 | 18 | Given("a consumer that will encounter an error without a message", () => { 19 | const mockSqs = { 20 | send() { 21 | throw new Error("Receive error"); 22 | }, 23 | }; 24 | 25 | consumer = new Consumer({ 26 | queueUrl: QUEUE_URL, 27 | sqs: mockSqs, 28 | handleMessage: async (message) => message, 29 | }); 30 | 31 | consumer.on("error", (err, message, metadata) => { 32 | errorEvent = err; 33 | messageParam = message; 34 | metadataParam = metadata; 35 | }); 36 | }); 37 | 38 | Given("a consumer that will encounter an error with a message", () => { 39 | const mockSqs = { 40 | send(command) { 41 | if (command.constructor.name === "DeleteMessageCommand") { 42 | const error = new Error("Delete error"); 43 | error.name = "SQSError"; 44 | throw error; 45 | } 46 | return sqs.send(command); 47 | }, 48 | }; 49 | 50 | consumer = new Consumer({ 51 | queueUrl: QUEUE_URL, 52 | sqs: mockSqs, 53 | handleMessage: async (message) => message, 54 | }); 55 | 56 | consumer.on("error", (err, message, metadata) => { 57 | errorEvent = err; 58 | messageParam = message; 59 | metadataParam = metadata; 60 | }); 61 | }); 62 | 63 | Given("a consumer that processes messages normally", () => { 64 | consumer = new Consumer({ 65 | queueUrl: QUEUE_URL, 66 | sqs, 67 | handleMessage: async (message) => message, 68 | pollingWaitTimeMs: 100, 69 | waitTimeSeconds: 1, 70 | }); 71 | 72 | [ 73 | "message_received", 74 | "message_processed", 75 | "empty", 76 | "started", 77 | "stopped", 78 | ].forEach((eventName) => { 79 | consumer.on(eventName, (...args) => { 80 | const metadata = args.at(-1); 81 | receivedEvents[eventName] = { 82 | args, 83 | metadata, 84 | }; 85 | }); 86 | }); 87 | }); 88 | 89 | Given("a test message is sent to the SQS queue for events test", async () => { 90 | await producer.send("test-message-for-events"); 91 | }); 92 | 93 | Given("an empty SQS queue", async () => { 94 | await sqs.send(new PurgeQueueCommand({ QueueUrl: QUEUE_URL })); 95 | }); 96 | 97 | When("the consumer starts", async () => { 98 | consumer.start(); 99 | strictEqual(consumer.status.isRunning, true); 100 | 101 | try { 102 | if (!errorEvent) { 103 | await Promise.race([ 104 | pEvent(consumer, "error", { timeout: 3000 }), 105 | new Promise((resolve) => setTimeout(resolve, 3000)), 106 | ]); 107 | } else { 108 | await new Promise((resolve) => setTimeout(resolve, 5000)); 109 | } 110 | } catch { 111 | // Timeout is expected in some cases 112 | } 113 | 114 | consumer.stop(); 115 | strictEqual(consumer.status.isRunning, false); 116 | }); 117 | 118 | When("the consumer polls an empty queue", async () => { 119 | consumer.start(); 120 | strictEqual(consumer.status.isRunning, true); 121 | 122 | try { 123 | await pEvent(consumer, "empty", { timeout: 10000 }); 124 | } catch { 125 | // If timeout occurs, we'll check if the event was registered anyway 126 | } 127 | 128 | consumer.stop(); 129 | strictEqual(consumer.status.isRunning, false); 130 | }); 131 | 132 | When("the consumer starts and stops", async () => { 133 | try { 134 | consumer.start(); 135 | strictEqual(consumer.status.isRunning, true); 136 | 137 | await Promise.race([ 138 | pEvent(consumer, "started", { timeout: 2000 }), 139 | new Promise((resolve) => setTimeout(resolve, 2000)), 140 | ]); 141 | 142 | await new Promise((resolve) => setTimeout(resolve, 1000)); 143 | 144 | consumer.stop(); 145 | 146 | await Promise.race([ 147 | pEvent(consumer, "stopped", { timeout: 2000 }), 148 | new Promise((resolve) => setTimeout(resolve, 2000)), 149 | ]); 150 | 151 | strictEqual(consumer.status.isRunning, false); 152 | } catch { 153 | consumer.stop(); 154 | } 155 | }); 156 | 157 | Then( 158 | "an error event should be emitted with undefined as the second parameter", 159 | () => { 160 | ok(errorEvent, "Error event should be defined"); 161 | strictEqual( 162 | messageParam, 163 | undefined, 164 | "Message parameter should be undefined", 165 | ); 166 | }, 167 | ); 168 | 169 | Then( 170 | "an error event should be emitted with the message as the second parameter", 171 | () => { 172 | ok(errorEvent, "Error event should be defined"); 173 | ok(messageParam, "Message parameter should be defined"); 174 | ok(messageParam.MessageId, "Message should have an ID"); 175 | ok(messageParam.Body, "Message should have a body"); 176 | }, 177 | ); 178 | 179 | Then("the event should include metadata with queueUrl", () => { 180 | deepStrictEqual( 181 | metadataParam, 182 | { queueUrl: QUEUE_URL }, 183 | "Metadata should contain the queue URL", 184 | ); 185 | }); 186 | 187 | Then("message_received event should be emitted with metadata", () => { 188 | ok( 189 | receivedEvents.message_received, 190 | "message_received event should be emitted", 191 | ); 192 | ok( 193 | receivedEvents.message_received.args.length > 0, 194 | "message_received event should have arguments", 195 | ); 196 | deepStrictEqual( 197 | receivedEvents.message_received.metadata, 198 | { queueUrl: QUEUE_URL }, 199 | "message_received metadata should contain the queue URL", 200 | ); 201 | }); 202 | 203 | Then("message_processed event should be emitted with metadata", () => { 204 | ok( 205 | receivedEvents.message_processed, 206 | "message_processed event should be emitted", 207 | ); 208 | ok( 209 | receivedEvents.message_processed.args.length > 0, 210 | "message_processed event should have arguments", 211 | ); 212 | deepStrictEqual( 213 | receivedEvents.message_processed.metadata, 214 | { queueUrl: QUEUE_URL }, 215 | "message_processed metadata should contain the queue URL", 216 | ); 217 | }); 218 | 219 | Then("empty event should be emitted with metadata", () => { 220 | ok(receivedEvents.empty, "empty event should be emitted"); 221 | deepStrictEqual( 222 | receivedEvents.empty.metadata, 223 | { queueUrl: QUEUE_URL }, 224 | "empty event metadata should contain the queue URL", 225 | ); 226 | }); 227 | 228 | Then("started event should be emitted with metadata", () => { 229 | ok(receivedEvents.started, "started event should be emitted"); 230 | deepStrictEqual( 231 | receivedEvents.started.metadata, 232 | { queueUrl: QUEUE_URL }, 233 | "started event metadata should contain the queue URL", 234 | ); 235 | }); 236 | 237 | Then("stopped event should be emitted with metadata", () => { 238 | ok(receivedEvents.stopped, "stopped event should be emitted"); 239 | deepStrictEqual( 240 | receivedEvents.stopped.metadata, 241 | { queueUrl: QUEUE_URL }, 242 | "stopped event metadata should contain the queue URL", 243 | ); 244 | }); 245 | 246 | After(() => { 247 | if (consumer && consumer.status.isRunning) { 248 | consumer.stop(); 249 | } 250 | 251 | errorEvent = undefined; 252 | messageParam = undefined; 253 | metadataParam = undefined; 254 | receivedEvents = {}; 255 | }); 256 | 257 | /* eslint-enable no-undefined -- This is a test file */ 258 | -------------------------------------------------------------------------------- /test/features/step_definitions/gracefulShutdown.js: -------------------------------------------------------------------------------- 1 | import { Given, Then, After } from "@cucumber/cucumber"; 2 | import { strictEqual } from "node:assert"; 3 | import { PurgeQueueCommand } from "@aws-sdk/client-sqs"; 4 | import { pEvent } from "p-event"; 5 | 6 | import { consumer } from "../utils/consumer/gracefulShutdown.js"; 7 | import { producer } from "../utils/producer.js"; 8 | import { sqs, QUEUE_URL } from "../utils/sqs.js"; 9 | 10 | Given("Several messages are sent to the SQS queue", async () => { 11 | const params = { 12 | QueueUrl: QUEUE_URL, 13 | }; 14 | const command = new PurgeQueueCommand(params); 15 | const response = await sqs.send(command); 16 | 17 | strictEqual(response.$metadata.httpStatusCode, 200); 18 | 19 | await new Promise((resolve) => setTimeout(resolve, 1000)); 20 | 21 | const size = await producer.queueSize(); 22 | 23 | if (size > 0) { 24 | await sqs.send(command); 25 | await new Promise((resolve) => setTimeout(resolve, 1000)); 26 | const sizeAfterSecondPurge = await producer.queueSize(); 27 | strictEqual( 28 | sizeAfterSecondPurge, 29 | 0, 30 | "Queue should be empty after second purge", 31 | ); 32 | } else { 33 | strictEqual(size, 0, "Queue should be empty after purge"); 34 | } 35 | 36 | await producer.send(["msg1", "msg2", "msg3"]); 37 | 38 | const size2 = await producer.queueSize(); 39 | strictEqual(size2, 3, "Queue should have exactly 3 messages"); 40 | }); 41 | 42 | Then("the application is stopped while messages are in flight", async () => { 43 | consumer.start(); 44 | 45 | consumer.stop(); 46 | 47 | strictEqual(consumer.status.isRunning, false); 48 | }); 49 | 50 | Then( 51 | "the in-flight messages should be processed before stopped is emitted", 52 | async () => { 53 | let numProcessed = 0; 54 | consumer.on("message_processed", () => { 55 | numProcessed++; 56 | }); 57 | 58 | await pEvent(consumer, "stopped"); 59 | 60 | strictEqual(numProcessed, 3, "Should process exactly 3 messages"); 61 | 62 | const size = await producer.queueSize(); 63 | strictEqual(size, 0, "Queue should be empty after processing"); 64 | }, 65 | ); 66 | 67 | After(async () => { 68 | consumer.stop(); 69 | 70 | await sqs.send(new PurgeQueueCommand({ QueueUrl: QUEUE_URL })); 71 | }); 72 | -------------------------------------------------------------------------------- /test/features/step_definitions/handleMessage.js: -------------------------------------------------------------------------------- 1 | import { Given, Then, After } from "@cucumber/cucumber"; 2 | import { strictEqual } from "node:assert"; 3 | import { PurgeQueueCommand } from "@aws-sdk/client-sqs"; 4 | import { pEvent } from "p-event"; 5 | 6 | import { consumer } from "../utils/consumer/handleMessage.js"; 7 | import { producer } from "../utils/producer.js"; 8 | import { sqs, QUEUE_URL } from "../utils/sqs.js"; 9 | 10 | Given("a message is sent to the SQS queue", async () => { 11 | const params = { 12 | QueueUrl: QUEUE_URL, 13 | }; 14 | const command = new PurgeQueueCommand(params); 15 | const response = await sqs.send(command); 16 | 17 | strictEqual(response.$metadata.httpStatusCode, 200); 18 | 19 | await producer.send(["msg1"]); 20 | 21 | const size = await producer.queueSize(); 22 | 23 | strictEqual(size, 1); 24 | }); 25 | 26 | Then("the message should be consumed without error", async () => { 27 | consumer.start(); 28 | 29 | strictEqual(consumer.status.isRunning, true); 30 | 31 | await pEvent(consumer, "response_processed"); 32 | 33 | consumer.stop(); 34 | strictEqual(consumer.status.isRunning, false); 35 | 36 | const size = await producer.queueSize(); 37 | strictEqual(size, 0); 38 | }); 39 | 40 | Given("messages are sent to the SQS queue", async () => { 41 | const params = { 42 | QueueUrl: QUEUE_URL, 43 | }; 44 | const command = new PurgeQueueCommand(params); 45 | const response = await sqs.send(command); 46 | 47 | strictEqual(response.$metadata.httpStatusCode, 200); 48 | 49 | await producer.send(["msg2", "msg3", "msg4"]); 50 | 51 | const size = await producer.queueSize(); 52 | 53 | strictEqual(size, 3); 54 | }); 55 | 56 | Then( 57 | "the messages should be consumed without error", 58 | { timeout: 2 * 5000 }, 59 | async () => { 60 | consumer.start(); 61 | 62 | strictEqual(consumer.status.isRunning, true); 63 | 64 | await pEvent(consumer, "message_received"); 65 | const size = await producer.queueSize(); 66 | 67 | strictEqual(size, 2); 68 | 69 | await pEvent(consumer, "message_received"); 70 | 71 | const size2 = await producer.queueSize(); 72 | 73 | strictEqual(size2, 1); 74 | 75 | await pEvent(consumer, "message_received"); 76 | 77 | const size3 = await producer.queueSize(); 78 | 79 | strictEqual(size3, 0); 80 | 81 | consumer.stop(); 82 | 83 | strictEqual(consumer.status.isRunning, false); 84 | }, 85 | ); 86 | 87 | After(() => consumer.stop()); 88 | -------------------------------------------------------------------------------- /test/features/step_definitions/handleMessageBatch.js: -------------------------------------------------------------------------------- 1 | import { Given, Then, After } from "@cucumber/cucumber"; 2 | import { strictEqual } from "node:assert"; 3 | import { PurgeQueueCommand } from "@aws-sdk/client-sqs"; 4 | import { pEvent } from "p-event"; 5 | 6 | import { consumer } from "../utils/consumer/handleMessageBatch.js"; 7 | import { producer } from "../utils/producer.js"; 8 | import { sqs, QUEUE_URL } from "../utils/sqs.js"; 9 | 10 | Given("a message batch is sent to the SQS queue", async () => { 11 | const params = { 12 | QueueUrl: QUEUE_URL, 13 | }; 14 | const command = new PurgeQueueCommand(params); 15 | const response = await sqs.send(command); 16 | 17 | strictEqual(response.$metadata.httpStatusCode, 200); 18 | 19 | await producer.send(["msg1", "msg2", "msg3", "msg4"]); 20 | 21 | const size = await producer.queueSize(); 22 | 23 | strictEqual(size, 4); 24 | }); 25 | 26 | Then("the message batch should be consumed without error", async () => { 27 | consumer.start(); 28 | 29 | strictEqual(consumer.status.isRunning, true); 30 | 31 | await pEvent(consumer, "response_processed"); 32 | 33 | consumer.stop(); 34 | strictEqual(consumer.status.isRunning, false); 35 | 36 | const size = await producer.queueSize(); 37 | strictEqual(size, 0); 38 | }); 39 | 40 | Given("message batches are sent to the SQS queue", async () => { 41 | const params = { 42 | QueueUrl: QUEUE_URL, 43 | }; 44 | const command = new PurgeQueueCommand(params); 45 | const response = await sqs.send(command); 46 | 47 | strictEqual(response.$metadata.httpStatusCode, 200); 48 | 49 | await producer.send(["msg1", "msg2", "msg3", "msg4", "msg5", "msg6"]); 50 | 51 | const size = await producer.queueSize(); 52 | 53 | strictEqual(size, 6); 54 | }); 55 | 56 | Then( 57 | "the message batches should be consumed without error", 58 | { timeout: 2 * 5000 }, 59 | async () => { 60 | consumer.start(); 61 | 62 | strictEqual(consumer.status.isRunning, true); 63 | 64 | await pEvent(consumer, "message_received"); 65 | 66 | const size = await producer.queueSize(); 67 | strictEqual(size, 1); 68 | 69 | await pEvent(consumer, "message_received"); 70 | 71 | const size2 = await producer.queueSize(); 72 | strictEqual(size2, 0); 73 | 74 | consumer.stop(); 75 | strictEqual(consumer.status.isRunning, false); 76 | }, 77 | ); 78 | 79 | After(() => consumer.stop()); 80 | -------------------------------------------------------------------------------- /test/features/utils/consumer/gracefulShutdown.js: -------------------------------------------------------------------------------- 1 | import { Consumer } from "../../../../dist/esm/consumer.js"; 2 | 3 | import { QUEUE_URL, sqs } from "../sqs.js"; 4 | 5 | export const consumer = Consumer.create({ 6 | queueUrl: QUEUE_URL, 7 | sqs, 8 | pollingWaitTimeMs: 1000, 9 | pollingCompleteWaitTimeMs: 5000, 10 | batchSize: 10, 11 | async handleMessage(message) { 12 | await new Promise((resolve) => setTimeout(resolve, 1500)); 13 | return message; 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /test/features/utils/consumer/handleMessage.js: -------------------------------------------------------------------------------- 1 | import { Consumer } from "../../../../dist/esm/consumer.js"; 2 | 3 | import { QUEUE_URL, sqs } from "../sqs.js"; 4 | 5 | export const consumer = Consumer.create({ 6 | queueUrl: QUEUE_URL, 7 | sqs, 8 | pollingWaitTimeMs: 100, 9 | async handleMessage(message) { 10 | return message; 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /test/features/utils/consumer/handleMessageBatch.js: -------------------------------------------------------------------------------- 1 | import { Consumer } from "../../../../dist/esm/consumer.js"; 2 | 3 | import { QUEUE_URL, sqs } from "../sqs.js"; 4 | 5 | export const consumer = Consumer.create({ 6 | queueUrl: QUEUE_URL, 7 | sqs, 8 | pollingWaitTimeMs: 100, 9 | batchSize: 5, 10 | async handleMessageBatch(messages) { 11 | return messages; 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /test/features/utils/delay.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Delay function 3 | * @param {number} ms time in milliseconds 4 | * @returns {Promise} 5 | */ 6 | export function delay(ms) { 7 | return new Promise((res) => setTimeout(res, ms)); 8 | } 9 | -------------------------------------------------------------------------------- /test/features/utils/producer.js: -------------------------------------------------------------------------------- 1 | import { Producer } from "sqs-producer"; 2 | 3 | import { QUEUE_URL, sqsConfig, sqs } from "./sqs.js"; 4 | 5 | export const producer = Producer.create({ 6 | queueUrl: QUEUE_URL, 7 | region: sqsConfig.region, 8 | sqs, 9 | }); 10 | -------------------------------------------------------------------------------- /test/features/utils/sqs.js: -------------------------------------------------------------------------------- 1 | import { SQSClient } from "@aws-sdk/client-sqs"; 2 | 3 | export const sqsConfig = { 4 | region: "eu-west-1", 5 | endpoint: "http://localhost:4566", 6 | credentials: { 7 | accessKeyId: "key", 8 | secretAccessKey: "secret", 9 | }, 10 | }; 11 | 12 | export const sqs = new SQSClient(sqsConfig); 13 | 14 | export const QUEUE_URL = 15 | process.env.SQS_QUEUE_URL || 16 | "http://localhost:4566/000000000000/sqs-consumer-data"; 17 | -------------------------------------------------------------------------------- /test/reports/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbc/sqs-consumer/48b981b5fed8b8c82cbd759f3b5cdbae92701c27/test/reports/.keep -------------------------------------------------------------------------------- /test/scripts/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | localstack: 4 | container_name: local_sqs_aws 5 | image: localstack/localstack:3.0.1@sha256:de413eee81c94bfd9b2206c016b82e83a3b2b8abd5775d05dbf829be2b02afb4 6 | environment: 7 | - AWS_DEFAULT_REGION=eu-west-1 8 | - EDGE_PORT=4566 9 | - SERVICES=sqs 10 | - AWS_ACCESS_KEY_ID=key 11 | - AWS_SECRET_ACCESS_KEY=secret 12 | - DOCKER_HOST=unix:///var/run/docker.sock 13 | - DEBUG=1 14 | ports: 15 | - "4566-4599:4566-4599" 16 | volumes: 17 | - '/var/run/docker.sock:/var/run/docker.sock' 18 | - ./localstack:/etc/localstack/init/ready.d 19 | healthcheck: 20 | test: curl http://localhost:4566/_localstack/health | jq '.services.sqs' | grep -q -x 'running' 21 | interval: 20s 22 | retries: 5 23 | start_period: 20s 24 | timeout: 10s -------------------------------------------------------------------------------- /test/scripts/initIntTests.sh: -------------------------------------------------------------------------------- 1 | docker compose --file ./test/scripts/docker-compose.yml up -d 2 | 3 | if [ $? -eq 0 ] 4 | then 5 | echo "Successfully started docker" 6 | else 7 | echo "Could not start docker" >&2 8 | exit 1 9 | fi 10 | 11 | export AWS_ACCESS_KEY_ID="key" 12 | export AWS_SECRET_ACCESS_KEY="secret" 13 | export AWS_DEFAULT_REGION="eu-west-1" 14 | 15 | echo "Waiting for SQS, attempting every 5s" 16 | until $(aws --endpoint-url=http://localhost:4566 sqs get-queue-url --queue-name sqs-consumer-data --region eu-west-1 | grep "{ 17 | "QueueUrl": "http://localhost:4566/000000000000/sqs-consumer-data" 18 | }" > /dev/null); do 19 | printf '.' 20 | sleep 5 21 | done 22 | echo ' Service is up!' 23 | -------------------------------------------------------------------------------- /test/scripts/localstack/init.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | echo "configuring localstack" 6 | echo "===================" 7 | LOCALSTACK_HOST=localhost 8 | AWS_REGION=eu-west-1 9 | 10 | 11 | # https://docs.aws.amazon.com/cli/latest/reference/sqs/create-queue.html 12 | create_queue() { 13 | local QUEUE_NAME_TO_CREATE=$1 14 | awslocal --endpoint-url=http://${LOCALSTACK_HOST}:4566 sqs create-queue --queue-name ${QUEUE_NAME_TO_CREATE} --region ${AWS_REGION} --attributes VisibilityTimeout=30 15 | } 16 | 17 | create_queue "sqs-consumer-data" -------------------------------------------------------------------------------- /test/tests/consumer.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeMessageVisibilityBatchCommand, 3 | ChangeMessageVisibilityCommand, 4 | DeleteMessageBatchCommand, 5 | DeleteMessageCommand, 6 | ReceiveMessageCommand, 7 | SQSClient, 8 | QueueAttributeName, 9 | Message, 10 | } from "@aws-sdk/client-sqs"; 11 | import { assert } from "chai"; 12 | import * as sinon from "sinon"; 13 | import { pEvent } from "p-event"; 14 | 15 | import { AWSError } from "../../src/types.js"; 16 | import { Consumer } from "../../src/consumer.js"; 17 | import { logger } from "../../src/logger.js"; 18 | 19 | const sandbox = sinon.createSandbox(); 20 | 21 | const AUTHENTICATION_ERROR_TIMEOUT = 20; 22 | const POLLING_TIMEOUT = 100; 23 | const QUEUE_URL = "some-queue-url"; 24 | const REGION = "some-region"; 25 | 26 | const mockReceiveMessage = sinon.match.instanceOf(ReceiveMessageCommand); 27 | const mockDeleteMessage = sinon.match.instanceOf(DeleteMessageCommand); 28 | const mockDeleteMessageBatch = sinon.match.instanceOf( 29 | DeleteMessageBatchCommand, 30 | ); 31 | const mockChangeMessageVisibility = sinon.match.instanceOf( 32 | ChangeMessageVisibilityCommand, 33 | ); 34 | const mockChangeMessageVisibilityBatch = sinon.match.instanceOf( 35 | ChangeMessageVisibilityBatchCommand, 36 | ); 37 | 38 | class MockSQSError extends Error implements AWSError { 39 | name: string; 40 | $metadata: AWSError["$metadata"]; 41 | $service: string; 42 | $retryable: AWSError["$retryable"]; 43 | $fault: AWSError["$fault"]; 44 | $response?: 45 | | { 46 | statusCode?: number | undefined; 47 | headers: Record; 48 | body?: any; 49 | } 50 | | undefined; 51 | time: Date; 52 | 53 | constructor(message: string) { 54 | super(message); 55 | this.message = message; 56 | } 57 | } 58 | 59 | describe("Consumer", () => { 60 | let consumer; 61 | let clock; 62 | let handleMessage; 63 | let handleMessageBatch; 64 | let sqs; 65 | const response = { 66 | Messages: [ 67 | { 68 | ReceiptHandle: "receipt-handle", 69 | MessageId: "123", 70 | Body: "body", 71 | }, 72 | ], 73 | }; 74 | 75 | beforeEach(() => { 76 | clock = sinon.useFakeTimers(); 77 | handleMessage = sandbox.stub().resolves(null); 78 | handleMessageBatch = sandbox.stub().resolves(null); 79 | sqs = sinon.createStubInstance(SQSClient); 80 | sqs.send = sinon.stub(); 81 | 82 | sqs.send.withArgs(mockReceiveMessage).resolves(response); 83 | sqs.send.withArgs(mockDeleteMessage).resolves(); 84 | sqs.send.withArgs(mockDeleteMessageBatch).resolves(); 85 | sqs.send.withArgs(mockChangeMessageVisibility).resolves(); 86 | sqs.send.withArgs(mockChangeMessageVisibilityBatch).resolves(); 87 | 88 | consumer = new Consumer({ 89 | queueUrl: QUEUE_URL, 90 | region: REGION, 91 | handleMessage, 92 | sqs, 93 | authenticationErrorTimeout: AUTHENTICATION_ERROR_TIMEOUT, 94 | }); 95 | }); 96 | 97 | afterEach(() => { 98 | clock.restore(); 99 | sandbox.restore(); 100 | }); 101 | 102 | describe("options validation", () => { 103 | it("requires a handleMessage or handleMessagesBatch function to be set", () => { 104 | assert.throws(() => { 105 | new Consumer({ 106 | handleMessage: undefined, 107 | region: REGION, 108 | queueUrl: QUEUE_URL, 109 | }); 110 | }, `Missing SQS consumer option [ handleMessage or handleMessageBatch ].`); 111 | }); 112 | 113 | it("requires the batchSize option to be no greater than 10", () => { 114 | assert.throws(() => { 115 | new Consumer({ 116 | region: REGION, 117 | queueUrl: QUEUE_URL, 118 | handleMessage, 119 | batchSize: 11, 120 | }); 121 | }, "batchSize must be between 1 and 10."); 122 | }); 123 | 124 | it("requires the batchSize option to be greater than 0", () => { 125 | assert.throws(() => { 126 | new Consumer({ 127 | region: REGION, 128 | queueUrl: QUEUE_URL, 129 | handleMessage, 130 | batchSize: -1, 131 | }); 132 | }, "batchSize must be between 1 and 10."); 133 | }); 134 | 135 | it("requires visibilityTimeout to be set with heartbeatInterval", () => { 136 | assert.throws(() => { 137 | new Consumer({ 138 | region: REGION, 139 | queueUrl: QUEUE_URL, 140 | handleMessage, 141 | heartbeatInterval: 30, 142 | }); 143 | }, "heartbeatInterval must be less than visibilityTimeout."); 144 | }); 145 | 146 | it("requires heartbeatInterval to be less than visibilityTimeout", () => { 147 | assert.throws(() => { 148 | new Consumer({ 149 | region: REGION, 150 | queueUrl: QUEUE_URL, 151 | handleMessage, 152 | heartbeatInterval: 30, 153 | visibilityTimeout: 30, 154 | }); 155 | }, "heartbeatInterval must be less than visibilityTimeout."); 156 | }); 157 | }); 158 | 159 | describe(".create", () => { 160 | it("creates a new instance of a Consumer object", () => { 161 | const instance = Consumer.create({ 162 | region: REGION, 163 | queueUrl: QUEUE_URL, 164 | batchSize: 1, 165 | visibilityTimeout: 10, 166 | waitTimeSeconds: 10, 167 | handleMessage, 168 | }); 169 | 170 | assert.instanceOf(instance, Consumer); 171 | }); 172 | }); 173 | 174 | describe(".start", () => { 175 | it("uses the correct abort signal", async () => { 176 | sqs.send 177 | .withArgs(mockReceiveMessage) 178 | .resolves(new Promise((res) => setTimeout(res, 100))); 179 | 180 | // Starts and abort is false 181 | consumer.start(); 182 | assert.isFalse(sqs.send.lastCall.lastArg.abortSignal.aborted); 183 | 184 | // normal stop without an abort and abort is false 185 | consumer.stop(); 186 | assert.isFalse(sqs.send.lastCall.lastArg.abortSignal.aborted); 187 | 188 | // Starts and abort is false 189 | consumer.start(); 190 | assert.isFalse(sqs.send.lastCall.lastArg.abortSignal.aborted); 191 | 192 | // Stop with abort and abort is true 193 | consumer.stop({ abort: true }); 194 | assert.isTrue(sqs.send.lastCall.lastArg.abortSignal.aborted); 195 | 196 | // Starts and abort is false 197 | consumer.start(); 198 | assert.isFalse(sqs.send.lastCall.lastArg.abortSignal.aborted); 199 | }); 200 | 201 | it("fires an event when the consumer is started", async () => { 202 | const handleStart = sandbox.stub().returns(null); 203 | 204 | consumer.on("started", handleStart); 205 | 206 | consumer.start(); 207 | consumer.stop(); 208 | 209 | sandbox.assert.calledOnce(handleStart); 210 | }); 211 | 212 | it("fires an error event when an error occurs receiving a message", async () => { 213 | const receiveErr = new Error("Receive error"); 214 | 215 | sqs.send.withArgs(mockReceiveMessage).rejects(receiveErr); 216 | 217 | consumer.start(); 218 | 219 | const err: any = await pEvent(consumer, "error"); 220 | 221 | consumer.stop(); 222 | assert.ok(err); 223 | assert.equal(err.message, "SQS receive message failed: Receive error"); 224 | }); 225 | 226 | it("retains sqs error information", async () => { 227 | const receiveErr = new MockSQSError("Receive error"); 228 | receiveErr.name = "short code"; 229 | receiveErr.$retryable = { 230 | throttling: false, 231 | }; 232 | receiveErr.$metadata = { 233 | httpStatusCode: 403, 234 | }; 235 | receiveErr.time = new Date(); 236 | receiveErr.$service = "service"; 237 | 238 | sqs.send.withArgs(mockReceiveMessage).rejects(receiveErr); 239 | 240 | consumer.start(); 241 | const err: any = await pEvent(consumer, "error"); 242 | consumer.stop(); 243 | 244 | assert.ok(err); 245 | assert.equal(err.message, "SQS receive message failed: Receive error"); 246 | assert.equal(err.code, receiveErr.name); 247 | assert.equal(err.retryable, receiveErr.$retryable.throttling); 248 | assert.equal(err.statusCode, receiveErr.$metadata.httpStatusCode); 249 | assert.equal(err.time.toString(), receiveErr.time.toString()); 250 | assert.equal(err.service, receiveErr.$service); 251 | assert.equal(err.fault, receiveErr.$fault); 252 | assert.isUndefined(err.response); 253 | assert.isUndefined(err.metadata); 254 | }); 255 | 256 | it('includes the response and metadata in the error when "extendedAWSErrors" is true', async () => { 257 | const receiveErr = new MockSQSError("Receive error"); 258 | receiveErr.name = "short code"; 259 | receiveErr.$retryable = { 260 | throttling: false, 261 | }; 262 | receiveErr.$metadata = { 263 | httpStatusCode: 403, 264 | }; 265 | receiveErr.time = new Date(); 266 | receiveErr.$service = "service"; 267 | receiveErr.$response = { 268 | statusCode: 200, 269 | headers: {}, 270 | body: "body", 271 | }; 272 | 273 | sqs.send.withArgs(mockReceiveMessage).rejects(receiveErr); 274 | 275 | consumer = new Consumer({ 276 | queueUrl: QUEUE_URL, 277 | region: REGION, 278 | handleMessage, 279 | sqs, 280 | authenticationErrorTimeout: AUTHENTICATION_ERROR_TIMEOUT, 281 | extendedAWSErrors: true, 282 | }); 283 | 284 | consumer.start(); 285 | const err: any = await pEvent(consumer, "error"); 286 | consumer.stop(); 287 | 288 | assert.ok(err); 289 | assert.equal(err.response, receiveErr.$response); 290 | assert.equal(err.metadata, receiveErr.$metadata); 291 | }); 292 | 293 | it("does not include the response and metadata in the error when extendedAWSErrors is false", async () => { 294 | const receiveErr = new MockSQSError("Receive error"); 295 | receiveErr.name = "short code"; 296 | receiveErr.$retryable = { 297 | throttling: false, 298 | }; 299 | receiveErr.$metadata = { 300 | httpStatusCode: 403, 301 | }; 302 | receiveErr.time = new Date(); 303 | receiveErr.$service = "service"; 304 | receiveErr.$response = { 305 | statusCode: 200, 306 | headers: {}, 307 | body: "body", 308 | }; 309 | 310 | sqs.send.withArgs(mockReceiveMessage).rejects(receiveErr); 311 | 312 | consumer = new Consumer({ 313 | queueUrl: QUEUE_URL, 314 | region: REGION, 315 | handleMessage, 316 | sqs, 317 | authenticationErrorTimeout: AUTHENTICATION_ERROR_TIMEOUT, 318 | extendedAWSErrors: false, 319 | }); 320 | 321 | consumer.start(); 322 | const err: any = await pEvent(consumer, "error"); 323 | consumer.stop(); 324 | 325 | assert.ok(err); 326 | assert.isUndefined(err.response); 327 | assert.isUndefined(err.metadata); 328 | }); 329 | 330 | it("fires a timeout event if handler function takes too long", async () => { 331 | const handleMessageTimeout = 500; 332 | consumer = new Consumer({ 333 | queueUrl: QUEUE_URL, 334 | region: REGION, 335 | handleMessage: () => 336 | new Promise((resolve) => setTimeout(resolve, 1000)), 337 | handleMessageTimeout, 338 | sqs, 339 | authenticationErrorTimeout: AUTHENTICATION_ERROR_TIMEOUT, 340 | }); 341 | 342 | consumer.start(); 343 | const [err]: any = await Promise.all([ 344 | pEvent(consumer, "timeout_error"), 345 | clock.tickAsync(handleMessageTimeout), 346 | ]); 347 | consumer.stop(); 348 | 349 | assert.ok(err); 350 | assert.equal( 351 | err.message, 352 | `Message handler timed out after ${handleMessageTimeout}ms: Operation timed out.`, 353 | ); 354 | }); 355 | 356 | it("handles unexpected exceptions thrown by the handler function", async () => { 357 | consumer = new Consumer({ 358 | queueUrl: QUEUE_URL, 359 | region: REGION, 360 | handleMessage: () => { 361 | throw new Error("unexpected parsing error"); 362 | }, 363 | sqs, 364 | authenticationErrorTimeout: AUTHENTICATION_ERROR_TIMEOUT, 365 | }); 366 | 367 | consumer.start(); 368 | const err: any = await pEvent(consumer, "processing_error"); 369 | consumer.stop(); 370 | 371 | assert.ok(err); 372 | assert.equal( 373 | err.message, 374 | "Unexpected message handler failure: unexpected parsing error", 375 | ); 376 | }); 377 | 378 | it("handles non-standard objects thrown by the handler function", async () => { 379 | class CustomError { 380 | private _message: string; 381 | 382 | constructor(message) { 383 | this._message = message; 384 | } 385 | 386 | get message() { 387 | return this._message; 388 | } 389 | } 390 | 391 | consumer = new Consumer({ 392 | queueUrl: QUEUE_URL, 393 | region: REGION, 394 | handleMessage: () => { 395 | throw new CustomError("unexpected parsing error"); 396 | }, 397 | sqs, 398 | authenticationErrorTimeout: AUTHENTICATION_ERROR_TIMEOUT, 399 | }); 400 | 401 | consumer.start(); 402 | const err: any = await pEvent(consumer, "processing_error"); 403 | consumer.stop(); 404 | 405 | assert.ok(err); 406 | assert.equal(err.message, "unexpected parsing error"); 407 | }); 408 | 409 | it("handles non-standard exceptions thrown by the handler function", async () => { 410 | const customError = new Error(); 411 | Object.defineProperty(customError, "message", { 412 | get: () => "unexpected parsing error", 413 | }); 414 | 415 | consumer = new Consumer({ 416 | queueUrl: QUEUE_URL, 417 | region: REGION, 418 | handleMessage: () => { 419 | throw customError; 420 | }, 421 | sqs, 422 | authenticationErrorTimeout: AUTHENTICATION_ERROR_TIMEOUT, 423 | }); 424 | 425 | consumer.start(); 426 | const err: any = await pEvent(consumer, "processing_error"); 427 | consumer.stop(); 428 | 429 | assert.ok(err); 430 | assert.equal( 431 | err.message, 432 | "Unexpected message handler failure: unexpected parsing error", 433 | ); 434 | }); 435 | 436 | it("fires an error event when an error occurs deleting a message", async () => { 437 | const deleteErr = new Error("Delete error"); 438 | 439 | handleMessage.resolves(null); 440 | sqs.send.withArgs(mockDeleteMessage).rejects(deleteErr); 441 | 442 | consumer.start(); 443 | const err: any = await pEvent(consumer, "error"); 444 | consumer.stop(); 445 | 446 | assert.ok(err); 447 | assert.equal(err.message, "SQS delete message failed: Delete error"); 448 | }); 449 | 450 | it("fires a `processing_error` event when a non-`SQSError` error occurs processing a message", async () => { 451 | const processingErr = new Error("Processing error"); 452 | 453 | handleMessage.rejects(processingErr); 454 | 455 | consumer.start(); 456 | const [err, message] = await pEvent< 457 | string | symbol, 458 | { [key: string]: string }[] 459 | >(consumer, "processing_error", { 460 | multiArgs: true, 461 | }); 462 | consumer.stop(); 463 | 464 | assert.equal( 465 | err instanceof Error ? err.message : "", 466 | "Unexpected message handler failure: Processing error", 467 | ); 468 | assert.equal(message.MessageId, "123"); 469 | assert.deepEqual((err as any).messageIds, ["123"]); 470 | }); 471 | 472 | it("fires an `error` event when an `SQSError` occurs processing a message", async () => { 473 | const sqsError = new Error("Processing error"); 474 | sqsError.name = "SQSError"; 475 | 476 | handleMessage.resolves(); 477 | sqs.send.withArgs(mockDeleteMessage).rejects(sqsError); 478 | 479 | consumer.start(); 480 | const [err, message] = await pEvent< 481 | string | symbol, 482 | { [key: string]: string }[] 483 | >(consumer, "error", { 484 | multiArgs: true, 485 | }); 486 | consumer.stop(); 487 | 488 | assert.equal(err.message, "SQS delete message failed: Processing error"); 489 | assert.equal(message.MessageId, "123"); 490 | }); 491 | 492 | it("waits before repolling when a credentials error occurs", async () => { 493 | const loggerDebug = sandbox.stub(logger, "debug"); 494 | 495 | const credentialsErr = { 496 | name: "CredentialsError", 497 | message: "Missing credentials in config", 498 | }; 499 | sqs.send.withArgs(mockReceiveMessage).rejects(credentialsErr); 500 | const errorListener = sandbox.stub(); 501 | consumer.on("error", errorListener); 502 | 503 | consumer.start(); 504 | await clock.tickAsync(AUTHENTICATION_ERROR_TIMEOUT); 505 | consumer.stop(); 506 | 507 | sandbox.assert.calledTwice(errorListener); 508 | sandbox.assert.calledWithMatch(sqs.send.firstCall, mockReceiveMessage); 509 | sandbox.assert.calledWithMatch(sqs.send.secondCall, mockReceiveMessage); 510 | 511 | sandbox.assert.calledWith(loggerDebug, "authentication_error", { 512 | code: "CredentialsError", 513 | detail: "There was an authentication error. Pausing before retrying.", 514 | }); 515 | }); 516 | 517 | it("waits before repolling when a 403 error occurs", async () => { 518 | const loggerDebug = sandbox.stub(logger, "debug"); 519 | 520 | const invalidSignatureErr = { 521 | $metadata: { 522 | httpStatusCode: 403, 523 | }, 524 | message: "The security token included in the request is invalid", 525 | }; 526 | sqs.send.withArgs(mockReceiveMessage).rejects(invalidSignatureErr); 527 | const errorListener = sandbox.stub(); 528 | consumer.on("error", errorListener); 529 | 530 | consumer.start(); 531 | await clock.tickAsync(AUTHENTICATION_ERROR_TIMEOUT); 532 | consumer.stop(); 533 | 534 | sandbox.assert.calledTwice(errorListener); 535 | sandbox.assert.calledWithMatch(sqs.send.firstCall, mockReceiveMessage); 536 | sandbox.assert.calledWithMatch(sqs.send.secondCall, mockReceiveMessage); 537 | 538 | sandbox.assert.calledWith(loggerDebug, "authentication_error", { 539 | code: "Unknown", 540 | detail: "There was an authentication error. Pausing before retrying.", 541 | }); 542 | }); 543 | 544 | it("waits before repolling when a UnknownEndpoint error occurs", async () => { 545 | const loggerDebug = sandbox.stub(logger, "debug"); 546 | 547 | const unknownEndpointErr = { 548 | name: "UnknownEndpoint", 549 | message: 550 | "Inaccessible host: `sqs.eu-west-1.amazonaws.com`. This service may not be available in the `eu-west-1` region.", 551 | }; 552 | sqs.send.withArgs(mockReceiveMessage).rejects(unknownEndpointErr); 553 | const errorListener = sandbox.stub(); 554 | consumer.on("error", errorListener); 555 | 556 | consumer.start(); 557 | await clock.tickAsync(AUTHENTICATION_ERROR_TIMEOUT); 558 | consumer.stop(); 559 | 560 | sandbox.assert.calledTwice(errorListener); 561 | sandbox.assert.calledTwice(sqs.send); 562 | sandbox.assert.calledWithMatch(sqs.send.firstCall, mockReceiveMessage); 563 | sandbox.assert.calledWithMatch(sqs.send.secondCall, mockReceiveMessage); 564 | 565 | sandbox.assert.calledWith(loggerDebug, "authentication_error", { 566 | code: "UnknownEndpoint", 567 | detail: "There was an authentication error. Pausing before retrying.", 568 | }); 569 | }); 570 | 571 | it("waits before repolling when a NonExistentQueue error occurs", async () => { 572 | const loggerDebug = sandbox.stub(logger, "debug"); 573 | 574 | const nonExistentQueueErr = { 575 | name: "AWS.SimpleQueueService.NonExistentQueue", 576 | message: "The specified queue does not exist for this wsdl version.", 577 | }; 578 | sqs.send.withArgs(mockReceiveMessage).rejects(nonExistentQueueErr); 579 | const errorListener = sandbox.stub(); 580 | consumer.on("error", errorListener); 581 | 582 | consumer.start(); 583 | await clock.tickAsync(AUTHENTICATION_ERROR_TIMEOUT); 584 | consumer.stop(); 585 | 586 | sandbox.assert.calledTwice(errorListener); 587 | sandbox.assert.calledTwice(sqs.send); 588 | sandbox.assert.calledWithMatch(sqs.send.firstCall, mockReceiveMessage); 589 | sandbox.assert.calledWithMatch(sqs.send.secondCall, mockReceiveMessage); 590 | 591 | sandbox.assert.calledWith(loggerDebug, "authentication_error", { 592 | code: "AWS.SimpleQueueService.NonExistentQueue", 593 | detail: "There was an authentication error. Pausing before retrying.", 594 | }); 595 | }); 596 | 597 | it("waits before repolling when a CredentialsProviderError error occurs", async () => { 598 | const loggerDebug = sandbox.stub(logger, "debug"); 599 | 600 | const credentialsProviderErr = { 601 | name: "CredentialsProviderError", 602 | message: "Could not load credentials from any providers.", 603 | }; 604 | sqs.send.withArgs(mockReceiveMessage).rejects(credentialsProviderErr); 605 | const errorListener = sandbox.stub(); 606 | consumer.on("error", errorListener); 607 | 608 | consumer.start(); 609 | await clock.tickAsync(AUTHENTICATION_ERROR_TIMEOUT); 610 | consumer.stop(); 611 | 612 | sandbox.assert.calledTwice(errorListener); 613 | sandbox.assert.calledTwice(sqs.send); 614 | sandbox.assert.calledWithMatch(sqs.send.firstCall, mockReceiveMessage); 615 | sandbox.assert.calledWithMatch(sqs.send.secondCall, mockReceiveMessage); 616 | 617 | sandbox.assert.calledWith(loggerDebug, "authentication_error", { 618 | code: "CredentialsProviderError", 619 | detail: "There was an authentication error. Pausing before retrying.", 620 | }); 621 | }); 622 | 623 | it("waits before repolling when a InvalidAddress error occurs", async () => { 624 | const loggerDebug = sandbox.stub(logger, "debug"); 625 | 626 | const credentialsProviderErr = { 627 | name: "InvalidAddress", 628 | message: "The address some-queue-url is not valid for this endpoint.", 629 | }; 630 | sqs.send.withArgs(mockReceiveMessage).rejects(credentialsProviderErr); 631 | const errorListener = sandbox.stub(); 632 | consumer.on("error", errorListener); 633 | 634 | consumer.start(); 635 | await clock.tickAsync(AUTHENTICATION_ERROR_TIMEOUT); 636 | consumer.stop(); 637 | 638 | sandbox.assert.calledTwice(errorListener); 639 | sandbox.assert.calledTwice(sqs.send); 640 | sandbox.assert.calledWithMatch(sqs.send.firstCall, mockReceiveMessage); 641 | sandbox.assert.calledWithMatch(sqs.send.secondCall, mockReceiveMessage); 642 | 643 | sandbox.assert.calledWith(loggerDebug, "authentication_error", { 644 | code: "InvalidAddress", 645 | detail: "There was an authentication error. Pausing before retrying.", 646 | }); 647 | }); 648 | 649 | it("waits before repolling when a InvalidSecurity error occurs", async () => { 650 | const loggerDebug = sandbox.stub(logger, "debug"); 651 | 652 | const credentialsProviderErr = { 653 | name: "InvalidSecurity", 654 | message: "The queue is not is not HTTPS and SigV4.", 655 | }; 656 | sqs.send.withArgs(mockReceiveMessage).rejects(credentialsProviderErr); 657 | const errorListener = sandbox.stub(); 658 | consumer.on("error", errorListener); 659 | 660 | consumer.start(); 661 | await clock.tickAsync(AUTHENTICATION_ERROR_TIMEOUT); 662 | consumer.stop(); 663 | 664 | sandbox.assert.calledTwice(errorListener); 665 | sandbox.assert.calledTwice(sqs.send); 666 | sandbox.assert.calledWithMatch(sqs.send.firstCall, mockReceiveMessage); 667 | sandbox.assert.calledWithMatch(sqs.send.secondCall, mockReceiveMessage); 668 | 669 | sandbox.assert.calledWith(loggerDebug, "authentication_error", { 670 | code: "InvalidSecurity", 671 | detail: "There was an authentication error. Pausing before retrying.", 672 | }); 673 | }); 674 | 675 | it("waits before repolling when a QueueDoesNotExist error occurs", async () => { 676 | const loggerDebug = sandbox.stub(logger, "debug"); 677 | 678 | const credentialsProviderErr = { 679 | name: "QueueDoesNotExist", 680 | message: "The queue does not exist.", 681 | }; 682 | sqs.send.withArgs(mockReceiveMessage).rejects(credentialsProviderErr); 683 | const errorListener = sandbox.stub(); 684 | consumer.on("error", errorListener); 685 | 686 | consumer.start(); 687 | await clock.tickAsync(AUTHENTICATION_ERROR_TIMEOUT); 688 | consumer.stop(); 689 | 690 | sandbox.assert.calledTwice(errorListener); 691 | sandbox.assert.calledTwice(sqs.send); 692 | sandbox.assert.calledWithMatch(sqs.send.firstCall, mockReceiveMessage); 693 | sandbox.assert.calledWithMatch(sqs.send.secondCall, mockReceiveMessage); 694 | 695 | sandbox.assert.calledWith(loggerDebug, "authentication_error", { 696 | code: "QueueDoesNotExist", 697 | detail: "There was an authentication error. Pausing before retrying.", 698 | }); 699 | }); 700 | 701 | it("waits before repolling when a RequestThrottled error occurs", async () => { 702 | const loggerDebug = sandbox.stub(logger, "debug"); 703 | 704 | const credentialsProviderErr = { 705 | name: "RequestThrottled", 706 | message: "Requests have been throttled.", 707 | }; 708 | sqs.send.withArgs(mockReceiveMessage).rejects(credentialsProviderErr); 709 | const errorListener = sandbox.stub(); 710 | consumer.on("error", errorListener); 711 | 712 | consumer.start(); 713 | await clock.tickAsync(AUTHENTICATION_ERROR_TIMEOUT); 714 | consumer.stop(); 715 | 716 | sandbox.assert.calledTwice(errorListener); 717 | sandbox.assert.calledTwice(sqs.send); 718 | sandbox.assert.calledWithMatch(sqs.send.firstCall, mockReceiveMessage); 719 | sandbox.assert.calledWithMatch(sqs.send.secondCall, mockReceiveMessage); 720 | 721 | sandbox.assert.calledWith(loggerDebug, "authentication_error", { 722 | code: "RequestThrottled", 723 | detail: "There was an authentication error. Pausing before retrying.", 724 | }); 725 | }); 726 | 727 | it("waits before repolling when a RequestThrottled error occurs", async () => { 728 | const loggerDebug = sandbox.stub(logger, "debug"); 729 | 730 | const credentialsProviderErr = { 731 | name: "OverLimit", 732 | message: "An over limit error.", 733 | }; 734 | sqs.send.withArgs(mockReceiveMessage).rejects(credentialsProviderErr); 735 | const errorListener = sandbox.stub(); 736 | consumer.on("error", errorListener); 737 | 738 | consumer.start(); 739 | await clock.tickAsync(AUTHENTICATION_ERROR_TIMEOUT); 740 | consumer.stop(); 741 | 742 | sandbox.assert.calledTwice(errorListener); 743 | sandbox.assert.calledTwice(sqs.send); 744 | sandbox.assert.calledWithMatch(sqs.send.firstCall, mockReceiveMessage); 745 | sandbox.assert.calledWithMatch(sqs.send.secondCall, mockReceiveMessage); 746 | 747 | sandbox.assert.calledWith(loggerDebug, "authentication_error", { 748 | code: "OverLimit", 749 | detail: "There was an authentication error. Pausing before retrying.", 750 | }); 751 | }); 752 | 753 | it("waits before repolling when a polling timeout is set", async () => { 754 | consumer = new Consumer({ 755 | queueUrl: QUEUE_URL, 756 | region: REGION, 757 | handleMessage, 758 | sqs, 759 | authenticationErrorTimeout: AUTHENTICATION_ERROR_TIMEOUT, 760 | pollingWaitTimeMs: POLLING_TIMEOUT, 761 | }); 762 | 763 | consumer.start(); 764 | await clock.tickAsync(POLLING_TIMEOUT); 765 | consumer.stop(); 766 | 767 | sandbox.assert.callCount(sqs.send, 4); 768 | sandbox.assert.calledWithMatch(sqs.send.firstCall, mockReceiveMessage); 769 | sandbox.assert.calledWithMatch(sqs.send.secondCall, mockDeleteMessage); 770 | sandbox.assert.calledWithMatch(sqs.send.thirdCall, mockReceiveMessage); 771 | sandbox.assert.calledWithMatch(sqs.send.getCall(3), mockDeleteMessage); 772 | }); 773 | 774 | it("fires a message_received event when a message is received", async () => { 775 | consumer.start(); 776 | const message = await pEvent(consumer, "message_received"); 777 | consumer.stop(); 778 | 779 | assert.equal(message, response.Messages[0]); 780 | }); 781 | 782 | it("fires a message_processed event when a message is successfully deleted", async () => { 783 | handleMessage.resolves(); 784 | 785 | consumer.start(); 786 | const message = await pEvent(consumer, "message_received"); 787 | consumer.stop(); 788 | 789 | assert.equal(message, response.Messages[0]); 790 | }); 791 | 792 | it("calls the handleMessage function when a message is received", async () => { 793 | consumer.start(); 794 | await pEvent(consumer, "message_processed"); 795 | consumer.stop(); 796 | 797 | sandbox.assert.calledWith(handleMessage, response.Messages[0]); 798 | }); 799 | 800 | it("calls the preReceiveMessageCallback and postReceiveMessageCallback function before receiving a message", async () => { 801 | const preReceiveMessageCallbackStub = sandbox.stub().resolves(null); 802 | const postReceiveMessageCallbackStub = sandbox.stub().resolves(null); 803 | 804 | consumer = new Consumer({ 805 | queueUrl: QUEUE_URL, 806 | region: REGION, 807 | handleMessage, 808 | sqs, 809 | authenticationErrorTimeout: AUTHENTICATION_ERROR_TIMEOUT, 810 | preReceiveMessageCallback: preReceiveMessageCallbackStub, 811 | postReceiveMessageCallback: postReceiveMessageCallbackStub, 812 | }); 813 | 814 | consumer.start(); 815 | await pEvent(consumer, "message_processed"); 816 | consumer.stop(); 817 | 818 | sandbox.assert.calledOnce(preReceiveMessageCallbackStub); 819 | sandbox.assert.calledOnce(postReceiveMessageCallbackStub); 820 | }); 821 | 822 | it("deletes the message when the handleMessage function is called", async () => { 823 | handleMessage.resolves(); 824 | 825 | consumer.start(); 826 | await pEvent(consumer, "message_processed"); 827 | consumer.stop(); 828 | 829 | sandbox.assert.calledWith(sqs.send.secondCall, mockDeleteMessage); 830 | sandbox.assert.match( 831 | sqs.send.secondCall.args[0].input, 832 | sinon.match({ 833 | QueueUrl: QUEUE_URL, 834 | ReceiptHandle: "receipt-handle", 835 | }), 836 | ); 837 | }); 838 | 839 | it("does not delete the message if shouldDeleteMessages is false", async () => { 840 | consumer = new Consumer({ 841 | queueUrl: QUEUE_URL, 842 | region: REGION, 843 | handleMessage, 844 | sqs, 845 | authenticationErrorTimeout: AUTHENTICATION_ERROR_TIMEOUT, 846 | shouldDeleteMessages: false, 847 | }); 848 | 849 | handleMessage.resolves(); 850 | 851 | consumer.start(); 852 | await pEvent(consumer, "message_processed"); 853 | consumer.stop(); 854 | 855 | sandbox.assert.neverCalledWithMatch(sqs.send, mockDeleteMessage); 856 | }); 857 | 858 | it("doesn't delete the message when a processing error is reported", async () => { 859 | handleMessage.rejects(new Error("Processing error")); 860 | 861 | consumer.start(); 862 | await pEvent(consumer, "processing_error"); 863 | consumer.stop(); 864 | 865 | sandbox.assert.neverCalledWithMatch(sqs.send, mockDeleteMessage); 866 | }); 867 | 868 | it("consumes another message once one is processed", async () => { 869 | handleMessage.resolves(); 870 | 871 | consumer.start(); 872 | await clock.runToLastAsync(); 873 | consumer.stop(); 874 | 875 | sandbox.assert.calledTwice(handleMessage); 876 | }); 877 | 878 | it("doesn't consume more messages when called multiple times", () => { 879 | sqs.send 880 | .withArgs(mockReceiveMessage) 881 | .resolves(new Promise((res) => setTimeout(res, 100))); 882 | consumer.start(); 883 | consumer.start(); 884 | consumer.start(); 885 | consumer.start(); 886 | consumer.start(); 887 | consumer.stop(); 888 | 889 | sqs.send.calledOnceWith(mockReceiveMessage); 890 | }); 891 | 892 | it("doesn't consume more messages when called multiple times after stopped", () => { 893 | sqs.send 894 | .withArgs(mockReceiveMessage) 895 | .resolves(new Promise((res) => setTimeout(res, 100))); 896 | consumer.start(); 897 | consumer.stop(); 898 | 899 | consumer.start(); 900 | consumer.start(); 901 | consumer.start(); 902 | consumer.start(); 903 | 904 | sqs.send.calledOnceWith(mockReceiveMessage); 905 | }); 906 | 907 | it("consumes multiple messages when the batchSize is greater than 1", async () => { 908 | sqs.send.withArgs(mockReceiveMessage).resolves({ 909 | Messages: [ 910 | { 911 | ReceiptHandle: "receipt-handle-1", 912 | MessageId: "1", 913 | Body: "body-1", 914 | }, 915 | { 916 | ReceiptHandle: "receipt-handle-2", 917 | MessageId: "2", 918 | Body: "body-2", 919 | }, 920 | { 921 | ReceiptHandle: "receipt-handle-3", 922 | MessageId: "3", 923 | Body: "body-3", 924 | }, 925 | ], 926 | }); 927 | 928 | consumer = new Consumer({ 929 | queueUrl: QUEUE_URL, 930 | messageAttributeNames: ["attribute-1", "attribute-2"], 931 | messageSystemAttributeNames: ["All"], 932 | region: REGION, 933 | handleMessage, 934 | batchSize: 3, 935 | sqs, 936 | }); 937 | 938 | consumer.start(); 939 | await pEvent(consumer, "message_received"); 940 | consumer.stop(); 941 | 942 | sandbox.assert.callCount(handleMessage, 3); 943 | sandbox.assert.calledWithMatch(sqs.send.firstCall, mockReceiveMessage); 944 | sandbox.assert.match( 945 | sqs.send.firstCall.args[0].input, 946 | sinon.match({ 947 | QueueUrl: QUEUE_URL, 948 | AttributeNames: [], 949 | MessageAttributeNames: ["attribute-1", "attribute-2"], 950 | MessageSystemAttributeNames: ["All"], 951 | MaxNumberOfMessages: 3, 952 | WaitTimeSeconds: AUTHENTICATION_ERROR_TIMEOUT, 953 | VisibilityTimeout: undefined, 954 | }), 955 | ); 956 | }); 957 | 958 | it("consumes messages with message attribute 'ApproximateReceiveCount'", async () => { 959 | const messageWithAttr = { 960 | ReceiptHandle: "receipt-handle-1", 961 | MessageId: "1", 962 | Body: "body-1", 963 | Attributes: { 964 | ApproximateReceiveCount: 1, 965 | }, 966 | }; 967 | 968 | sqs.send.withArgs(mockReceiveMessage).resolves({ 969 | Messages: [messageWithAttr], 970 | }); 971 | 972 | const attributeNames: QueueAttributeName[] = [ 973 | "ApproximateReceiveCount" as QueueAttributeName, 974 | ]; 975 | 976 | consumer = new Consumer({ 977 | queueUrl: QUEUE_URL, 978 | attributeNames, 979 | region: REGION, 980 | handleMessage, 981 | sqs, 982 | }); 983 | 984 | consumer.start(); 985 | const message = await pEvent(consumer, "message_received"); 986 | consumer.stop(); 987 | 988 | sandbox.assert.calledWith(sqs.send, mockReceiveMessage); 989 | sandbox.assert.match( 990 | sqs.send.firstCall.args[0].input, 991 | sinon.match({ 992 | QueueUrl: QUEUE_URL, 993 | AttributeNames: ["ApproximateReceiveCount"], 994 | MessageAttributeNames: [], 995 | MessageSystemAttributeNames: [], 996 | MaxNumberOfMessages: 1, 997 | WaitTimeSeconds: AUTHENTICATION_ERROR_TIMEOUT, 998 | VisibilityTimeout: undefined, 999 | }), 1000 | ); 1001 | 1002 | assert.equal(message, messageWithAttr); 1003 | }); 1004 | 1005 | it("fires an emptyQueue event when all messages have been consumed", async () => { 1006 | sqs.send.withArgs(mockReceiveMessage).resolves({}); 1007 | 1008 | consumer.start(); 1009 | await pEvent(consumer, "empty"); 1010 | consumer.stop(); 1011 | }); 1012 | 1013 | it("terminates message visibility timeout on processing error", async () => { 1014 | handleMessage.rejects(new Error("Processing error")); 1015 | 1016 | consumer.terminateVisibilityTimeout = true; 1017 | 1018 | consumer.start(); 1019 | await pEvent(consumer, "processing_error"); 1020 | consumer.stop(); 1021 | 1022 | sandbox.assert.calledWith( 1023 | sqs.send.secondCall, 1024 | mockChangeMessageVisibility, 1025 | ); 1026 | sandbox.assert.match( 1027 | sqs.send.secondCall.args[0].input, 1028 | sinon.match({ 1029 | QueueUrl: QUEUE_URL, 1030 | ReceiptHandle: "receipt-handle", 1031 | VisibilityTimeout: 0, 1032 | }), 1033 | ); 1034 | }); 1035 | 1036 | it("terminates message visibility timeout with a function to calculate timeout on processing error", async () => { 1037 | const messageWithAttr = { 1038 | ReceiptHandle: "receipt-handle", 1039 | MessageId: "1", 1040 | Body: "body-2", 1041 | Attributes: { 1042 | ApproximateReceiveCount: 2, 1043 | }, 1044 | }; 1045 | sqs.send.withArgs(mockReceiveMessage).resolves({ 1046 | Messages: [messageWithAttr], 1047 | }); 1048 | 1049 | consumer = new Consumer({ 1050 | queueUrl: QUEUE_URL, 1051 | messageSystemAttributeNames: ["ApproximateReceiveCount"], 1052 | region: REGION, 1053 | handleMessage, 1054 | sqs, 1055 | terminateVisibilityTimeout: (messages: Message[]) => { 1056 | const receiveCount = 1057 | Number.parseInt( 1058 | messages[0].Attributes?.ApproximateReceiveCount || "1", 1059 | ) || 1; 1060 | return receiveCount * 10; 1061 | }, 1062 | }); 1063 | 1064 | handleMessage.rejects(new Error("Processing error")); 1065 | 1066 | consumer.start(); 1067 | await pEvent(consumer, "processing_error"); 1068 | consumer.stop(); 1069 | 1070 | sandbox.assert.calledWith( 1071 | sqs.send.secondCall, 1072 | mockChangeMessageVisibility, 1073 | ); 1074 | sandbox.assert.match( 1075 | sqs.send.secondCall.args[0].input, 1076 | sinon.match({ 1077 | QueueUrl: QUEUE_URL, 1078 | ReceiptHandle: "receipt-handle", 1079 | VisibilityTimeout: 20, 1080 | }), 1081 | ); 1082 | }); 1083 | 1084 | it("changes message visibility timeout on processing error", async () => { 1085 | handleMessage.rejects(new Error("Processing error")); 1086 | 1087 | consumer.terminateVisibilityTimeout = 10; 1088 | 1089 | consumer.start(); 1090 | await pEvent(consumer, "processing_error"); 1091 | consumer.stop(); 1092 | 1093 | sandbox.assert.calledWith( 1094 | sqs.send.secondCall, 1095 | mockChangeMessageVisibility, 1096 | ); 1097 | sandbox.assert.match( 1098 | sqs.send.secondCall.args[0].input, 1099 | sinon.match({ 1100 | QueueUrl: QUEUE_URL, 1101 | ReceiptHandle: "receipt-handle", 1102 | VisibilityTimeout: 10, 1103 | }), 1104 | ); 1105 | }); 1106 | 1107 | it("does not terminate visibility timeout when `terminateVisibilityTimeout` option is false", async () => { 1108 | handleMessage.rejects(new Error("Processing error")); 1109 | consumer.terminateVisibilityTimeout = false; 1110 | 1111 | consumer.start(); 1112 | await pEvent(consumer, "processing_error"); 1113 | consumer.stop(); 1114 | 1115 | sqs.send.neverCalledWith(mockChangeMessageVisibility); 1116 | }); 1117 | 1118 | it("fires error event when failed to terminate visibility timeout on processing error", async () => { 1119 | handleMessage.rejects(new Error("Processing error")); 1120 | 1121 | const sqsError = new Error("Processing error"); 1122 | sqsError.name = "SQSError"; 1123 | sqs.send.withArgs(mockChangeMessageVisibility).rejects(sqsError); 1124 | consumer.terminateVisibilityTimeout = true; 1125 | 1126 | consumer.start(); 1127 | await pEvent(consumer, "error"); 1128 | consumer.stop(); 1129 | 1130 | sandbox.assert.calledWith( 1131 | sqs.send.secondCall, 1132 | mockChangeMessageVisibility, 1133 | ); 1134 | sandbox.assert.match( 1135 | sqs.send.secondCall.args[0].input, 1136 | sinon.match({ 1137 | QueueUrl: QUEUE_URL, 1138 | ReceiptHandle: "receipt-handle", 1139 | VisibilityTimeout: 0, 1140 | }), 1141 | ); 1142 | }); 1143 | 1144 | it("fires response_processed event for each batch", async () => { 1145 | sqs.send.withArgs(mockReceiveMessage).resolves({ 1146 | Messages: [ 1147 | { 1148 | ReceiptHandle: "receipt-handle-1", 1149 | MessageId: "1", 1150 | Body: "body-1", 1151 | }, 1152 | { 1153 | ReceiptHandle: "receipt-handle-2", 1154 | MessageId: "2", 1155 | Body: "body-2", 1156 | }, 1157 | ], 1158 | }); 1159 | handleMessage.resolves(null); 1160 | 1161 | consumer = new Consumer({ 1162 | queueUrl: QUEUE_URL, 1163 | messageAttributeNames: ["attribute-1", "attribute-2"], 1164 | region: REGION, 1165 | handleMessage, 1166 | batchSize: 2, 1167 | sqs, 1168 | }); 1169 | 1170 | consumer.start(); 1171 | await pEvent(consumer, "response_processed"); 1172 | consumer.stop(); 1173 | 1174 | sandbox.assert.callCount(handleMessage, 2); 1175 | }); 1176 | 1177 | it("calls the handleMessagesBatch function when a batch of messages is received", async () => { 1178 | consumer = new Consumer({ 1179 | queueUrl: QUEUE_URL, 1180 | messageAttributeNames: ["attribute-1", "attribute-2"], 1181 | region: REGION, 1182 | handleMessageBatch, 1183 | batchSize: 2, 1184 | sqs, 1185 | }); 1186 | 1187 | consumer.start(); 1188 | await pEvent(consumer, "response_processed"); 1189 | consumer.stop(); 1190 | 1191 | sandbox.assert.callCount(handleMessageBatch, 1); 1192 | }); 1193 | 1194 | it("handles unexpected exceptions thrown by the handler batch function", async () => { 1195 | consumer = new Consumer({ 1196 | queueUrl: QUEUE_URL, 1197 | messageAttributeNames: ["attribute-1", "attribute-2"], 1198 | region: REGION, 1199 | handleMessageBatch: () => { 1200 | throw new Error("unexpected parsing error"); 1201 | }, 1202 | batchSize: 2, 1203 | sqs, 1204 | authenticationErrorTimeout: AUTHENTICATION_ERROR_TIMEOUT, 1205 | }); 1206 | 1207 | consumer.start(); 1208 | const err: any = await pEvent(consumer, "error"); 1209 | consumer.stop(); 1210 | 1211 | assert.ok(err); 1212 | assert.equal( 1213 | err.message, 1214 | "Unexpected message handler failure: unexpected parsing error", 1215 | ); 1216 | }); 1217 | 1218 | it("handles non-standard objects thrown by the handler batch function", async () => { 1219 | class CustomError { 1220 | private _message: string; 1221 | 1222 | constructor(message) { 1223 | this._message = message; 1224 | } 1225 | 1226 | get message() { 1227 | return this._message; 1228 | } 1229 | } 1230 | 1231 | consumer = new Consumer({ 1232 | queueUrl: QUEUE_URL, 1233 | messageAttributeNames: ["attribute-1", "attribute-2"], 1234 | region: REGION, 1235 | handleMessageBatch: () => { 1236 | throw new CustomError("unexpected parsing error"); 1237 | }, 1238 | batchSize: 2, 1239 | sqs, 1240 | authenticationErrorTimeout: AUTHENTICATION_ERROR_TIMEOUT, 1241 | }); 1242 | 1243 | consumer.start(); 1244 | const err: any = await pEvent(consumer, "error"); 1245 | consumer.stop(); 1246 | 1247 | assert.ok(err); 1248 | assert.equal(err.message, "unexpected parsing error"); 1249 | }); 1250 | 1251 | it("handles non-standard exceptions thrown by the handler batch function", async () => { 1252 | const customError = new Error(); 1253 | Object.defineProperty(customError, "message", { 1254 | get: () => "unexpected parsing error", 1255 | }); 1256 | 1257 | consumer = new Consumer({ 1258 | queueUrl: QUEUE_URL, 1259 | messageAttributeNames: ["attribute-1", "attribute-2"], 1260 | region: REGION, 1261 | handleMessageBatch: () => { 1262 | throw customError; 1263 | }, 1264 | batchSize: 2, 1265 | sqs, 1266 | authenticationErrorTimeout: AUTHENTICATION_ERROR_TIMEOUT, 1267 | }); 1268 | 1269 | consumer.start(); 1270 | const err: any = await pEvent(consumer, "error"); 1271 | consumer.stop(); 1272 | 1273 | assert.ok(err); 1274 | assert.equal( 1275 | err.message, 1276 | "Unexpected message handler failure: unexpected parsing error", 1277 | ); 1278 | }); 1279 | 1280 | it("prefers handleMessagesBatch over handleMessage when both are set", async () => { 1281 | consumer = new Consumer({ 1282 | queueUrl: QUEUE_URL, 1283 | messageAttributeNames: ["attribute-1", "attribute-2"], 1284 | region: REGION, 1285 | handleMessageBatch, 1286 | handleMessage, 1287 | batchSize: 2, 1288 | sqs, 1289 | }); 1290 | 1291 | consumer.start(); 1292 | await pEvent(consumer, "response_processed"); 1293 | consumer.stop(); 1294 | 1295 | sandbox.assert.callCount(handleMessageBatch, 1); 1296 | sandbox.assert.callCount(handleMessage, 0); 1297 | }); 1298 | 1299 | it("ack the message if handleMessage returns void", async () => { 1300 | consumer = new Consumer({ 1301 | queueUrl: QUEUE_URL, 1302 | region: REGION, 1303 | handleMessage: async () => {}, 1304 | sqs, 1305 | }); 1306 | 1307 | consumer.start(); 1308 | await pEvent(consumer, "message_processed"); 1309 | consumer.stop(); 1310 | 1311 | sandbox.assert.callCount(sqs.send, 2); 1312 | sandbox.assert.calledWithMatch(sqs.send.firstCall, mockReceiveMessage); 1313 | sandbox.assert.calledWithMatch(sqs.send.secondCall, mockDeleteMessage); 1314 | sandbox.assert.match( 1315 | sqs.send.secondCall.args[0].input, 1316 | sinon.match({ 1317 | QueueUrl: QUEUE_URL, 1318 | ReceiptHandle: "receipt-handle", 1319 | }), 1320 | ); 1321 | }); 1322 | 1323 | it("ack the message if handleMessage returns a message with the same ID", async () => { 1324 | consumer = new Consumer({ 1325 | queueUrl: QUEUE_URL, 1326 | region: REGION, 1327 | handleMessage: async () => { 1328 | return { 1329 | MessageId: "123", 1330 | }; 1331 | }, 1332 | sqs, 1333 | }); 1334 | 1335 | consumer.start(); 1336 | await pEvent(consumer, "message_processed"); 1337 | consumer.stop(); 1338 | 1339 | sandbox.assert.callCount(sqs.send, 2); 1340 | sandbox.assert.calledWithMatch(sqs.send.firstCall, mockReceiveMessage); 1341 | sandbox.assert.calledWithMatch(sqs.send.secondCall, mockDeleteMessage); 1342 | sandbox.assert.match( 1343 | sqs.send.secondCall.args[0].input, 1344 | sinon.match({ 1345 | QueueUrl: QUEUE_URL, 1346 | ReceiptHandle: "receipt-handle", 1347 | }), 1348 | ); 1349 | }); 1350 | 1351 | it("does not ack the message if handleMessage returns an empty object", async () => { 1352 | consumer = new Consumer({ 1353 | queueUrl: QUEUE_URL, 1354 | region: REGION, 1355 | handleMessage: async () => { 1356 | return {}; 1357 | }, 1358 | sqs, 1359 | }); 1360 | 1361 | consumer.start(); 1362 | await pEvent(consumer, "response_processed"); 1363 | consumer.stop(); 1364 | 1365 | sandbox.assert.callCount(sqs.send, 1); 1366 | sandbox.assert.neverCalledWithMatch(sqs.send, mockDeleteMessage); 1367 | }); 1368 | 1369 | it("does not ack the message if handleMessage returns a different ID", async () => { 1370 | consumer = new Consumer({ 1371 | queueUrl: QUEUE_URL, 1372 | region: REGION, 1373 | handleMessage: async () => { 1374 | return { 1375 | MessageId: "143", 1376 | }; 1377 | }, 1378 | sqs, 1379 | }); 1380 | 1381 | consumer.start(); 1382 | await pEvent(consumer, "response_processed"); 1383 | consumer.stop(); 1384 | 1385 | sandbox.assert.callCount(sqs.send, 1); 1386 | sandbox.assert.neverCalledWithMatch(sqs.send, mockDeleteMessage); 1387 | }); 1388 | 1389 | it("deletes the message if alwaysAcknowledge is `true` and handleMessage returns an empty object", async () => { 1390 | consumer = new Consumer({ 1391 | queueUrl: QUEUE_URL, 1392 | region: REGION, 1393 | handleMessage: async () => { 1394 | return {}; 1395 | }, 1396 | sqs, 1397 | alwaysAcknowledge: true, 1398 | }); 1399 | 1400 | consumer.start(); 1401 | await pEvent(consumer, "response_processed"); 1402 | consumer.stop(); 1403 | 1404 | sandbox.assert.callCount(sqs.send, 2); 1405 | sandbox.assert.calledWithMatch(sqs.send.firstCall, mockReceiveMessage); 1406 | sandbox.assert.calledWithMatch(sqs.send.secondCall, mockDeleteMessage); 1407 | sandbox.assert.match( 1408 | sqs.send.secondCall.args[0].input, 1409 | sinon.match({ 1410 | QueueUrl: QUEUE_URL, 1411 | ReceiptHandle: "receipt-handle", 1412 | }), 1413 | ); 1414 | }); 1415 | 1416 | it("does not call deleteMessageBatch if handleMessagesBatch returns an empty array", async () => { 1417 | consumer = new Consumer({ 1418 | queueUrl: QUEUE_URL, 1419 | region: REGION, 1420 | handleMessageBatch: async () => [], 1421 | batchSize: 2, 1422 | sqs, 1423 | }); 1424 | 1425 | consumer.start(); 1426 | await pEvent(consumer, "response_processed"); 1427 | consumer.stop(); 1428 | 1429 | sandbox.assert.callCount(sqs.send, 1); 1430 | sandbox.assert.neverCalledWithMatch(sqs.send, mockDeleteMessageBatch); 1431 | }); 1432 | 1433 | it("calls deleteMessageBatch if alwaysAcknowledge is `true` and handleMessagesBatch returns an empty array", async () => { 1434 | consumer = new Consumer({ 1435 | queueUrl: QUEUE_URL, 1436 | region: REGION, 1437 | handleMessageBatch: async () => [], 1438 | batchSize: 2, 1439 | sqs, 1440 | alwaysAcknowledge: true, 1441 | }); 1442 | 1443 | consumer.start(); 1444 | await pEvent(consumer, "response_processed"); 1445 | consumer.stop(); 1446 | 1447 | sandbox.assert.callCount(sqs.send, 2); 1448 | sandbox.assert.calledWithMatch(sqs.send.firstCall, mockReceiveMessage); 1449 | sandbox.assert.calledWithMatch( 1450 | sqs.send.secondCall, 1451 | mockDeleteMessageBatch, 1452 | ); 1453 | sandbox.assert.match( 1454 | sqs.send.secondCall.args[0].input, 1455 | sinon.match({ 1456 | QueueUrl: QUEUE_URL, 1457 | Entries: [{ Id: "123", ReceiptHandle: "receipt-handle" }], 1458 | }), 1459 | ); 1460 | }); 1461 | 1462 | it("ack all messages if handleMessageBatch returns void", async () => { 1463 | consumer = new Consumer({ 1464 | queueUrl: QUEUE_URL, 1465 | region: REGION, 1466 | handleMessageBatch: async () => {}, 1467 | batchSize: 2, 1468 | sqs, 1469 | }); 1470 | 1471 | consumer.start(); 1472 | await pEvent(consumer, "response_processed"); 1473 | consumer.stop(); 1474 | 1475 | sandbox.assert.callCount(sqs.send, 2); 1476 | sandbox.assert.calledWithMatch(sqs.send.firstCall, mockReceiveMessage); 1477 | sandbox.assert.calledWithMatch( 1478 | sqs.send.secondCall, 1479 | mockDeleteMessageBatch, 1480 | ); 1481 | sandbox.assert.match( 1482 | sqs.send.secondCall.args[0].input, 1483 | sinon.match({ 1484 | QueueUrl: QUEUE_URL, 1485 | Entries: [{ Id: "123", ReceiptHandle: "receipt-handle" }], 1486 | }), 1487 | ); 1488 | }); 1489 | 1490 | it("ack only returned messages if handleMessagesBatch returns an array", async () => { 1491 | consumer = new Consumer({ 1492 | queueUrl: QUEUE_URL, 1493 | region: REGION, 1494 | handleMessageBatch: async () => [ 1495 | { MessageId: "123", ReceiptHandle: "receipt-handle" }, 1496 | ], 1497 | batchSize: 2, 1498 | sqs, 1499 | }); 1500 | 1501 | consumer.start(); 1502 | await pEvent(consumer, "response_processed"); 1503 | consumer.stop(); 1504 | 1505 | sandbox.assert.callCount(sqs.send, 2); 1506 | sandbox.assert.calledWithMatch(sqs.send.firstCall, mockReceiveMessage); 1507 | sandbox.assert.calledWithMatch( 1508 | sqs.send.secondCall, 1509 | mockDeleteMessageBatch, 1510 | ); 1511 | sandbox.assert.match( 1512 | sqs.send.secondCall.args[0].input, 1513 | sinon.match({ 1514 | QueueUrl: QUEUE_URL, 1515 | Entries: [{ Id: "123", ReceiptHandle: "receipt-handle" }], 1516 | }), 1517 | ); 1518 | }); 1519 | 1520 | it("uses the correct visibility timeout for long running handler functions", async () => { 1521 | consumer = new Consumer({ 1522 | queueUrl: QUEUE_URL, 1523 | region: REGION, 1524 | handleMessage: () => 1525 | new Promise((resolve) => setTimeout(resolve, 75000)), 1526 | sqs, 1527 | visibilityTimeout: 40, 1528 | heartbeatInterval: 30, 1529 | }); 1530 | const clearIntervalSpy = sinon.spy(global, "clearInterval"); 1531 | 1532 | consumer.start(); 1533 | await Promise.all([ 1534 | pEvent(consumer, "response_processed"), 1535 | clock.tickAsync(75000), 1536 | ]); 1537 | consumer.stop(); 1538 | 1539 | sandbox.assert.calledWith( 1540 | sqs.send.secondCall, 1541 | mockChangeMessageVisibility, 1542 | ); 1543 | sandbox.assert.match( 1544 | sqs.send.secondCall.args[0].input, 1545 | sinon.match({ 1546 | QueueUrl: QUEUE_URL, 1547 | ReceiptHandle: "receipt-handle", 1548 | VisibilityTimeout: 40, 1549 | }), 1550 | ); 1551 | sandbox.assert.calledWith( 1552 | sqs.send.thirdCall, 1553 | mockChangeMessageVisibility, 1554 | ); 1555 | sandbox.assert.match( 1556 | sqs.send.thirdCall.args[0].input, 1557 | sinon.match({ 1558 | QueueUrl: QUEUE_URL, 1559 | ReceiptHandle: "receipt-handle", 1560 | VisibilityTimeout: 40, 1561 | }), 1562 | ); 1563 | sandbox.assert.calledOnce(clearIntervalSpy); 1564 | }); 1565 | 1566 | it("passes in the correct visibility timeout for long running batch handler functions", async () => { 1567 | sqs.send.withArgs(mockReceiveMessage).resolves({ 1568 | Messages: [ 1569 | { MessageId: "1", ReceiptHandle: "receipt-handle-1", Body: "body-1" }, 1570 | { MessageId: "2", ReceiptHandle: "receipt-handle-2", Body: "body-2" }, 1571 | { MessageId: "3", ReceiptHandle: "receipt-handle-3", Body: "body-3" }, 1572 | ], 1573 | }); 1574 | consumer = new Consumer({ 1575 | queueUrl: QUEUE_URL, 1576 | region: REGION, 1577 | handleMessageBatch: () => 1578 | new Promise((resolve) => setTimeout(resolve, 75000)), 1579 | batchSize: 3, 1580 | sqs, 1581 | visibilityTimeout: 40, 1582 | heartbeatInterval: 30, 1583 | }); 1584 | const clearIntervalSpy = sinon.spy(global, "clearInterval"); 1585 | 1586 | consumer.start(); 1587 | await Promise.all([ 1588 | pEvent(consumer, "response_processed"), 1589 | clock.tickAsync(75000), 1590 | ]); 1591 | consumer.stop(); 1592 | 1593 | sandbox.assert.calledWith( 1594 | sqs.send.secondCall, 1595 | mockChangeMessageVisibilityBatch, 1596 | ); 1597 | sandbox.assert.match( 1598 | sqs.send.secondCall.args[0].input, 1599 | sinon.match({ 1600 | QueueUrl: QUEUE_URL, 1601 | Entries: sinon.match.array.deepEquals([ 1602 | { 1603 | Id: "1", 1604 | ReceiptHandle: "receipt-handle-1", 1605 | VisibilityTimeout: 40, 1606 | }, 1607 | { 1608 | Id: "2", 1609 | ReceiptHandle: "receipt-handle-2", 1610 | VisibilityTimeout: 40, 1611 | }, 1612 | { 1613 | Id: "3", 1614 | ReceiptHandle: "receipt-handle-3", 1615 | VisibilityTimeout: 40, 1616 | }, 1617 | ]), 1618 | }), 1619 | ); 1620 | sandbox.assert.calledWith( 1621 | sqs.send.thirdCall, 1622 | mockChangeMessageVisibilityBatch, 1623 | ); 1624 | sandbox.assert.match( 1625 | sqs.send.thirdCall.args[0].input, 1626 | sinon.match({ 1627 | QueueUrl: QUEUE_URL, 1628 | Entries: [ 1629 | { 1630 | Id: "1", 1631 | ReceiptHandle: "receipt-handle-1", 1632 | VisibilityTimeout: 40, 1633 | }, 1634 | { 1635 | Id: "2", 1636 | ReceiptHandle: "receipt-handle-2", 1637 | VisibilityTimeout: 40, 1638 | }, 1639 | { 1640 | Id: "3", 1641 | ReceiptHandle: "receipt-handle-3", 1642 | VisibilityTimeout: 40, 1643 | }, 1644 | ], 1645 | }), 1646 | ); 1647 | sandbox.assert.calledOnce(clearIntervalSpy); 1648 | }); 1649 | 1650 | it("emit error when changing visibility timeout fails", async () => { 1651 | sqs.send.withArgs(mockReceiveMessage).resolves({ 1652 | Messages: [ 1653 | { MessageId: "1", ReceiptHandle: "receipt-handle-1", Body: "body-1" }, 1654 | ], 1655 | }); 1656 | consumer = new Consumer({ 1657 | queueUrl: QUEUE_URL, 1658 | region: REGION, 1659 | handleMessage: () => 1660 | new Promise((resolve) => setTimeout(resolve, 75000)), 1661 | sqs, 1662 | visibilityTimeout: 40, 1663 | heartbeatInterval: 30, 1664 | }); 1665 | 1666 | const receiveErr = new MockSQSError("failed"); 1667 | sqs.send.withArgs(mockChangeMessageVisibility).rejects(receiveErr); 1668 | 1669 | consumer.start(); 1670 | const [err]: any[] = await Promise.all([ 1671 | pEvent(consumer, "error"), 1672 | clock.tickAsync(75000), 1673 | ]); 1674 | consumer.stop(); 1675 | 1676 | assert.ok(err); 1677 | assert.equal(err.message, "Error changing visibility timeout: failed"); 1678 | assert.equal(err.queueUrl, QUEUE_URL); 1679 | assert.deepEqual(err.messageIds, ["1"]); 1680 | }); 1681 | 1682 | it("emit error when changing visibility timeout fails for batch handler functions", async () => { 1683 | sqs.send.withArgs(mockReceiveMessage).resolves({ 1684 | Messages: [ 1685 | { MessageId: "1", ReceiptHandle: "receipt-handle-1", Body: "body-1" }, 1686 | { MessageId: "2", ReceiptHandle: "receipt-handle-2", Body: "body-2" }, 1687 | ], 1688 | }); 1689 | consumer = new Consumer({ 1690 | queueUrl: QUEUE_URL, 1691 | region: REGION, 1692 | handleMessageBatch: () => 1693 | new Promise((resolve) => setTimeout(resolve, 75000)), 1694 | sqs, 1695 | batchSize: 2, 1696 | visibilityTimeout: 40, 1697 | heartbeatInterval: 30, 1698 | }); 1699 | 1700 | const receiveErr = new MockSQSError("failed"); 1701 | sqs.send.withArgs(mockChangeMessageVisibilityBatch).rejects(receiveErr); 1702 | 1703 | consumer.start(); 1704 | const [err]: any[] = await Promise.all([ 1705 | pEvent(consumer, "error"), 1706 | clock.tickAsync(75000), 1707 | ]); 1708 | consumer.stop(); 1709 | 1710 | assert.ok(err); 1711 | assert.equal(err.message, "Error changing visibility timeout: failed"); 1712 | assert.equal(err.queueUrl, QUEUE_URL); 1713 | assert.deepEqual(err.messageIds, ["1", "2"]); 1714 | }); 1715 | 1716 | it("includes messageIds in timeout errors", async () => { 1717 | const handleMessageTimeout = 500; 1718 | consumer = new Consumer({ 1719 | queueUrl: QUEUE_URL, 1720 | region: REGION, 1721 | handleMessage: () => 1722 | new Promise((resolve) => setTimeout(resolve, 1000)), 1723 | handleMessageTimeout, 1724 | sqs, 1725 | authenticationErrorTimeout: AUTHENTICATION_ERROR_TIMEOUT, 1726 | }); 1727 | 1728 | consumer.start(); 1729 | const [err]: any = await Promise.all([ 1730 | pEvent(consumer, "timeout_error"), 1731 | clock.tickAsync(handleMessageTimeout), 1732 | ]); 1733 | consumer.stop(); 1734 | 1735 | assert.ok(err); 1736 | assert.equal( 1737 | err.message, 1738 | `Message handler timed out after ${handleMessageTimeout}ms: Operation timed out.`, 1739 | ); 1740 | assert.deepEqual(err.messageIds, ["123"]); 1741 | }); 1742 | 1743 | it("includes messageIds in batch processing errors", async () => { 1744 | sqs.send.withArgs(mockReceiveMessage).resolves({ 1745 | Messages: [ 1746 | { MessageId: "1", ReceiptHandle: "receipt-handle-1", Body: "body-1" }, 1747 | { MessageId: "2", ReceiptHandle: "receipt-handle-2", Body: "body-2" }, 1748 | ], 1749 | }); 1750 | 1751 | consumer = new Consumer({ 1752 | queueUrl: QUEUE_URL, 1753 | region: REGION, 1754 | handleMessageBatch: () => { 1755 | throw new Error("Batch processing error"); 1756 | }, 1757 | batchSize: 2, 1758 | sqs, 1759 | authenticationErrorTimeout: AUTHENTICATION_ERROR_TIMEOUT, 1760 | }); 1761 | 1762 | consumer.start(); 1763 | const [err]: any = await Promise.all([ 1764 | pEvent(consumer, "error"), 1765 | clock.tickAsync(100), 1766 | ]); 1767 | consumer.stop(); 1768 | 1769 | assert.ok(err); 1770 | assert.equal( 1771 | err.message, 1772 | "Unexpected message handler failure: Batch processing error", 1773 | ); 1774 | assert.deepEqual(err.messageIds, ["1", "2"]); 1775 | }); 1776 | 1777 | it("includes queueUrl and messageIds in SQS errors when deleting message", async () => { 1778 | const deleteErr = new Error("Delete error"); 1779 | deleteErr.name = "SQSError"; 1780 | 1781 | handleMessage.resolves(null); 1782 | sqs.send.withArgs(mockDeleteMessage).rejects(deleteErr); 1783 | 1784 | consumer.start(); 1785 | const [err]: any = await Promise.all([ 1786 | pEvent(consumer, "error"), 1787 | clock.tickAsync(100), 1788 | ]); 1789 | consumer.stop(); 1790 | 1791 | assert.ok(err); 1792 | assert.equal(err.message, "SQS delete message failed: Delete error"); 1793 | assert.equal(err.queueUrl, QUEUE_URL); 1794 | assert.deepEqual(err.messageIds, ["123"]); 1795 | }); 1796 | 1797 | it("includes queueUrl and messageIds in SQS errors when changing visibility timeout", async () => { 1798 | sqs.send.withArgs(mockReceiveMessage).resolves({ 1799 | Messages: [ 1800 | { MessageId: "1", ReceiptHandle: "receipt-handle-1", Body: "body-1" }, 1801 | ], 1802 | }); 1803 | consumer = new Consumer({ 1804 | queueUrl: QUEUE_URL, 1805 | region: REGION, 1806 | handleMessage: () => 1807 | new Promise((resolve) => setTimeout(resolve, 75000)), 1808 | sqs, 1809 | visibilityTimeout: 40, 1810 | heartbeatInterval: 30, 1811 | }); 1812 | 1813 | const receiveErr = new MockSQSError("failed"); 1814 | sqs.send.withArgs(mockChangeMessageVisibility).rejects(receiveErr); 1815 | 1816 | consumer.start(); 1817 | const [err]: any = await Promise.all([ 1818 | pEvent(consumer, "error"), 1819 | clock.tickAsync(75000), 1820 | ]); 1821 | consumer.stop(); 1822 | 1823 | assert.ok(err); 1824 | assert.equal(err.message, "Error changing visibility timeout: failed"); 1825 | assert.equal(err.queueUrl, QUEUE_URL); 1826 | assert.deepEqual(err.messageIds, ["1"]); 1827 | }); 1828 | 1829 | it("includes queueUrl and messageIds in batch SQS errors", async () => { 1830 | sqs.send.withArgs(mockReceiveMessage).resolves({ 1831 | Messages: [ 1832 | { MessageId: "1", ReceiptHandle: "receipt-handle-1", Body: "body-1" }, 1833 | { MessageId: "2", ReceiptHandle: "receipt-handle-2", Body: "body-2" }, 1834 | ], 1835 | }); 1836 | 1837 | consumer = new Consumer({ 1838 | queueUrl: QUEUE_URL, 1839 | region: REGION, 1840 | handleMessageBatch: () => 1841 | new Promise((resolve) => setTimeout(resolve, 75000)), 1842 | sqs, 1843 | batchSize: 2, 1844 | visibilityTimeout: 40, 1845 | heartbeatInterval: 30, 1846 | }); 1847 | 1848 | const receiveErr = new MockSQSError("failed"); 1849 | sqs.send.withArgs(mockChangeMessageVisibilityBatch).rejects(receiveErr); 1850 | 1851 | consumer.start(); 1852 | const [err]: any = await Promise.all([ 1853 | pEvent(consumer, "error"), 1854 | clock.tickAsync(75000), 1855 | ]); 1856 | consumer.stop(); 1857 | 1858 | assert.ok(err); 1859 | assert.equal(err.message, "Error changing visibility timeout: failed"); 1860 | assert.equal(err.queueUrl, QUEUE_URL); 1861 | assert.deepEqual(err.messageIds, ["1", "2"]); 1862 | }); 1863 | 1864 | it("includes undefined in error event when receiveMessage fails", async () => { 1865 | const receiveErr = new Error("Receive error"); 1866 | sqs.send.withArgs(mockReceiveMessage).rejects(receiveErr); 1867 | 1868 | const errorListener = sandbox.stub(); 1869 | consumer.on("error", errorListener); 1870 | 1871 | consumer.start(); 1872 | await pEvent(consumer, "error"); 1873 | consumer.stop(); 1874 | 1875 | sandbox.assert.calledOnce(errorListener); 1876 | sandbox.assert.calledWith( 1877 | errorListener, 1878 | sinon.match.instanceOf(Error), 1879 | undefined, 1880 | { queueUrl: QUEUE_URL }, 1881 | ); 1882 | }); 1883 | 1884 | it("includes undefined in error event when poll method catches an error", async () => { 1885 | const pollError = new Error("Poll error"); 1886 | 1887 | sqs.send.withArgs(mockReceiveMessage).resolves({}); 1888 | 1889 | const originalPrototype = Object.getPrototypeOf(consumer); 1890 | const originalHandleSqsResponse = originalPrototype.handleSqsResponse; 1891 | 1892 | Object.defineProperty(originalPrototype, "handleSqsResponse", { 1893 | value: sandbox.stub().throws(pollError), 1894 | }); 1895 | 1896 | const errorListener = sandbox.stub(); 1897 | consumer.on("error", errorListener); 1898 | 1899 | consumer.start(); 1900 | await pEvent(consumer, "error"); 1901 | consumer.stop(); 1902 | 1903 | Object.defineProperty(originalPrototype, "handleSqsResponse", { 1904 | value: originalHandleSqsResponse, 1905 | }); 1906 | 1907 | sandbox.assert.calledOnce(errorListener); 1908 | sandbox.assert.calledWith( 1909 | errorListener, 1910 | sinon.match.instanceOf(Error), 1911 | undefined, 1912 | { queueUrl: QUEUE_URL }, 1913 | ); 1914 | }); 1915 | }); 1916 | 1917 | describe("FIFO Queue Warning", () => { 1918 | let warnStub: sinon.SinonStub; 1919 | 1920 | beforeEach(() => { 1921 | warnStub = sandbox.stub(logger, "warn"); 1922 | }); 1923 | 1924 | it("emits a warning when starting with a FIFO queue URL", () => { 1925 | consumer = new Consumer({ 1926 | queueUrl: "https://sqs.us-east-1.amazonaws.com/123456789012/queue.fifo", 1927 | region: REGION, 1928 | handleMessage, 1929 | sqs, 1930 | }); 1931 | 1932 | consumer.start(); 1933 | consumer.stop(); 1934 | 1935 | sandbox.assert.calledOnce(warnStub); 1936 | sandbox.assert.calledWithMatch( 1937 | warnStub, 1938 | "WARNING: A FIFO queue was detected. SQS Consumer does not guarantee FIFO queues will work as expected. Set 'suppressFifoWarning: true' to disable this warning.", 1939 | ); 1940 | }); 1941 | 1942 | it("does not emit warning for standard queue URLs", () => { 1943 | consumer = new Consumer({ 1944 | queueUrl: QUEUE_URL, 1945 | region: REGION, 1946 | handleMessage, 1947 | sqs, 1948 | }); 1949 | 1950 | consumer.start(); 1951 | consumer.stop(); 1952 | 1953 | sandbox.assert.notCalled(warnStub); 1954 | }); 1955 | 1956 | it("suppresses warning when suppressFifoWarning option is true", () => { 1957 | consumer = new Consumer({ 1958 | queueUrl: "https://sqs.us-east-1.amazonaws.com/123456789012/queue.fifo", 1959 | region: REGION, 1960 | handleMessage, 1961 | sqs, 1962 | suppressFifoWarning: true, 1963 | }); 1964 | 1965 | consumer.start(); 1966 | consumer.stop(); 1967 | 1968 | sandbox.assert.notCalled(warnStub); 1969 | }); 1970 | 1971 | it("emits warning on multiple start calls with FIFO queue", () => { 1972 | consumer = new Consumer({ 1973 | queueUrl: "https://sqs.us-east-1.amazonaws.com/123456789012/queue.fifo", 1974 | region: REGION, 1975 | handleMessage, 1976 | sqs, 1977 | }); 1978 | 1979 | consumer.start(); 1980 | consumer.stop(); 1981 | consumer.start(); 1982 | consumer.stop(); 1983 | 1984 | sandbox.assert.calledTwice(warnStub); 1985 | }); 1986 | }); 1987 | 1988 | describe("event listeners", () => { 1989 | it("fires the event multiple times", async () => { 1990 | sqs.send.withArgs(mockReceiveMessage).resolves({}); 1991 | 1992 | const handleEmpty = sandbox.stub().returns(null); 1993 | 1994 | consumer.on("empty", handleEmpty); 1995 | 1996 | consumer.start(); 1997 | 1998 | await clock.tickAsync(0); 1999 | 2000 | consumer.stop(); 2001 | 2002 | await clock.runAllAsync(); 2003 | 2004 | sandbox.assert.calledTwice(handleEmpty); 2005 | }); 2006 | 2007 | it("fires the events only once", async () => { 2008 | sqs.send.withArgs(mockReceiveMessage).resolves({}); 2009 | 2010 | const handleEmpty = sandbox.stub().returns(null); 2011 | 2012 | consumer.once("empty", handleEmpty); 2013 | 2014 | consumer.start(); 2015 | 2016 | await clock.tickAsync(0); 2017 | 2018 | consumer.stop(); 2019 | 2020 | await clock.runAllAsync(); 2021 | 2022 | sandbox.assert.calledOnce(handleEmpty); 2023 | }); 2024 | }); 2025 | 2026 | describe(".stop", () => { 2027 | it("stops the consumer polling for messages", async () => { 2028 | const handleStop = sandbox.stub().returns(null); 2029 | 2030 | consumer.on("stopped", handleStop); 2031 | 2032 | consumer.start(); 2033 | consumer.stop(); 2034 | 2035 | await clock.runAllAsync(); 2036 | 2037 | sandbox.assert.calledOnce(handleStop); 2038 | sandbox.assert.calledOnce(handleMessage); 2039 | }); 2040 | 2041 | it("clears the polling timeout when stopped", async () => { 2042 | sinon.spy(clock, "clearTimeout"); 2043 | 2044 | consumer.start(); 2045 | await clock.tickAsync(0); 2046 | consumer.stop(); 2047 | 2048 | await clock.runAllAsync(); 2049 | 2050 | sinon.assert.calledTwice(clock.clearTimeout); 2051 | }); 2052 | 2053 | it("fires a stopped event only once when stopped multiple times", async () => { 2054 | const handleStop = sandbox.stub().returns(null); 2055 | 2056 | consumer.on("stopped", handleStop); 2057 | 2058 | consumer.start(); 2059 | consumer.stop(); 2060 | consumer.stop(); 2061 | consumer.stop(); 2062 | await clock.runAllAsync(); 2063 | 2064 | sandbox.assert.calledOnce(handleStop); 2065 | }); 2066 | 2067 | it("fires a stopped event a second time if started and stopped twice", async () => { 2068 | const handleStop = sandbox.stub().returns(null); 2069 | 2070 | consumer.on("stopped", handleStop); 2071 | 2072 | consumer.start(); 2073 | consumer.stop(); 2074 | consumer.start(); 2075 | consumer.stop(); 2076 | await clock.runAllAsync(); 2077 | 2078 | sandbox.assert.calledTwice(handleStop); 2079 | }); 2080 | 2081 | it("aborts requests when the abort param is true", async () => { 2082 | const handleStop = sandbox.stub().returns(null); 2083 | const handleAbort = sandbox.stub().returns(null); 2084 | 2085 | consumer.on("stopped", handleStop); 2086 | consumer.on("aborted", handleAbort); 2087 | 2088 | consumer.start(); 2089 | consumer.stop({ abort: true }); 2090 | 2091 | await clock.runAllAsync(); 2092 | 2093 | assert.isTrue(consumer.abortController.signal.aborted); 2094 | sandbox.assert.calledOnce(handleMessage); 2095 | sandbox.assert.calledOnce(handleAbort); 2096 | sandbox.assert.calledOnce(handleStop); 2097 | }); 2098 | 2099 | it("waits for in-flight messages before emitting stopped (within timeout)", async () => { 2100 | sqs.send.withArgs(mockReceiveMessage).resolves({ 2101 | Messages: [ 2102 | { MessageId: "1", ReceiptHandle: "receipt-handle-1", Body: "body-1" }, 2103 | ], 2104 | }); 2105 | const handleStop = sandbox.stub().returns(null); 2106 | const handleResponseProcessed = sandbox.stub().returns(null); 2107 | const waitingForPollingComplete = sandbox.stub().returns(null); 2108 | const waitingForPollingCompleteTimeoutExceeded = sandbox 2109 | .stub() 2110 | .returns(null); 2111 | 2112 | // A slow message handler 2113 | handleMessage = sandbox 2114 | .stub() 2115 | .resolves(new Promise((resolve) => setTimeout(resolve, 5000))); 2116 | 2117 | consumer = new Consumer({ 2118 | queueUrl: QUEUE_URL, 2119 | region: REGION, 2120 | handleMessage, 2121 | sqs, 2122 | pollingCompleteWaitTimeMs: 5000, 2123 | authenticationErrorTimeout: AUTHENTICATION_ERROR_TIMEOUT, 2124 | }); 2125 | 2126 | consumer.on("stopped", handleStop); 2127 | consumer.on("response_processed", handleResponseProcessed); 2128 | consumer.on("waiting_for_polling_to_complete", waitingForPollingComplete); 2129 | consumer.on( 2130 | "waiting_for_polling_to_complete_timeout_exceeded", 2131 | waitingForPollingCompleteTimeoutExceeded, 2132 | ); 2133 | 2134 | consumer.start(); 2135 | await Promise.all([clock.tickAsync(1)]); 2136 | consumer.stop(); 2137 | 2138 | await clock.runAllAsync(); 2139 | 2140 | sandbox.assert.calledOnce(handleStop); 2141 | sandbox.assert.calledOnce(handleResponseProcessed); 2142 | sandbox.assert.calledOnce(handleMessage); 2143 | assert(waitingForPollingComplete.callCount === 5); 2144 | assert(waitingForPollingCompleteTimeoutExceeded.callCount === 0); 2145 | 2146 | assert.ok(handleMessage.calledBefore(handleStop)); 2147 | 2148 | // handleResponseProcessed is called after handleMessage, indicating 2149 | // messages were allowed to complete before 'stopped' was emitted 2150 | assert.ok(handleResponseProcessed.calledBefore(handleStop)); 2151 | }); 2152 | 2153 | it("waits for in-flight messages before emitting stopped (timeout reached)", async () => { 2154 | sqs.send.withArgs(mockReceiveMessage).resolves({ 2155 | Messages: [ 2156 | { MessageId: "1", ReceiptHandle: "receipt-handle-1", Body: "body-1" }, 2157 | ], 2158 | }); 2159 | const handleStop = sandbox.stub().returns(null); 2160 | const handleResponseProcessed = sandbox.stub().returns(null); 2161 | const waitingForPollingComplete = sandbox.stub().returns(null); 2162 | const waitingForPollingCompleteTimeoutExceeded = sandbox 2163 | .stub() 2164 | .returns(null); 2165 | 2166 | // A slow message handler 2167 | handleMessage = sandbox 2168 | .stub() 2169 | .resolves(new Promise((resolve) => setTimeout(resolve, 5000))); 2170 | 2171 | consumer = new Consumer({ 2172 | queueUrl: QUEUE_URL, 2173 | region: REGION, 2174 | handleMessage, 2175 | sqs, 2176 | pollingCompleteWaitTimeMs: 500, 2177 | authenticationErrorTimeout: AUTHENTICATION_ERROR_TIMEOUT, 2178 | }); 2179 | 2180 | consumer.on("stopped", handleStop); 2181 | consumer.on("response_processed", handleResponseProcessed); 2182 | consumer.on("waiting_for_polling_to_complete", waitingForPollingComplete); 2183 | consumer.on( 2184 | "waiting_for_polling_to_complete_timeout_exceeded", 2185 | waitingForPollingCompleteTimeoutExceeded, 2186 | ); 2187 | 2188 | consumer.start(); 2189 | await Promise.all([clock.tickAsync(1)]); 2190 | consumer.stop(); 2191 | 2192 | await clock.runAllAsync(); 2193 | 2194 | sandbox.assert.calledOnce(handleStop); 2195 | sandbox.assert.calledOnce(handleResponseProcessed); 2196 | sandbox.assert.calledOnce(handleMessage); 2197 | sandbox.assert.calledOnce(waitingForPollingComplete); 2198 | sandbox.assert.calledOnce(waitingForPollingCompleteTimeoutExceeded); 2199 | assert(handleMessage.calledBefore(handleStop)); 2200 | 2201 | // Stop was called before the message could be processed, because we reached timeout. 2202 | assert(handleStop.calledBefore(handleResponseProcessed)); 2203 | }); 2204 | }); 2205 | 2206 | describe("status", async () => { 2207 | it("returns the defaults before the consumer is started", () => { 2208 | assert.isFalse(consumer.status.isRunning); 2209 | assert.isFalse(consumer.status.isPolling); 2210 | }); 2211 | 2212 | it("returns true for `isRunning` if the consumer has not been stopped", () => { 2213 | consumer.start(); 2214 | assert.isTrue(consumer.status.isRunning); 2215 | consumer.stop(); 2216 | }); 2217 | 2218 | it("returns false for `isRunning` if the consumer has been stopped", () => { 2219 | consumer.start(); 2220 | consumer.stop(); 2221 | assert.isFalse(consumer.status.isRunning); 2222 | }); 2223 | 2224 | it("returns true for `isPolling` if the consumer is polling for messages", async () => { 2225 | sqs.send.withArgs(mockReceiveMessage).resolves({ 2226 | Messages: [ 2227 | { MessageId: "1", ReceiptHandle: "receipt-handle-1", Body: "body-1" }, 2228 | ], 2229 | }); 2230 | consumer = new Consumer({ 2231 | queueUrl: QUEUE_URL, 2232 | region: REGION, 2233 | handleMessage: () => new Promise((resolve) => setTimeout(resolve, 20)), 2234 | sqs, 2235 | }); 2236 | 2237 | consumer.start(); 2238 | await Promise.all([clock.tickAsync(1)]); 2239 | assert.isTrue(consumer.status.isPolling); 2240 | consumer.stop(); 2241 | assert.isTrue(consumer.status.isPolling); 2242 | await Promise.all([clock.tickAsync(21)]); 2243 | assert.isFalse(consumer.status.isPolling); 2244 | }); 2245 | }); 2246 | 2247 | describe("updateOption", async () => { 2248 | it("updates the visibilityTimeout option and emits an event", () => { 2249 | const optionUpdatedListener = sandbox.stub(); 2250 | consumer.on("option_updated", optionUpdatedListener); 2251 | 2252 | consumer.updateOption("visibilityTimeout", 45); 2253 | 2254 | assert.equal(consumer.visibilityTimeout, 45); 2255 | 2256 | sandbox.assert.calledWithMatch( 2257 | optionUpdatedListener, 2258 | "visibilityTimeout", 2259 | 45, 2260 | ); 2261 | }); 2262 | 2263 | it("does not update the visibilityTimeout if the value is less than the heartbeatInterval", () => { 2264 | consumer = new Consumer({ 2265 | region: REGION, 2266 | queueUrl: QUEUE_URL, 2267 | handleMessage, 2268 | heartbeatInterval: 30, 2269 | visibilityTimeout: 60, 2270 | }); 2271 | 2272 | const optionUpdatedListener = sandbox.stub(); 2273 | consumer.on("option_updated", optionUpdatedListener); 2274 | 2275 | assert.throws(() => { 2276 | consumer.updateOption("visibilityTimeout", 30); 2277 | }, "heartbeatInterval must be less than visibilityTimeout."); 2278 | 2279 | assert.equal(consumer.visibilityTimeout, 60); 2280 | 2281 | sandbox.assert.notCalled(optionUpdatedListener); 2282 | }); 2283 | 2284 | it("updates the batchSize option and emits an event", () => { 2285 | const optionUpdatedListener = sandbox.stub(); 2286 | consumer.on("option_updated", optionUpdatedListener); 2287 | 2288 | consumer.updateOption("batchSize", 4); 2289 | 2290 | assert.equal(consumer.batchSize, 4); 2291 | 2292 | sandbox.assert.calledWithMatch(optionUpdatedListener, "batchSize", 4); 2293 | }); 2294 | 2295 | it("does not update the batchSize if the value is more than 10", () => { 2296 | const optionUpdatedListener = sandbox.stub(); 2297 | consumer.on("option_updated", optionUpdatedListener); 2298 | 2299 | assert.throws(() => { 2300 | consumer.updateOption("batchSize", 13); 2301 | }, "batchSize must be between 1 and 10."); 2302 | 2303 | assert.equal(consumer.batchSize, 1); 2304 | 2305 | sandbox.assert.notCalled(optionUpdatedListener); 2306 | }); 2307 | 2308 | it("does not update the batchSize if the value is less than 1", () => { 2309 | const optionUpdatedListener = sandbox.stub(); 2310 | consumer.on("option_updated", optionUpdatedListener); 2311 | 2312 | assert.throws(() => { 2313 | consumer.updateOption("batchSize", 0); 2314 | }, "batchSize must be between 1 and 10."); 2315 | 2316 | assert.equal(consumer.batchSize, 1); 2317 | 2318 | sandbox.assert.notCalled(optionUpdatedListener); 2319 | }); 2320 | 2321 | it("updates the waitTimeSeconds option and emits an event", () => { 2322 | const optionUpdatedListener = sandbox.stub(); 2323 | consumer.on("option_updated", optionUpdatedListener); 2324 | 2325 | consumer.updateOption("waitTimeSeconds", 18); 2326 | 2327 | assert.equal(consumer.waitTimeSeconds, 18); 2328 | 2329 | sandbox.assert.calledWithMatch( 2330 | optionUpdatedListener, 2331 | "waitTimeSeconds", 2332 | 18, 2333 | ); 2334 | }); 2335 | 2336 | it("does not update the batchSize if the value is less than 0", () => { 2337 | const optionUpdatedListener = sandbox.stub(); 2338 | consumer.on("option_updated", optionUpdatedListener); 2339 | 2340 | assert.throws(() => { 2341 | consumer.updateOption("waitTimeSeconds", -1); 2342 | }, "waitTimeSeconds must be between 0 and 20."); 2343 | 2344 | assert.equal(consumer.waitTimeSeconds, 20); 2345 | 2346 | sandbox.assert.notCalled(optionUpdatedListener); 2347 | }); 2348 | 2349 | it("does not update the batchSize if the value is more than 20", () => { 2350 | const optionUpdatedListener = sandbox.stub(); 2351 | consumer.on("option_updated", optionUpdatedListener); 2352 | 2353 | assert.throws(() => { 2354 | consumer.updateOption("waitTimeSeconds", 27); 2355 | }, "waitTimeSeconds must be between 0 and 20."); 2356 | 2357 | assert.equal(consumer.waitTimeSeconds, 20); 2358 | 2359 | sandbox.assert.notCalled(optionUpdatedListener); 2360 | }); 2361 | 2362 | it("updates the pollingWaitTimeMs option and emits an event", () => { 2363 | const optionUpdatedListener = sandbox.stub(); 2364 | consumer.on("option_updated", optionUpdatedListener); 2365 | 2366 | consumer.updateOption("pollingWaitTimeMs", 1000); 2367 | 2368 | assert.equal(consumer.pollingWaitTimeMs, 1000); 2369 | 2370 | sandbox.assert.calledWithMatch( 2371 | optionUpdatedListener, 2372 | "pollingWaitTimeMs", 2373 | 1000, 2374 | ); 2375 | }); 2376 | 2377 | it("does not update the pollingWaitTimeMs if the value is less than 0", () => { 2378 | const optionUpdatedListener = sandbox.stub(); 2379 | consumer.on("option_updated", optionUpdatedListener); 2380 | 2381 | assert.throws(() => { 2382 | consumer.updateOption("pollingWaitTimeMs", -1); 2383 | }, "pollingWaitTimeMs must be greater than 0."); 2384 | 2385 | assert.equal(consumer.pollingWaitTimeMs, 0); 2386 | 2387 | sandbox.assert.notCalled(optionUpdatedListener); 2388 | }); 2389 | 2390 | it("throws an error for an unknown option", () => { 2391 | consumer = new Consumer({ 2392 | region: REGION, 2393 | queueUrl: QUEUE_URL, 2394 | handleMessage, 2395 | visibilityTimeout: 60, 2396 | }); 2397 | 2398 | assert.throws(() => { 2399 | consumer.updateOption("unknown", "value"); 2400 | }, `The update unknown cannot be updated`); 2401 | }); 2402 | }); 2403 | 2404 | describe("events", () => { 2405 | it("logs a debug event when an event is emitted", async () => { 2406 | const loggerDebug = sandbox.stub(logger, "debug"); 2407 | 2408 | consumer.start(); 2409 | consumer.stop(); 2410 | 2411 | sandbox.assert.callCount(loggerDebug, 5); 2412 | // Logged directly 2413 | sandbox.assert.calledWithMatch(loggerDebug, "starting"); 2414 | // Sent from the emitter 2415 | sandbox.assert.calledWithMatch(loggerDebug, "started", { 2416 | queueUrl: QUEUE_URL, 2417 | }); 2418 | // Logged directly 2419 | sandbox.assert.calledWithMatch(loggerDebug, "polling"); 2420 | // Logged directly 2421 | sandbox.assert.calledWithMatch(loggerDebug, "stopping"); 2422 | // Sent from the emitter 2423 | sandbox.assert.calledWithMatch(loggerDebug, "stopped", { 2424 | queueUrl: QUEUE_URL, 2425 | }); 2426 | }); 2427 | 2428 | it("includes queueUrl in emitted events", async () => { 2429 | const startedListener = sandbox.stub(); 2430 | const messageReceivedListener = sandbox.stub(); 2431 | const messageProcessedListener = sandbox.stub(); 2432 | const emptyListener = sandbox.stub(); 2433 | const stoppedListener = sandbox.stub(); 2434 | const errorListener = sandbox.stub(); 2435 | const processingErrorListener = sandbox.stub(); 2436 | 2437 | consumer.on("started", startedListener); 2438 | consumer.on("message_received", messageReceivedListener); 2439 | consumer.on("message_processed", messageProcessedListener); 2440 | consumer.on("empty", emptyListener); 2441 | consumer.on("stopped", stoppedListener); 2442 | consumer.on("error", errorListener); 2443 | consumer.on("processing_error", processingErrorListener); 2444 | 2445 | consumer.start(); 2446 | await pEvent(consumer, "message_processed"); 2447 | consumer.stop(); 2448 | 2449 | handleMessage.rejects(new Error("Processing error")); 2450 | consumer.start(); 2451 | await pEvent(consumer, "processing_error"); 2452 | consumer.stop(); 2453 | 2454 | sandbox.assert.calledWith(startedListener, { queueUrl: QUEUE_URL }); 2455 | sandbox.assert.calledWith(messageReceivedListener, response.Messages[0], { 2456 | queueUrl: QUEUE_URL, 2457 | }); 2458 | sandbox.assert.calledWith( 2459 | messageProcessedListener, 2460 | response.Messages[0], 2461 | { queueUrl: QUEUE_URL }, 2462 | ); 2463 | sandbox.assert.calledWith(stoppedListener, { queueUrl: QUEUE_URL }); 2464 | sandbox.assert.calledWith( 2465 | processingErrorListener, 2466 | sinon.match.instanceOf(Error), 2467 | response.Messages[0], 2468 | { queueUrl: QUEUE_URL }, 2469 | ); 2470 | }); 2471 | }); 2472 | }); 2473 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist/cjs", 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "noEmit": false, 8 | "declaration": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist/esm", 5 | "module": "Node16", 6 | "moduleResolution": "Node16", 7 | "noEmit": false, 8 | "declaration": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ES2021"], 4 | "target": "ES2021", 5 | "module": "Node16", 6 | "moduleResolution": "Node16", 7 | "noEmit": true, 8 | "sourceMap": false, 9 | "allowJs": false, 10 | "noUnusedLocals": true, 11 | "declaration": false 12 | }, 13 | "include": ["src/**/*"], 14 | "exclude": ["node_modules", "dist"] 15 | } 16 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://typedoc.org/schema.json", 3 | "sort": ["kind", "instance-first", "required-first", "alphabetical"], 4 | "treatWarningsAsErrors": false, 5 | "searchInComments": true, 6 | "entryPoints": ["./src/index.ts"], 7 | "out": "public", 8 | "name": "SQS Consumer", 9 | "hideGenerator": true, 10 | "navigationLinks": { 11 | "GitHub": "https://github.com/bbc/sqs-consumer" 12 | } 13 | } 14 | --------------------------------------------------------------------------------