├── .editorconfig
├── .env
├── .env.example
├── .github
├── labels.json
├── lock.yml
├── stale.yml
└── workflows
│ ├── checks.yml
│ ├── labels.yml
│ └── release.yml
├── .gitignore
├── .husky
└── commit-msg
├── .npmrc
├── .prettierignore
├── LICENSE.md
├── README.md
├── bin
└── test.ts
├── configure.ts
├── docker-compose.yml
├── eslint.config.js
├── index.ts
├── package.json
├── providers
└── limiter_provider.ts
├── services
└── main.ts
├── src
├── debug.ts
├── define_config.ts
├── errors.ts
├── http_limiter.ts
├── limiter.ts
├── limiter_manager.ts
├── response.ts
├── stores
│ ├── bridge.ts
│ ├── database.ts
│ ├── memory.ts
│ └── redis.ts
└── types.ts
├── stubs
├── config
│ └── limiter.stub
├── main.ts
├── make
│ └── migration
│ │ └── rate_limits.stub
└── start
│ └── limiter.stub
├── tests
├── configure.spec.ts
├── define_config.spec.ts
├── helpers.ts
├── http_limiter.spec.ts
├── limiter.spec.ts
├── limiter_manager.spec.ts
├── limiter_provider.spec.ts
├── stores
│ ├── database.spec.ts
│ ├── memory.spec.ts
│ └── redis.spec.ts
├── throttle_exception.spec.ts
└── throttle_middleware.spec.ts
└── tsconfig.json
/.editorconfig:
--------------------------------------------------------------------------------
1 | # http://editorconfig.org
2 |
3 | [*]
4 | indent_style = space
5 | indent_size = 2
6 | end_of_line = lf
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 |
11 | [*.json]
12 | insert_final_newline = ignore
13 |
14 | [*.md]
15 | trim_trailing_whitespace = false
16 |
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 | MYSQL_HOST=localhost
2 | MYSQL_PASSWORD=secret
3 | MYSQL_DATABASE=limiter
4 | MYSQL_PORT=3306
5 | MYSQL_USER=virk
6 |
7 | PG_HOST=localhost
8 | PG_PASSWORD=secret
9 | PG_DATABASE=limiter
10 | PG_PORT=5432
11 | PG_USER=virk
12 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | MYSQL_HOST=localhost
2 | MYSQL_PASSWORD=secret
3 | MYSQL_DATABASE=limiter
4 | MYSQL_PORT=3306
5 | MYSQL_USER=virk
6 |
7 | PG_HOST=localhost
8 | PG_PASSWORD=secret
9 | PG_DATABASE=limiter
10 | PG_PORT=5432
11 | PG_USER=virk
12 |
--------------------------------------------------------------------------------
/.github/labels.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "name": "Priority: Critical",
4 | "color": "ea0056",
5 | "description": "The issue needs urgent attention",
6 | "aliases": []
7 | },
8 | {
9 | "name": "Priority: High",
10 | "color": "5666ed",
11 | "description": "Look into this issue before picking up any new work",
12 | "aliases": []
13 | },
14 | {
15 | "name": "Priority: Medium",
16 | "color": "f4ff61",
17 | "description": "Try to fix the issue for the next patch/minor release",
18 | "aliases": []
19 | },
20 | {
21 | "name": "Priority: Low",
22 | "color": "87dfd6",
23 | "description": "Something worth considering, but not a top priority for the team",
24 | "aliases": []
25 | },
26 | {
27 | "name": "Semver: Alpha",
28 | "color": "008480",
29 | "description": "Will make it's way to the next alpha version of the package",
30 | "aliases": []
31 | },
32 | {
33 | "name": "Semver: Major",
34 | "color": "ea0056",
35 | "description": "Has breaking changes",
36 | "aliases": []
37 | },
38 | {
39 | "name": "Semver: Minor",
40 | "color": "fbe555",
41 | "description": "Mainly new features and improvements",
42 | "aliases": []
43 | },
44 | {
45 | "name": "Semver: Next",
46 | "color": "5666ed",
47 | "description": "Will make it's way to the bleeding edge version of the package",
48 | "aliases": []
49 | },
50 | {
51 | "name": "Semver: Patch",
52 | "color": "87dfd6",
53 | "description": "A bug fix",
54 | "aliases": []
55 | },
56 | {
57 | "name": "Status: Abandoned",
58 | "color": "ffffff",
59 | "description": "Dropped and not into consideration",
60 | "aliases": ["wontfix"]
61 | },
62 | {
63 | "name": "Status: Accepted",
64 | "color": "e5fbf2",
65 | "description": "The proposal or the feature has been accepted for the future versions",
66 | "aliases": []
67 | },
68 | {
69 | "name": "Status: Blocked",
70 | "color": "ea0056",
71 | "description": "The work on the issue or the PR is blocked. Check comments for reasoning",
72 | "aliases": []
73 | },
74 | {
75 | "name": "Status: Completed",
76 | "color": "008672",
77 | "description": "The work has been completed, but not released yet",
78 | "aliases": []
79 | },
80 | {
81 | "name": "Status: In Progress",
82 | "color": "73dbc4",
83 | "description": "Still banging the keyboard",
84 | "aliases": ["in progress"]
85 | },
86 | {
87 | "name": "Status: On Hold",
88 | "color": "f4ff61",
89 | "description": "The work was started earlier, but is on hold now. Check comments for reasoning",
90 | "aliases": ["On Hold"]
91 | },
92 | {
93 | "name": "Status: Review Needed",
94 | "color": "fbe555",
95 | "description": "Review from the core team is required before moving forward",
96 | "aliases": []
97 | },
98 | {
99 | "name": "Status: Awaiting More Information",
100 | "color": "89f8ce",
101 | "description": "Waiting on the issue reporter or PR author to provide more information",
102 | "aliases": []
103 | },
104 | {
105 | "name": "Status: Need Contributors",
106 | "color": "7057ff",
107 | "description": "Looking for contributors to help us move forward with this issue or PR",
108 | "aliases": []
109 | },
110 | {
111 | "name": "Type: Bug",
112 | "color": "ea0056",
113 | "description": "The issue has indentified a bug",
114 | "aliases": ["bug"]
115 | },
116 | {
117 | "name": "Type: Security",
118 | "color": "ea0056",
119 | "description": "Spotted security vulnerability and is a top priority for the core team",
120 | "aliases": []
121 | },
122 | {
123 | "name": "Type: Duplicate",
124 | "color": "00837e",
125 | "description": "Already answered or fixed previously",
126 | "aliases": ["duplicate"]
127 | },
128 | {
129 | "name": "Type: Enhancement",
130 | "color": "89f8ce",
131 | "description": "Improving an existing feature",
132 | "aliases": ["enhancement"]
133 | },
134 | {
135 | "name": "Type: Feature Request",
136 | "color": "483add",
137 | "description": "Request to add a new feature to the package",
138 | "aliases": []
139 | },
140 | {
141 | "name": "Type: Invalid",
142 | "color": "dbdbdb",
143 | "description": "Doesn't really belong here. Maybe use discussion threads?",
144 | "aliases": ["invalid"]
145 | },
146 | {
147 | "name": "Type: Question",
148 | "color": "eceafc",
149 | "description": "Needs clarification",
150 | "aliases": ["help wanted", "question"]
151 | },
152 | {
153 | "name": "Type: Documentation Change",
154 | "color": "7057ff",
155 | "description": "Documentation needs some improvements",
156 | "aliases": ["documentation"]
157 | },
158 | {
159 | "name": "Type: Dependencies Update",
160 | "color": "00837e",
161 | "description": "Bump dependencies",
162 | "aliases": ["dependencies"]
163 | },
164 | {
165 | "name": "Good First Issue",
166 | "color": "008480",
167 | "description": "Want to contribute? Just filter by this label",
168 | "aliases": ["good first issue"]
169 | }
170 | ]
171 |
--------------------------------------------------------------------------------
/.github/lock.yml:
--------------------------------------------------------------------------------
1 | # Configuration for Lock Threads - https://github.com/dessant/lock-threads-app
2 |
3 | # Number of days of inactivity before a closed issue or pull request is locked
4 | daysUntilLock: 60
5 |
6 | # Skip issues and pull requests created before a given timestamp. Timestamp must
7 | # follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable
8 | skipCreatedBefore: false
9 |
10 | # Issues and pull requests with these labels will be ignored. Set to `[]` to disable
11 | exemptLabels: ['Type: Security']
12 |
13 | # Label to add before locking, such as `outdated`. Set to `false` to disable
14 | lockLabel: false
15 |
16 | # Comment to post before locking. Set to `false` to disable
17 | lockComment: >
18 | This thread has been automatically locked since there has not been
19 | any recent activity after it was closed. Please open a new issue for
20 | related bugs.
21 |
22 | # Assign `resolved` as the reason for locking. Set to `false` to disable
23 | setLockReason: false
24 |
--------------------------------------------------------------------------------
/.github/stale.yml:
--------------------------------------------------------------------------------
1 | # Number of days of inactivity before an issue becomes stale
2 | daysUntilStale: 60
3 |
4 | # Number of days of inactivity before a stale issue is closed
5 | daysUntilClose: 7
6 |
7 | # Issues with these labels will never be considered stale
8 | exemptLabels:
9 | - 'Type: Security'
10 |
11 | # Label to use when marking an issue as stale
12 | staleLabel: 'Status: Abandoned'
13 |
14 | # Comment to post when marking an issue as stale. Set to `false` to disable
15 | markComment: >
16 | This issue has been automatically marked as stale because it has not had
17 | recent activity. It will be closed if no further activity occurs. Thank you
18 | for your contributions.
19 |
20 | # Comment to post when closing a stale issue. Set to `false` to disable
21 | closeComment: false
22 |
--------------------------------------------------------------------------------
/.github/workflows/checks.yml:
--------------------------------------------------------------------------------
1 | name: test
2 | on:
3 | - push
4 | - pull_request
5 | - workflow_call
6 |
7 | jobs:
8 | lint:
9 | uses: adonisjs/.github/.github/workflows/lint.yml@main
10 |
11 | typecheck:
12 | uses: adonisjs/.github/.github/workflows/typecheck.yml@main
13 |
14 | test-postgres:
15 | runs-on: ubuntu-latest
16 | strategy:
17 | fail-fast: false
18 | matrix:
19 | node-version: [20, 22]
20 | postgres-version: [11]
21 | services:
22 | postgres:
23 | image: postgres:${{ matrix.postgres-version }}
24 | env:
25 | POSTGRES_DB: limiter
26 | POSTGRES_USER: virk
27 | POSTGRES_PASSWORD: secret
28 | ports:
29 | - 5432:5432
30 | redis:
31 | image: redis
32 | options: >-
33 | --health-cmd "redis-cli ping"
34 | --health-interval 10s
35 | --health-timeout 5s
36 | --health-retries 5
37 | ports:
38 | - 6379:6379
39 | steps:
40 | - uses: actions/checkout@v3
41 | - uses: actions/setup-node@v3
42 | with:
43 | node-version: ${{ matrix.node-version }}
44 | - name: Install Playwright Browsers
45 | run: npx playwright install --with-deps
46 | - name: Install
47 | run: npm install
48 | - name: Run Postgres Tests
49 | run: npm run test:pg
50 |
51 | test-mysql:
52 | runs-on: ubuntu-latest
53 | strategy:
54 | fail-fast: false
55 | matrix:
56 | mysql: [{ version: '8.0', command: 'mysql' }]
57 | node-version: [22]
58 | services:
59 | mysql:
60 | image: mysql:${{ matrix.mysql.version }}
61 | env:
62 | MYSQL_DATABASE: limiter
63 | MYSQL_USER: virk
64 | MYSQL_PASSWORD: secret
65 | MYSQL_ROOT_PASSWORD: secret
66 | MYSQL_PORT: 3306
67 | ports:
68 | - '3306:3306'
69 | redis:
70 | image: redis
71 | options: >-
72 | --health-cmd "redis-cli ping"
73 | --health-interval 10s
74 | --health-timeout 5s
75 | --health-retries 5
76 | ports:
77 | - 6379:6379
78 | steps:
79 | - uses: actions/checkout@v3
80 | - uses: actions/setup-node@v3
81 | with:
82 | node-version: ${{ matrix.node-version }}
83 | - name: Install
84 | run: npm install
85 | - name: Install Playwright Browsers
86 | run: npx playwright install --with-deps
87 | - name: Run Mysql Tests
88 | run: npm run test:${{ matrix.mysql.command }}
89 | test-sqlite:
90 | runs-on: ubuntu-latest
91 | strategy:
92 | matrix:
93 | node-version: [20, 22]
94 | services:
95 | redis:
96 | image: redis
97 | options: >-
98 | --health-cmd "redis-cli ping"
99 | --health-interval 10s
100 | --health-timeout 5s
101 | --health-retries 5
102 | ports:
103 | - 6379:6379
104 | steps:
105 | - uses: actions/checkout@v3
106 | - uses: actions/setup-node@v3
107 | with:
108 | node-version: ${{ matrix.node-version }}
109 | - name: Install Playwright Browsers
110 | run: npx playwright install --with-deps
111 | - name: Install
112 | run: npm install
113 | - name: Run SQLite Tests
114 | run: npm run test:sqlite
115 |
--------------------------------------------------------------------------------
/.github/workflows/labels.yml:
--------------------------------------------------------------------------------
1 | name: Sync labels
2 | on:
3 | workflow_dispatch:
4 | permissions:
5 | issues: write
6 | jobs:
7 | labels:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - uses: actions/checkout@v4
11 | - uses: EndBug/label-sync@v2
12 | with:
13 | config-file: 'https://raw.githubusercontent.com/thetutlage/static/main/labels.yml'
14 | delete-other-labels: true
15 | token: ${{ secrets.GITHUB_TOKEN }}
16 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: release
2 | on: workflow_dispatch
3 | permissions:
4 | contents: write
5 | id-token: write
6 | jobs:
7 | checks:
8 | uses: ./.github/workflows/checks.yml
9 | release:
10 | needs: checks
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v4
14 | with:
15 | fetch-depth: 0
16 | - uses: actions/setup-node@v4
17 | with:
18 | node-version: 20
19 | - name: git config
20 | run: |
21 | git config user.name "${GITHUB_ACTOR}"
22 | git config user.email "${GITHUB_ACTOR}@users.noreply.github.com"
23 | - name: Init npm config
24 | run: npm config set //registry.npmjs.org/:_authToken $NPM_TOKEN
25 | env:
26 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
27 | - run: npm install
28 | - run: npm run release -- --ci
29 | env:
30 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
32 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
33 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | coverage
3 | .DS_STORE
4 | .nyc_output
5 | .idea
6 | .vscode/
7 | *.sublime-project
8 | *.sublime-workspace
9 | *.log
10 | build
11 | dist
12 | yarn.lock
13 | shrinkwrap.yaml
14 | package-lock.json
15 |
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | npx --no -- commitlint --edit "$1"
5 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | package-lock=false
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | build
2 | docs
3 | *.html
4 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | # The MIT License
2 |
3 | Copyright 2022 Harminder Virk, contributors
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # @adonisjs/limiter
2 |
3 |
4 |
5 | [![gh-workflow-image]][gh-workflow-url] [![npm-image]][npm-url] ![][typescript-image] [![license-image]][license-url]
6 |
7 | ## Introduction
8 |
9 | A first-party package to implement rate limits in your AdonisJS application. The package is built on top of [node-rate-limiter-flexible](https://github.com/animir/node-rate-limiter-flexible) with extensive changes to the API.
10 |
11 | ## Official Documentation
12 |
13 | The documentation is available on the [AdonisJS website](https://docs.adonisjs.com/guides/rate-limiter)
14 |
15 | ## Contributing
16 |
17 | One of the primary goals of AdonisJS is to have a vibrant community of users and contributors who believes in the principles of the framework.
18 |
19 | We encourage you to read the [contribution guide](https://github.com/adonisjs/.github/blob/main/docs/CONTRIBUTING.md) before contributing to the framework.
20 |
21 | ## Code of Conduct
22 |
23 | In order to ensure that the AdonisJS community is welcoming to all, please review and abide by the [Code of Conduct](https://github.com/adonisjs/.github/blob/main/docs/CODE_OF_CONDUCT.md).
24 |
25 | ## License
26 |
27 | AdonisJS Limiter is open-sourced software licensed under the [MIT license](LICENSE.md).
28 |
29 | [gh-workflow-image]: https://img.shields.io/github/actions/workflow/status/adonisjs/limiter/checks.yml?style=for-the-badge
30 | [gh-workflow-url]: https://github.com/adonisjs/limiter/actions/workflows/checks.yml 'Github action'
31 | [typescript-image]: https://img.shields.io/badge/Typescript-294E80.svg?style=for-the-badge&logo=typescript
32 | [typescript-url]: "typescript"
33 | [npm-image]: https://img.shields.io/npm/v/@adonisjs/limiter.svg?style=for-the-badge&logo=npm
34 | [npm-url]: https://npmjs.org/package/@adonisjs/limiter 'npm'
35 | [license-image]: https://img.shields.io/npm/l/@adonisjs/limiter?color=blueviolet&style=for-the-badge
36 | [license-url]: LICENSE.md 'license'
37 |
--------------------------------------------------------------------------------
/bin/test.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * @adonisjs/limiter
3 | *
4 | * (c) AdonisJS
5 | *
6 | * For the full copyright and license information, please view the LICENSE
7 | * file that was distributed with this source code.
8 | */
9 |
10 | import { assert } from '@japa/assert'
11 | import { fileSystem } from '@japa/file-system'
12 | import { expectTypeOf } from '@japa/expect-type'
13 | import { processCLIArgs, configure, run } from '@japa/runner'
14 |
15 | /*
16 | |--------------------------------------------------------------------------
17 | | Configure tests
18 | |--------------------------------------------------------------------------
19 | |
20 | | The configure method accepts the configuration to configure the Japa
21 | | tests runner.
22 | |
23 | | The first method call "processCliArgs" process the command line arguments
24 | | and turns them into a config object. Using this method is not mandatory.
25 | |
26 | | Please consult japa.dev/runner-config for the config docs.
27 | */
28 | processCLIArgs(process.argv.slice(2))
29 | configure({
30 | files: ['tests/**/*.spec.ts'],
31 | plugins: [assert(), fileSystem(), expectTypeOf()],
32 | forceExit: true,
33 | })
34 |
35 | /*
36 | |--------------------------------------------------------------------------
37 | | Run tests
38 | |--------------------------------------------------------------------------
39 | |
40 | | The following "run" method is required to execute all the tests.
41 | |
42 | */
43 | run()
44 |
--------------------------------------------------------------------------------
/configure.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * @adonisjs/limiter
3 | *
4 | * (c) AdonisJS
5 | *
6 | * For the full copyright and license information, please view the LICENSE
7 | * file that was distributed with this source code.
8 | */
9 |
10 | import string from '@adonisjs/core/helpers/string'
11 | import type Configure from '@adonisjs/core/commands/configure'
12 | import { stubsRoot } from './stubs/main.js'
13 |
14 | /**
15 | * Available stores for selection
16 | */
17 | const KNOWN_STORES = ['database', 'redis']
18 |
19 | /**
20 | * Configures the limiter package
21 | */
22 | export async function configure(command: Configure) {
23 | /**
24 | * Read store from the "--store" CLI flag
25 | */
26 | let selectedStore: string | undefined = command.parsedFlags.store
27 |
28 | /**
29 | * Display prompts when no store have been selected
30 | * via the CLI flag
31 | */
32 | if (!selectedStore) {
33 | selectedStore = await command.prompt.choice(
34 | 'Select the storage layer you want to use',
35 | KNOWN_STORES,
36 | {
37 | validate(value) {
38 | return !value ? 'Please select a store' : true
39 | },
40 | }
41 | )
42 | }
43 |
44 | /**
45 | * Ensure the select store is valid
46 | */
47 | if (!KNOWN_STORES.includes(selectedStore!)) {
48 | command.exitCode = 1
49 | command.logger.logError(
50 | `Invalid limiter store "${selectedStore}". Supported stores are: ${string.sentence(
51 | KNOWN_STORES
52 | )}`
53 | )
54 | return
55 | }
56 |
57 | const codemods = await command.createCodemods()
58 |
59 | /**
60 | * Publish config file
61 | */
62 | await codemods.makeUsingStub(stubsRoot, 'config/limiter.stub', {
63 | store: selectedStore,
64 | })
65 |
66 | /**
67 | * Publish start/limiter file
68 | */
69 | await codemods.makeUsingStub(stubsRoot, 'start/limiter.stub', {})
70 |
71 | /**
72 | * Publish provider
73 | */
74 | await codemods.updateRcFile((rcFile) => {
75 | rcFile.addProvider('@adonisjs/limiter/limiter_provider')
76 | })
77 |
78 | /**
79 | * Publish migration when using database to store
80 | * rate limits
81 | */
82 | if (selectedStore === 'database') {
83 | await codemods.makeUsingStub(stubsRoot, 'make/migration/rate_limits.stub', {
84 | entity: command.app.generators.createEntity('rate_limits'),
85 | migration: {
86 | folder: 'database/migrations',
87 | fileName: `${new Date().getTime()}_create_rate_limits_table.ts`,
88 | },
89 | })
90 | }
91 |
92 | /**
93 | * Define env variables for the selected store
94 | */
95 | await codemods.defineEnvVariables({
96 | LIMITER_STORE: selectedStore!,
97 | })
98 |
99 | /**
100 | * Define env variables validation for the selected store
101 | */
102 | await codemods.defineEnvValidations({
103 | leadingComment: 'Variables for configuring the limiter package',
104 | variables: {
105 | LIMITER_STORE: `Env.schema.enum(['${selectedStore}', 'memory'] as const)`,
106 | },
107 | })
108 | }
109 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.8'
2 |
3 | services:
4 | mysql:
5 | platform: linux/x86_64
6 | image: mysql:8.0
7 | command: --default-authentication-plugin=mysql_native_password --sync_binlog=0 --innodb_doublewrite=OFF --innodb-flush-log-at-trx-commit=0 --innodb-flush-method=nosync
8 | container_name: mysql
9 | env_file: ./.env
10 | environment:
11 | - MYSQL_USER=$MYSQL_USER
12 | - MYSQL_PASSWORD=$MYSQL_PASSWORD
13 | - MYSQL_DATABASE=$MYSQL_DATABASE
14 | - MYSQL_ROOT_PASSWORD=$MYSQL_PASSWORD
15 | ports:
16 | - $MYSQL_PORT:3306
17 | expose:
18 | - $MYSQL_PORT
19 |
20 | pg:
21 | image: postgres:11
22 | container_name: pg
23 | env_file: ./.env
24 | environment:
25 | - POSTGRES_DB=$PG_DATABASE
26 | - POSTGRES_USER=$PG_USER
27 | - POSTGRES_PASSWORD=$PG_PASSWORD
28 | ports:
29 | - $PG_PORT:5432
30 | expose:
31 | - $PG_PORT
32 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import { configPkg } from '@adonisjs/eslint-config'
2 | export default configPkg()
3 |
--------------------------------------------------------------------------------
/index.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * @adonisjs/limiter
3 | *
4 | * (c) AdonisJS
5 | *
6 | * For the full copyright and license information, please view the LICENSE
7 | * file that was distributed with this source code.
8 | */
9 |
10 | export * as errors from './src/errors.js'
11 | export { configure } from './configure.js'
12 | export { Limiter } from './src/limiter.js'
13 | export { stubsRoot } from './stubs/main.js'
14 | export { LimiterResponse } from './src/response.js'
15 | export { HttpLimiter } from './src/http_limiter.js'
16 | export { LimiterManager } from './src/limiter_manager.js'
17 | export { defineConfig, stores } from './src/define_config.js'
18 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@adonisjs/limiter",
3 | "description": "Rate limiting package for AdonisJS framework",
4 | "version": "2.4.0",
5 | "type": "module",
6 | "files": [
7 | "build",
8 | "!build/bin",
9 | "!build/tests"
10 | ],
11 | "exports": {
12 | ".": "./build/index.js",
13 | "./limiter_provider": "./build/providers/limiter_provider.js",
14 | "./services/main": "./build/services/main.js",
15 | "./stores/*": "./build/src/stores/*.js",
16 | "./types": "./build/src/types.js"
17 | },
18 | "scripts": {
19 | "pretest": "npm run lint && del-cli coverage",
20 | "test": "npm run test:pg && npm run test:mysql && npm run test:sqlite && mkdir coverage/tmp && cp -r coverage/*/tmp/. coverage/tmp && c8 report",
21 | "test:pg": "cross-env DB=pg c8 --reporter=json --report-dir=coverage/pg npm run quick:test",
22 | "test:mysql": "cross-env DB=mysql c8 --reporter=json --report-dir=coverage/mysql npm run quick:test",
23 | "test:sqlite": "cross-env DB=sqlite c8 --reporter=json --report-dir=coverage/sqlite npm run quick:test",
24 | "clean": "del-cli build",
25 | "typecheck": "tsc --noEmit",
26 | "copy:templates": "copyfiles \"stubs/**/*.stub\" --up=\"1\" build",
27 | "precompile": "npm run lint && npm run clean",
28 | "compile": "tsup-node && tsc --emitDeclarationOnly --declaration",
29 | "postcompile": "npm run copy:templates",
30 | "build": "npm run compile",
31 | "prepublishOnly": "npm run build",
32 | "lint": "eslint",
33 | "format": "prettier --write .",
34 | "release": "release-it",
35 | "version": "npm run build",
36 | "quick:test": "cross-env NODE_DEBUG=adonisjs:limiter node --import=ts-node-maintained/register/esm --enable-source-maps bin/test.ts"
37 | },
38 | "devDependencies": {
39 | "@adonisjs/assembler": "^7.8.2",
40 | "@adonisjs/core": "^6.17.2",
41 | "@adonisjs/eslint-config": "^2.0.0",
42 | "@adonisjs/i18n": "^2.2.0",
43 | "@adonisjs/lucid": "^21.6.1",
44 | "@adonisjs/prettier-config": "^1.4.4",
45 | "@adonisjs/redis": "^9.2.0",
46 | "@adonisjs/tsconfig": "^1.4.0",
47 | "@japa/assert": "^4.0.1",
48 | "@japa/expect-type": "^2.0.3",
49 | "@japa/file-system": "^2.3.2",
50 | "@japa/runner": "^4.2.0",
51 | "@libsql/sqlite3": "^0.3.1",
52 | "@release-it/conventional-changelog": "^10.0.0",
53 | "@swc/core": "1.10.7",
54 | "@types/node": "^22.14.1",
55 | "@types/sinon": "^17.0.4",
56 | "better-sqlite3": "^11.9.1",
57 | "c8": "^10.1.3",
58 | "copyfiles": "^2.4.1",
59 | "cross-env": "^7.0.3",
60 | "del-cli": "^6.0.0",
61 | "dotenv": "^16.5.0",
62 | "eslint": "^9.24.0",
63 | "luxon": "^3.6.1",
64 | "mysql2": "^3.14.0",
65 | "pg": "^8.14.1",
66 | "prettier": "^3.5.3",
67 | "release-it": "^18.1.2",
68 | "sinon": "^20.0.0",
69 | "timekeeper": "^2.3.1",
70 | "ts-node-maintained": "^10.9.5",
71 | "tsup": "^8.4.0",
72 | "typescript": "^5.8.3"
73 | },
74 | "dependencies": {
75 | "rate-limiter-flexible": "^7.0.0"
76 | },
77 | "peerDependencies": {
78 | "@adonisjs/core": "^6.12.1",
79 | "@adonisjs/lucid": "^20.1.0 || ^21.0.0",
80 | "@adonisjs/redis": "^8.0.1 || ^9.0.0"
81 | },
82 | "peerDependenciesMeta": {
83 | "@adonisjs/lucid": {
84 | "optional": true
85 | },
86 | "@adonisjs/redis": {
87 | "optional": true
88 | }
89 | },
90 | "author": "virk,adonisjs",
91 | "license": "MIT",
92 | "homepage": "https://github.com/adonisjs/limiter#readme",
93 | "repository": {
94 | "type": "git",
95 | "url": "git+https://github.com/adonisjs/limiter.git"
96 | },
97 | "bugs": {
98 | "url": "https://github.com/adonisjs/limiter/issues"
99 | },
100 | "keywords": [
101 | "adonis",
102 | "rate-limiter"
103 | ],
104 | "publishConfig": {
105 | "access": "public",
106 | "provenance": true
107 | },
108 | "tsup": {
109 | "entry": [
110 | "./index.ts",
111 | "./providers/limiter_provider.ts",
112 | "./services/main.ts",
113 | "./src/stores/*.ts",
114 | "./src/types.ts"
115 | ],
116 | "outDir": "./build",
117 | "clean": true,
118 | "format": "esm",
119 | "dts": false,
120 | "sourcemap": false,
121 | "target": "esnext"
122 | },
123 | "release-it": {
124 | "git": {
125 | "requireCleanWorkingDir": true,
126 | "requireUpstream": true,
127 | "commitMessage": "chore(release): ${version}",
128 | "tagAnnotation": "v${version}",
129 | "push": true,
130 | "tagName": "v${version}"
131 | },
132 | "github": {
133 | "release": true
134 | },
135 | "npm": {
136 | "publish": true,
137 | "skipChecks": true
138 | },
139 | "plugins": {
140 | "@release-it/conventional-changelog": {
141 | "preset": {
142 | "name": "angular"
143 | }
144 | }
145 | }
146 | },
147 | "c8": {
148 | "reporter": [
149 | "text",
150 | "html"
151 | ],
152 | "exclude": [
153 | "tests/**"
154 | ]
155 | },
156 | "prettier": "@adonisjs/prettier-config"
157 | }
158 |
--------------------------------------------------------------------------------
/providers/limiter_provider.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * @adonisjs/limiter
3 | *
4 | * (c) AdonisJS
5 | *
6 | * For the full copyright and license information, please view the LICENSE
7 | * file that was distributed with this source code.
8 | */
9 |
10 | import { configProvider } from '@adonisjs/core'
11 | import { ApplicationService } from '@adonisjs/core/types'
12 | import { RuntimeException } from '@adonisjs/core/exceptions'
13 |
14 | import { LimiterManager } from '../index.js'
15 | import type { LimiterService } from '../src/types.js'
16 |
17 | declare module '@adonisjs/core/types' {
18 | export interface ContainerBindings {
19 | 'limiter.manager': LimiterService
20 | }
21 | }
22 |
23 | export default class LimiterProvider {
24 | constructor(protected app: ApplicationService) {}
25 |
26 | register() {
27 | this.app.container.singleton('limiter.manager', async () => {
28 | const limiterConfigProvider = this.app.config.get('limiter', {})
29 |
30 | /**
31 | * Resolve config from the provider
32 | */
33 | const config = await configProvider.resolve(this.app, limiterConfigProvider)
34 | if (!config) {
35 | throw new RuntimeException(
36 | 'Invalid "config/limiter.ts" file. Make sure you are using the "defineConfig" method'
37 | )
38 | }
39 |
40 | return new LimiterManager(config)
41 | })
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/services/main.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * @adonisjs/limiter
3 | *
4 | * (c) AdonisJS
5 | *
6 | * For the full copyright and license information, please view the LICENSE
7 | * file that was distributed with this source code.
8 | */
9 |
10 | import app from '@adonisjs/core/services/app'
11 | import { LimiterService } from '../src/types.js'
12 |
13 | let limiter: LimiterService
14 |
15 | /**
16 | * Returns a singleton instance of the LimiterManager class from the
17 | * container.
18 | */
19 | await app.booted(async () => {
20 | limiter = await app.container.make('limiter.manager')
21 | })
22 |
23 | export { limiter as default }
24 |
--------------------------------------------------------------------------------
/src/debug.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * @adonisjs/limiter
3 | *
4 | * (c) AdonisJS
5 | *
6 | * For the full copyright and license information, please view the LICENSE
7 | * file that was distributed with this source code.
8 | */
9 |
10 | import { debuglog } from 'node:util'
11 |
12 | export default debuglog('adonisjs:limiter')
13 |
--------------------------------------------------------------------------------
/src/define_config.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * @adonisjs/limiter
3 | *
4 | * (c) AdonisJS
5 | *
6 | * For the full copyright and license information, please view the LICENSE
7 | * file that was distributed with this source code.
8 | */
9 |
10 | ///
11 | ///
12 |
13 | import { configProvider } from '@adonisjs/core'
14 | import type { ConfigProvider } from '@adonisjs/core/types'
15 | import type { RedisConnections } from '@adonisjs/redis/types'
16 | import { InvalidArgumentsException, RuntimeException } from '@adonisjs/core/exceptions'
17 |
18 | import debug from './debug.js'
19 | import LimiterMemoryStore from './stores/memory.js'
20 | import type {
21 | LimiterRedisStoreConfig,
22 | LimiterMemoryStoreConfig,
23 | LimiterManagerStoreFactory,
24 | LimiterDatabaseStoreConfig,
25 | LimiterConsumptionOptions,
26 | } from './types.js'
27 |
28 | /**
29 | * Helper to define limiter config. This function exports a
30 | * config provider and hence you cannot access raw config
31 | * directly.
32 | *
33 | * Therefore use the "limiterManager.config" property to access
34 | * raw config.
35 | */
36 | export function defineConfig<
37 | KnownStores extends Record<
38 | string,
39 | LimiterManagerStoreFactory | ConfigProvider
40 | >,
41 | >(config: {
42 | default: keyof KnownStores
43 | stores: KnownStores
44 | }): ConfigProvider<{
45 | default: keyof KnownStores
46 | stores: {
47 | [K in keyof KnownStores]: KnownStores[K] extends ConfigProvider ? A : KnownStores[K]
48 | }
49 | }> {
50 | /**
51 | * Limiter stores should always be provided
52 | */
53 | if (!config.stores) {
54 | throw new InvalidArgumentsException('Missing "stores" property in limiter config')
55 | }
56 |
57 | /**
58 | * Default store should always be provided
59 | */
60 | if (!config.default) {
61 | throw new InvalidArgumentsException(`Missing "default" store in limiter config`)
62 | }
63 |
64 | /**
65 | * Default store should be configured within the stores
66 | * object
67 | */
68 | if (!config.stores[config.default]) {
69 | throw new InvalidArgumentsException(
70 | `Missing "stores.${String(
71 | config.default
72 | )}" in limiter config. It is referenced by the "default" property`
73 | )
74 | }
75 |
76 | return configProvider.create(async (app) => {
77 | debug('resolving limiter config')
78 |
79 | const storesList = Object.keys(config.stores)
80 | const stores = {} as Record<
81 | string,
82 | LimiterManagerStoreFactory | ConfigProvider
83 | >
84 |
85 | /**
86 | * Looping for stores collection and invoking
87 | * config providers to resolve stores in use
88 | */
89 | for (let storeName of storesList) {
90 | const store = config.stores[storeName]
91 | if (typeof store === 'function') {
92 | stores[storeName] = store
93 | } else {
94 | stores[storeName] = await store.resolver(app)
95 | }
96 | }
97 |
98 | return {
99 | default: config.default,
100 | stores: stores as {
101 | [K in keyof KnownStores]: KnownStores[K] extends ConfigProvider
102 | ? A
103 | : KnownStores[K]
104 | },
105 | }
106 | })
107 | }
108 |
109 | /**
110 | * Config helpers to instantiate limiter stores inside
111 | * an AdonisJS application
112 | */
113 | export const stores: {
114 | /**
115 | * Configure redis limiter store
116 | */
117 | redis: (
118 | config: Omit & {
119 | connectionName?: keyof RedisConnections
120 | }
121 | ) => ConfigProvider
122 |
123 | /**
124 | * Configure database limiter store
125 | */
126 | database: (
127 | config: Omit & {
128 | connectionName?: string
129 | }
130 | ) => ConfigProvider
131 |
132 | /**
133 | * Configure memory limiter store
134 | */
135 | memory: (
136 | config: Omit
137 | ) => LimiterManagerStoreFactory
138 | } = {
139 | redis: (config) => {
140 | return configProvider.create(async (app) => {
141 | const redis = await app.container.make('redis')
142 | const { default: LimiterRedisStore } = await import('./stores/redis.js')
143 | return (consumptionOptions) =>
144 | new LimiterRedisStore(redis.connection(config.connectionName), {
145 | ...config,
146 | ...consumptionOptions,
147 | })
148 | })
149 | },
150 | database: (config) => {
151 | return configProvider.create(async (app) => {
152 | const db = await app.container.make('lucid.db')
153 | const { default: LimiterDatabaseStore } = await import('./stores/database.js')
154 |
155 | config.connectionName = config.connectionName || db.primaryConnectionName
156 | const connection = db.manager.get(config.connectionName)
157 |
158 | /**
159 | * Throw error when mentioned connection is not specified
160 | * in the database file
161 | */
162 | if (!connection) {
163 | throw new RuntimeException(
164 | `Invalid connection name "${config.connectionName}" referenced by "config/limiter.ts" file. First register the connection inside "config/database.ts" file`
165 | )
166 | }
167 |
168 | /**
169 | * Infer database name from the connection config
170 | */
171 | if (
172 | !config.dbName &&
173 | connection.config.connection &&
174 | typeof connection.config.connection !== 'string' &&
175 | 'database' in connection.config.connection
176 | ) {
177 | config.dbName = connection.config.connection.database
178 | }
179 |
180 | return (consumptionOptions) =>
181 | new LimiterDatabaseStore(db.connection(config.connectionName), {
182 | ...config,
183 | ...consumptionOptions,
184 | })
185 | })
186 | },
187 | memory: (config) => {
188 | return (consumptionOptions) =>
189 | new LimiterMemoryStore({
190 | ...config,
191 | ...consumptionOptions,
192 | })
193 | },
194 | }
195 |
--------------------------------------------------------------------------------
/src/errors.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * @adonisjs/limiter
3 | *
4 | * (c) AdonisJS
5 | *
6 | * For the full copyright and license information, please view the LICENSE
7 | * file that was distributed with this source code.
8 | */
9 |
10 | import type { I18n } from '@adonisjs/i18n'
11 | import { Exception } from '@adonisjs/core/exceptions'
12 | import type { HttpContext } from '@adonisjs/core/http'
13 |
14 | import type { LimiterResponse } from './response.js'
15 |
16 | /**
17 | * Throttle exception is raised when the user has exceeded
18 | * the number of requests allowed during a given duration
19 | */
20 | export class ThrottleException extends Exception {
21 | message = 'Too many requests'
22 | status = 429
23 | code = 'E_TOO_MANY_REQUESTS'
24 |
25 | /**
26 | * Error identifier to lookup translation message
27 | */
28 | identifier = 'errors.E_TOO_MANY_REQUESTS'
29 |
30 | /**
31 | * The response headers to set when converting exception
32 | * to response
33 | */
34 | headers?: { [name: string]: any }
35 |
36 | /**
37 | * Translation identifier to use for creating throttle
38 | * response.
39 | */
40 | translation?: {
41 | identifier: string
42 | data?: Record
43 | }
44 |
45 | constructor(
46 | public response: LimiterResponse,
47 | options?: ErrorOptions & {
48 | code?: string
49 | status?: number
50 | }
51 | ) {
52 | super('Too many requests', options)
53 | }
54 |
55 | /**
56 | * Returns the default headers for the response
57 | */
58 | getDefaultHeaders(): { [K: string]: any } {
59 | return {
60 | 'X-RateLimit-Limit': this.response.limit,
61 | 'X-RateLimit-Remaining': this.response.remaining,
62 | 'Retry-After': this.response.availableIn,
63 | 'X-RateLimit-Reset': new Date(Date.now() + this.response.availableIn * 1000).toISOString(),
64 | }
65 | }
66 |
67 | /**
68 | * Returns the message to be sent in the HTTP response.
69 | * Feel free to override this method and return a custom
70 | * response.
71 | */
72 | getResponseMessage(ctx: HttpContext) {
73 | /**
74 | * Use translation when using i18n package
75 | */
76 | if ('i18n' in ctx) {
77 | /**
78 | * Give preference to response translation and fallback to static
79 | * identifier.
80 | */
81 | const identifier = this.translation?.identifier || this.identifier
82 | const data = this.translation?.data || {}
83 | return (ctx.i18n as I18n).t(identifier, data, this.message)
84 | }
85 |
86 | return this.message
87 | }
88 |
89 | /**
90 | * Update the default error message
91 | */
92 | setMessage(message: string): this {
93 | this.message = message
94 | return this
95 | }
96 |
97 | /**
98 | * Update the default error status code
99 | */
100 | setStatus(status: number): this {
101 | this.status = status
102 | return this
103 | }
104 |
105 | /**
106 | * Define custom response headers. Existing headers will
107 | * be removed
108 | */
109 | setHeaders(headers: { [name: string]: any }): this {
110 | this.headers = headers
111 | return this
112 | }
113 |
114 | /**
115 | * Define the translation identifier for the throttle response
116 | */
117 | t(identifier: string, data?: Record) {
118 | this.translation = { identifier, data }
119 | return this
120 | }
121 |
122 | /**
123 | * Converts the throttle exception to an HTTP response
124 | */
125 | async handle(error: ThrottleException, ctx: HttpContext) {
126 | const status = error.status
127 | const message = this.getResponseMessage(ctx)
128 | const headers = this.headers || this.getDefaultHeaders()
129 |
130 | Object.keys(headers).forEach((header) => ctx.response.header(header, headers[header]))
131 |
132 | switch (ctx.request.accepts(['html', 'application/vnd.api+json', 'json'])) {
133 | case 'html':
134 | case null:
135 | ctx.response.status(status).send(message)
136 | break
137 | case 'json':
138 | ctx.response.status(status).send({
139 | errors: [
140 | {
141 | message,
142 | retryAfter: this.response.availableIn,
143 | },
144 | ],
145 | })
146 | break
147 | case 'application/vnd.api+json':
148 | ctx.response.status(status).send({
149 | errors: [
150 | {
151 | code: this.code,
152 | title: message,
153 | meta: {
154 | retryAfter: this.response.availableIn,
155 | },
156 | },
157 | ],
158 | })
159 | break
160 | }
161 | }
162 | }
163 |
164 | export const E_TOO_MANY_REQUESTS = ThrottleException
165 |
--------------------------------------------------------------------------------
/src/http_limiter.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * @adonisjs/limiter
3 | *
4 | * (c) AdonisJS
5 | *
6 | * For the full copyright and license information, please view the LICENSE
7 | * file that was distributed with this source code.
8 | */
9 |
10 | import type { HttpContext } from '@adonisjs/core/http'
11 | import { RuntimeException } from '@adonisjs/core/exceptions'
12 |
13 | import debug from './debug.js'
14 | import { LimiterResponse } from './response.js'
15 | import type { LimiterManager } from './limiter_manager.js'
16 | import { E_TOO_MANY_REQUESTS, type ThrottleException } from './errors.js'
17 | import type { LimiterConsumptionOptions, LimiterManagerStoreFactory } from './types.js'
18 |
19 | /**
20 | * HttpLimiter is a special type of limiter instance created specifically
21 | * for HTTP requests. It exposes a single method to throttle the request
22 | * using the request ip address or the pre-defined unique key.
23 | */
24 | export class HttpLimiter> {
25 | /**
26 | * The manager reference to create limiter instances
27 | * for a given store
28 | */
29 | #manager: LimiterManager
30 |
31 | /**
32 | * The runtime options configured using the fluent
33 | * API
34 | */
35 | #options: Partial
36 |
37 | /**
38 | * The selected store. Otherwise the default store will
39 | * be used
40 | */
41 | #store?: keyof KnownStores
42 |
43 | /**
44 | * The key to unique identify the user. Defaults to "request.ip"
45 | */
46 | #key?: string | number
47 |
48 | /**
49 | * A custom callback function to modify error messages.
50 | */
51 | #exceptionModifier: (error: ThrottleException) => void = () => {}
52 |
53 | constructor(manager: LimiterManager, options?: LimiterConsumptionOptions) {
54 | this.#manager = manager
55 | this.#options = options || {}
56 | }
57 |
58 | /**
59 | * Specify the store you want to use during
60 | * the request
61 | */
62 | store(store: keyof KnownStores) {
63 | this.#store = store
64 | return this
65 | }
66 |
67 | /**
68 | * Specify the number of requests to allow
69 | */
70 | allowRequests(requests: number) {
71 | this.#options.requests = requests
72 | return this
73 | }
74 |
75 | /**
76 | * Specify the duration in seconds or a time expression
77 | * for which the requests to allow.
78 | *
79 | * For example: allowRequests(10).every('1 minute')
80 | */
81 | every(duration: number | string) {
82 | this.#options.duration = duration
83 | return this
84 | }
85 |
86 | /**
87 | * Specify a custom unique key to identify the user.
88 | * Defaults to: request.ip()
89 | */
90 | usingKey(key: string | number) {
91 | this.#key = key
92 | return this
93 | }
94 |
95 | /**
96 | * Register a callback function to modify the ThrottleException.
97 | */
98 | limitExceeded(callback: (error: ThrottleException) => void) {
99 | this.#exceptionModifier = callback
100 | return this
101 | }
102 |
103 | /**
104 | * Define the block duration. The key will be blocked for the
105 | * specified duration after all the requests have been
106 | * exhausted
107 | */
108 | blockFor(duration: number | string): this {
109 | this.#options.blockDuration = duration
110 | return this
111 | }
112 |
113 | /**
114 | * JSON representation of the HTTP limiter
115 | */
116 | toJSON() {
117 | return {
118 | store: this.#store,
119 | ...this.#options,
120 | }
121 | }
122 |
123 | /**
124 | * Throttle request using the pre-defined options. Returns
125 | * LimiterResponse when request is allowed or throws
126 | * an exception.
127 | */
128 | async throttle(prefix: string, ctx: HttpContext): Promise {
129 | if (!this.#options.requests || !this.#options.duration) {
130 | throw new RuntimeException(
131 | `Cannot throttle requests for "${prefix}" limiter. Make sure to define the allowed requests and duration`
132 | )
133 | }
134 |
135 | const limiter = this.#store
136 | ? this.#manager.use(this.#store, this.#options as LimiterConsumptionOptions)
137 | : this.#manager.use(this.#options as LimiterConsumptionOptions)
138 |
139 | const key = `${prefix}_${this.#key || `ip_${ctx.request.ip()}`}`
140 | debug('throttling HTTP request for key "%s"', key)
141 | const limiterResponse = await limiter.get(key)
142 |
143 | /**
144 | * Abort when user has exhausted all the requests.
145 | *
146 | * We still run the "consume" method when consumed is same as
147 | * the limit, this will allow the consume method to trigger
148 | * the block logic.
149 | */
150 | if (limiterResponse && limiterResponse.consumed > limiterResponse.limit) {
151 | debug('requests exhausted for key "%s"', key)
152 | const error = new E_TOO_MANY_REQUESTS(limiterResponse)
153 | this.#exceptionModifier(error)
154 | throw error
155 | }
156 |
157 | try {
158 | const consumeResponse = await limiter.consume(key)
159 | return consumeResponse
160 | } catch (error) {
161 | if (error instanceof E_TOO_MANY_REQUESTS) {
162 | debug('requests exhausted for key "%s"', key)
163 | this.#exceptionModifier(error)
164 | }
165 | throw error
166 | }
167 | }
168 | }
169 |
--------------------------------------------------------------------------------
/src/limiter.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * @adonisjs/limiter
3 | *
4 | * (c) AdonisJS
5 | *
6 | * For the full copyright and license information, please view the LICENSE
7 | * file that was distributed with this source code.
8 | */
9 |
10 | import { LimiterResponse } from './response.js'
11 | import { E_TOO_MANY_REQUESTS, ThrottleException } from './errors.js'
12 | import type { LimiterStoreContract } from './types.js'
13 |
14 | /**
15 | * Limiter acts as an adapter on top of the limiter
16 | * stores and offers additional APIs
17 | */
18 | export class Limiter implements LimiterStoreContract {
19 | #store: LimiterStoreContract
20 |
21 | /**
22 | * The number of configured requests on the store
23 | */
24 | get name() {
25 | return this.#store.name
26 | }
27 |
28 | /**
29 | * The number of configured requests on the store
30 | */
31 | get requests() {
32 | return this.#store.requests
33 | }
34 |
35 | /**
36 | * The duration (in seconds) for which the requests are configured
37 | */
38 | get duration() {
39 | return this.#store.duration
40 | }
41 |
42 | /**
43 | * The duration (in seconds) for which to block the key
44 | */
45 | get blockDuration() {
46 | return this.#store.blockDuration
47 | }
48 |
49 | constructor(store: LimiterStoreContract) {
50 | this.#store = store
51 | }
52 |
53 | /**
54 | * Consume 1 request for a given key. An exception is raised
55 | * when all the requests have already been consumed or if
56 | * the key is blocked.
57 | */
58 | consume(key: string | number): Promise {
59 | return this.#store.consume(key)
60 | }
61 |
62 | /**
63 | * Increment the number of consumed requests for a given key.
64 | * No errors are thrown when limit has reached
65 | */
66 | increment(key: string | number): Promise {
67 | return this.#store.increment(key)
68 | }
69 |
70 | /**
71 | * Decrement the number of consumed requests for a given key.
72 | */
73 | decrement(key: string | number): Promise {
74 | return this.#store.decrement(key)
75 | }
76 |
77 | /**
78 | * Consume 1 request for a given key and execute the provided
79 | * callback.
80 | */
81 | async attempt(key: string | number, callback: () => T | Promise): Promise {
82 | /**
83 | * Return early when remaining requests are less than
84 | * zero.
85 | *
86 | * We still run the "consume" method when consumed is same as
87 | * the limit, this will allow the consume method to trigger
88 | * the block logic.
89 | */
90 | const response = await this.get(key)
91 | if (response && response.consumed > response.limit) {
92 | return
93 | }
94 |
95 | try {
96 | await this.consume(key)
97 | return callback()
98 | } catch (error) {
99 | if (error instanceof E_TOO_MANY_REQUESTS === false) {
100 | throw error
101 | }
102 | }
103 | }
104 |
105 | /**
106 | * Consume 1 request for a given key when the executed method throws
107 | * an error.
108 | *
109 | * - Check if all the requests have been exhausted. If yes, throw limiter
110 | * error.
111 | * - Otherwise, execute the provided callback.
112 | * - Increment the requests counter, if provided callback throws an error and rethrow
113 | * the error
114 | * - Delete key, if the provided callback succeeds and return the results.
115 | */
116 | async penalize(
117 | key: string | number,
118 | callback: () => T | Promise
119 | ): Promise<[null, T] | [ThrottleException, null]> {
120 | const response = await this.get(key)
121 |
122 | /**
123 | * Abort when user has exhausted all the requests
124 | */
125 | if (response && response.remaining <= 0) {
126 | return [new E_TOO_MANY_REQUESTS(response), null]
127 | }
128 |
129 | let callbackResult: T
130 | let callbackError: unknown
131 |
132 | try {
133 | callbackResult = await callback()
134 | } catch (error) {
135 | callbackError = error
136 | }
137 |
138 | /**
139 | * Consume one point and block the key if there is
140 | * an error.
141 | */
142 | if (callbackError) {
143 | const { consumed, limit } = await this.increment(key)
144 | if (consumed >= limit && this.blockDuration) {
145 | await this.block(key, this.blockDuration)
146 | }
147 | throw callbackError
148 | }
149 |
150 | /**
151 | * Reset key
152 | */
153 | await this.delete(key)
154 | return [null, callbackResult!]
155 | }
156 |
157 | /**
158 | * Block a given key for the given duration. The duration must be
159 | * a value in seconds or a string expression.
160 | */
161 | block(key: string | number, duration: string | number): Promise {
162 | return this.#store.block(key, duration)
163 | }
164 |
165 | /**
166 | * Manually set the number of requests exhausted for
167 | * a given key for the given time duration.
168 | *
169 | * For example: "ip_127.0.0.1" has made "20 requests" in "1 minute".
170 | * Now, if you allow 25 requests in 1 minute, then only 5 requests
171 | * are left.
172 | *
173 | * The duration must be a value in seconds or a string expression.
174 | */
175 | set(
176 | key: string | number,
177 | requests: number,
178 | duration?: string | number
179 | ): Promise {
180 | return this.#store.set(key, requests, duration)
181 | }
182 |
183 | /**
184 | * Delete a given key
185 | */
186 | delete(key: string | number): Promise {
187 | return this.#store.delete(key)
188 | }
189 |
190 | /**
191 | * Delete all keys blocked within the memory
192 | */
193 | deleteInMemoryBlockedKeys(): void {
194 | return this.#store.deleteInMemoryBlockedKeys?.()
195 | }
196 |
197 | /**
198 | * Get limiter response for a given key. Returns null when
199 | * key doesn't exist.
200 | */
201 | get(key: string | number): Promise {
202 | return this.#store.get(key)
203 | }
204 |
205 | /**
206 | * Find the number of remaining requests for a given key
207 | */
208 | async remaining(key: string | number): Promise {
209 | const response = await this.get(key)
210 | if (!response) {
211 | return this.requests
212 | }
213 |
214 | return response.remaining
215 | }
216 |
217 | /**
218 | * Find the number of seconds remaining until the key will
219 | * be available for new request
220 | */
221 | async availableIn(key: string | number): Promise {
222 | const response = await this.get(key)
223 | if (!response) {
224 | return 0
225 | }
226 |
227 | return response.remaining === 0 ? response.availableIn : 0
228 | }
229 |
230 | /**
231 | * Find if the current key is blocked. This method checks
232 | * if the consumed points are equal to or greater than
233 | * the allowed limit.
234 | */
235 | async isBlocked(key: string | number): Promise {
236 | const response = await this.get(key)
237 | if (!response) {
238 | return false
239 | }
240 |
241 | return response.consumed >= response.limit
242 | }
243 |
244 | /**
245 | * Clear the storage database
246 | */
247 | clear(): Promise {
248 | return this.#store.clear()
249 | }
250 | }
251 |
--------------------------------------------------------------------------------
/src/limiter_manager.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * @adonisjs/limiter
3 | *
4 | * (c) AdonisJS
5 | *
6 | * For the full copyright and license information, please view the LICENSE
7 | * file that was distributed with this source code.
8 | */
9 |
10 | import string from '@adonisjs/core/helpers/string'
11 | import type { HttpContext } from '@adonisjs/core/http'
12 | import type { MiddlewareFn } from '@adonisjs/core/types/http'
13 | import { RuntimeException } from '@adonisjs/core/exceptions'
14 |
15 | import debug from './debug.js'
16 | import { Limiter } from './limiter.js'
17 | import { HttpLimiter } from './http_limiter.js'
18 | import type { LimiterConsumptionOptions, LimiterManagerStoreFactory } from './types.js'
19 |
20 | /**
21 | * Limiter manager is used to manage multiple rate limiters
22 | * using different storage providers.
23 | *
24 | * Also, you can create limiter instances with runtime options
25 | * for "requests", "duration", and "blockDuration".
26 | */
27 | export class LimiterManager> {
28 | /**
29 | * Cached limiters. One limiter is created for a unique combination
30 | * of "store,requests,duration,blockDuration" options
31 | */
32 | #limiters: Map> = new Map()
33 |
34 | constructor(public config: { default: keyof KnownStores; stores: KnownStores }) {
35 | this.config = config
36 | }
37 |
38 | /**
39 | * Creates a unique key for a limiter instance. Since, we allow creating
40 | * limiters with runtime options for "requests", "duration" and "blockDuration".
41 | * The limiterKey is used to identify a limiter instance.
42 | */
43 | protected makeLimiterKey(store: keyof KnownStores, options: LimiterConsumptionOptions) {
44 | const chunks = [`s:${String(store)}`, `r:${options.requests}`, `d:${options.duration}`]
45 | if (options.blockDuration) {
46 | chunks.push(`bd:${options.blockDuration}`)
47 | }
48 | if (options.inMemoryBlockOnConsumed) {
49 | chunks.push(`mbc:${options.inMemoryBlockOnConsumed}`)
50 | }
51 | if (options.inMemoryBlockDuration) {
52 | chunks.push(`mbd:${options.inMemoryBlockDuration}`)
53 | }
54 | return chunks.join(',')
55 | }
56 |
57 | /**
58 | * Make a limiter instance for a given store and with
59 | * runtime options.
60 | *
61 | * Caches instances forever for the lifecycle of the process.
62 | */
63 | use(options: LimiterConsumptionOptions): Limiter
64 | use(store: K, options: LimiterConsumptionOptions): Limiter
65 | use(
66 | store: keyof KnownStores | LimiterConsumptionOptions,
67 | options?: LimiterConsumptionOptions
68 | ): Limiter {
69 | /**
70 | * Normalize options
71 | */
72 | let storeToUse: keyof KnownStores = typeof store === 'string' ? store : this.config.default
73 | let optionsToUse: LimiterConsumptionOptions | undefined =
74 | typeof store === 'object' ? store : options
75 |
76 | /**
77 | * Ensure options are defined
78 | */
79 | if (!optionsToUse) {
80 | throw new RuntimeException(
81 | 'Specify the number of allowed requests and duration to create a limiter'
82 | )
83 | }
84 |
85 | optionsToUse.duration = string.seconds.parse(optionsToUse.duration)
86 | if (optionsToUse.blockDuration) {
87 | optionsToUse.blockDuration = string.seconds.parse(optionsToUse.blockDuration)
88 | }
89 | if (optionsToUse.inMemoryBlockDuration) {
90 | optionsToUse.inMemoryBlockDuration = string.seconds.parse(optionsToUse.inMemoryBlockDuration)
91 | }
92 |
93 | /**
94 | * Initiate the store map when it does not have any
95 | * cached limiters
96 | */
97 | if (!this.#limiters.has(storeToUse as string)) {
98 | this.#limiters.set(storeToUse as string, new Map())
99 | }
100 |
101 | const storeLimiters = this.#limiters.get(storeToUse as string)!
102 |
103 | /**
104 | * Make limiter key to uniquely identify a limiter
105 | */
106 | const limiterKey = this.makeLimiterKey(storeToUse, optionsToUse)
107 | debug('created limiter key "%s"', limiterKey)
108 |
109 | /**
110 | * Read and return from cache
111 | */
112 | if (storeLimiters.has(limiterKey)) {
113 | debug('re-using cached limiter store "%s", options %O', storeToUse, optionsToUse)
114 | return storeLimiters.get(limiterKey)!
115 | }
116 |
117 | /**
118 | * Create a fresh instance and cache it
119 | */
120 | const limiter = new Limiter(this.config.stores[storeToUse](optionsToUse))
121 | debug('creating new limiter instance "%s", options %O', storeToUse, optionsToUse)
122 | storeLimiters.set(limiterKey, limiter)
123 | return limiter
124 | }
125 |
126 | /**
127 | * Clear stored data with the stores
128 | */
129 | async clear(stores?: Extract[]) {
130 | const storesToUse = stores || Object.keys(this.config.stores)
131 |
132 | /**
133 | * Loop over all the limiters created across all the stores
134 | * and clear their storage.
135 | *
136 | * Since, all stores uses a central database, we just need the
137 | * first instance and call clear on it.
138 | *
139 | * In case of memory store, we have to clear all the stores.
140 | */
141 | for (let store of storesToUse) {
142 | const storeLimiters = this.#limiters.get(store)
143 | if (storeLimiters) {
144 | /**
145 | * Clear all instances in case of the memory
146 | * store
147 | */
148 | if (store === 'memory') {
149 | for (let limiter of storeLimiters.values()) {
150 | await limiter.clear()
151 | }
152 | } else {
153 | /**
154 | * Clear first store
155 | */
156 | const [limiter] = storeLimiters.values()
157 | limiter && (await limiter.clear())
158 | }
159 | }
160 | }
161 | }
162 |
163 | /**
164 | * Creates HTTP limiter instance
165 | */
166 | allowRequests(requests: number) {
167 | return new HttpLimiter(this).allowRequests(requests)
168 | }
169 |
170 | /**
171 | * A shorthand method that returns null to disable
172 | * rate limiting
173 | */
174 | noLimit() {
175 | return null
176 | }
177 |
178 | /**
179 | * Define a named HTTP middleware to apply rate
180 | * limits on specific routes
181 | */
182 | define(
183 | name: string,
184 | builder: (
185 | ctx: HttpContext
186 | ) => HttpLimiter | null | Promise> | Promise
187 | ): MiddlewareFn {
188 | const middlewareFn: MiddlewareFn = async (ctx, next) => {
189 | /**
190 | * Invoke the builder for every HTTP request and we use
191 | * the return value to decide how to apply the rate
192 | * limit on the request
193 | */
194 | const limiter = await builder(ctx)
195 |
196 | /**
197 | * Do not throttle when no limiter is used for
198 | * the request
199 | */
200 | if (!limiter) {
201 | return next()
202 | }
203 |
204 | /**
205 | * Throttle request using the HTTP limiter
206 | */
207 | const limiterResponse = await limiter.throttle(name, ctx)
208 |
209 | /**
210 | * Invoke rest of the pipeline
211 | */
212 | const response = await next()
213 |
214 | /**
215 | * Define appropriate headers
216 | */
217 | ctx.response.header('X-RateLimit-Limit', limiterResponse.limit)
218 | ctx.response.header('X-RateLimit-Remaining', limiterResponse.remaining)
219 |
220 | /**
221 | * Return response
222 | */
223 | return response
224 | }
225 |
226 | Object.defineProperty(middlewareFn, 'name', {
227 | value: `${name}Throttle`,
228 | })
229 | return middlewareFn
230 | }
231 | }
232 |
--------------------------------------------------------------------------------
/src/response.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * @adonisjs/limiter
3 | *
4 | * (c) AdonisJS
5 | *
6 | * For the full copyright and license information, please view the LICENSE
7 | * file that was distributed with this source code.
8 | */
9 |
10 | export class LimiterResponse {
11 | /**
12 | * Allowed number of requests for a pre-defined
13 | * duration
14 | */
15 | limit: number
16 |
17 | /**
18 | * Requests remaining for the pre-defined duration
19 | */
20 | remaining: number
21 |
22 | /**
23 | * Requests consumed for the pre-defined duration
24 | */
25 | consumed: number
26 |
27 | /**
28 | * Number of seconds after which the requests count will
29 | * reset
30 | */
31 | availableIn: number
32 |
33 | constructor(rawResponse: {
34 | limit: number
35 | remaining: number
36 | consumed: number
37 | availableIn: number
38 | }) {
39 | this.limit = rawResponse.limit
40 | this.remaining = rawResponse.remaining
41 | this.consumed = rawResponse.consumed
42 | this.availableIn = rawResponse.availableIn
43 | }
44 |
45 | toJSON() {
46 | return {
47 | limit: this.limit,
48 | remaining: this.remaining,
49 | consumed: this.consumed,
50 | availableIn: this.availableIn,
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/stores/bridge.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * @adonisjs/limiter
3 | *
4 | * (c) AdonisJS
5 | *
6 | * For the full copyright and license information, please view the LICENSE
7 | * file that was distributed with this source code.
8 | */
9 |
10 | import string from '@adonisjs/core/helpers/string'
11 | import {
12 | RateLimiterRes,
13 | type RateLimiterAbstract,
14 | type RateLimiterStoreAbstract,
15 | } from 'rate-limiter-flexible'
16 |
17 | import debug from '../debug.js'
18 | import { LimiterResponse } from '../response.js'
19 | import { E_TOO_MANY_REQUESTS } from '../errors.js'
20 | import type { LimiterStoreContract } from '../types.js'
21 |
22 | /**
23 | * The bridget store acts as a bridge between the "rate-limiter-flexible"
24 | * package and the AdonisJS limiter store.
25 | *
26 | * If you are wrapping an existing "rate-limiter-flexible" store, then you
27 | * must inherit your implementation from this class.
28 | */
29 | export default abstract class RateLimiterBridge implements LimiterStoreContract {
30 | protected rateLimiter: RateLimiterStoreAbstract | RateLimiterAbstract
31 |
32 | /**
33 | * A unique name for the store
34 | */
35 | abstract readonly name: string
36 |
37 | /**
38 | * The number of configured requests on the store
39 | */
40 | get requests() {
41 | return this.rateLimiter.points
42 | }
43 |
44 | /**
45 | * The duration (in seconds) for which the requests are configured
46 | */
47 | get duration() {
48 | return this.rateLimiter.duration
49 | }
50 |
51 | /**
52 | * The duration (in seconds) for which to block the key
53 | */
54 | get blockDuration() {
55 | return this.rateLimiter.blockDuration
56 | }
57 |
58 | constructor(rateLimiter: RateLimiterStoreAbstract | RateLimiterAbstract) {
59 | this.rateLimiter = rateLimiter
60 | }
61 |
62 | /**
63 | * Clear database
64 | */
65 | abstract clear(): Promise
66 |
67 | /**
68 | * Makes LimiterResponse from "node-rate-limiter-flexible" response
69 | * object
70 | */
71 | protected makeLimiterResponse(response: RateLimiterRes): LimiterResponse {
72 | return new LimiterResponse({
73 | limit: this.rateLimiter.points,
74 | remaining: response.remainingPoints,
75 | consumed: response.consumedPoints,
76 | availableIn: Math.ceil(response.msBeforeNext / 1000),
77 | })
78 | }
79 |
80 | /**
81 | * Consume 1 request for a given key. An exception is raised
82 | * when all the requests have already been consumed or if
83 | * the key is blocked.
84 | */
85 | async consume(key: string | number): Promise {
86 | try {
87 | const response = await this.rateLimiter.consume(key, 1)
88 | debug('request consumed for key %s', key)
89 | return this.makeLimiterResponse(response)
90 | } catch (errorResponse: unknown) {
91 | debug('unable to consume request for key %s, %O', key, errorResponse)
92 | if (errorResponse instanceof RateLimiterRes) {
93 | throw new E_TOO_MANY_REQUESTS(this.makeLimiterResponse(errorResponse))
94 | }
95 |
96 | throw errorResponse
97 | }
98 | }
99 |
100 | /**
101 | * Increment the number of consumed requests for a given key.
102 | * No errors are thrown when limit has reached
103 | */
104 | async increment(key: string | number): Promise {
105 | const response = await this.rateLimiter.penalty(key, 1)
106 | debug('increased requests count for key %s', key)
107 |
108 | return this.makeLimiterResponse(response)
109 | }
110 |
111 | /**
112 | * Decrement the number of consumed requests for a given key.
113 | */
114 | async decrement(key: string | number): Promise {
115 | const existingKey = await this.rateLimiter.get(key)
116 |
117 | /**
118 | * Set key with zero when key does not exists
119 | */
120 | if (!existingKey) {
121 | return this.set(key, 0, this.duration)
122 | }
123 |
124 | /**
125 | * Do not decrement beyond zero
126 | */
127 | if (existingKey.consumedPoints <= 0) {
128 | return this.makeLimiterResponse(existingKey)
129 | }
130 |
131 | /**
132 | * Decrement
133 | */
134 | const response = await this.rateLimiter.reward(key, 1)
135 | debug('decreased requests count for key %s', key)
136 |
137 | return this.makeLimiterResponse(response)
138 | }
139 |
140 | /**
141 | * Block a given key for the given duration. The duration must be
142 | * a value in seconds or a string expression.
143 | */
144 | async block(key: string | number, duration: string | number): Promise {
145 | const response = await this.rateLimiter.block(key, string.seconds.parse(duration))
146 | debug('blocked key %s', key)
147 | return this.makeLimiterResponse(response)
148 | }
149 |
150 | /**
151 | * Manually set the number of requests exhausted for
152 | * a given key for the given time duration.
153 | *
154 | * For example: "ip_127.0.0.1" has made "20 requests" in "1 minute".
155 | * Now, if you allow 25 requests in 1 minute, then only 5 requests
156 | * are left.
157 | *
158 | * The duration must be a value in seconds or a string expression.
159 | */
160 | async set(
161 | key: string | number,
162 | requests: number,
163 | duration?: string | number
164 | ): Promise {
165 | const response = await this.rateLimiter.set(
166 | key,
167 | requests,
168 | duration ? string.seconds.parse(duration) : this.duration
169 | )
170 | debug('updated key %s with requests: %s, duration: %s', key, requests, duration)
171 |
172 | /**
173 | * The value of "response.remainingPoints" in a set method call
174 | * is always zero. It is hard coded as such in
175 | * the "rate-limiter-flexible" package.
176 | *
177 | * Therefore, we compute it locally
178 | */
179 | const remaining = this.requests - response.consumedPoints
180 |
181 | const limiterResponse = this.makeLimiterResponse(response)
182 | limiterResponse.remaining = remaining < 0 ? 0 : remaining
183 | return limiterResponse
184 | }
185 |
186 | /**
187 | * Delete a given key
188 | */
189 | delete(key: string | number): Promise {
190 | debug('deleting key %s', key)
191 | return this.rateLimiter.delete(key)
192 | }
193 |
194 | /**
195 | * Delete all keys blocked within the memory
196 | */
197 | deleteInMemoryBlockedKeys(): void {
198 | if ('deleteInMemoryBlockedAll' in this.rateLimiter) {
199 | return this.rateLimiter.deleteInMemoryBlockedAll()
200 | }
201 | }
202 |
203 | /**
204 | * Get limiter response for a given key. Returns null when
205 | * key doesn't exist.
206 | */
207 | async get(key: string | number): Promise {
208 | const response = await this.rateLimiter.get(key)
209 | debug('fetching key %s, %O', key, response)
210 | if (!response || Number.isNaN(response.remainingPoints)) {
211 | return null
212 | }
213 |
214 | return this.makeLimiterResponse(response)
215 | }
216 | }
217 |
--------------------------------------------------------------------------------
/src/stores/database.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * @adonisjs/limiter
3 | *
4 | * (c) AdonisJS
5 | *
6 | * For the full copyright and license information, please view the LICENSE
7 | * file that was distributed with this source code.
8 | */
9 |
10 | import string from '@adonisjs/core/helpers/string'
11 | import { RuntimeException } from '@adonisjs/core/exceptions'
12 | import type { DialectContract, QueryClientContract } from '@adonisjs/lucid/types/database'
13 | import { RateLimiterMySQL, RateLimiterPostgres, RateLimiterSQLite } from 'rate-limiter-flexible'
14 |
15 | import debug from '../debug.js'
16 | import RateLimiterBridge from './bridge.js'
17 | import type { LimiterDatabaseStoreConfig } from '../types.js'
18 |
19 | const SUPPORTED_CLIENTS = [
20 | 'mysql',
21 | 'postgres',
22 | 'better-sqlite3',
23 | 'sqlite3',
24 | ] satisfies DialectContract['name'][]
25 |
26 | /**
27 | * Limiter database store wraps the "RateLimiterMySQL" or "RateLimiterPostgres"
28 | * implementations from the "rate-limiter-flixible" package.
29 | */
30 | export default class LimiterDatabaseStore extends RateLimiterBridge {
31 | #config: LimiterDatabaseStoreConfig
32 | #client: QueryClientContract
33 |
34 | get name() {
35 | return 'database'
36 | }
37 |
38 | constructor(client: QueryClientContract, config: LimiterDatabaseStoreConfig) {
39 | const dialectName = client.dialect.name as (typeof SUPPORTED_CLIENTS)[number]
40 | if (!SUPPORTED_CLIENTS.includes(dialectName)) {
41 | throw new RuntimeException(
42 | `Unsupported database "${dialectName}". The limiter can only work with PostgreSQL, MySQL, and SQLite databases`
43 | )
44 | }
45 |
46 | debug('creating %s limiter store %O', dialectName, config)
47 |
48 | switch (dialectName) {
49 | case 'mysql':
50 | super(
51 | new RateLimiterMySQL({
52 | storeType: 'knex',
53 | storeClient: client.getWriteClient(),
54 | tableCreated: true,
55 | dbName: config.dbName,
56 | tableName: config.tableName,
57 | keyPrefix: config.keyPrefix,
58 | execEvenly: config.execEvenly,
59 | points: config.requests,
60 | clearExpiredByTimeout: config.clearExpiredByTimeout,
61 | duration: string.seconds.parse(config.duration),
62 | inMemoryBlockOnConsumed: config.inMemoryBlockOnConsumed,
63 | blockDuration: config.blockDuration
64 | ? string.seconds.parse(config.blockDuration)
65 | : undefined,
66 | inMemoryBlockDuration: config.inMemoryBlockDuration
67 | ? string.seconds.parse(config.inMemoryBlockDuration)
68 | : undefined,
69 | })
70 | )
71 | this.#client = client
72 | this.#config = config
73 | break
74 | case 'postgres':
75 | super(
76 | new RateLimiterPostgres({
77 | storeType: 'knex',
78 | schemaName: config.schemaName,
79 | storeClient: client.getWriteClient(),
80 | tableCreated: true,
81 | dbName: config.dbName,
82 | tableName: config.tableName,
83 | keyPrefix: config.keyPrefix,
84 | execEvenly: config.execEvenly,
85 | points: config.requests,
86 | clearExpiredByTimeout: config.clearExpiredByTimeout,
87 | duration: string.seconds.parse(config.duration),
88 | inMemoryBlockOnConsumed: config.inMemoryBlockOnConsumed,
89 | blockDuration: config.blockDuration
90 | ? string.seconds.parse(config.blockDuration)
91 | : undefined,
92 | inMemoryBlockDuration: config.inMemoryBlockDuration
93 | ? string.seconds.parse(config.inMemoryBlockDuration)
94 | : undefined,
95 | })
96 | )
97 | this.#client = client
98 | this.#config = config
99 | break
100 | case 'better-sqlite3':
101 | case 'sqlite3':
102 | super(
103 | new RateLimiterSQLite({
104 | storeType: 'knex',
105 | storeClient: client.getWriteClient(),
106 | tableCreated: true,
107 | dbName: config.dbName,
108 | tableName: config.tableName,
109 | keyPrefix: config.keyPrefix,
110 | execEvenly: config.execEvenly,
111 | points: config.requests,
112 | clearExpiredByTimeout: config.clearExpiredByTimeout,
113 | duration: string.seconds.parse(config.duration),
114 | inMemoryBlockOnConsumed: config.inMemoryBlockOnConsumed,
115 | blockDuration: config.blockDuration
116 | ? string.seconds.parse(config.blockDuration)
117 | : undefined,
118 | inMemoryBlockDuration: config.inMemoryBlockDuration
119 | ? string.seconds.parse(config.inMemoryBlockDuration)
120 | : undefined,
121 | })
122 | )
123 | this.#client = client
124 | this.#config = config
125 | break
126 | }
127 | }
128 |
129 | /**
130 | * Deletes all rows from the database table. Make sure to
131 | * use separate database tables for every rate limiter
132 | * your configure.
133 | */
134 | async clear() {
135 | debug('truncating database table %s', this.#config.tableName)
136 | this.deleteInMemoryBlockedKeys()
137 | await this.#client.dialect.truncate(this.#config.tableName, true)
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/src/stores/memory.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * @adonisjs/limiter
3 | *
4 | * (c) AdonisJS
5 | *
6 | * For the full copyright and license information, please view the LICENSE
7 | * file that was distributed with this source code.
8 | */
9 |
10 | import string from '@adonisjs/core/helpers/string'
11 | import { type IRateLimiterOptions, RateLimiterMemory } from 'rate-limiter-flexible'
12 |
13 | import debug from '../debug.js'
14 | import RateLimiterBridge from './bridge.js'
15 | import type { LimiterMemoryStoreConfig } from '../types.js'
16 |
17 | /**
18 | * Limiter memory store wraps the "RateLimiterMemory" implementation
19 | * from the "rate-limiter-flixible" package.
20 | */
21 | export default class LimiterMemoryStore extends RateLimiterBridge {
22 | #config: IRateLimiterOptions
23 |
24 | get name() {
25 | return 'memory'
26 | }
27 |
28 | constructor(config: LimiterMemoryStoreConfig) {
29 | debug('creating memory limiter store %O', config)
30 | const resolvedConfig = {
31 | keyPrefix: config.keyPrefix,
32 | execEvenly: config.execEvenly,
33 | points: config.requests,
34 | duration: string.seconds.parse(config.duration),
35 | blockDuration: config.blockDuration ? string.seconds.parse(config.blockDuration) : undefined,
36 | }
37 |
38 | super(new RateLimiterMemory(resolvedConfig))
39 | this.#config = resolvedConfig
40 | }
41 |
42 | /**
43 | * Clears the existing memory store to reset
44 | * rate limits
45 | */
46 | async clear() {
47 | debug('clearing memory store')
48 | this.rateLimiter = new RateLimiterMemory(this.#config)
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/stores/redis.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * @adonisjs/limiter
3 | *
4 | * (c) AdonisJS
5 | *
6 | * For the full copyright and license information, please view the LICENSE
7 | * file that was distributed with this source code.
8 | */
9 |
10 | import string from '@adonisjs/core/helpers/string'
11 | import { RateLimiterRedis } from 'rate-limiter-flexible'
12 | import { RedisClusterConnection, RedisConnection } from '@adonisjs/redis'
13 |
14 | import debug from '../debug.js'
15 | import RateLimiterBridge from './bridge.js'
16 | import type { LimiterRedisStoreConfig } from '../types.js'
17 |
18 | /**
19 | * Limiter redis store wraps the "RateLimiterRedis" implementation
20 | * from the "rate-limiter-flixible" package.
21 | */
22 | export default class LimiterRedisStore extends RateLimiterBridge {
23 | #client: RedisConnection | RedisClusterConnection
24 |
25 | get name() {
26 | return 'redis'
27 | }
28 |
29 | constructor(client: RedisConnection | RedisClusterConnection, config: LimiterRedisStoreConfig) {
30 | debug('creating redis limiter store %O', config)
31 | super(
32 | new RateLimiterRedis({
33 | rejectIfRedisNotReady: config.rejectIfRedisNotReady,
34 | storeClient: client.ioConnection,
35 | keyPrefix: config.keyPrefix,
36 | execEvenly: config.execEvenly,
37 | points: config.requests,
38 | duration: string.seconds.parse(config.duration),
39 | inMemoryBlockOnConsumed: config.inMemoryBlockOnConsumed,
40 | blockDuration: config.blockDuration
41 | ? string.seconds.parse(config.blockDuration)
42 | : undefined,
43 | inMemoryBlockDuration: config.inMemoryBlockDuration
44 | ? string.seconds.parse(config.inMemoryBlockDuration)
45 | : undefined,
46 | })
47 | )
48 | this.#client = client
49 | }
50 |
51 | /**
52 | * Flushes the redis database to clear existing
53 | * rate limits.
54 | *
55 | * Make sure to use a separate db for store rate limits
56 | * as this method flushes the entire database
57 | */
58 | async clear() {
59 | this.deleteInMemoryBlockedKeys()
60 | if (this.#client instanceof RedisClusterConnection) {
61 | debug('flushing redis cluster')
62 | for (let node of this.#client.nodes('master')) {
63 | await node.flushdb()
64 | }
65 | } else {
66 | debug('flushing redis database')
67 | await this.#client.flushdb()
68 | }
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * @adonisjs/limiter
3 | *
4 | * (c) AdonisJS
5 | *
6 | * For the full copyright and license information, please view the LICENSE
7 | * file that was distributed with this source code.
8 | */
9 |
10 | import { ConfigProvider } from '@adonisjs/core/types'
11 | import { LimiterManager } from './limiter_manager.js'
12 | import type { LimiterResponse } from './response.js'
13 |
14 | /**
15 | * The base configuration shared across all the stores.
16 | *
17 | * These options are inherited from the "rate-limiter-flexible"
18 | * package. However, a custom store can ignore these options
19 | * and create the implementation from scratch with custom
20 | * set of options.
21 | */
22 | export type LimiterStoreBaseConfig = {
23 | /**
24 | * The prefix to apply to all keys to ensure they are
25 | * unique across different limiter instances.
26 | *
27 | * Defaults to the key of the stores collection.
28 | */
29 | keyPrefix?: string
30 |
31 | /**
32 | * Define the number of requests after which the key should
33 | * be blocked within memory and avoid hitting the store.
34 | *
35 | * Let's understand this with an example:
36 | * - You allow 100 requests per minute to a user
37 | * - They make 140 requests. The last 40 requests will be denied
38 | * - However, the store still has to consult the database to know
39 | * if there are any requests left for a user on a given key.
40 | * - With this option, you can tell the store to stop consulting
41 | * the database after the count reaches 120.
42 | */
43 | inMemoryBlockOnConsumed?: number
44 |
45 | /**
46 | * The duration for which to block the user within memory after the
47 | * user has consumed all the requests. The value of this property
48 | * must match the "blockDuration" property in most case.
49 | *
50 | * The value must be a number in seconds or a string expression
51 | */
52 | inMemoryBlockDuration?: number | string
53 |
54 | /**
55 | * Delay the subsequent requests, so that all requests finish at the
56 | * end of the duration timeframe.
57 | *
58 | * Let's understand with an example
59 | *
60 | * - You allow a user to make 10 requests every 5 mins.
61 | * - Now, if they make all 10 requests within the first minute, they
62 | * will be sitting idle for next 4 mins.
63 | * - Now multiply this behavior across all the users of your app and
64 | * therefore you might see a peak in traffic during the first min
65 | * but no traffic in the last 4 mins.
66 | *
67 | *
68 | * - With "execEvenly" enabled, if a user makes 10 requests within the
69 | * first minute, they all will be kept waiting with incremental
70 | * timeouts.
71 | * - Hence, the last request made during that 1st minute will finish
72 | * after 5mins.
73 | *
74 | * Learn more
75 | * https://github.com/animir/node-rate-limiter-flexible/wiki/Smooth-out-traffic-peaks
76 | */
77 | execEvenly?: boolean
78 | }
79 |
80 | /**
81 | * The options accepted by stores to consume request/points
82 | * for a given key.
83 | */
84 | export type LimiterConsumptionOptions = Pick<
85 | LimiterStoreBaseConfig,
86 | 'inMemoryBlockDuration' | 'inMemoryBlockOnConsumed'
87 | > & {
88 | /**
89 | * Number of requests to allow during the specific
90 | * duration
91 | */
92 | requests: number
93 |
94 | /**
95 | * The duration after which the requests will be reset.
96 | *
97 | * The value must be a number in seconds or a string expression.
98 | */
99 | duration: number | string
100 |
101 | /**
102 | * The duration for which the key will be blocked after
103 | * consuming all the requests.
104 | *
105 | * The blocking should be performed when you want to penalize
106 | * a user for consuming all the requests.
107 | *
108 | * The value must be a number in seconds or a string expression
109 | */
110 | blockDuration?: number | string
111 | }
112 |
113 | /**
114 | * Config accepted by the limiter's memory store
115 | */
116 | export type LimiterMemoryStoreConfig = LimiterStoreBaseConfig & LimiterConsumptionOptions
117 |
118 | /**
119 | * Config accepted by the limiter's redis store
120 | */
121 | export type LimiterRedisStoreConfig = LimiterStoreBaseConfig &
122 | LimiterConsumptionOptions & {
123 | /**
124 | * Reject limiter instance creation when redis is not
125 | * ready to accept connection
126 | */
127 | rejectIfRedisNotReady?: boolean
128 | }
129 |
130 | /**
131 | * Config accepted by the limiter's database store
132 | */
133 | export type LimiterDatabaseStoreConfig = LimiterStoreBaseConfig &
134 | LimiterConsumptionOptions & {
135 | /**
136 | * The database to connect with.
137 | */
138 | dbName?: string
139 |
140 | /**
141 | * The database table to use for storing keys. Defaults
142 | * to "keyPrefix"
143 | */
144 | tableName: string
145 |
146 | /**
147 | * Define schema to use for making database queries.
148 | *
149 | * Applicable for postgres only
150 | */
151 | schemaName?: string
152 |
153 | /**
154 | * Automatically clear expired keys every 5 minutes.
155 | */
156 | clearExpiredByTimeout?: boolean
157 | }
158 |
159 | /**
160 | * The limiter store contract that all stores should
161 | * implement.
162 | */
163 | export interface LimiterStoreContract {
164 | /**
165 | * A unique name for the store
166 | */
167 | readonly name: string
168 |
169 | /**
170 | * The number of configured requests on the store
171 | */
172 | readonly requests: number
173 |
174 | /**
175 | * The duration (in seconds) for which the requests are configured
176 | */
177 | readonly duration: number
178 |
179 | /**
180 | * The duration (in seconds) for which to block the key
181 | */
182 | readonly blockDuration: number
183 |
184 | /**
185 | * Consume 1 request for a given key. An exception is raised
186 | * when all the requests have already been consumed or if
187 | * the key is blocked.
188 | */
189 | consume(key: string | number): Promise
190 |
191 | /**
192 | * Increment the number of consumed requests for a given key.
193 | * No errors are thrown when limit has reached
194 | */
195 | increment(key: string | number): Promise
196 |
197 | /**
198 | * Decrement the number of consumed requests for a given key.
199 | */
200 | decrement(key: string | number): Promise
201 |
202 | /**
203 | * Block a given key for the given duration. The duration must be
204 | * a value in seconds or a string expression.
205 | */
206 | block(key: string | number, duration: string | number): Promise
207 |
208 | /**
209 | * Manually set the number of requests exhausted for
210 | * a given key for the given time duration.
211 | *
212 | * For example: "ip_127.0.0.1" has made "20 requests" in "1 minute".
213 | * Now, if you allow 25 requests in 1 minute, then only 5 requests
214 | * are left.
215 | *
216 | * The duration must be a value in seconds or a string expression.
217 | */
218 | set(key: string | number, requests: number, duration?: string | number): Promise
219 |
220 | /**
221 | * Delete a given key
222 | */
223 | delete(key: string | number): Promise
224 |
225 | /**
226 | * Delete all keys blocked within the memory
227 | */
228 | deleteInMemoryBlockedKeys?(): void
229 |
230 | /**
231 | * Clear the storage database
232 | */
233 | clear(): Promise
234 |
235 | /**
236 | * Get limiter response for a given key. Returns null when
237 | * key doesn't exist.
238 | */
239 | get(key: string | number): Promise
240 | }
241 |
242 | /**
243 | * The manager factory is used to create an instance of the
244 | * store with consumption options
245 | */
246 | export type LimiterManagerStoreFactory = (
247 | options: LimiterConsumptionOptions
248 | ) => LimiterStoreContract
249 |
250 | /**
251 | * A list of known limiters inferred from the user config
252 | */
253 | export interface LimitersList {}
254 |
255 | /**
256 | * Helper method to resolve configured limiters
257 | * inside user app
258 | */
259 | export type InferLimiters<
260 | T extends ConfigProvider<{ stores: Record }>,
261 | > = Awaited>['stores']
262 |
263 | /**
264 | * Limiter service is a singleton instance of limiter
265 | * manager configured using user app's config
266 | */
267 | export interface LimiterService
268 | extends LimiterManager<
269 | LimitersList extends Record ? LimitersList : never
270 | > {}
271 |
--------------------------------------------------------------------------------
/stubs/config/limiter.stub:
--------------------------------------------------------------------------------
1 | {{{
2 | exports({ to: app.configPath('limiter.ts') })
3 | }}}
4 | import env from '#start/env'
5 | import { defineConfig, stores } from '@adonisjs/limiter'
6 |
7 | const limiterConfig = defineConfig({
8 | default: env.get('LIMITER_STORE'),
9 | stores: {
10 | {{#if store === 'redis'}}
11 | /**
12 | * Redis store to save rate limiting data inside a
13 | * redis database.
14 | *
15 | * It is recommended to use a separate database for
16 | * the limiter connection.
17 | */
18 | redis: stores.redis({}),
19 | {{/if}}
20 | {{#if store === 'database'}}
21 | /**
22 | * Database store to save rate limiting data inside a
23 | * MYSQL or PostgreSQL database.
24 | */
25 | database: stores.database({
26 | tableName: 'rate_limits'
27 | }),
28 | {{/if}}
29 | /**
30 | * Memory store could be used during
31 | * testing
32 | */
33 | memory: stores.memory({})
34 | },
35 | })
36 |
37 | export default limiterConfig
38 |
39 | declare module '@adonisjs/limiter/types' {
40 | export interface LimitersList extends InferLimiters {}
41 | }
42 |
--------------------------------------------------------------------------------
/stubs/main.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * @adonisjs/limiter
3 | *
4 | * (c) AdonisJS
5 | *
6 | * For the full copyright and license information, please view the LICENSE
7 | * file that was distributed with this source code.
8 | */
9 |
10 | import { getDirname } from '@adonisjs/core/helpers'
11 |
12 | export const stubsRoot = getDirname(import.meta.url)
13 |
--------------------------------------------------------------------------------
/stubs/make/migration/rate_limits.stub:
--------------------------------------------------------------------------------
1 | {{{
2 | exports({
3 | to: app.makePath(migration.folder, entity.path, migration.fileName)
4 | })
5 | }}}
6 | import { BaseSchema } from '@adonisjs/lucid/schema'
7 |
8 | export default class extends BaseSchema {
9 | protected tableName = 'rate_limits'
10 |
11 | async up() {
12 | this.schema.createTable(this.tableName, (table) => {
13 | table.string('key', 255).notNullable().primary()
14 | table.integer('points', 9).notNullable().defaultTo(0)
15 | table.bigint('expire').unsigned()
16 | })
17 | }
18 |
19 | async down() {
20 | this.schema.dropTable(this.tableName)
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/stubs/start/limiter.stub:
--------------------------------------------------------------------------------
1 | {{{
2 | exports({ to: app.startPath('limiter.ts') })
3 | }}}
4 | /*
5 | |--------------------------------------------------------------------------
6 | | Define HTTP limiters
7 | |--------------------------------------------------------------------------
8 | |
9 | | The "limiter.define" method creates an HTTP middleware to apply rate
10 | | limits on a route or a group of routes. Feel free to define as many
11 | | throttle middleware as needed.
12 | |
13 | */
14 |
15 | import limiter from '@adonisjs/limiter/services/main'
16 |
17 | export const throttle = limiter.define('global', () => {
18 | return limiter.allowRequests(10).every('1 minute')
19 | })
20 |
--------------------------------------------------------------------------------
/tests/configure.spec.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * @adonisjs/limiter
3 | *
4 | * (c) AdonisJS
5 | *
6 | * For the full copyright and license information, please view the LICENSE
7 | * file that was distributed with this source code.
8 | */
9 |
10 | import timekeeper from 'timekeeper'
11 | import { test } from '@japa/runner'
12 | import { fileURLToPath } from 'node:url'
13 | import { IgnitorFactory } from '@adonisjs/core/factories'
14 | import Configure from '@adonisjs/core/commands/configure'
15 |
16 | const BASE_URL = new URL('../tmp/', import.meta.url)
17 |
18 | test.group('Configure', (group) => {
19 | group.each.setup(({ context }) => {
20 | context.fs.baseUrl = BASE_URL
21 | context.fs.basePath = fileURLToPath(BASE_URL)
22 | })
23 |
24 | group.each.timeout(0)
25 |
26 | test('publish provider and env variables', async ({ assert, fs }) => {
27 | const ignitor = new IgnitorFactory()
28 | .withCoreProviders()
29 | .withCoreConfig()
30 | .create(fs.baseUrl, {
31 | importer: (filePath) => {
32 | if (filePath.startsWith('./') || filePath.startsWith('../')) {
33 | return import(new URL(filePath, fs.baseUrl).href)
34 | }
35 |
36 | return import(filePath)
37 | },
38 | })
39 |
40 | const app = ignitor.createApp('console')
41 | await app.init()
42 | await app.boot()
43 |
44 | await fs.create('.env', '')
45 | await fs.createJson('tsconfig.json', {})
46 | await fs.create('start/env.ts', `export default Env.create(new URL('./'), {})`)
47 | await fs.create('adonisrc.ts', `export default defineConfig({})`)
48 |
49 | const ace = await app.container.make('ace')
50 | ace.prompt
51 | .trap('Select the storage layer you want to use')
52 | .assertFails('', 'Please select a store')
53 | .assertPasses('redis')
54 | .chooseOption(1)
55 |
56 | const command = await ace.create(Configure, ['../index.js'])
57 | await command.exec()
58 |
59 | await assert.fileExists('config/limiter.ts')
60 | await assert.fileExists('start/limiter.ts')
61 | await assert.fileContains('adonisrc.ts', '@adonisjs/limiter/limiter_provider')
62 |
63 | await assert.fileContains('config/limiter.ts', [
64 | ` default: env.get('LIMITER_STORE'),`,
65 | `redis: stores.redis`,
66 | `memory: stores.memory`,
67 | ])
68 | await assert.fileContains('.env', 'LIMITER_STORE')
69 | await assert.fileContains(
70 | 'start/env.ts',
71 | `LIMITER_STORE: Env.schema.enum(['redis', 'memory'] as const)`
72 | )
73 | })
74 |
75 | test('configure using the --store CLI flag', async ({ assert, fs }) => {
76 | const ignitor = new IgnitorFactory()
77 | .withCoreProviders()
78 | .withCoreConfig()
79 | .create(fs.baseUrl, {
80 | importer: (filePath) => {
81 | if (filePath.startsWith('./') || filePath.startsWith('../')) {
82 | return import(new URL(filePath, fs.baseUrl).href)
83 | }
84 |
85 | return import(filePath)
86 | },
87 | })
88 |
89 | const app = ignitor.createApp('console')
90 | await app.init()
91 | await app.boot()
92 |
93 | await fs.create('.env', '')
94 | await fs.createJson('tsconfig.json', {})
95 | await fs.create('start/env.ts', `export default Env.create(new URL('./'), {})`)
96 | await fs.create('adonisrc.ts', `export default defineConfig({})`)
97 |
98 | const ace = await app.container.make('ace')
99 | const command = await ace.create(Configure, ['../index.js', '--store=database'])
100 | await command.exec()
101 |
102 | await assert.fileExists('config/limiter.ts')
103 | await assert.fileContains('adonisrc.ts', '@adonisjs/limiter/limiter_provider')
104 |
105 | await assert.fileContains('config/limiter.ts', [
106 | ` default: env.get('LIMITER_STORE'),`,
107 | `database: stores.database`,
108 | `memory: stores.memory`,
109 | ])
110 | await assert.fileContains('.env', 'LIMITER_STORE')
111 | await assert.fileContains(
112 | 'start/env.ts',
113 | `LIMITER_STORE: Env.schema.enum(['database', 'memory'] as const)`
114 | )
115 | })
116 |
117 | test('throw error when select store is invalid', async ({ fs }) => {
118 | const ignitor = new IgnitorFactory()
119 | .withCoreProviders()
120 | .withCoreConfig()
121 | .create(fs.baseUrl, {
122 | importer: (filePath) => {
123 | if (filePath.startsWith('./') || filePath.startsWith('../')) {
124 | return import(new URL(filePath, fs.baseUrl).href)
125 | }
126 |
127 | return import(filePath)
128 | },
129 | })
130 |
131 | const app = ignitor.createApp('console')
132 | await app.init()
133 | await app.boot()
134 |
135 | await fs.create('.env', '')
136 | await fs.createJson('tsconfig.json', {})
137 | await fs.create('start/env.ts', `export default Env.create(new URL('./'), {})`)
138 | await fs.create('adonisrc.ts', `export default defineConfig({})`)
139 |
140 | const ace = await app.container.make('ace')
141 | ace.ui.switchMode('raw')
142 | const command = await ace.create(Configure, ['../index.js', '--store=foo'])
143 | await command.exec()
144 |
145 | command.assertFailed()
146 | command.assertLog('Invalid limiter store "foo". Supported stores are: database and redis')
147 | })
148 |
149 | test('create migration file when database store is used', async ({ assert, fs }) => {
150 | timekeeper.freeze()
151 |
152 | const ignitor = new IgnitorFactory()
153 | .withCoreProviders()
154 | .withCoreConfig()
155 | .create(fs.baseUrl, {
156 | importer: (filePath) => {
157 | if (filePath.startsWith('./') || filePath.startsWith('../')) {
158 | return import(new URL(filePath, fs.baseUrl).href)
159 | }
160 |
161 | return import(filePath)
162 | },
163 | })
164 |
165 | const app = ignitor.createApp('console')
166 | await app.init()
167 | await app.boot()
168 |
169 | await fs.create('.env', '')
170 | await fs.createJson('tsconfig.json', {})
171 | await fs.create('start/env.ts', `export default Env.create(new URL('./'), {})`)
172 | await fs.create('adonisrc.ts', `export default defineConfig({})`)
173 |
174 | const ace = await app.container.make('ace')
175 | const command = await ace.create(Configure, ['../index.js', '--store=database'])
176 | await command.exec()
177 |
178 | await assert.fileExists(
179 | `database/migrations/${new Date().getTime()}_create_rate_limits_table.ts`
180 | )
181 | })
182 | })
183 |
--------------------------------------------------------------------------------
/tests/define_config.spec.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * @adonisjs/limiter
3 | *
4 | * (c) AdonisJS
5 | *
6 | * For the full copyright and license information, please view the LICENSE
7 | * file that was distributed with this source code.
8 | */
9 |
10 | import { test } from '@japa/runner'
11 | import { RedisService } from '@adonisjs/redis/types'
12 | import { ApplicationService } from '@adonisjs/core/types'
13 | import { AppFactory } from '@adonisjs/core/factories/app'
14 |
15 | import { Limiter } from '../src/limiter.js'
16 | import LimiterRedisStore from '../src/stores/redis.js'
17 | import LimiterMemoryStore from '../src/stores/memory.js'
18 | import { LimiterManager } from '../src/limiter_manager.js'
19 | import LimiterDatabaseStore from '../src/stores/database.js'
20 | import { defineConfig, stores } from '../src/define_config.js'
21 | import type { LimiterConsumptionOptions } from '../src/types.js'
22 | import { createDatabase, createRedis, createTables } from './helpers.js'
23 |
24 | test.group('Define config', () => {
25 | test('define redis store', async ({ assert }) => {
26 | const redis = createRedis() as unknown as RedisService
27 | const app = new AppFactory().create(new URL('./', import.meta.url)) as ApplicationService
28 | await app.init()
29 |
30 | app.container.singleton('redis', () => redis)
31 | const redisProvider = stores.redis({
32 | connectionName: 'main',
33 | })
34 |
35 | const storeFactory = await redisProvider.resolver(app)
36 | const store = storeFactory({ duration: '1mins', requests: 5 })
37 | assert.instanceOf(store, LimiterRedisStore)
38 | assert.isNull(await store.get('ip_localhost'))
39 | })
40 |
41 | test('define database store', async ({ assert }) => {
42 | const database = createDatabase()
43 | await createTables(database)
44 |
45 | const app = new AppFactory().create(new URL('./', import.meta.url)) as ApplicationService
46 | await app.init()
47 |
48 | app.container.singleton('lucid.db', () => database)
49 | const dbProvider = stores.database({
50 | connectionName: process.env.DB as any,
51 | dbName: 'limiter',
52 | tableName: 'rate_limits',
53 | })
54 |
55 | const storeFactory = await dbProvider.resolver(app)
56 | const store = storeFactory({ duration: '1mins', requests: 5 })
57 | assert.instanceOf(store, LimiterDatabaseStore)
58 | assert.isNull(await store.get('ip_localhost'))
59 | })
60 |
61 | test('use default database when no explicit database is configured', async ({ assert }) => {
62 | const database = createDatabase()
63 | await createTables(database)
64 |
65 | const app = new AppFactory().create(new URL('./', import.meta.url)) as ApplicationService
66 | await app.init()
67 |
68 | app.container.singleton('lucid.db', () => database)
69 | const dbProvider = stores.database({
70 | tableName: 'rate_limits',
71 | })
72 |
73 | const storeFactory = await dbProvider.resolver(app)
74 | const store = storeFactory({ duration: '1mins', requests: 5 })
75 | assert.instanceOf(store, LimiterDatabaseStore)
76 | assert.isNull(await store.get('ip_localhost'))
77 | })
78 |
79 | test('throw error when unregistered db connection is used', async () => {
80 | const database = createDatabase()
81 | await createTables(database)
82 |
83 | const app = new AppFactory().create(new URL('./', import.meta.url)) as ApplicationService
84 | await app.init()
85 |
86 | app.container.singleton('lucid.db', () => database)
87 | const dbProvider = stores.database({
88 | connectionName: 'foo',
89 | tableName: 'rate_limits',
90 | })
91 |
92 | await dbProvider.resolver(app)
93 | }).throws(
94 | 'Invalid connection name "foo" referenced by "config/limiter.ts" file. First register the connection inside "config/database.ts" file'
95 | )
96 |
97 | test('define memory store', async ({ assert }) => {
98 | const storeFactory = stores.memory({})
99 | const store = storeFactory({ duration: '1mins', requests: 5 })
100 | assert.instanceOf(store, LimiterMemoryStore)
101 | assert.isNull(await store.get('ip_localhost'))
102 | })
103 |
104 | test('throw error when config is invalid', async ({ assert }) => {
105 | const redis = createRedis() as unknown as RedisService
106 | const database = createDatabase()
107 | await createTables(database)
108 |
109 | const app = new AppFactory().create(new URL('./', import.meta.url)) as ApplicationService
110 | await app.init()
111 |
112 | app.container.singleton('redis', () => redis)
113 | app.container.singleton('lucid.db', () => database)
114 |
115 | assert.throws(
116 | () =>
117 | defineConfig({
118 | // @ts-expect-error
119 | default: 'redis',
120 | stores: {},
121 | }),
122 | 'Missing "stores.redis" in limiter config. It is referenced by the "default" property'
123 | )
124 |
125 | assert.throws(
126 | // @ts-expect-error
127 | () => defineConfig({}),
128 | 'Missing "stores" property in limiter config'
129 | )
130 |
131 | assert.throws(
132 | // @ts-expect-error
133 | () => defineConfig({ stores: {} }),
134 | 'Missing "default" store in limiter config'
135 | )
136 | })
137 |
138 | test('create manager from define config output', async ({ assert, expectTypeOf }) => {
139 | const redis = createRedis() as unknown as RedisService
140 | const database = createDatabase()
141 | await createTables(database)
142 |
143 | const app = new AppFactory().create(new URL('./', import.meta.url)) as ApplicationService
144 | await app.init()
145 |
146 | app.container.singleton('redis', () => redis)
147 | app.container.singleton('lucid.db', () => database)
148 |
149 | const config = defineConfig({
150 | default: 'redis',
151 | stores: {
152 | redis: stores.redis({
153 | connectionName: 'main',
154 | }),
155 | db: stores.database({
156 | connectionName: process.env.DB as any,
157 | dbName: 'limiter',
158 | tableName: 'rate_limits',
159 | }),
160 | memory: stores.memory({}),
161 | },
162 | })
163 |
164 | const limiter = new LimiterManager(await config.resolver(app))
165 | expectTypeOf(limiter.use).parameters.toEqualTypeOf<
166 | [LimiterConsumptionOptions] | ['redis' | 'db' | 'memory', LimiterConsumptionOptions]
167 | >()
168 | expectTypeOf(limiter.use).returns.toEqualTypeOf()
169 |
170 | assert.isNull(
171 | await limiter.use('redis', { duration: '1 min', requests: 5 }).get('ip_localhost')
172 | )
173 | assert.isNull(await limiter.use('db', { duration: '1 min', requests: 5 }).get('ip_localhost'))
174 | assert.isNull(
175 | await limiter.use('memory', { duration: '1 min', requests: 5 }).get('ip_localhost')
176 | )
177 | })
178 | })
179 |
--------------------------------------------------------------------------------
/tests/helpers.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * @adonisjs/limiter
3 | *
4 | * (c) AdonisJS
5 | *
6 | * For the full copyright and license information, please view the LICENSE
7 | * file that was distributed with this source code.
8 | */
9 |
10 | import { join } from 'node:path'
11 | import { configDotenv } from 'dotenv'
12 | import { getActiveTest } from '@japa/runner'
13 | import { Emitter } from '@adonisjs/core/events'
14 | import { Database } from '@adonisjs/lucid/database'
15 | import { AppFactory } from '@adonisjs/core/factories/app'
16 | import { RedisConnection, RedisManager } from '@adonisjs/redis'
17 | import { LoggerFactory } from '@adonisjs/core/factories/logger'
18 |
19 | configDotenv()
20 |
21 | declare module '@adonisjs/redis/types' {
22 | interface RedisConnections {
23 | main: RedisConnection
24 | }
25 | }
26 |
27 | /**
28 | * Creates an instance of the database class for making queries
29 | */
30 | export function createDatabase() {
31 | const test = getActiveTest()
32 | if (!test) {
33 | throw new Error('Cannot use "createDatabase" outside of a Japa test')
34 | }
35 |
36 | const app = new AppFactory().create(test.context.fs.baseUrl, () => {})
37 | const logger = new LoggerFactory().create()
38 | const emitter = new Emitter(app)
39 | const db = new Database(
40 | {
41 | connection: process.env.DB || 'pg',
42 | connections: {
43 | sqlite: {
44 | client: 'better-sqlite3',
45 | connection: {
46 | filename: ':memory:',
47 | },
48 | },
49 | libsql: {
50 | client: 'libsql',
51 | connection: {
52 | filename: join(test.context.fs.basePath, `file:libsql.db`),
53 | },
54 | },
55 | pg: {
56 | client: 'pg',
57 | connection: {
58 | host: process.env.PG_HOST as string,
59 | port: Number(process.env.PG_PORT),
60 | database: process.env.PG_DATABASE as string,
61 | user: process.env.PG_USER as string,
62 | password: process.env.PG_PASSWORD as string,
63 | },
64 | },
65 | mysql: {
66 | client: 'mysql2',
67 | connection: {
68 | host: process.env.MYSQL_HOST as string,
69 | port: Number(process.env.MYSQL_PORT),
70 | database: process.env.MYSQL_DATABASE as string,
71 | user: process.env.MYSQL_USER as string,
72 | password: process.env.MYSQL_PASSWORD as string,
73 | },
74 | },
75 | },
76 | },
77 | logger,
78 | emitter
79 | )
80 |
81 | test.cleanup(() => db.manager.closeAll())
82 | return db
83 | }
84 |
85 | /**
86 | * Creates redis manager instance to execute redis
87 | * commands
88 | */
89 | export function createRedis(keysToClear?: string[]) {
90 | const test = getActiveTest()
91 | if (!test) {
92 | throw new Error('Cannot use "createDatabase" outside of a Japa test')
93 | }
94 |
95 | const logger = new LoggerFactory().create()
96 | const redis = new RedisManager(
97 | {
98 | connection: 'main',
99 | connections: {
100 | main: {
101 | host: process.env.REDIS_HOST || '0.0.0.0',
102 | port: process.env.REDIS_PORT || 6379,
103 | },
104 | },
105 | },
106 | logger
107 | )
108 |
109 | test.cleanup(async () => {
110 | if (keysToClear) {
111 | await redis.del(...keysToClear)
112 | }
113 |
114 | await redis.disconnectAll()
115 | })
116 | return redis
117 | }
118 |
119 | /**
120 | * Creates needed database tables
121 | */
122 | export async function createTables(db: Database) {
123 | const test = getActiveTest()
124 | if (!test) {
125 | throw new Error('Cannot use "createTables" outside of a Japa test')
126 | }
127 |
128 | test.cleanup(async () => {
129 | await db.connection().schema.dropTable('rate_limits')
130 | })
131 |
132 | await db.connection().schema.createTable('rate_limits', (table) => {
133 | table.string('key', 255).notNullable().primary()
134 | table.integer('points', 9).notNullable().defaultTo(0)
135 | table.bigint('expire').unsigned()
136 | })
137 | }
138 |
--------------------------------------------------------------------------------
/tests/http_limiter.spec.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * @adonisjs/limiter
3 | *
4 | * (c) AdonisJS
5 | *
6 | * For the full copyright and license information, please view the LICENSE
7 | * file that was distributed with this source code.
8 | */
9 |
10 | import { test } from '@japa/runner'
11 | import { HttpContextFactory } from '@adonisjs/core/factories/http'
12 |
13 | import { createRedis } from './helpers.js'
14 | import { HttpLimiter } from '../src/http_limiter.js'
15 | import LimiterRedisStore from '../src/stores/redis.js'
16 | import { LimiterManager } from '../src/limiter_manager.js'
17 |
18 | test.group('Http limiter', () => {
19 | test('define http limiter', async ({ assert }) => {
20 | const redis = createRedis(['rlflx:ip_localhost']).connection()
21 | const limiterManager = new LimiterManager({
22 | default: 'redis',
23 | stores: {
24 | redis: (options) => new LimiterRedisStore(redis, options),
25 | },
26 | })
27 |
28 | const ctx = new HttpContextFactory().create()
29 | ctx.request.ip = function () {
30 | return 'localhost'
31 | }
32 |
33 | const limiter = new HttpLimiter(limiterManager)
34 | limiter.allowRequests(10).every('1 minute').blockFor('20 mins')
35 |
36 | assert.instanceOf(limiter, HttpLimiter)
37 | assert.deepEqual(limiter!.toJSON(), {
38 | duration: '1 minute',
39 | requests: 10,
40 | blockDuration: '20 mins',
41 | store: undefined,
42 | })
43 | })
44 |
45 | test('define custom unique key', async ({ assert }) => {
46 | const redis = createRedis(['rlflx:api_1']).connection()
47 | const limiterManager = new LimiterManager({
48 | default: 'redis',
49 | stores: {
50 | redis: (options) => new LimiterRedisStore(redis, options),
51 | },
52 | })
53 |
54 | const limiter = new HttpLimiter(limiterManager)
55 | limiter.allowRequests(10).every('1 minute').usingKey(1)
56 |
57 | assert.instanceOf(limiter, HttpLimiter)
58 | assert.deepEqual(limiter!.toJSON(), {
59 | duration: '1 minute',
60 | requests: 10,
61 | store: undefined,
62 | })
63 | })
64 |
65 | test('define named store', async ({ assert }) => {
66 | const redis = createRedis(['rlflx:api_1']).connection()
67 | const limiterManager = new LimiterManager({
68 | default: 'redis',
69 | stores: {
70 | redis: (options) => new LimiterRedisStore(redis, options),
71 | },
72 | })
73 |
74 | const limiter = new HttpLimiter(limiterManager)
75 | limiter.allowRequests(10).every('1 minute').usingKey(1).store('redis')
76 |
77 | assert.instanceOf(limiter, HttpLimiter)
78 | assert.deepEqual(limiter!.toJSON(), {
79 | duration: '1 minute',
80 | requests: 10,
81 | store: 'redis',
82 | })
83 | })
84 |
85 | test('throttle requests', async ({ assert }) => {
86 | const redis = createRedis(['rlflx:api_1']).connection()
87 | const limiterManager = new LimiterManager({
88 | default: 'redis',
89 | stores: {
90 | redis: (options) => new LimiterRedisStore(redis, options),
91 | },
92 | })
93 |
94 | const ctx = new HttpContextFactory().create()
95 | const limiter = new HttpLimiter(limiterManager)
96 | limiter.allowRequests(1).every('1 minute').usingKey(1)
97 |
98 | await assert.doesNotReject(() => limiter.throttle('api', ctx))
99 | await assert.rejects(() => limiter.throttle('api', ctx))
100 | })
101 |
102 | test('customize exception', async ({ assert }) => {
103 | assert.plan(2)
104 |
105 | const redis = createRedis(['rlflx:api_1']).connection()
106 | const limiterManager = new LimiterManager({
107 | default: 'redis',
108 | stores: {
109 | redis: (options) => new LimiterRedisStore(redis, options),
110 | },
111 | })
112 |
113 | const ctx = new HttpContextFactory().create()
114 | const limiter = new HttpLimiter(limiterManager)
115 | limiter
116 | .allowRequests(1)
117 | .every('1 minute')
118 | .usingKey(1)
119 | .limitExceeded((error) => {
120 | error.setMessage('Requests exhaused').setStatus(400)
121 | })
122 |
123 | await limiter.throttle('api', ctx)
124 | try {
125 | await limiter.throttle('api', ctx)
126 | } catch (error) {
127 | assert.equal(error.message, 'Requests exhaused')
128 | assert.equal(error.status, 400)
129 | }
130 | })
131 |
132 | test('throttle concurrent requests', async ({ assert }) => {
133 | const redis = createRedis(['rlflx:api_1']).connection()
134 | const limiterManager = new LimiterManager({
135 | default: 'redis',
136 | stores: {
137 | redis: (options) => new LimiterRedisStore(redis, options),
138 | },
139 | })
140 |
141 | const ctx = new HttpContextFactory().create()
142 | const limiter = new HttpLimiter(limiterManager)
143 | limiter
144 | .allowRequests(1)
145 | .every('1 minute')
146 | .usingKey(1)
147 | .store('redis')
148 | .limitExceeded((error) => {
149 | error.setMessage('Requests exhaused').setStatus(400)
150 | })
151 |
152 | const [first, second] = await Promise.allSettled([
153 | limiter!.throttle('api', ctx),
154 | limiter!.throttle('api', ctx),
155 | ])
156 | assert.equal(first.status, 'fulfilled')
157 | assert.equal(second.status, 'rejected')
158 | })
159 |
160 | test('throw error when requests are not configured', async ({ assert }) => {
161 | const redis = createRedis(['rlflx:api_1']).connection()
162 | const limiterManager = new LimiterManager({
163 | default: 'redis',
164 | stores: {
165 | redis: (options) => new LimiterRedisStore(redis, options),
166 | },
167 | })
168 |
169 | const ctx = new HttpContextFactory().create()
170 |
171 | const noRequests = new HttpLimiter(limiterManager)
172 | noRequests.every('1 minute').usingKey(1)
173 |
174 | const noDuration = new HttpLimiter(limiterManager)
175 | noDuration.allowRequests(100).usingKey(1)
176 |
177 | const noConfig = new HttpLimiter(limiterManager)
178 |
179 | await assert.rejects(
180 | async () => noRequests.throttle('api', ctx),
181 | 'Cannot throttle requests for "api" limiter. Make sure to define the allowed requests and duration'
182 | )
183 | await assert.rejects(
184 | async () => noDuration.throttle('api', ctx),
185 | 'Cannot throttle requests for "api" limiter. Make sure to define the allowed requests and duration'
186 | )
187 | await assert.rejects(
188 | async () => noConfig.throttle('api', ctx),
189 | 'Cannot throttle requests for "api" limiter. Make sure to define the allowed requests and duration'
190 | )
191 | })
192 | })
193 |
--------------------------------------------------------------------------------
/tests/limiter.spec.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * @adonisjs/limiter
3 | *
4 | * (c) AdonisJS
5 | *
6 | * For the full copyright and license information, please view the LICENSE
7 | * file that was distributed with this source code.
8 | */
9 |
10 | import sinon from 'sinon'
11 | import { test } from '@japa/runner'
12 |
13 | import { createRedis } from './helpers.js'
14 | import { Limiter } from '../src/limiter.js'
15 | import LimiterRedisStore from '../src/stores/redis.js'
16 | import { ThrottleException } from '../src/errors.js'
17 |
18 | test.group('Limiter', () => {
19 | test('proxy store methods', async ({ assert }) => {
20 | const redis = createRedis(['rlflx:ip_localhost']).connection()
21 | const store = new LimiterRedisStore(redis, {
22 | duration: '1 minute',
23 | requests: 5,
24 | })
25 |
26 | const limiter = new Limiter(store)
27 |
28 | assert.equal(limiter.requests, 5)
29 | assert.equal(limiter.duration, 60)
30 | assert.equal(limiter.name, 'redis')
31 |
32 | /**
33 | * consume call
34 | */
35 | const consumeCall = sinon.spy(store, 'consume')
36 | await limiter.consume('ip_localhost')
37 | assert.isTrue(consumeCall.calledOnceWithExactly('ip_localhost'), 'consume called')
38 |
39 | /**
40 | * increment call
41 | */
42 | const incrementCall = sinon.spy(store, 'increment')
43 | await limiter.increment('ip_localhost')
44 | assert.isTrue(incrementCall.calledOnceWithExactly('ip_localhost'), 'increment called')
45 |
46 | /**
47 | * decrement call
48 | */
49 | const decrementCall = sinon.spy(store, 'decrement')
50 | await limiter.decrement('ip_localhost')
51 | assert.isTrue(decrementCall.calledOnceWithExactly('ip_localhost'), 'decrement called')
52 |
53 | /**
54 | * get call
55 | */
56 | const getCall = sinon.spy(store, 'get')
57 | await limiter.get('ip_localhost')
58 | assert.isTrue(getCall.calledOnceWithExactly('ip_localhost'), 'get called')
59 |
60 | /**
61 | * set call
62 | */
63 | const setCall = sinon.spy(store, 'set')
64 | await limiter.set('ip_localhost', 10, '1 minute')
65 | assert.isTrue(setCall.calledOnceWithExactly('ip_localhost', 10, '1 minute'), 'set called')
66 |
67 | /**
68 | * block call
69 | */
70 | const blockCall = sinon.spy(store, 'block')
71 | await limiter.block('ip_localhost', '2 minutes')
72 | assert.isTrue(blockCall.calledOnceWithExactly('ip_localhost', '2 minutes'), 'block called')
73 |
74 | /**
75 | * delete call
76 | */
77 | const deleteCall = sinon.spy(store, 'delete')
78 | await limiter.delete('ip_localhost')
79 | assert.isTrue(deleteCall.calledOnceWithExactly('ip_localhost'), 'delete called')
80 |
81 | /**
82 | * Consume call
83 | */
84 | const deleteInMemoryBlockedKeys = sinon.spy(store, 'deleteInMemoryBlockedKeys')
85 | limiter.deleteInMemoryBlockedKeys()
86 | assert.isTrue(
87 | deleteInMemoryBlockedKeys.calledOnceWithExactly(),
88 | 'deleteInMemoryBlockedKeys called'
89 | )
90 | })
91 |
92 | test('increment requests count without throwing an error', async ({ assert }) => {
93 | const redis = createRedis(['rlflx:ip_localhost']).connection()
94 | const store = new LimiterRedisStore(redis, {
95 | duration: '1 minute',
96 | requests: 2,
97 | })
98 |
99 | const limiter = new Limiter(store)
100 |
101 | await limiter.increment('ip_localhost')
102 | await limiter.increment('ip_localhost')
103 | await assert.doesNotReject(() => limiter.increment('ip_localhost'))
104 | await assert.doesNotReject(() => limiter.increment('ip_localhost'))
105 | })
106 |
107 | test('do not run action when all requests have been exhausted', async ({ assert }) => {
108 | const executionStack: string[] = []
109 | const redis = createRedis(['rlflx:ip_localhost']).connection()
110 | const store = new LimiterRedisStore(redis, {
111 | duration: '1 minute',
112 | requests: 2,
113 | })
114 |
115 | const limiter = new Limiter(store)
116 |
117 | await limiter.attempt('ip_localhost', () => {
118 | executionStack.push('executed 1')
119 | })
120 | await limiter.attempt('ip_localhost', () => {
121 | executionStack.push('executed 2')
122 | })
123 | await limiter.attempt('ip_localhost', () => {
124 | executionStack.push('executed 3')
125 | })
126 | await limiter.attempt('ip_localhost', () => {
127 | executionStack.push('executed 4')
128 | })
129 |
130 | assert.deepEqual(executionStack, ['executed 1', 'executed 2'])
131 | assert.equal(await limiter.remaining('ip_localhost'), 0)
132 | })
133 |
134 | test('block key when trying to attempt after exhausting all requests', async ({ assert }) => {
135 | const executionStack: string[] = []
136 | const redis = createRedis(['rlflx:ip_localhost']).connection()
137 | const store = new LimiterRedisStore(redis, {
138 | duration: '1 minute',
139 | requests: 2,
140 | blockDuration: '30 mins',
141 | })
142 |
143 | const limiter = new Limiter(store)
144 |
145 | await limiter.attempt('ip_localhost', () => {
146 | executionStack.push('executed 1')
147 | })
148 | await limiter.attempt('ip_localhost', () => {
149 | executionStack.push('executed 2')
150 | })
151 | await limiter.attempt('ip_localhost', () => {
152 | executionStack.push('executed 3')
153 | })
154 | await limiter.attempt('ip_localhost', () => {
155 | executionStack.push('executed 4')
156 | })
157 |
158 | assert.deepEqual(executionStack, ['executed 1', 'executed 2'])
159 | assert.closeTo(await limiter.availableIn('ip_localhost'), 30 * 60, 5)
160 | })
161 |
162 | test('get seconds left until the key will be available for new request', async ({ assert }) => {
163 | const redis = createRedis(['rlflx:ip_localhost']).connection()
164 | const store = new LimiterRedisStore(redis, {
165 | duration: '1 minute',
166 | requests: 2,
167 | })
168 |
169 | const limiter = new Limiter(store)
170 |
171 | /**
172 | * Non-existing key is available right away
173 | */
174 | assert.equal(await limiter.availableIn('ip_localhost'), 0)
175 |
176 | /**
177 | * Key with pending requests is also available right away
178 | */
179 | await limiter.increment('ip_localhost')
180 | assert.equal(await limiter.availableIn('ip_localhost'), 0)
181 |
182 | /**
183 | * Exhausted keys have to wait
184 | */
185 | await limiter.increment('ip_localhost')
186 | assert.closeTo(await limiter.availableIn('ip_localhost'), 60, 5)
187 | })
188 |
189 | test('get remaining counts of a key', async ({ assert }) => {
190 | const redis = createRedis(['rlflx:ip_localhost']).connection()
191 | const store = new LimiterRedisStore(redis, {
192 | duration: '1 minute',
193 | requests: 2,
194 | })
195 |
196 | const limiter = new Limiter(store)
197 |
198 | assert.equal(await limiter.remaining('ip_localhost'), 2)
199 |
200 | await limiter.increment('ip_localhost')
201 | assert.equal(await limiter.remaining('ip_localhost'), 1)
202 |
203 | await limiter.increment('ip_localhost')
204 | await limiter.increment('ip_localhost')
205 | await limiter.increment('ip_localhost')
206 | assert.equal(await limiter.remaining('ip_localhost'), 0)
207 | })
208 |
209 | test('check if a key has exhausted all attempts', async ({ assert }) => {
210 | const redis = createRedis(['rlflx:ip_localhost']).connection()
211 | const store = new LimiterRedisStore(redis, {
212 | duration: '1 minute',
213 | requests: 2,
214 | })
215 |
216 | const limiter = new Limiter(store)
217 |
218 | assert.isFalse(await limiter.isBlocked('ip_localhost'))
219 |
220 | await limiter.increment('ip_localhost')
221 | assert.isFalse(await limiter.isBlocked('ip_localhost'))
222 |
223 | await limiter.increment('ip_localhost')
224 | assert.isTrue(await limiter.isBlocked('ip_localhost'))
225 | })
226 |
227 | test('consume point when the provided callback throws exception', async ({ assert }) => {
228 | const redis = createRedis(['rlflx:ip_localhost']).connection()
229 | const store = new LimiterRedisStore(redis, {
230 | duration: '1 minute',
231 | requests: 2,
232 | })
233 |
234 | const limiter = new Limiter(store)
235 |
236 | await assert.rejects(async () => {
237 | await limiter.penalize('ip_localhost', () => {
238 | throw new Error('Something went wrong')
239 | })
240 | }, 'Something went wrong')
241 | assert.equal(await limiter.remaining('ip_localhost'), 1)
242 |
243 | const [, result] = await limiter.penalize('ip_localhost', () => {
244 | return true
245 | })
246 | assert.isTrue(result)
247 |
248 | assert.isNull(await limiter.get('ip_localhost'))
249 | })
250 |
251 | test('return error via penalize when all requests has been exhausted', async ({
252 | assert,
253 | expectTypeOf,
254 | }) => {
255 | const redis = createRedis(['rlflx:ip_localhost']).connection()
256 | const store = new LimiterRedisStore(redis, {
257 | duration: '1 minute',
258 | requests: 2,
259 | })
260 |
261 | const limiter = new Limiter(store)
262 |
263 | await assert.rejects(async () => {
264 | await limiter.penalize('ip_localhost', () => {
265 | throw new Error('Something went wrong')
266 | })
267 | }, 'Something went wrong')
268 |
269 | await assert.rejects(async () => {
270 | await limiter.penalize('ip_localhost', () => {
271 | throw new Error('Something went wrong')
272 | })
273 | }, 'Something went wrong')
274 |
275 | const [error, user] = await limiter.penalize('ip_localhost', () => {
276 | return {
277 | id: 1,
278 | }
279 | })
280 |
281 | if (error) {
282 | expectTypeOf(error).toEqualTypeOf()
283 | expectTypeOf(user).toEqualTypeOf()
284 | } else {
285 | expectTypeOf(user).toEqualTypeOf<{ id: number }>()
286 | expectTypeOf(error).toEqualTypeOf()
287 | }
288 |
289 | assert.instanceOf(error, ThrottleException)
290 | assert.equal(error?.response.remaining, 0)
291 | assert.equal(await limiter.remaining('ip_localhost'), 0)
292 | assert.closeTo(await limiter.availableIn('ip_localhost'), 60, 5)
293 | })
294 |
295 | test('block key when all requests have been exhausted', async ({ assert }) => {
296 | const redis = createRedis(['rlflx:ip_localhost']).connection()
297 | const store = new LimiterRedisStore(redis, {
298 | duration: '1 minute',
299 | requests: 2,
300 | blockDuration: '30 mins',
301 | })
302 |
303 | const limiter = new Limiter(store)
304 |
305 | await assert.rejects(async () => {
306 | await limiter.penalize('ip_localhost', () => {
307 | throw new Error('Something went wrong')
308 | })
309 | }, 'Something went wrong')
310 |
311 | await assert.rejects(async () => {
312 | await limiter.penalize('ip_localhost', () => {
313 | throw new Error('Something went wrong')
314 | })
315 | }, 'Something went wrong')
316 |
317 | assert.closeTo(await limiter.availableIn('ip_localhost'), 60 * 30, 5)
318 | })
319 | })
320 |
--------------------------------------------------------------------------------
/tests/limiter_manager.spec.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * @adonisjs/limiter
3 | *
4 | * (c) AdonisJS
5 | *
6 | * For the full copyright and license information, please view the LICENSE
7 | * file that was distributed with this source code.
8 | */
9 |
10 | import { test } from '@japa/runner'
11 |
12 | import { createRedis } from './helpers.js'
13 | import { Limiter } from '../src/limiter.js'
14 | import LimiterRedisStore from '../src/stores/redis.js'
15 | import { LimiterManager } from '../src/limiter_manager.js'
16 | import LimiterMemoryStore from '../src/stores/memory.js'
17 |
18 | test.group('Limiter manager', () => {
19 | test('create limiter instances using manager', async ({ assert }) => {
20 | const redis = createRedis(['rlflx:ip_localhost']).connection()
21 | const limiterManager = new LimiterManager({
22 | default: 'redis',
23 | stores: {
24 | redis: (options) => new LimiterRedisStore(redis, options),
25 | },
26 | })
27 |
28 | const limiter = limiterManager.use('redis', { requests: 10, duration: '2 minutes' })
29 | assert.instanceOf(limiter, Limiter)
30 |
31 | const response = await limiter.consume('ip_localhost')
32 | assert.containsSubset(response.toJSON(), {
33 | limit: 10,
34 | remaining: 9,
35 | consumed: 1,
36 | })
37 | assert.closeTo(response.availableIn, 120, 5)
38 | })
39 |
40 | test('re-use instances as long as all options are the same', async ({ assert }) => {
41 | const redis = createRedis(['rlflx:ip_localhost']).connection()
42 | const limiterManager = new LimiterManager({
43 | default: 'redis',
44 | stores: {
45 | redis: (options) => new LimiterRedisStore(redis, options),
46 | },
47 | })
48 |
49 | assert.strictEqual(
50 | limiterManager.use('redis', { requests: 10, duration: '2 minutes' }),
51 | limiterManager.use('redis', { requests: 10, duration: '2 minutes' })
52 | )
53 | assert.strictEqual(
54 | limiterManager.use('redis', { requests: 10, duration: '2 minutes' }),
55 | limiterManager.use({ requests: 10, duration: '2 minutes' })
56 | )
57 | assert.notStrictEqual(
58 | limiterManager.use('redis', { requests: 10, duration: '2 minutes' }),
59 | limiterManager.use('redis', { requests: 10, duration: '1 minute' })
60 | )
61 | assert.notStrictEqual(
62 | limiterManager.use('redis', { requests: 10, duration: '2 minutes' }),
63 | limiterManager.use('redis', { requests: 5, duration: '2 minutes' })
64 | )
65 | assert.notStrictEqual(
66 | limiterManager.use('redis', { requests: 10, duration: '2 minutes' }),
67 | limiterManager.use('redis', { requests: 10, duration: '2 minutes', blockDuration: '2 mins' })
68 | )
69 | assert.notStrictEqual(
70 | limiterManager.use('redis', { requests: 10, duration: '2 minutes' }),
71 | limiterManager.use('redis', {
72 | requests: 10,
73 | duration: '2 minutes',
74 | inMemoryBlockOnConsumed: 12,
75 | inMemoryBlockDuration: '2 minutes',
76 | })
77 | )
78 | })
79 |
80 | test('throw error when no options are provided', async ({ assert }) => {
81 | const redis = createRedis(['rlflx:ip_localhost']).connection()
82 | const limiterManager = new LimiterManager({
83 | default: 'redis',
84 | stores: {
85 | redis: (options) => new LimiterRedisStore(redis, options),
86 | },
87 | })
88 |
89 | assert.throws(
90 | // @ts-expect-error
91 | () => limiterManager.use('redis'),
92 | 'Specify the number of allowed requests and duration to create a limiter'
93 | )
94 | assert.throws(
95 | // @ts-expect-error
96 | () => limiterManager.use(),
97 | 'Specify the number of allowed requests and duration to create a limiter'
98 | )
99 | })
100 |
101 | test('clear all stores', async ({ assert }) => {
102 | const redis = createRedis(['rlflx:ip_localhost', 'rlflx:id_1']).connection()
103 |
104 | const limiterManager = new LimiterManager({
105 | default: 'redis',
106 | stores: {
107 | redis: (options) => new LimiterRedisStore(redis, options),
108 | memory: (options) => new LimiterMemoryStore(options),
109 | },
110 | })
111 |
112 | const global = limiterManager.use('redis', { duration: 60, requests: 2 })
113 | const user = limiterManager.use('redis', { duration: 60, requests: 4 })
114 |
115 | const memoryGlobal = limiterManager.use('memory', { duration: 60, requests: 2 })
116 | const memoryUser = limiterManager.use('memory', { duration: 60, requests: 4 })
117 |
118 | await global.consume('ip_localhost')
119 | await user.consume('id_1')
120 | await memoryGlobal.consume('ip_localhost')
121 | await memoryUser.consume('id_1')
122 |
123 | assert.equal(await global.remaining('ip_localhost'), 1)
124 | assert.equal(await user.remaining('id_1'), 3)
125 | assert.equal(await memoryGlobal.remaining('ip_localhost'), 1)
126 | assert.equal(await memoryUser.remaining('id_1'), 3)
127 |
128 | await limiterManager.clear()
129 | assert.equal(await global.remaining('ip_localhost'), 2)
130 | assert.equal(await user.remaining('id_1'), 4)
131 | assert.equal(await memoryGlobal.remaining('ip_localhost'), 2)
132 | assert.equal(await memoryUser.remaining('id_1'), 4)
133 | })
134 |
135 | test('clear selected stores', async ({ assert }) => {
136 | const redis = createRedis(['rlflx:ip_localhost', 'rlflx:id_1']).connection()
137 |
138 | const limiterManager = new LimiterManager({
139 | default: 'redis',
140 | stores: {
141 | redis: (options) => new LimiterRedisStore(redis, options),
142 | memory: (options) => new LimiterMemoryStore(options),
143 | },
144 | })
145 |
146 | const global = limiterManager.use('redis', { duration: 60, requests: 2 })
147 | const user = limiterManager.use('redis', { duration: 60, requests: 4 })
148 |
149 | const memoryGlobal = limiterManager.use('memory', { duration: 60, requests: 2 })
150 | const memoryUser = limiterManager.use('memory', { duration: 60, requests: 4 })
151 |
152 | await global.consume('ip_localhost')
153 | await user.consume('id_1')
154 | await memoryGlobal.consume('ip_localhost')
155 | await memoryUser.consume('id_1')
156 |
157 | assert.equal(await global.remaining('ip_localhost'), 1)
158 | assert.equal(await user.remaining('id_1'), 3)
159 | assert.equal(await memoryGlobal.remaining('ip_localhost'), 1)
160 | assert.equal(await memoryUser.remaining('id_1'), 3)
161 |
162 | await limiterManager.clear(['redis'])
163 | assert.equal(await global.remaining('ip_localhost'), 2)
164 | assert.equal(await user.remaining('id_1'), 4)
165 | assert.equal(await memoryGlobal.remaining('ip_localhost'), 1)
166 | assert.equal(await memoryUser.remaining('id_1'), 3)
167 | })
168 | })
169 |
--------------------------------------------------------------------------------
/tests/limiter_provider.spec.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * @adonisjs/limiter
3 | *
4 | * (c) AdonisJS
5 | *
6 | * For the full copyright and license information, please view the LICENSE
7 | * file that was distributed with this source code.
8 | */
9 |
10 | import { test } from '@japa/runner'
11 | import { IgnitorFactory } from '@adonisjs/core/factories'
12 | import type { RedisService } from '@adonisjs/redis/types'
13 |
14 | import { createRedis } from './helpers.js'
15 | import { LimiterManager, defineConfig, stores } from '../index.js'
16 |
17 | const BASE_URL = new URL('./tmp/', import.meta.url)
18 | const IMPORTER = (filePath: string) => {
19 | if (filePath.startsWith('./') || filePath.startsWith('../')) {
20 | return import(new URL(filePath, BASE_URL).href)
21 | }
22 | return import(filePath)
23 | }
24 |
25 | test.group('Limiter provider', () => {
26 | test('register limiter provider', async ({ assert }) => {
27 | const redis = createRedis() as unknown as RedisService
28 |
29 | const ignitor = new IgnitorFactory()
30 | .merge({
31 | rcFileContents: {
32 | providers: [() => import('../providers/limiter_provider.js')],
33 | },
34 | })
35 | .withCoreConfig()
36 | .withCoreProviders()
37 | .merge({
38 | config: {
39 | limiter: defineConfig({
40 | default: 'redis',
41 | stores: {
42 | redis: stores.redis({
43 | connectionName: 'main',
44 | }),
45 | },
46 | }),
47 | },
48 | })
49 | .create(BASE_URL, {
50 | importer: IMPORTER,
51 | })
52 |
53 | const app = ignitor.createApp('web')
54 | await app.init()
55 | app.container.singleton('redis', () => redis)
56 | await app.boot()
57 |
58 | assert.instanceOf(await app.container.make('limiter.manager'), LimiterManager)
59 | })
60 |
61 | test('throw error when config is invalid', async () => {
62 | const redis = createRedis() as unknown as RedisService
63 |
64 | const ignitor = new IgnitorFactory()
65 | .merge({
66 | rcFileContents: {
67 | providers: [() => import('../providers/limiter_provider.js')],
68 | },
69 | })
70 | .withCoreConfig()
71 | .withCoreProviders()
72 | .merge({
73 | config: {
74 | limiter: {},
75 | },
76 | })
77 | .create(BASE_URL, {
78 | importer: IMPORTER,
79 | })
80 |
81 | const app = ignitor.createApp('web')
82 | await app.init()
83 | app.container.singleton('redis', () => redis)
84 | await app.boot()
85 |
86 | await app.container.make('limiter.manager')
87 | }).throws('Invalid "config/limiter.ts" file. Make sure you are using the "defineConfig" method')
88 | })
89 |
--------------------------------------------------------------------------------
/tests/stores/database.spec.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * @adonisjs/limiter
3 | *
4 | * (c) AdonisJS
5 | *
6 | * For the full copyright and license information, please view the LICENSE
7 | * file that was distributed with this source code.
8 | */
9 |
10 | import { test } from '@japa/runner'
11 | import { LimiterResponse } from '../../src/response.js'
12 | import { E_TOO_MANY_REQUESTS } from '../../src/errors.js'
13 | import { createDatabase, createTables } from '../helpers.js'
14 | import LimiterDatabaseStore from '../../src/stores/database.js'
15 |
16 | test.group('Limiter database store | wrapper', () => {
17 | test('throw error when trying to use connection other than mysql, sqlite or pg', async () => {
18 | const db = createDatabase()
19 | await createTables(db)
20 |
21 | new LimiterDatabaseStore(db.connection('libsql'), {
22 | dbName: 'limiter',
23 | tableName: 'rate_limits',
24 | duration: '1 minute',
25 | requests: 5,
26 | })
27 | }).throws(
28 | 'Unsupported database "libsql". The limiter can only work with PostgreSQL, MySQL, and SQLite databases'
29 | )
30 |
31 | test('define readonly properties', async ({ assert }) => {
32 | const db = createDatabase()
33 | await createTables(db)
34 |
35 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), {
36 | dbName: 'limiter',
37 | tableName: 'rate_limits',
38 | duration: '1 minute',
39 | requests: 5,
40 | })
41 |
42 | assert.equal(store.name, 'database')
43 | assert.equal(store.requests, 5)
44 | assert.equal(store.duration, 60)
45 | })
46 | })
47 |
48 | test.group('Limiter database store | wrapper | consume', () => {
49 | test('consume points using the database store', async ({ assert }) => {
50 | const db = createDatabase()
51 | await createTables(db)
52 |
53 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), {
54 | dbName: 'limiter',
55 | tableName: 'rate_limits',
56 | duration: '1 minute',
57 | requests: 5,
58 | })
59 |
60 | const response = await store.consume('ip_localhost')
61 | assert.instanceOf(response, LimiterResponse)
62 | assert.containsSubset(response.toJSON(), {
63 | limit: 5,
64 | remaining: 4,
65 | consumed: 1,
66 | })
67 | assert.closeTo(response.availableIn, 60, 5)
68 | })
69 |
70 | test('throw error when no points are left', async ({ assert }) => {
71 | const db = createDatabase()
72 | await createTables(db)
73 |
74 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), {
75 | dbName: 'limiter',
76 | tableName: 'rate_limits',
77 | duration: '1 minute',
78 | requests: 1,
79 | })
80 |
81 | await store.consume('ip_localhost')
82 | try {
83 | await store.consume('ip_localhost')
84 | } catch (error) {
85 | assert.instanceOf(error, E_TOO_MANY_REQUESTS)
86 | assert.containsSubset(error.response.toJSON(), {
87 | limit: 1,
88 | remaining: 0,
89 | consumed: 2,
90 | })
91 | assert.closeTo(error.response.availableIn, 60, 5)
92 | }
93 | })
94 |
95 | test('block key when all points have been consumed', async ({ assert }) => {
96 | const db = createDatabase()
97 | await createTables(db)
98 |
99 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), {
100 | dbName: 'limiter',
101 | tableName: 'rate_limits',
102 | duration: '1 minute',
103 | requests: 1,
104 | blockDuration: '2 minutes',
105 | })
106 |
107 | await store.consume('ip_localhost')
108 | try {
109 | await store.consume('ip_localhost')
110 | } catch (error) {
111 | assert.instanceOf(error, E_TOO_MANY_REQUESTS)
112 | assert.containsSubset(error.response.toJSON(), {
113 | limit: 1,
114 | remaining: 0,
115 | consumed: 2,
116 | })
117 | assert.closeTo(error.response.availableIn, 120, 5)
118 | }
119 | })
120 |
121 | test('increment request counter even when the key has consumed all requests', async ({
122 | assert,
123 | }) => {
124 | const db = createDatabase()
125 | await createTables(db)
126 |
127 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), {
128 | dbName: 'limiter',
129 | tableName: 'rate_limits',
130 | duration: '1 minute',
131 | requests: 2,
132 | })
133 |
134 | await store.consume('ip_localhost')
135 | await store.consume('ip_localhost')
136 | await assert.rejects(() => store.consume('ip_localhost'))
137 | await assert.rejects(() => store.consume('ip_localhost'))
138 |
139 | const response = await store.get('ip_localhost')
140 | assert.instanceOf(response, LimiterResponse)
141 | assert.equal(response!.consumed, 4)
142 | })
143 |
144 | test('do not increment request counter when blocking keys in memory', async ({ assert }) => {
145 | const db = createDatabase()
146 | await createTables(db)
147 |
148 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), {
149 | dbName: 'limiter',
150 | tableName: 'rate_limits',
151 | duration: '1 minute',
152 | inMemoryBlockOnConsumed: 2,
153 | inMemoryBlockDuration: '1 minute',
154 | requests: 2,
155 | })
156 |
157 | await store.consume('ip_localhost')
158 | await store.consume('ip_localhost')
159 | await assert.rejects(() => store.consume('ip_localhost'))
160 | await assert.rejects(() => store.consume('ip_localhost'))
161 | await assert.rejects(() => store.consume('ip_localhost'))
162 | await assert.rejects(() => store.consume('ip_localhost'))
163 |
164 | const response = await store.get('ip_localhost')
165 | assert.instanceOf(response, LimiterResponse)
166 | assert.equal(response!.consumed, 3)
167 | })
168 |
169 | test('reset in memory blocked keys', async ({ assert }) => {
170 | const db = createDatabase()
171 | await createTables(db)
172 |
173 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), {
174 | dbName: 'limiter',
175 | tableName: 'rate_limits',
176 | duration: '1 minute',
177 | inMemoryBlockOnConsumed: 2,
178 | inMemoryBlockDuration: '1 minute',
179 | requests: 2,
180 | })
181 |
182 | await store.consume('ip_localhost')
183 | await store.consume('ip_localhost')
184 | await assert.rejects(() => store.consume('ip_localhost'))
185 | await assert.rejects(() => store.consume('ip_localhost'))
186 | await assert.rejects(() => store.consume('ip_localhost'))
187 |
188 | const response = await store.get('ip_localhost')
189 | assert.equal(response!.consumed, 3)
190 |
191 | store.deleteInMemoryBlockedKeys()
192 | await assert.rejects(() => store.consume('ip_localhost'))
193 |
194 | const freshResponse = await store.get('ip_localhost')
195 | assert.equal(freshResponse!.consumed, 4)
196 | })
197 |
198 | test('throw error when no database table does not exists', async ({ assert }) => {
199 | const db = createDatabase()
200 | await createTables(db)
201 |
202 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), {
203 | dbName: 'limiter',
204 | tableName: 'foo',
205 | duration: '1 minute',
206 | requests: 1,
207 | })
208 |
209 | try {
210 | await store.consume('ip_localhost')
211 | } catch (error) {
212 | assert.match(
213 | error.message,
214 | /relation "foo" does not exist|Table 'limiter.foo' doesn't exist|no such table: foo/
215 | )
216 | }
217 | })
218 | })
219 |
220 | test.group('Limiter database store | wrapper | get', () => {
221 | test('get response for a pre-existing key', async ({ assert }) => {
222 | const db = createDatabase()
223 | await createTables(db)
224 |
225 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), {
226 | dbName: 'limiter',
227 | tableName: 'rate_limits',
228 | duration: '1 minute',
229 | requests: 5,
230 | })
231 |
232 | await store.consume('ip_localhost')
233 | const response = await store.get('ip_localhost')
234 | assert.instanceOf(response, LimiterResponse)
235 | assert.containsSubset(response!.toJSON(), {
236 | limit: 5,
237 | remaining: 4,
238 | consumed: 1,
239 | })
240 | assert.closeTo(response!.availableIn, 60, 5)
241 | })
242 |
243 | test('return null when key does not exists', async ({ assert }) => {
244 | const db = createDatabase()
245 | await createTables(db)
246 |
247 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), {
248 | dbName: 'limiter',
249 | tableName: 'rate_limits',
250 | duration: '1 minute',
251 | requests: 5,
252 | })
253 |
254 | const response = await store.get('ip_localhost')
255 | assert.isNull(response)
256 | })
257 | })
258 |
259 | test.group('Limiter database store | wrapper | set', () => {
260 | test('set requests consumed for a given key', async ({ assert }) => {
261 | const db = createDatabase()
262 | await createTables(db)
263 |
264 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), {
265 | dbName: 'limiter',
266 | tableName: 'rate_limits',
267 | duration: '1 minute',
268 | requests: 5,
269 | })
270 |
271 | const response = await store.set('ip_localhost', 2, '1 minute')
272 | const freshResponse = await store.get('ip_localhost')
273 | assert.instanceOf(response, LimiterResponse)
274 | assert.containsSubset(response!.toJSON(), {
275 | limit: 5,
276 | remaining: 3,
277 | consumed: 2,
278 | })
279 | assert.closeTo(response.availableIn, 60, 5)
280 | assert.equal(response.remaining, freshResponse?.remaining)
281 | assert.equal(response.consumed, freshResponse?.consumed)
282 | })
283 |
284 | test('overwrite existing points of a key', async ({ assert }) => {
285 | const db = createDatabase()
286 | await createTables(db)
287 |
288 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), {
289 | dbName: 'limiter',
290 | tableName: 'rate_limits',
291 | duration: '1 minute',
292 | requests: 5,
293 | })
294 |
295 | await store.consume('ip_localhost')
296 | await store.consume('ip_localhost')
297 | await store.consume('ip_localhost')
298 |
299 | const response = await store.set('ip_localhost', 2, '1 minute')
300 | const freshResponse = await store.get('ip_localhost')
301 | assert.instanceOf(response, LimiterResponse)
302 | assert.containsSubset(response!.toJSON(), {
303 | limit: 5,
304 | remaining: 3,
305 | consumed: 2,
306 | })
307 |
308 | assert.closeTo(response.availableIn, 60, 5)
309 | assert.equal(response.remaining, freshResponse?.remaining)
310 | assert.equal(response.consumed, freshResponse?.consumed)
311 | })
312 | })
313 |
314 | test.group('Limiter database store | wrapper | block', () => {
315 | test('block a given key', async ({ assert }) => {
316 | const db = createDatabase()
317 | await createTables(db)
318 |
319 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), {
320 | dbName: 'limiter',
321 | tableName: 'rate_limits',
322 | duration: '1 minute',
323 | requests: 5,
324 | })
325 |
326 | const response = await store.block('ip_localhost', '2 minutes')
327 | const freshResponse = await store.get('ip_localhost')
328 | assert.instanceOf(response, LimiterResponse)
329 | assert.containsSubset(response!.toJSON(), {
330 | limit: 5,
331 | remaining: 0,
332 | consumed: 6,
333 | availableIn: 120,
334 | })
335 |
336 | assert.closeTo(response.availableIn, 120, 5)
337 | assert.equal(response.remaining, freshResponse?.remaining)
338 | assert.equal(response.consumed, freshResponse?.consumed)
339 | })
340 |
341 | test('disallow consume calls on a blocked key', async () => {
342 | const db = createDatabase()
343 | await createTables(db)
344 |
345 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), {
346 | dbName: 'limiter',
347 | tableName: 'rate_limits',
348 | duration: '1 minute',
349 | requests: 5,
350 | })
351 |
352 | await store.block('ip_localhost', '2 minutes')
353 | await store.consume('ip_localhost')
354 | }).throws('Too many requests')
355 | })
356 |
357 | test.group('Limiter database store | wrapper | delete', () => {
358 | test('delete blocked key', async ({ assert }) => {
359 | const db = createDatabase()
360 | await createTables(db)
361 |
362 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), {
363 | dbName: 'limiter',
364 | tableName: 'rate_limits',
365 | duration: '1 minute',
366 | requests: 5,
367 | })
368 |
369 | await store.block('ip_localhost', '2 minutes')
370 | const response = await store.get('ip_localhost')
371 | assert.instanceOf(response, LimiterResponse)
372 | assert.containsSubset(response!.toJSON(), {
373 | limit: 5,
374 | remaining: 0,
375 | consumed: 6,
376 | })
377 | assert.closeTo(response!.availableIn, 120, 5)
378 |
379 | await store.delete('ip_localhost')
380 | const freshResponse = await store.get('ip_localhost')
381 | assert.isNull(freshResponse)
382 | })
383 |
384 | test('allow consume calls after delete', async ({ assert }) => {
385 | const db = createDatabase()
386 | await createTables(db)
387 |
388 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), {
389 | dbName: 'limiter',
390 | tableName: 'rate_limits',
391 | duration: '1 minute',
392 | requests: 5,
393 | })
394 |
395 | await store.block('ip_localhost', '2 minutes')
396 | await assert.rejects(() => store.consume('ip_localhost'))
397 |
398 | await store.delete('ip_localhost')
399 | await assert.doesNotReject(() => store.consume('ip_localhost'))
400 | })
401 | })
402 |
403 | test.group('Limiter database store | wrapper | clear', () => {
404 | test('clear db', async ({ assert }) => {
405 | const db = createDatabase()
406 | await createTables(db)
407 |
408 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), {
409 | dbName: 'limiter',
410 | tableName: 'rate_limits',
411 | duration: '1 minute',
412 | requests: 5,
413 | })
414 |
415 | await store.consume('ip_localhost')
416 | const response = await store.get('ip_localhost')
417 | assert.instanceOf(response, LimiterResponse)
418 |
419 | await store.clear()
420 | assert.isNull(await store.get('ip_localhost'))
421 | })
422 | })
423 |
424 | test.group('Limiter database store | wrapper | increment', () => {
425 | test('increment the requests count', async ({ assert }) => {
426 | const db = createDatabase()
427 | await createTables(db)
428 |
429 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), {
430 | dbName: 'limiter',
431 | tableName: 'rate_limits',
432 | duration: '1 minute',
433 | requests: 5,
434 | })
435 |
436 | await store.consume('ip_localhost')
437 | const response = await store.increment('ip_localhost')
438 | assert.instanceOf(response, LimiterResponse)
439 | assert.containsSubset(response.toJSON(), {
440 | limit: 5,
441 | remaining: 3,
442 | consumed: 2,
443 | })
444 | })
445 |
446 | test('do not throw when incrementing beyond the limit', async ({ assert }) => {
447 | const db = createDatabase()
448 | await createTables(db)
449 |
450 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), {
451 | dbName: 'limiter',
452 | tableName: 'rate_limits',
453 | duration: '1 minute',
454 | requests: 1,
455 | })
456 |
457 | await store.consume('ip_localhost')
458 | await store.increment('ip_localhost')
459 | const response = await store.increment('ip_localhost')
460 | assert.instanceOf(response, LimiterResponse)
461 | assert.containsSubset(response.toJSON(), {
462 | limit: 1,
463 | remaining: 0,
464 | consumed: 3,
465 | })
466 | })
467 |
468 | test('increment for non-existing key', async ({ assert }) => {
469 | const db = createDatabase()
470 | await createTables(db)
471 |
472 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), {
473 | dbName: 'limiter',
474 | tableName: 'rate_limits',
475 | duration: '1 minute',
476 | requests: 1,
477 | })
478 |
479 | const response = await store.increment('ip_localhost')
480 | assert.instanceOf(response, LimiterResponse)
481 | assert.containsSubset(response.toJSON(), {
482 | limit: 1,
483 | remaining: 0,
484 | consumed: 1,
485 | })
486 | })
487 | })
488 |
489 | test.group('Limiter database store | wrapper | decrement', () => {
490 | test('decrement the requests count', async ({ assert }) => {
491 | const db = createDatabase()
492 | await createTables(db)
493 |
494 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), {
495 | dbName: 'limiter',
496 | tableName: 'rate_limits',
497 | duration: '1 minute',
498 | requests: 5,
499 | })
500 |
501 | await store.consume('ip_localhost')
502 | const response = await store.decrement('ip_localhost')
503 | assert.instanceOf(response, LimiterResponse)
504 | assert.containsSubset(response.toJSON(), {
505 | limit: 5,
506 | remaining: 5,
507 | consumed: 0,
508 | })
509 | })
510 |
511 | test('do not throw when decrementing beyond zero', async ({ assert }) => {
512 | const db = createDatabase()
513 | await createTables(db)
514 |
515 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), {
516 | dbName: 'limiter',
517 | tableName: 'rate_limits',
518 | duration: '1 minute',
519 | requests: 1,
520 | })
521 |
522 | await store.consume('ip_localhost')
523 | await store.decrement('ip_localhost')
524 | const response = await store.decrement('ip_localhost')
525 | const freshResponse = await store.get('ip_localhost')
526 |
527 | assert.instanceOf(response, LimiterResponse)
528 | assert.containsSubset(response.toJSON(), {
529 | limit: 1,
530 | remaining: 1,
531 | consumed: 0,
532 | })
533 |
534 | assert.instanceOf(freshResponse, LimiterResponse)
535 | assert.containsSubset(freshResponse!.toJSON(), {
536 | limit: 1,
537 | remaining: 1,
538 | consumed: 0,
539 | })
540 | })
541 |
542 | test('decrement non-existing key', async ({ assert }) => {
543 | const db = createDatabase()
544 | await createTables(db)
545 |
546 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), {
547 | dbName: 'limiter',
548 | tableName: 'rate_limits',
549 | duration: '1 minute',
550 | requests: 1,
551 | })
552 |
553 | const response = await store.decrement('ip_localhost')
554 | assert.instanceOf(response, LimiterResponse)
555 | assert.containsSubset(response.toJSON(), {
556 | limit: 1,
557 | remaining: 1,
558 | consumed: 0,
559 | })
560 |
561 | await assert.doesNotReject(() => store.consume('ip_localhost'))
562 | await assert.rejects(() => store.consume('ip_localhost'))
563 | })
564 | })
565 |
--------------------------------------------------------------------------------
/tests/stores/memory.spec.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * @adonisjs/limiter
3 | *
4 | * (c) AdonisJS
5 | *
6 | * For the full copyright and license information, please view the LICENSE
7 | * file that was distributed with this source code.
8 | */
9 |
10 | import { test } from '@japa/runner'
11 | import { LimiterResponse } from '../../src/response.js'
12 | import { E_TOO_MANY_REQUESTS } from '../../src/errors.js'
13 | import LimiterMemoryStore from '../../src/stores/memory.js'
14 |
15 | test.group('Limiter memory store | wrapper', () => {
16 | test('define readonly properties', async ({ assert }) => {
17 | const store = new LimiterMemoryStore({
18 | duration: '1 minute',
19 | requests: 5,
20 | })
21 |
22 | assert.equal(store.name, 'memory')
23 | assert.equal(store.requests, 5)
24 | assert.equal(store.duration, 60)
25 | })
26 | })
27 |
28 | test.group('Limiter memory store | wrapper | consume', () => {
29 | test('consume points using the memory store', async ({ assert }) => {
30 | const store = new LimiterMemoryStore({
31 | duration: '1 minute',
32 | requests: 5,
33 | })
34 |
35 | const response = await store.consume('ip_localhost')
36 | assert.instanceOf(response, LimiterResponse)
37 | assert.containsSubset(response.toJSON(), {
38 | limit: 5,
39 | remaining: 4,
40 | consumed: 1,
41 | })
42 | assert.closeTo(response.availableIn, 60, 5)
43 | })
44 |
45 | test('throw error when no points are left', async ({ assert }) => {
46 | const store = new LimiterMemoryStore({
47 | duration: '1 minute',
48 | requests: 1,
49 | })
50 |
51 | await store.consume('ip_localhost')
52 | try {
53 | await store.consume('ip_localhost')
54 | } catch (error) {
55 | assert.instanceOf(error, E_TOO_MANY_REQUESTS)
56 | assert.containsSubset(error.response.toJSON(), {
57 | limit: 1,
58 | remaining: 0,
59 | consumed: 2,
60 | })
61 | assert.closeTo(error.response.availableIn, 60, 5)
62 | }
63 | })
64 |
65 | test('block key when all points have been consumed', async ({ assert }) => {
66 | const store = new LimiterMemoryStore({
67 | duration: '1 minute',
68 | requests: 1,
69 | blockDuration: '2 minutes',
70 | })
71 |
72 | await store.consume('ip_localhost')
73 | try {
74 | await store.consume('ip_localhost')
75 | } catch (error) {
76 | assert.instanceOf(error, E_TOO_MANY_REQUESTS)
77 | assert.containsSubset(error.response.toJSON(), {
78 | limit: 1,
79 | remaining: 0,
80 | consumed: 2,
81 | })
82 | assert.closeTo(error.response.availableIn, 120, 5)
83 | }
84 | })
85 | })
86 |
87 | test.group('Limiter memory store | wrapper | get', () => {
88 | test('get response for a pre-existing key', async ({ assert }) => {
89 | const store = new LimiterMemoryStore({
90 | duration: '1 minute',
91 | requests: 5,
92 | })
93 |
94 | await store.consume('ip_localhost')
95 | const response = await store.get('ip_localhost')
96 | assert.instanceOf(response, LimiterResponse)
97 | assert.containsSubset(response!.toJSON(), {
98 | limit: 5,
99 | remaining: 4,
100 | consumed: 1,
101 | })
102 | assert.closeTo(response!.availableIn, 60, 5)
103 | })
104 |
105 | test('return null when key does not exists', async ({ assert }) => {
106 | const store = new LimiterMemoryStore({
107 | duration: '1 minute',
108 | requests: 5,
109 | })
110 |
111 | const response = await store.get('ip_localhost')
112 | assert.isNull(response)
113 | })
114 | })
115 |
116 | test.group('Limiter memory store | wrapper | set', () => {
117 | test('set requests consumed for a given key', async ({ assert }) => {
118 | const store = new LimiterMemoryStore({
119 | duration: '1 minute',
120 | requests: 5,
121 | })
122 |
123 | const response = await store.set('ip_localhost', 2, '1 minute')
124 | const freshResponse = await store.get('ip_localhost')
125 | assert.instanceOf(response, LimiterResponse)
126 | assert.containsSubset(response!.toJSON(), {
127 | limit: 5,
128 | remaining: 3,
129 | consumed: 2,
130 | })
131 |
132 | assert.closeTo(response.availableIn, 60, 5)
133 | assert.equal(response.remaining, freshResponse?.remaining)
134 | assert.equal(response.consumed, freshResponse?.consumed)
135 | })
136 |
137 | test('overwrite existing points of a key', async ({ assert }) => {
138 | const store = new LimiterMemoryStore({
139 | duration: '1 minute',
140 | requests: 5,
141 | })
142 |
143 | await store.consume('ip_localhost')
144 | await store.consume('ip_localhost')
145 | await store.consume('ip_localhost')
146 |
147 | const response = await store.set('ip_localhost', 2, '1 minute')
148 | const freshResponse = await store.get('ip_localhost')
149 | assert.instanceOf(response, LimiterResponse)
150 | assert.containsSubset(response!.toJSON(), {
151 | limit: 5,
152 | remaining: 3,
153 | consumed: 2,
154 | })
155 |
156 | assert.closeTo(response.availableIn, 60, 5)
157 | assert.equal(response.remaining, freshResponse?.remaining)
158 | assert.equal(response.consumed, freshResponse?.consumed)
159 | })
160 | })
161 |
162 | test.group('Limiter memory store | wrapper | block', () => {
163 | test('block a given key', async ({ assert }) => {
164 | const store = new LimiterMemoryStore({
165 | duration: '1 minute',
166 | requests: 5,
167 | })
168 |
169 | const response = await store.block('ip_localhost', '2 minutes')
170 | const freshResponse = await store.get('ip_localhost')
171 | assert.instanceOf(response, LimiterResponse)
172 | assert.containsSubset(response!.toJSON(), {
173 | limit: 5,
174 | remaining: 0,
175 | consumed: 6,
176 | })
177 |
178 | assert.closeTo(response.availableIn, 120, 5)
179 | assert.equal(response.remaining, freshResponse?.remaining)
180 | assert.equal(response.consumed, freshResponse?.consumed)
181 | })
182 |
183 | test('disallow consume calls on a blocked key', async () => {
184 | const store = new LimiterMemoryStore({
185 | duration: '1 minute',
186 | requests: 5,
187 | })
188 |
189 | await store.block('ip_localhost', '2 minutes')
190 | await store.consume('ip_localhost')
191 | }).throws('Too many requests')
192 | })
193 |
194 | test.group('Limiter memory store | wrapper | delete', () => {
195 | test('delete blocked key', async ({ assert }) => {
196 | const store = new LimiterMemoryStore({
197 | duration: '1 minute',
198 | requests: 5,
199 | })
200 |
201 | await store.block('ip_localhost', '2 minutes')
202 | const response = await store.get('ip_localhost')
203 | assert.instanceOf(response, LimiterResponse)
204 | assert.containsSubset(response!.toJSON(), {
205 | limit: 5,
206 | remaining: 0,
207 | consumed: 6,
208 | })
209 | assert.closeTo(response!.availableIn, 120, 5)
210 |
211 | await store.delete('ip_localhost')
212 | const freshResponse = await store.get('ip_localhost')
213 | assert.isNull(freshResponse)
214 | })
215 |
216 | test('allow consume calls after delete', async ({ assert }) => {
217 | const store = new LimiterMemoryStore({
218 | duration: '1 minute',
219 | requests: 5,
220 | })
221 |
222 | await store.block('ip_localhost', '2 minutes')
223 | await assert.rejects(() => store.consume('ip_localhost'))
224 |
225 | await store.delete('ip_localhost')
226 | await assert.doesNotReject(() => store.consume('ip_localhost'))
227 | })
228 | })
229 |
230 | test.group('Limiter memory store | wrapper | clear', () => {
231 | test('clear db', async ({ assert }) => {
232 | const store = new LimiterMemoryStore({
233 | duration: '1 minute',
234 | requests: 5,
235 | })
236 |
237 | await store.consume('ip_localhost')
238 | const response = await store.get('ip_localhost')
239 | assert.instanceOf(response, LimiterResponse)
240 |
241 | await store.clear()
242 | assert.isNull(await store.get('ip_localhost'))
243 | })
244 | })
245 |
246 | test.group('Limiter database store | wrapper | increment', () => {
247 | test('increment the requests count', async ({ assert }) => {
248 | const store = new LimiterMemoryStore({
249 | duration: '1 minute',
250 | requests: 5,
251 | })
252 |
253 | await store.consume('ip_localhost')
254 | const response = await store.increment('ip_localhost')
255 | assert.instanceOf(response, LimiterResponse)
256 | assert.containsSubset(response.toJSON(), {
257 | limit: 5,
258 | remaining: 3,
259 | consumed: 2,
260 | })
261 | })
262 |
263 | test('do not throw when incrementing beyond the limit', async ({ assert }) => {
264 | const store = new LimiterMemoryStore({
265 | duration: '1 minute',
266 | requests: 1,
267 | })
268 |
269 | await store.consume('ip_localhost')
270 | await store.increment('ip_localhost')
271 | const response = await store.increment('ip_localhost')
272 | assert.instanceOf(response, LimiterResponse)
273 | assert.containsSubset(response.toJSON(), {
274 | limit: 1,
275 | remaining: 0,
276 | consumed: 3,
277 | })
278 | })
279 |
280 | test('increment for non-existing key', async ({ assert }) => {
281 | const store = new LimiterMemoryStore({
282 | duration: '1 minute',
283 | requests: 1,
284 | })
285 |
286 | const response = await store.increment('ip_localhost')
287 | assert.instanceOf(response, LimiterResponse)
288 | assert.containsSubset(response.toJSON(), {
289 | limit: 1,
290 | remaining: 0,
291 | consumed: 1,
292 | })
293 | })
294 | })
295 |
296 | test.group('Limiter database store | wrapper | decrement', () => {
297 | test('decrement the requests count', async ({ assert }) => {
298 | const store = new LimiterMemoryStore({
299 | duration: '1 minute',
300 | requests: 5,
301 | })
302 |
303 | await store.consume('ip_localhost')
304 | const response = await store.decrement('ip_localhost')
305 | assert.instanceOf(response, LimiterResponse)
306 | assert.containsSubset(response.toJSON(), {
307 | limit: 5,
308 | remaining: 5,
309 | consumed: 0,
310 | })
311 | })
312 |
313 | test('do not throw when decrementing beyond zero', async ({ assert }) => {
314 | const store = new LimiterMemoryStore({
315 | duration: '1 minute',
316 | requests: 1,
317 | })
318 |
319 | await store.consume('ip_localhost')
320 | await store.decrement('ip_localhost')
321 | const response = await store.decrement('ip_localhost')
322 | const freshResponse = await store.get('ip_localhost')
323 |
324 | assert.instanceOf(response, LimiterResponse)
325 | assert.containsSubset(response.toJSON(), {
326 | limit: 1,
327 | remaining: 1,
328 | consumed: 0,
329 | })
330 |
331 | assert.instanceOf(freshResponse, LimiterResponse)
332 | assert.containsSubset(freshResponse!.toJSON(), {
333 | limit: 1,
334 | remaining: 1,
335 | consumed: 0,
336 | })
337 | })
338 |
339 | test('decrement non-existing key', async ({ assert }) => {
340 | const store = new LimiterMemoryStore({
341 | duration: '1 minute',
342 | requests: 1,
343 | })
344 |
345 | const response = await store.decrement('ip_localhost')
346 | assert.instanceOf(response, LimiterResponse)
347 | assert.containsSubset(response.toJSON(), {
348 | limit: 1,
349 | remaining: 1,
350 | consumed: 0,
351 | })
352 |
353 | await assert.doesNotReject(() => store.consume('ip_localhost'))
354 | await assert.rejects(() => store.consume('ip_localhost'))
355 | })
356 | })
357 |
--------------------------------------------------------------------------------
/tests/stores/redis.spec.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * @adonisjs/limiter
3 | *
4 | * (c) AdonisJS
5 | *
6 | * For the full copyright and license information, please view the LICENSE
7 | * file that was distributed with this source code.
8 | */
9 |
10 | import { test } from '@japa/runner'
11 |
12 | import { createRedis } from '../helpers.js'
13 | import { LimiterResponse } from '../../src/response.js'
14 | import { E_TOO_MANY_REQUESTS } from '../../src/errors.js'
15 | import LimiterRedisStore from '../../src/stores/redis.js'
16 |
17 | test.group('Limiter redis store | wrapper', () => {
18 | test('define readonly properties', async ({ assert }) => {
19 | const redis = createRedis(['rlflx:ip_localhost']).connection()
20 | const store = new LimiterRedisStore(redis, {
21 | duration: '1 minute',
22 | requests: 5,
23 | })
24 |
25 | assert.equal(store.name, 'redis')
26 | assert.equal(store.requests, 5)
27 | assert.equal(store.duration, 60)
28 | })
29 | })
30 |
31 | test.group('Limiter redis store | wrapper | consume', () => {
32 | test('consume points using the redis store', async ({ assert }) => {
33 | const redis = createRedis(['rlflx:ip_localhost']).connection()
34 | const store = new LimiterRedisStore(redis, {
35 | duration: '1 minute',
36 | requests: 5,
37 | })
38 |
39 | const response = await store.consume('ip_localhost')
40 | assert.instanceOf(response, LimiterResponse)
41 | assert.containsSubset(response.toJSON(), {
42 | limit: 5,
43 | remaining: 4,
44 | consumed: 1,
45 | })
46 | assert.closeTo(response.availableIn, 60, 5)
47 | })
48 |
49 | test('throw error when no points are left', async ({ assert }) => {
50 | const redis = createRedis(['rlflx:ip_localhost']).connection()
51 | const store = new LimiterRedisStore(redis, {
52 | duration: '1 minute',
53 | requests: 1,
54 | })
55 |
56 | await store.consume('ip_localhost')
57 | try {
58 | await store.consume('ip_localhost')
59 | } catch (error) {
60 | assert.instanceOf(error, E_TOO_MANY_REQUESTS)
61 | assert.containsSubset(error.response.toJSON(), {
62 | limit: 1,
63 | remaining: 0,
64 | consumed: 2,
65 | })
66 | assert.closeTo(error.response.availableIn, 60, 5)
67 | }
68 | })
69 |
70 | test('block key when all points have been consumed', async ({ assert }) => {
71 | const redis = createRedis(['rlflx:ip_localhost']).connection()
72 | const store = new LimiterRedisStore(redis, {
73 | duration: '1 minute',
74 | requests: 1,
75 | blockDuration: '2 minutes',
76 | })
77 |
78 | await store.consume('ip_localhost')
79 | try {
80 | await store.consume('ip_localhost')
81 | } catch (error) {
82 | assert.instanceOf(error, E_TOO_MANY_REQUESTS)
83 | assert.containsSubset(error.response.toJSON(), {
84 | limit: 1,
85 | remaining: 0,
86 | consumed: 2,
87 | })
88 | assert.closeTo(error.response.availableIn, 120, 5)
89 | }
90 | })
91 |
92 | test('increment request counter even when the key has consumed all requests', async ({
93 | assert,
94 | }) => {
95 | const redis = createRedis(['rlflx:ip_localhost']).connection()
96 | const store = new LimiterRedisStore(redis, {
97 | duration: '1 minute',
98 | requests: 2,
99 | })
100 |
101 | await store.consume('ip_localhost')
102 | await store.consume('ip_localhost')
103 | await assert.rejects(() => store.consume('ip_localhost'))
104 | await assert.rejects(() => store.consume('ip_localhost'))
105 |
106 | const response = await store.get('ip_localhost')
107 | assert.instanceOf(response, LimiterResponse)
108 | assert.equal(response!.consumed, 4)
109 | })
110 |
111 | test('do not increment request counter when blocking keys in memory', async ({ assert }) => {
112 | const redis = createRedis(['rlflx:ip_localhost']).connection()
113 | const store = new LimiterRedisStore(redis, {
114 | duration: '1 minute',
115 | inMemoryBlockOnConsumed: 2,
116 | inMemoryBlockDuration: '1 minute',
117 | requests: 2,
118 | })
119 |
120 | await store.consume('ip_localhost')
121 | await store.consume('ip_localhost')
122 | await assert.rejects(() => store.consume('ip_localhost'))
123 | await assert.rejects(() => store.consume('ip_localhost'))
124 | await assert.rejects(() => store.consume('ip_localhost'))
125 | await assert.rejects(() => store.consume('ip_localhost'))
126 |
127 | const response = await store.get('ip_localhost')
128 | assert.instanceOf(response, LimiterResponse)
129 | assert.equal(response!.consumed, 3)
130 | })
131 |
132 | test('reset in memory blocked keys', async ({ assert }) => {
133 | const redis = createRedis(['rlflx:ip_localhost']).connection()
134 | const store = new LimiterRedisStore(redis, {
135 | duration: '1 minute',
136 | inMemoryBlockOnConsumed: 2,
137 | inMemoryBlockDuration: '1 minute',
138 | requests: 2,
139 | })
140 |
141 | await store.consume('ip_localhost')
142 | await store.consume('ip_localhost')
143 | await assert.rejects(() => store.consume('ip_localhost'))
144 | await assert.rejects(() => store.consume('ip_localhost'))
145 | await assert.rejects(() => store.consume('ip_localhost'))
146 |
147 | const response = await store.get('ip_localhost')
148 | assert.equal(response!.consumed, 3)
149 |
150 | store.deleteInMemoryBlockedKeys()
151 | await assert.rejects(() => store.consume('ip_localhost'))
152 |
153 | const freshResponse = await store.get('ip_localhost')
154 | assert.equal(freshResponse!.consumed, 4)
155 | })
156 | })
157 |
158 | test.group('Limiter redis store | wrapper | get', () => {
159 | test('get response for a pre-existing key', async ({ assert }) => {
160 | const redis = createRedis(['rlflx:ip_localhost']).connection()
161 | const store = new LimiterRedisStore(redis, {
162 | duration: '1 minute',
163 | requests: 5,
164 | })
165 |
166 | await store.consume('ip_localhost')
167 | const response = await store.get('ip_localhost')
168 | assert.instanceOf(response, LimiterResponse)
169 | assert.containsSubset(response!.toJSON(), {
170 | limit: 5,
171 | remaining: 4,
172 | consumed: 1,
173 | })
174 | assert.closeTo(response!.availableIn, 60, 5)
175 | })
176 |
177 | test('return null when key does not exists', async ({ assert }) => {
178 | const redis = createRedis(['rlflx:ip_localhost']).connection()
179 | const store = new LimiterRedisStore(redis, {
180 | duration: '1 minute',
181 | requests: 5,
182 | })
183 |
184 | const response = await store.get('ip_localhost')
185 | assert.isNull(response)
186 | })
187 |
188 | test('get response for negative points', async ({ assert }) => {
189 | const redis = createRedis(['rlflx:ip_localhost']).connection()
190 | const store = new LimiterRedisStore(redis, {
191 | duration: '1 minute',
192 | requests: 1,
193 | })
194 |
195 | await store.consume('ip_localhost')
196 | await assert.rejects(() => store.consume('ip_localhost'))
197 | const response = await store.get('ip_localhost')
198 | assert.instanceOf(response, LimiterResponse)
199 | assert.containsSubset(response!.toJSON(), {
200 | limit: 1,
201 | remaining: 0,
202 | consumed: 2,
203 | })
204 | assert.closeTo(response!.availableIn, 60, 5)
205 | })
206 | })
207 |
208 | test.group('Limiter redis store | wrapper | set', () => {
209 | test('set requests consumed for a given key', async ({ assert }) => {
210 | const redis = createRedis(['rlflx:ip_localhost']).connection()
211 | const store = new LimiterRedisStore(redis, {
212 | duration: '1 minute',
213 | requests: 5,
214 | })
215 |
216 | const response = await store.set('ip_localhost', 2, '1 minute')
217 | const freshResponse = await store.get('ip_localhost')
218 | assert.instanceOf(response, LimiterResponse)
219 | assert.containsSubset(response!.toJSON(), {
220 | limit: 5,
221 | remaining: 3,
222 | consumed: 2,
223 | })
224 | assert.closeTo(response.availableIn, 60, 5)
225 | assert.equal(response.remaining, freshResponse?.remaining)
226 | assert.equal(response.consumed, freshResponse?.consumed)
227 | })
228 |
229 | test('overwrite existing points of a key', async ({ assert }) => {
230 | const redis = createRedis(['rlflx:ip_localhost']).connection()
231 | const store = new LimiterRedisStore(redis, {
232 | duration: '1 minute',
233 | requests: 5,
234 | })
235 |
236 | await store.consume('ip_localhost')
237 | await store.consume('ip_localhost')
238 | await store.consume('ip_localhost')
239 |
240 | const response = await store.set('ip_localhost', 2, '1 minute')
241 | const freshResponse = await store.get('ip_localhost')
242 | assert.instanceOf(response, LimiterResponse)
243 | assert.containsSubset(response!.toJSON(), {
244 | limit: 5,
245 | remaining: 3,
246 | consumed: 2,
247 | })
248 |
249 | assert.closeTo(response.availableIn, 60, 5)
250 | assert.equal(response.remaining, freshResponse?.remaining)
251 | assert.equal(response.consumed, freshResponse?.consumed)
252 | })
253 | })
254 |
255 | test.group('Limiter redis store | wrapper | block', () => {
256 | test('block a given key', async ({ assert }) => {
257 | const redis = createRedis(['rlflx:ip_localhost']).connection()
258 | const store = new LimiterRedisStore(redis, {
259 | duration: '1 minute',
260 | requests: 5,
261 | })
262 |
263 | const response = await store.block('ip_localhost', '2 minutes')
264 | const freshResponse = await store.get('ip_localhost')
265 | assert.instanceOf(response, LimiterResponse)
266 | assert.containsSubset(response!.toJSON(), {
267 | limit: 5,
268 | remaining: 0,
269 | consumed: 6,
270 | availableIn: 120,
271 | })
272 |
273 | assert.closeTo(response.availableIn, 120, 5)
274 | assert.equal(response.remaining, freshResponse?.remaining)
275 | assert.equal(response.consumed, freshResponse?.consumed)
276 | })
277 |
278 | test('disallow consume calls on a blocked key', async () => {
279 | const redis = createRedis(['rlflx:ip_localhost']).connection()
280 | const store = new LimiterRedisStore(redis, {
281 | duration: '1 minute',
282 | requests: 5,
283 | })
284 |
285 | await store.block('ip_localhost', '2 minutes')
286 | await store.consume('ip_localhost')
287 | }).throws('Too many requests')
288 | })
289 |
290 | test.group('Limiter redis store | wrapper | delete', () => {
291 | test('delete blocked key', async ({ assert }) => {
292 | const redis = createRedis(['rlflx:ip_localhost']).connection()
293 | const store = new LimiterRedisStore(redis, {
294 | duration: '1 minute',
295 | requests: 5,
296 | })
297 |
298 | await store.block('ip_localhost', '2 minutes')
299 | const response = await store.get('ip_localhost')
300 | assert.instanceOf(response, LimiterResponse)
301 | assert.containsSubset(response!.toJSON(), {
302 | limit: 5,
303 | remaining: 0,
304 | consumed: 6,
305 | })
306 | assert.closeTo(response!.availableIn, 120, 5)
307 |
308 | await store.delete('ip_localhost')
309 | const freshResponse = await store.get('ip_localhost')
310 | assert.isNull(freshResponse)
311 | })
312 |
313 | test('allow consume calls after delete', async ({ assert }) => {
314 | const redis = createRedis(['rlflx:ip_localhost']).connection()
315 | const store = new LimiterRedisStore(redis, {
316 | duration: '1 minute',
317 | requests: 5,
318 | })
319 |
320 | await store.block('ip_localhost', '2 minutes')
321 | await assert.rejects(() => store.consume('ip_localhost'))
322 |
323 | await store.delete('ip_localhost')
324 | await assert.doesNotReject(() => store.consume('ip_localhost'))
325 | })
326 | })
327 |
328 | test.group('Limiter redis store | wrapper | clear', () => {
329 | test('clear db', async ({ assert }) => {
330 | const redis = createRedis(['rlflx:ip_localhost']).connection()
331 | const store = new LimiterRedisStore(redis, {
332 | duration: '1 minute',
333 | requests: 5,
334 | })
335 |
336 | await store.consume('ip_localhost')
337 | const response = await store.get('ip_localhost')
338 | assert.instanceOf(response, LimiterResponse)
339 |
340 | await store.clear()
341 | assert.isNull(await store.get('ip_localhost'))
342 | })
343 | })
344 |
345 | test.group('Limiter redis store | wrapper | increment', () => {
346 | test('increment the requests count', async ({ assert }) => {
347 | const redis = createRedis(['rlflx:ip_localhost']).connection()
348 | const store = new LimiterRedisStore(redis, {
349 | duration: '1 minute',
350 | requests: 5,
351 | })
352 |
353 | await store.consume('ip_localhost')
354 | const response = await store.increment('ip_localhost')
355 | assert.instanceOf(response, LimiterResponse)
356 | assert.containsSubset(response.toJSON(), {
357 | limit: 5,
358 | remaining: 3,
359 | consumed: 2,
360 | })
361 | })
362 |
363 | test('do not throw when incrementing beyond the limit', async ({ assert }) => {
364 | const redis = createRedis(['rlflx:ip_localhost']).connection()
365 | const store = new LimiterRedisStore(redis, {
366 | duration: '1 minute',
367 | requests: 1,
368 | })
369 |
370 | await store.consume('ip_localhost')
371 | await store.increment('ip_localhost')
372 | const response = await store.increment('ip_localhost')
373 | assert.instanceOf(response, LimiterResponse)
374 | assert.containsSubset(response.toJSON(), {
375 | limit: 1,
376 | remaining: 0,
377 | consumed: 3,
378 | })
379 | })
380 |
381 | test('increment for non-existing key', async ({ assert }) => {
382 | const redis = createRedis(['rlflx:ip_localhost']).connection()
383 | const store = new LimiterRedisStore(redis, {
384 | duration: '1 minute',
385 | requests: 1,
386 | })
387 |
388 | const response = await store.increment('ip_localhost')
389 | assert.instanceOf(response, LimiterResponse)
390 | assert.containsSubset(response.toJSON(), {
391 | limit: 1,
392 | remaining: 0,
393 | consumed: 1,
394 | })
395 | })
396 | })
397 |
398 | test.group('Limiter redis store | wrapper | decrement', () => {
399 | test('decrement the requests count', async ({ assert }) => {
400 | const redis = createRedis(['rlflx:ip_localhost']).connection()
401 | const store = new LimiterRedisStore(redis, {
402 | duration: '1 minute',
403 | requests: 5,
404 | })
405 |
406 | await store.consume('ip_localhost')
407 | const response = await store.decrement('ip_localhost')
408 | assert.instanceOf(response, LimiterResponse)
409 | assert.containsSubset(response.toJSON(), {
410 | limit: 5,
411 | remaining: 5,
412 | consumed: 0,
413 | })
414 | })
415 |
416 | test('do not throw when decrementing beyond zero', async ({ assert }) => {
417 | const redis = createRedis(['rlflx:ip_localhost']).connection()
418 | const store = new LimiterRedisStore(redis, {
419 | duration: '1 minute',
420 | requests: 1,
421 | })
422 |
423 | await store.consume('ip_localhost')
424 | await store.decrement('ip_localhost')
425 | const response = await store.decrement('ip_localhost')
426 | const freshResponse = await store.get('ip_localhost')
427 |
428 | assert.instanceOf(response, LimiterResponse)
429 | assert.containsSubset(response.toJSON(), {
430 | limit: 1,
431 | remaining: 1,
432 | consumed: 0,
433 | })
434 |
435 | assert.instanceOf(freshResponse, LimiterResponse)
436 | assert.containsSubset(freshResponse!.toJSON(), {
437 | limit: 1,
438 | remaining: 1,
439 | consumed: 0,
440 | })
441 | })
442 |
443 | test('decrement non-existing key', async ({ assert }) => {
444 | const redis = createRedis(['rlflx:ip_localhost']).connection()
445 | const store = new LimiterRedisStore(redis, {
446 | duration: '1 minute',
447 | requests: 1,
448 | })
449 |
450 | const response = await store.decrement('ip_localhost')
451 | assert.instanceOf(response, LimiterResponse)
452 | assert.containsSubset(response.toJSON(), {
453 | limit: 1,
454 | remaining: 1,
455 | consumed: 0,
456 | })
457 |
458 | await assert.doesNotReject(() => store.consume('ip_localhost'))
459 | await assert.rejects(() => store.consume('ip_localhost'))
460 | })
461 | })
462 |
--------------------------------------------------------------------------------
/tests/throttle_exception.spec.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * @adonisjs/bouncer
3 | *
4 | * (c) AdonisJS
5 | *
6 | * For the full copyright and license information, please view the LICENSE
7 | * file that was distributed with this source code.
8 | */
9 |
10 | import { test } from '@japa/runner'
11 | import { E_TOO_MANY_REQUESTS } from '../src/errors.js'
12 | import { LimiterResponse } from '../src/response.js'
13 | import { I18nManagerFactory } from '@adonisjs/i18n/factories'
14 | import { HttpContextFactory } from '@adonisjs/core/factories/http'
15 |
16 | test.group('LimiterException', () => {
17 | test('make HTTP response with default message, headers and status code', async ({ assert }) => {
18 | const exception = new E_TOO_MANY_REQUESTS(
19 | new LimiterResponse({
20 | availableIn: 10,
21 | consumed: 10,
22 | limit: 10,
23 | remaining: 0,
24 | })
25 | )
26 | const ctx = new HttpContextFactory().create()
27 |
28 | await exception.handle(exception, ctx)
29 | assert.equal(ctx.response.getBody(), 'Too many requests')
30 | assert.equal(ctx.response.getStatus(), 429)
31 | assert.containsSubset(ctx.response.getHeaders(), {
32 | 'retry-after': '10',
33 | 'x-ratelimit-limit': '10',
34 | 'x-ratelimit-remaining': '0',
35 | })
36 | })
37 |
38 | test('use default translation identifier for message when using i18n', async ({ assert }) => {
39 | const i18nManager = new I18nManagerFactory()
40 | .merge({
41 | config: {
42 | loaders: [
43 | () => {
44 | return {
45 | async load() {
46 | return {
47 | en: {
48 | 'errors.E_TOO_MANY_REQUESTS': 'You have made too many requests',
49 | },
50 | }
51 | },
52 | }
53 | },
54 | ],
55 | },
56 | })
57 | .create()
58 |
59 | await i18nManager.loadTranslations()
60 |
61 | const exception = new E_TOO_MANY_REQUESTS(
62 | new LimiterResponse({
63 | availableIn: 10,
64 | consumed: 10,
65 | limit: 10,
66 | remaining: 0,
67 | })
68 | )
69 | const ctx = new HttpContextFactory().create()
70 | ctx.i18n = i18nManager.locale('en')
71 |
72 | await exception.handle(exception, ctx)
73 | assert.equal(ctx.response.getBody(), 'You have made too many requests')
74 | assert.equal(ctx.response.getStatus(), 429)
75 | })
76 |
77 | test('use custom translation identifier for message when using i18n', async ({ assert }) => {
78 | const i18nManager = new I18nManagerFactory()
79 | .merge({
80 | config: {
81 | loaders: [
82 | () => {
83 | return {
84 | async load() {
85 | return {
86 | en: {
87 | 'errors.limit_exceeded': 'You have made too many requests',
88 | },
89 | }
90 | },
91 | }
92 | },
93 | ],
94 | },
95 | })
96 | .create()
97 |
98 | await i18nManager.loadTranslations()
99 |
100 | const exception = new E_TOO_MANY_REQUESTS(
101 | new LimiterResponse({
102 | availableIn: 10,
103 | consumed: 10,
104 | limit: 10,
105 | remaining: 0,
106 | })
107 | )
108 | exception.t('errors.limit_exceeded').setStatus(400)
109 | const ctx = new HttpContextFactory().create()
110 | ctx.i18n = i18nManager.locale('en')
111 |
112 | await exception.handle(exception, ctx)
113 | assert.equal(ctx.response.getBody(), 'You have made too many requests')
114 | assert.equal(ctx.response.getStatus(), 400)
115 | })
116 |
117 | test('make JSON response', async ({ assert }) => {
118 | const exception = new E_TOO_MANY_REQUESTS(
119 | new LimiterResponse({
120 | availableIn: 10,
121 | consumed: 10,
122 | limit: 10,
123 | remaining: 0,
124 | })
125 | )
126 | const ctx = new HttpContextFactory().create()
127 | ctx.request.request.headers.accept = 'application/json'
128 |
129 | await exception.handle(exception, ctx)
130 | assert.deepEqual(ctx.response.getBody(), {
131 | errors: [{ message: 'Too many requests', retryAfter: 10 }],
132 | })
133 | assert.equal(ctx.response.getStatus(), 429)
134 | assert.containsSubset(ctx.response.getHeaders(), {
135 | 'retry-after': '10',
136 | 'x-ratelimit-limit': '10',
137 | 'x-ratelimit-remaining': '0',
138 | })
139 | })
140 |
141 | test('make JSONAPI response', async ({ assert }) => {
142 | const exception = new E_TOO_MANY_REQUESTS(
143 | new LimiterResponse({
144 | availableIn: 10,
145 | consumed: 10,
146 | limit: 10,
147 | remaining: 0,
148 | })
149 | )
150 | const ctx = new HttpContextFactory().create()
151 | ctx.request.request.headers.accept = 'application/vnd.api+json'
152 |
153 | await exception.handle(exception, ctx)
154 | assert.deepEqual(ctx.response.getBody(), {
155 | errors: [
156 | { title: 'Too many requests', code: 'E_TOO_MANY_REQUESTS', meta: { retryAfter: 10 } },
157 | ],
158 | })
159 | assert.equal(ctx.response.getStatus(), 429)
160 | assert.containsSubset(ctx.response.getHeaders(), {
161 | 'retry-after': '10',
162 | 'x-ratelimit-limit': '10',
163 | 'x-ratelimit-remaining': '0',
164 | })
165 | })
166 |
167 | test('overwrite default message', async ({ assert }) => {
168 | const exception = new E_TOO_MANY_REQUESTS(
169 | new LimiterResponse({
170 | availableIn: 10,
171 | consumed: 10,
172 | limit: 10,
173 | remaining: 0,
174 | })
175 | )
176 | exception.setMessage('You have made too many requests')
177 |
178 | const ctx = new HttpContextFactory().create()
179 | await exception.handle(exception, ctx)
180 | assert.equal(ctx.response.getBody(), 'You have made too many requests')
181 | })
182 |
183 | test('overwrite default headers', async ({ assert }) => {
184 | const exception = new E_TOO_MANY_REQUESTS(
185 | new LimiterResponse({
186 | availableIn: 10,
187 | consumed: 10,
188 | limit: 10,
189 | remaining: 0,
190 | })
191 | )
192 | exception.setHeaders({ 'x-blocked': true })
193 |
194 | const ctx = new HttpContextFactory().create()
195 | await exception.handle(exception, ctx)
196 | assert.deepEqual(ctx.response.getHeaders(), {
197 | 'x-blocked': 'true',
198 | })
199 | })
200 | })
201 |
--------------------------------------------------------------------------------
/tests/throttle_middleware.spec.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * @adonisjs/limiter
3 | *
4 | * (c) AdonisJS
5 | *
6 | * For the full copyright and license information, please view the LICENSE
7 | * file that was distributed with this source code.
8 | */
9 |
10 | import { test } from '@japa/runner'
11 | import { HttpContextFactory } from '@adonisjs/core/factories/http'
12 |
13 | import { createRedis } from './helpers.js'
14 | import LimiterRedisStore from '../src/stores/redis.js'
15 | import { LimiterManager } from '../src/limiter_manager.js'
16 |
17 | test.group('Throttle middleware', () => {
18 | test('throttle requests using the middleware', async ({ assert }) => {
19 | let nextCalled: boolean = false
20 |
21 | const redis = createRedis(['rlflx:api_1']).connection()
22 | const limiterManager = new LimiterManager({
23 | default: 'redis',
24 | stores: {
25 | redis: (options) => new LimiterRedisStore(redis, options),
26 | },
27 | })
28 |
29 | const apiLimiter = limiterManager.define('api', () => {
30 | return limiterManager.allowRequests(1).every('1 minute').usingKey(1)
31 | })
32 | assert.equal(apiLimiter.name, 'apiThrottle')
33 |
34 | const ctx = new HttpContextFactory().create()
35 | await apiLimiter(ctx, () => {
36 | nextCalled = true
37 | })
38 |
39 | assert.equal(await limiterManager.use({ duration: 60, requests: 1 }).remaining('api_1'), 0)
40 | assert.isTrue(nextCalled)
41 | })
42 |
43 | test('do not call next when key has exhausted all requests', async ({ assert }) => {
44 | let nextCalled: boolean = false
45 |
46 | const redis = createRedis(['rlflx:api_1']).connection()
47 | const limiterManager = new LimiterManager({
48 | default: 'redis',
49 | stores: {
50 | redis: (options) => new LimiterRedisStore(redis, options),
51 | },
52 | })
53 |
54 | const apiLimiter = limiterManager.define('api', () => {
55 | return limiterManager.allowRequests(1).every('1 minute').usingKey(1)
56 | })
57 |
58 | /**
59 | * This will consume all the requests the
60 | * key has
61 | */
62 | await limiterManager.use({ duration: 60, requests: 1 }).consume('api_1')
63 |
64 | const ctx = new HttpContextFactory().create()
65 |
66 | try {
67 | await apiLimiter(ctx, () => {
68 | nextCalled = true
69 | })
70 | } catch (error) {
71 | assert.equal(error.message, 'Too many requests')
72 | assert.isFalse(nextCalled)
73 | }
74 | })
75 |
76 | test('block key when requests are made even after rate limited', async ({ assert }) => {
77 | let nextCalled: boolean = false
78 |
79 | const redis = createRedis(['rlflx:api_1']).connection()
80 | const limiterManager = new LimiterManager({
81 | default: 'redis',
82 | stores: {
83 | redis: (options) => new LimiterRedisStore(redis, options),
84 | },
85 | })
86 |
87 | const apiLimiter = limiterManager.define('api', () => {
88 | return limiterManager.allowRequests(1).every('1 minute').usingKey(1).blockFor('30 mins')
89 | })
90 |
91 | /**
92 | * This will consume all the requests the
93 | * key has
94 | */
95 | await limiterManager.use({ duration: 60, requests: 1 }).consume('api_1')
96 |
97 | const ctx = new HttpContextFactory().create()
98 |
99 | try {
100 | await apiLimiter(ctx, () => {
101 | nextCalled = true
102 | })
103 | } catch (error) {
104 | assert.equal(error.message, 'Too many requests')
105 | assert.closeTo(error.response.availableIn, 30 * 60, 5)
106 | assert.isFalse(nextCalled)
107 | }
108 | })
109 |
110 | test('do not throttle request when no limiter is used', async ({ assert }) => {
111 | let nextCalled: boolean = false
112 |
113 | const redis = createRedis(['rlflx:api_1']).connection()
114 | const limiterManager = new LimiterManager({
115 | default: 'redis',
116 | stores: {
117 | redis: (options) => new LimiterRedisStore(redis, options),
118 | },
119 | })
120 |
121 | const apiLimiter = limiterManager.define('api', () => {
122 | return limiterManager.noLimit()
123 | })
124 | const ctx = new HttpContextFactory().create()
125 |
126 | await apiLimiter(ctx, () => {
127 | nextCalled = true
128 | })
129 | assert.isTrue(nextCalled)
130 | })
131 | })
132 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@adonisjs/tsconfig/tsconfig.package.json",
3 | "compilerOptions": {
4 | "rootDir": "./",
5 | "outDir": "./build",
6 | },
7 | }
8 |
--------------------------------------------------------------------------------