├── .gitattributes
├── .github
├── dependabot.yml
├── stale.yml
└── workflows
│ └── ci.yml
├── .gitignore
├── .npmrc
├── LICENSE
├── README.md
├── eslint.config.js
├── example
├── example-auto-pipeline.mjs
├── example-knex-mysql.js
├── example-knex.js
├── example-sequelize.js
├── example-simple.mjs
└── example.js
├── index.js
├── package.json
├── store
├── LocalStore.js
└── RedisStore.js
├── test
├── create-rate-limit.test.js
├── exponential-backoff.test.js
├── github-issues
│ ├── issue-207.test.js
│ ├── issue-215.test.js
│ └── issue-284.test.js
├── global-rate-limit.test.js
├── group-rate-limit.test.js
├── local-store-close.test.js
├── not-found-handler-rate-limited.test.js
├── redis-rate-limit.test.js
└── route-rate-limit.test.js
└── types
├── index.d.ts
└── index.test-d.ts
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Set the default behavior, in case people don't have core.autocrlf set
2 | * text=auto
3 |
4 | # Require Unix line endings
5 | * text eol=lf
6 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "github-actions"
4 | directory: "/"
5 | schedule:
6 | interval: "monthly"
7 | open-pull-requests-limit: 10
8 |
9 | - package-ecosystem: "npm"
10 | directory: "/"
11 | schedule:
12 | interval: "monthly"
13 | open-pull-requests-limit: 10
14 |
--------------------------------------------------------------------------------
/.github/stale.yml:
--------------------------------------------------------------------------------
1 | # Number of days of inactivity before an issue becomes stale
2 | daysUntilStale: 15
3 | # Number of days of inactivity before a stale issue is closed
4 | daysUntilClose: 7
5 | # Issues with these labels will never be considered stale
6 | exemptLabels:
7 | - "discussion"
8 | - "feature request"
9 | - "bug"
10 | - "help wanted"
11 | - "plugin suggestion"
12 | - "good first issue"
13 | # Label to use when marking an issue as stale
14 | staleLabel: stale
15 | # Comment to post when marking an issue as stale. Set to `false` to disable
16 | markComment: >
17 | This issue has been automatically marked as stale because it has not had
18 | recent activity. It will be closed if no further activity occurs. Thank you
19 | for your contributions.
20 | # Comment to post when closing a stale issue. Set to `false` to disable
21 | closeComment: false
22 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | paths-ignore:
6 | - 'docs/**'
7 | - '*.md'
8 | pull_request:
9 | paths-ignore:
10 | - 'docs/**'
11 | - '*.md'
12 |
13 | permissions:
14 | contents: read
15 |
16 | jobs:
17 | test:
18 | permissions:
19 | contents: write
20 | pull-requests: write
21 | uses: fastify/workflows/.github/workflows/plugins-ci-redis.yml@v5
22 | with:
23 | license-check: true
24 | lint: true
25 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 | .pnpm-debug.log*
9 |
10 | # Diagnostic reports (https://nodejs.org/api/report.html)
11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
12 |
13 | # Runtime data
14 | pids
15 | *.pid
16 | *.seed
17 | *.pid.lock
18 |
19 | # Directory for instrumented libs generated by jscoverage/JSCover
20 | lib-cov
21 |
22 | # Coverage directory used by tools like istanbul
23 | coverage
24 | *.lcov
25 |
26 | # nyc test coverage
27 | .nyc_output
28 |
29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
30 | .grunt
31 |
32 | # Bower dependency directory (https://bower.io/)
33 | bower_components
34 |
35 | # node-waf configuration
36 | .lock-wscript
37 |
38 | # Compiled binary addons (https://nodejs.org/api/addons.html)
39 | build/Release
40 |
41 | # Dependency directories
42 | node_modules/
43 | jspm_packages/
44 |
45 | # Snowpack dependency directory (https://snowpack.dev/)
46 | web_modules/
47 |
48 | # TypeScript cache
49 | *.tsbuildinfo
50 |
51 | # Optional npm cache directory
52 | .npm
53 |
54 | # Optional eslint cache
55 | .eslintcache
56 |
57 | # Optional stylelint cache
58 | .stylelintcache
59 |
60 | # Microbundle cache
61 | .rpt2_cache/
62 | .rts2_cache_cjs/
63 | .rts2_cache_es/
64 | .rts2_cache_umd/
65 |
66 | # Optional REPL history
67 | .node_repl_history
68 |
69 | # Output of 'npm pack'
70 | *.tgz
71 |
72 | # Yarn Integrity file
73 | .yarn-integrity
74 |
75 | # dotenv environment variable files
76 | .env
77 | .env.development.local
78 | .env.test.local
79 | .env.production.local
80 | .env.local
81 |
82 | # parcel-bundler cache (https://parceljs.org/)
83 | .cache
84 | .parcel-cache
85 |
86 | # Next.js build output
87 | .next
88 | out
89 |
90 | # Nuxt.js build / generate output
91 | .nuxt
92 | dist
93 |
94 | # Gatsby files
95 | .cache/
96 | # Comment in the public line in if your project uses Gatsby and not Next.js
97 | # https://nextjs.org/blog/next-9-1#public-directory-support
98 | # public
99 |
100 | # vuepress build output
101 | .vuepress/dist
102 |
103 | # vuepress v2.x temp and cache directory
104 | .temp
105 | .cache
106 |
107 | # Docusaurus cache and generated files
108 | .docusaurus
109 |
110 | # Serverless directories
111 | .serverless/
112 |
113 | # FuseBox cache
114 | .fusebox/
115 |
116 | # DynamoDB Local files
117 | .dynamodb/
118 |
119 | # TernJS port file
120 | .tern-port
121 |
122 | # Stores VSCode versions used for testing VSCode extensions
123 | .vscode-test
124 |
125 | # yarn v2
126 | .yarn/cache
127 | .yarn/unplugged
128 | .yarn/build-state.yml
129 | .yarn/install-state.gz
130 | .pnp.*
131 |
132 | # Vim swap files
133 | *.swp
134 |
135 | # macOS files
136 | .DS_Store
137 |
138 | # Clinic
139 | .clinic
140 |
141 | # lock files
142 | bun.lockb
143 | package-lock.json
144 | pnpm-lock.yaml
145 | yarn.lock
146 |
147 | # editor files
148 | .vscode
149 | .idea
150 |
151 | #tap files
152 | .tap/
153 |
154 | # redis
155 | dump.rdb
156 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | package-lock=false
2 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Fastify
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # @fastify/rate-limit
2 |
3 | [](https://github.com/fastify/fastify-rate-limit/actions/workflows/ci.yml)
4 | [](https://www.npmjs.com/package/@fastify/rate-limit)
5 | [](https://github.com/neostandard/neostandard)
6 |
7 | A low overhead rate limiter for your routes.
8 |
9 |
10 | ## Install
11 | ```
12 | npm i @fastify/rate-limit
13 | ```
14 |
15 | ### Compatibility
16 |
17 | | Plugin version | Fastify version |
18 | | -------------- | -------------------- |
19 | | `>=10.x` | `^5.x` |
20 | | `>=7.x <10.x` | `^4.x` |
21 | | `>=3.x <7.x` | `^3.x` |
22 | | `>=2.x <7.x` | `^2.x` |
23 | | `^1.x` | `^1.x` |
24 |
25 |
26 | Please note that if a Fastify version is out of support, then so are the corresponding versions of this plugin
27 | in the table above.
28 | See [Fastify's LTS policy](https://github.com/fastify/fastify/blob/main/docs/Reference/LTS.md) for more details.
29 |
30 |
31 | ## Usage
32 | Register the plugin and, if required, pass some custom options.
33 | This plugin will add an `onRequest` hook to check if a client (based on their IP address) has made too many requests in the given timeWindow.
34 | ```js
35 | import Fastify from 'fastify'
36 |
37 | const fastify = Fastify()
38 | await fastify.register(import('@fastify/rate-limit'), {
39 | max: 100,
40 | timeWindow: '1 minute'
41 | })
42 |
43 | fastify.get('/', (request, reply) => {
44 | reply.send({ hello: 'world' })
45 | })
46 |
47 | fastify.listen({ port: 3000 }, err => {
48 | if (err) throw err
49 | console.log('Server listening at http://localhost:3000')
50 | })
51 | ```
52 |
53 | In case a client reaches the maximum number of allowed requests, an error will be sent to the user with the status code set to `429`:
54 | ```js
55 | {
56 | statusCode: 429,
57 | error: 'Too Many Requests',
58 | message: 'Rate limit exceeded, retry in 1 minute'
59 | }
60 | ```
61 | You can change the response by providing a callback to `errorResponseBuilder` or setting a [custom error handler](https://fastify.dev/docs/latest/Reference/Server/#seterrorhandler):
62 |
63 | ```js
64 | fastify.setErrorHandler(function (error, request, reply) {
65 | if (error.statusCode === 429) {
66 | reply.code(429)
67 | error.message = 'You hit the rate limit! Slow down please!'
68 | }
69 | reply.send(error)
70 | })
71 | ```
72 |
73 | The response will have some additional headers:
74 |
75 | | Header | Description |
76 | |--------|-------------|
77 | |`x-ratelimit-limit` | how many requests the client can make
78 | |`x-ratelimit-remaining` | how many requests remain to the client in the timewindow
79 | |`x-ratelimit-reset` | how many seconds must pass before the rate limit resets
80 | |`retry-after` | if the max has been reached, the seconds the client must wait before they can make new requests
81 |
82 |
83 | ### Preventing guessing of URLS through 404s
84 |
85 | An attacker could search for valid URLs if your 404 error handling is not rate limited.
86 | To rate limit your 404 response, you can use a custom handler:
87 |
88 | ```js
89 | const fastify = Fastify()
90 | await fastify.register(rateLimit, { global: true, max: 2, timeWindow: 1000 })
91 | fastify.setNotFoundHandler({
92 | preHandler: fastify.rateLimit()
93 | }, function (request, reply) {
94 | reply.code(404).send({ hello: 'world' })
95 | })
96 | ```
97 |
98 | Note that you can customize the behavior of the preHandler in the same way you would for specific routes:
99 |
100 | ```js
101 | const fastify = Fastify()
102 | await fastify.register(rateLimit, { global: true, max: 2, timeWindow: 1000 })
103 | fastify.setNotFoundHandler({
104 | preHandler: fastify.rateLimit({
105 | max: 4,
106 | timeWindow: 500
107 | })
108 | }, function (request, reply) {
109 | reply.code(404).send({ hello: 'world' })
110 | })
111 | ```
112 |
113 | ### Options
114 |
115 | You can pass the following options during the plugin registration:
116 | ```js
117 | await fastify.register(import('@fastify/rate-limit'), {
118 | global : false, // default true
119 | max: 3, // default 1000
120 | ban: 2, // default -1
121 | timeWindow: 5000, // default 1000 * 60
122 | hook: 'preHandler', // default 'onRequest'
123 | cache: 10000, // default 5000
124 | allowList: ['127.0.0.1'], // default []
125 | redis: new Redis({ host: '127.0.0.1' }), // default null
126 | nameSpace: 'teste-ratelimit-', // default is 'fastify-rate-limit-'
127 | continueExceeding: true, // default false
128 | skipOnError: true, // default false
129 | keyGenerator: function (request) { /* ... */ }, // default (request) => request.ip
130 | errorResponseBuilder: function (request, context) { /* ... */},
131 | enableDraftSpec: true, // default false. Uses IEFT draft header standard
132 | addHeadersOnExceeding: { // default show all the response headers when rate limit is not reached
133 | 'x-ratelimit-limit': true,
134 | 'x-ratelimit-remaining': true,
135 | 'x-ratelimit-reset': true
136 | },
137 | addHeaders: { // default show all the response headers when rate limit is reached
138 | 'x-ratelimit-limit': true,
139 | 'x-ratelimit-remaining': true,
140 | 'x-ratelimit-reset': true,
141 | 'retry-after': true
142 | }
143 | })
144 | ```
145 |
146 | - `global` : indicates if the plugin should apply rate limiting to all routes within the encapsulation scope.
147 | - `max`: maximum number of requests a single client can perform inside a timeWindow. It can be an async function with the signature `async (request, key) => {}` where `request` is the Fastify request object and `key` is the value generated by the `keyGenerator`. The function **must** return a number.
148 | - `ban`: maximum number of 429 responses to return to a client before returning 403 responses. When the ban limit is exceeded, the context argument that is passed to `errorResponseBuilder` will have its `ban` property set to `true`. **Note:** `0` can also be passed to directly return 403 responses when a client exceeds the `max` limit.
149 | - `timeWindow:` the duration of the time window. It can be expressed in milliseconds, as a string (in the [`ms`](https://github.com/zeit/ms) format), or as an async function with the signature `async (request, key) => {}` where `request` is the Fastify request object and `key` is the value generated by the `keyGenerator`. The function **must** return a number.
150 | - `cache`: this plugin internally uses an LRU cache to handle the clients, you can change the size of the cache with this option
151 | - `allowList`: array of string of IPs to exclude from rate limiting. It can be a sync or async function with the signature `(request, key) => {}` where `request` is the Fastify request object and `key` is the value generated by the `keyGenerator`. If the function return a truthy value, the request will be excluded from the rate limit.
152 | - `redis`: by default, this plugin uses an in-memory store, but if an application runs on multiple servers, an external store will be needed. This plugin requires the use of [`ioredis`](https://github.com/redis/ioredis).
**Note:** the [default settings](https://github.com/redis/ioredis/blob/v4.16.0/API.md#new_Redis_new) of an ioredis instance are not optimal for rate limiting. We recommend customizing the `connectTimeout` and `maxRetriesPerRequest` parameters as shown in the [`example`](https://github.com/fastify/fastify-rate-limit/tree/main/example/example.js).
153 | - `nameSpace`: choose which prefix to use in the redis, default is 'fastify-rate-limit-'
154 | - `continueExceeding`: Renew user limitation when user sends a request to the server when still limited. This will take priority over `exponentialBackoff`
155 | - `store`: a custom store to track requests and rates which allows you to use your own storage mechanism (using an RDBMS, MongoDB, etc.) as well as further customizing the logic used in calculating the rate limits. A simple example is provided below as well as a more detailed example using Knex.js can be found in the [`example/`](https://github.com/fastify/fastify-rate-limit/tree/main/example) folder
156 | - `skipOnError`: if `true` it will skip errors generated by the storage (e.g. redis not reachable).
157 | - `keyGenerator`: a sync or async function to generate a unique identifier for each incoming request. Defaults to `(request) => request.ip`, the IP is resolved by fastify using `request.connection.remoteAddress` or `request.headers['x-forwarded-for']` if [trustProxy](https://fastify.dev/docs/latest/Reference/Server/#trustproxy) option is enabled. Use it if you want to override this behavior
158 | - `groupId`: a string to group multiple routes together introducing separate per-group rate limit. This will be added on top of the result of `keyGenerator`.
159 | - `errorResponseBuilder`: a function to generate a custom response object. Defaults to `(request, context) => ({statusCode: 429, error: 'Too Many Requests', message: ``Rate limit exceeded, retry in ${context.after}``})`
160 | - `addHeadersOnExceeding`: define which headers should be added in the response when the limit is not reached. Defaults all the headers will be shown
161 | - `addHeaders`: define which headers should be added in the response when the limit is reached. Defaults all the headers will be shown
162 | - `enableDraftSpec`: if `true` it will change the HTTP rate limit headers following the IEFT draft document. More information at [draft-ietf-httpapi-ratelimit-headers.md](https://github.com/ietf-wg-httpapi/ratelimit-headers/blob/f6a7bc7560a776ea96d800cf5ed3752d6d397b06/draft-ietf-httpapi-ratelimit-headers.md).
163 | - `onExceeding`: callback that will be executed before request limit has been reached.
164 | - `onExceeded`: callback that will be executed after request limit has been reached.
165 | - `onBanReach`: callback that will be executed when the ban limit has been reached.
166 | - `exponentialBackoff`: Renew user limitation exponentially when user sends a request to the server when still limited.
167 |
168 | `keyGenerator` example usage:
169 | ```js
170 | await fastify.register(import('@fastify/rate-limit'), {
171 | /* ... */
172 | keyGenerator: function (request) {
173 | return request.headers['x-real-ip'] // nginx
174 | || request.headers['x-client-ip'] // apache
175 | || request.headers['x-forwarded-for'] // use this only if you trust the header
176 | || request.session.username // you can limit based on any session value
177 | || request.ip // fallback to default
178 | }
179 | })
180 | ```
181 |
182 | Variable `max` example usage:
183 | ```js
184 | // In the same timeWindow, the max value can change based on request and/or key like this
185 | fastify.register(rateLimit, {
186 | /* ... */
187 | keyGenerator (request) { return request.headers['service-key'] },
188 | max: async (request, key) => { return key === 'pro' ? 3 : 2 },
189 | timeWindow: 1000
190 | })
191 | ```
192 |
193 | `errorResponseBuilder` example usage:
194 | ```js
195 | await fastify.register(import('@fastify/rate-limit'), {
196 | /* ... */
197 | errorResponseBuilder: function (request, context) {
198 | return {
199 | statusCode: 429,
200 | error: 'Too Many Requests',
201 | message: `I only allow ${context.max} requests per ${context.after} to this Website. Try again soon.`,
202 | date: Date.now(),
203 | expiresIn: context.ttl // milliseconds
204 | }
205 | }
206 | })
207 | ```
208 |
209 | Dynamic `allowList` example usage:
210 | ```js
211 | await fastify.register(import('@fastify/rate-limit'), {
212 | /* ... */
213 | allowList: function (request, key) {
214 | return request.headers['x-app-client-id'] === 'internal-usage'
215 | }
216 | })
217 | ```
218 |
219 | Custom `hook` example usage (after authentication):
220 | ```js
221 | await fastify.register(import('@fastify/rate-limit'), {
222 | hook: 'preHandler',
223 | keyGenerator: function (request) {
224 | return request.userId || request.ip
225 | }
226 | })
227 |
228 | fastify.decorateRequest('userId', '')
229 | fastify.addHook('preHandler', async function (request) {
230 | const { userId } = request.query
231 | if (userId) {
232 | request.userId = userId
233 | }
234 | })
235 | ```
236 |
237 | Custom `store` example usage:
238 |
239 | NOTE: The ```timeWindow``` will always be passed as the numeric value in milliseconds into the store's constructor.
240 |
241 | ```js
242 | function CustomStore (options) {
243 | this.options = options
244 | this.current = 0
245 | }
246 |
247 | CustomStore.prototype.incr = function (key, cb) {
248 | const timeWindow = this.options.timeWindow
249 | this.current++
250 | cb(null, { current: this.current, ttl: timeWindow - (this.current * 1000) })
251 | }
252 |
253 | CustomStore.prototype.child = function (routeOptions) {
254 | // We create a merged copy of the current parent parameters with the specific
255 | // route parameters and pass them into the child store.
256 | const childParams = Object.assign(this.options, routeOptions)
257 | const store = new CustomStore(childParams)
258 | // Here is where you may want to do some custom calls on the store with the information
259 | // in routeOptions first...
260 | // store.setSubKey(routeOptions.method + routeOptions.url)
261 | return store
262 | }
263 |
264 | await fastify.register(import('@fastify/rate-limit'), {
265 | /* ... */
266 | store: CustomStore
267 | })
268 | ```
269 |
270 | The `routeOptions` object passed to the `child` method of the store will contain the same options that are detailed above for plugin registration with any specific overrides provided on the route. In addition, the following parameter is provided:
271 |
272 | - `routeInfo`: The configuration of the route including `method`, `url`, `path`, and the full route `config`
273 |
274 | Custom `onExceeding` example usage:
275 | ```js
276 | await fastify.register(import('@fastify/rate-limit'), {
277 | /* */
278 | onExceeding: function (req, key) {
279 | console.log('callback on exceeding ... executed before response to client')
280 | }
281 | })
282 | ```
283 |
284 | Custom `onExceeded` example usage:
285 | ```js
286 | await fastify.register(import('@fastify/rate-limit'), {
287 | /* */
288 | onExceeded: function (req, key) {
289 | console.log('callback on exceeded ... executed before response to client')
290 | }
291 | })
292 | ```
293 |
294 | Custom `onBanReach` example usage:
295 | ```js
296 | await fastify.register(import('@fastify/rate-limit'), {
297 | /* */
298 | ban: 10,
299 | onBanReach: function (req, key) {
300 | console.log('callback on exceeded ban limit')
301 | }
302 | })
303 | ```
304 |
305 | ### Options on the endpoint itself
306 |
307 | Rate limiting can also be configured at the route level, applying the configuration independently.
308 |
309 | For example the `allowList` if configured:
310 | - on plugin registration will affect all endpoints within the encapsulation scope
311 | - on route declaration will affect only the targeted endpoint
312 |
313 | The global allowlist is configured when registering it with `fastify.register(...)`.
314 |
315 | The endpoint allowlist is set on the endpoint directly with the `{ config : { rateLimit : { allowList : [] } } }` object.
316 |
317 | ACL checking is performed based on the value of the key from the `keyGenerator`.
318 |
319 | In this example, we are checking the IP address, but it could be an allowlist of specific user identifiers (like JWT or tokens):
320 |
321 | ```js
322 | import Fastify from 'fastify'
323 |
324 | const fastify = Fastify()
325 | await fastify.register(import('@fastify/rate-limit'),
326 | {
327 | global : false, // don't apply these settings to all the routes of the context
328 | max: 3000, // default global max rate limit
329 | allowList: ['192.168.0.10'], // global allowlist access.
330 | redis: redis, // custom connection to redis
331 | })
332 |
333 | // add a limited route with this configuration plus the global one
334 | fastify.get('/', {
335 | config: {
336 | rateLimit: {
337 | max: 3,
338 | timeWindow: '1 minute'
339 | }
340 | }
341 | }, (request, reply) => {
342 | reply.send({ hello: 'from ... root' })
343 | })
344 |
345 | // add a limited route with this configuration plus the global one
346 | fastify.get('/private', {
347 | config: {
348 | rateLimit: {
349 | max: 3,
350 | timeWindow: '1 minute'
351 | }
352 | }
353 | }, (request, reply) => {
354 | reply.send({ hello: 'from ... private' })
355 | })
356 |
357 | // this route doesn't have any rate limit
358 | fastify.get('/public', (request, reply) => {
359 | reply.send({ hello: 'from ... public' })
360 | })
361 |
362 | // add a limited route with this configuration plus the global one
363 | fastify.get('/public/sub-rated-1', {
364 | config: {
365 | rateLimit: {
366 | timeWindow: '1 minute',
367 | allowList: ['127.0.0.1'],
368 | onExceeding: function (request, key) {
369 | console.log('callback on exceeding ... executed before response to client')
370 | },
371 | onExceeded: function (request, key) {
372 | console.log('callback on exceeded ... to black ip in security group for example, request is give as argument')
373 | }
374 | }
375 | }
376 | }, (request, reply) => {
377 | reply.send({ hello: 'from sub-rated-1 ... using default max value ... ' })
378 | })
379 |
380 | // group routes and add a rate limit
381 | fastify.get('/otp/send', {
382 | config: {
383 | rateLimit: {
384 | max: 3,
385 | timeWindow: '1 minute',
386 | groupId:"OTP"
387 | }
388 | }
389 | }, (request, reply) => {
390 | reply.send({ hello: 'from ... grouped rate limit' })
391 | })
392 |
393 | fastify.get('/otp/resend', {
394 | config: {
395 | rateLimit: {
396 | max: 3,
397 | timeWindow: '1 minute',
398 | groupId:"OTP"
399 | }
400 | }
401 | }, (request, reply) => {
402 | reply.send({ hello: 'from ... grouped rate limit' })
403 | })
404 | ```
405 |
406 | In the route creation you can override the same settings of the plugin registration plus the following additional options:
407 |
408 | - `onExceeding` : callback that will be executed each time a request is made to a route that is rate-limited
409 | - `onExceeded` : callback that will be executed when a user reaches the maximum number of tries. Can be useful to blacklist clients
410 |
411 | You may also want to set a global rate limiter and then disable it on some routes:
412 |
413 | ```js
414 | import Fastify from 'fastify'
415 |
416 | const fastify = Fastify()
417 | await fastify.register(import('@fastify/rate-limit'), {
418 | max: 100,
419 | timeWindow: '1 minute'
420 | })
421 |
422 | // add a limited route with global config
423 | fastify.get('/', (request, reply) => {
424 | reply.send({ hello: 'from ... rate limited root' })
425 | })
426 |
427 | // this route doesn't have any rate limit
428 | fastify.get('/public', {
429 | config: {
430 | rateLimit: false
431 | }
432 | }, (request, reply) => {
433 | reply.send({ hello: 'from ... public' })
434 | })
435 |
436 | // add a limited route with global config and different max
437 | fastify.get('/private', {
438 | config: {
439 | rateLimit: {
440 | max: 9
441 | }
442 | }
443 | }, (request, reply) => {
444 | reply.send({ hello: 'from ... private and more limited' })
445 | })
446 | ```
447 |
448 | ### Manual Rate Limit
449 |
450 | A custom limiter function can be created with `fastify.createRateLimit()`, which is handy when needing to integrate with
451 | technologies like [GraphQL](https://graphql.org/) or [tRPC](https://trpc.io/). This function uses the global [options](#options) set
452 | during plugin registration, but you can override options such as `store`, `skipOnError`, `max`, `timeWindow`,
453 | `allowList`, `keyGenerator`, and `ban`.
454 |
455 | Example usage:
456 |
457 | ```js
458 | import Fastify from 'fastify'
459 |
460 | const fastify = Fastify()
461 |
462 | // register with global options
463 | await fastify.register(import('@fastify/rate-limit'), {
464 | global : false,
465 | max: 100,
466 | timeWindow: '1 minute'
467 | })
468 |
469 | // checkRateLimit will use the global options provided above when called
470 | const checkRateLimit = fastify.createRateLimit();
471 |
472 | fastify.get("/", async (request, reply) => {
473 | // manually check the rate limit (using global options)
474 | const limit = await checkRateLimit(request);
475 |
476 | if(!limit.isAllowed && limit.isExceeded) {
477 | return reply.code(429).send("Limit exceeded");
478 | }
479 |
480 | return reply.send("Hello world");
481 | });
482 |
483 | // override global max option
484 | const checkCustomRateLimit = fastify.createRateLimit({ max: 100 });
485 |
486 | fastify.get("/custom", async (request, reply) => {
487 | // manually check the rate limit (using global options and overridden max option)
488 | const limit = await checkCustomRateLimit(request);
489 |
490 | // manually handle limit exceedance
491 | if(!limit.isAllowed && limit.isExceeded) {
492 | return reply.code(429).send("Limit exceeded");
493 | }
494 |
495 | return reply.send("Hello world");
496 | });
497 | ```
498 |
499 | A custom limiter function created with `fastify.createRateLimit()` only requires a `FastifyRequest` as the first parameter:
500 |
501 | ```js
502 | const checkRateLimit = fastify.createRateLimit();
503 | const limit = await checkRateLimit(request);
504 | ```
505 |
506 | The returned `limit` is an object containing the following properties for the `request` passed to `checkRateLimit`.
507 |
508 | - `isAllowed`: if `true`, the request was excluded from rate limiting according to the configured `allowList`.
509 | - `key`: the generated key as returned by the `keyGenerator` function.
510 |
511 | If `isAllowed` is `false` the object also contains these additional properties:
512 |
513 | - `max`: the configured `max` option as a number. If a `max` function was supplied as global option or to `fastify.createRateLimit()`, this property will correspond to the function's return type for the given `request`.
514 | - `timeWindow`: the configured `timeWindow` option in milliseconds. If a function was supplied to `timeWindow`, similar to the `max` property above, this property will be equal to the function's return type.
515 | - `remaining`: the remaining amount of requests before the limit is exceeded.
516 | - `ttl`: the remaining time until the limit will be reset in milliseconds.
517 | - `ttlInSeconds`: `ttl` in seconds.
518 | - `isExceeded`: `true` if the limit was exceeded.
519 | - `isBanned`: `true` if the request was banned according to the `ban` option.
520 |
521 | ### Examples of Custom Store
522 |
523 | These examples show an overview of the `store` feature and you should take inspiration from it and tweak as you need:
524 |
525 | - [Knex-SQLite](./example/example-knex.js)
526 | - [Knex-MySQL](./example/example-knex-mysql.js)
527 | - [Sequelize-PostgreSQL](./example/example-sequelize.js)
528 |
529 | ### IETF Draft Spec Headers
530 |
531 | The response will have the following headers if `enableDraftSpec` is `true`:
532 |
533 |
534 | | Header | Description |
535 | |--------|-------------|
536 | |`ratelimit-limit` | how many requests the client can make
537 | |`ratelimit-remaining` | how many requests remain to the client in the timewindow
538 | |`ratelimit-reset` | how many seconds must pass before the rate limit resets
539 | |`retry-after` | contains the same value in time as `ratelimit-reset`
540 |
541 | ### Contribute
542 | To run tests locally, you need a Redis instance that you can launch with this command:
543 | ```
544 | npm run redis
545 | ```
546 |
547 |
548 | ## License
549 |
550 | Licensed under [MIT](./LICENSE).
551 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | module.exports = require('neostandard')({
4 | ignores: require('neostandard').resolveIgnoresFromGitignore(),
5 | ts: true
6 | })
7 |
--------------------------------------------------------------------------------
/example/example-auto-pipeline.mjs:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | import Redis from 'ioredis'
4 | import Fastify from 'fastify'
5 |
6 | const redis = new Redis({
7 | enableAutoPipelining: true,
8 | connectionName: 'my-connection-name',
9 | host: 'localhost',
10 | port: 6379,
11 | connectTimeout: 500,
12 | maxRetriesPerRequest: 1
13 | })
14 |
15 | const fastify = Fastify()
16 |
17 | await fastify.register(import('../index.js'),
18 | {
19 | global: false,
20 | max: 3000, // default max rate limit
21 | // timeWindow: 1000*60,
22 | // cache: 10000,
23 | allowList: ['127.0.0.2'], // global allowList access ( ACL based on the key from the keyGenerator)
24 | redis, // connection to redis
25 | skipOnError: false // default false
26 | // keyGenerator: function(req) { /* ... */ }, // default (req) => req.raw.ip
27 | })
28 |
29 | fastify.get('/', {
30 | config: {
31 | rateLimit: {
32 | max: 3,
33 | timeWindow: '1 minute'
34 | }
35 | }
36 | }, (_req, reply) => {
37 | reply.send({ hello: 'from ... root' })
38 | })
39 |
40 | fastify.get('/private', {
41 | config: {
42 | rateLimit: {
43 | max: 3,
44 | allowList: ['127.0.2.1', '127.0.3.1'],
45 | timeWindow: '1 minute'
46 | }
47 | }
48 | }, (_req, reply) => {
49 | reply.send({ hello: 'from ... private' })
50 | })
51 |
52 | fastify.get('/public', (_req, reply) => {
53 | reply.send({ hello: 'from ... public' })
54 | })
55 |
56 | fastify.get('/public/sub-rated-1', {
57 | config: {
58 | rateLimit: {
59 | timeWindow: '1 minute',
60 | allowList: ['127.0.2.1'],
61 | onExceeding: function () {
62 | console.log('callback on exceededing ... executed before response to client. req is give as argument')
63 | },
64 | onExceeded: function () {
65 | console.log('callback on exceeded ... to black ip in security group for example, req is give as argument')
66 | }
67 | }
68 | }
69 | }, (_req, reply) => {
70 | reply.send({ hello: 'from sub-rated-1 ... using default max value ... ' })
71 | })
72 |
73 | fastify.get('/public/sub-rated-2', {
74 | config: {
75 | rateLimit: {
76 | max: 3,
77 | timeWindow: '1 minute',
78 | onExceeding: function () {
79 | console.log('callback on exceededing ... executed before response to client. req is give as argument')
80 | },
81 | onExceeded: function () {
82 | console.log('callback on exceeded ... to black ip in security group for example, req is give as argument')
83 | }
84 | }
85 | }
86 | }, (_req, reply) => {
87 | reply.send({ hello: 'from ... sub-rated-2' })
88 | })
89 |
90 | fastify.get('/home', {
91 | config: {
92 | rateLimit: {
93 | max: 200,
94 | timeWindow: '1 minute'
95 | }
96 | }
97 | }, (_req, reply) => {
98 | reply.send({ hello: 'toto' })
99 | })
100 |
101 | fastify.get('/customerrormessage', {
102 | config: {
103 | rateLimit: {
104 | max: 2,
105 | timeWindow: '1 minute',
106 | errorResponseBuilder: (_req, context) => ({ code: 429, timeWindow: context.after, limit: context.max })
107 | }
108 | }
109 | }, (_req, reply) => {
110 | reply.send({ hello: 'toto' })
111 | })
112 |
113 | fastify.listen({ port: 3000 }, err => {
114 | if (err) throw err
115 | console.log('Server listening at http://localhost:3000')
116 | })
117 |
--------------------------------------------------------------------------------
/example/example-knex-mysql.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | /* eslint-disable no-undef */
3 |
4 | // Example of a custom store using Knex.js and MySQL.
5 | //
6 | // Assumes you have access to a configured knex object.
7 | //
8 | // Note that the rate check should place a read lock on the row.
9 | // For MySQL see:
10 | // https://dev.mysql.com/doc/refman/8.0/en/innodb-locking-reads.html
11 | // https://blog.nodeswat.com/concurrency-mysql-and-node-js-a-journey-of-discovery-31281e53572e
12 | //
13 | // Below is an example table to store rate limits that must be created
14 | // in the database first.
15 | //
16 | // exports.up = async knex => {
17 | // await knex.schema.createTable('rate_limits', table => {
18 | // table.string('source').notNullable()
19 | // table.string('route').notNullable()
20 | // table.integer('count').unsigned()
21 | // table.bigInteger ('ttl')
22 | // table.primary(['route', 'source'])
23 | // })
24 | // }
25 | //
26 | // exports.down = async knex => {
27 | // await knex.schema.dropTable('rate_limits')
28 | // }
29 | //
30 | // CREATE TABLE `rate_limits` (
31 | // `source` varchar(255) NOT NULL,
32 | // `route` varchar(255) NOT NULL,
33 | // `count` int unsigned DEFAULT NULL,
34 | // `ttl` int unsigned DEFAULT NULL,
35 | // PRIMARY KEY (`route`,`source`)
36 | // ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
37 |
38 | function KnexStore (options) {
39 | this.options = options
40 | this.route = ''
41 | }
42 |
43 | KnexStore.prototype.routeKey = function (route) {
44 | if (route) this.route = route
45 | return route
46 | }
47 |
48 | KnexStore.prototype.incr = async function (key, cb) {
49 | const now = (new Date()).getTime()
50 | const ttl = now + this.options.timeWindow
51 | const max = this.options.max
52 | const cond = { route: this.route, source: key }
53 | const trx = await knex.transaction()
54 | try {
55 | // NOTE: MySQL syntax FOR UPDATE for read lock on counter stats in row
56 | const row = await trx('rate_limits')
57 | .whereRaw('route = ? AND source = ? FOR UPDATE', [cond.route || '', cond.source]) // Create read lock
58 | const d = row[0]
59 | if (d && d.ttl > now) {
60 | // Optimization - no need to UPDATE if max has been reached.
61 | if (d.count < max) {
62 | await trx
63 | .raw('UPDATE rate_limits SET count = ? WHERE route = ? AND source = ?', [d.count + 1, cond.route, key])
64 | }
65 | // If we were already at max no need to UPDATE but we must still send d.count + 1 to trigger rate limit.
66 | process.nextTick(cb, null, { current: d.count + 1, ttl: d.ttl })
67 | } else {
68 | await trx
69 | .raw('INSERT INTO rate_limits(route, source, count, ttl) VALUES(?,?,1,?) ON DUPLICATE KEY UPDATE count = 1, ttl = ?', [cond.route, key, d?.ttl || ttl, ttl])
70 | process.nextTick(cb, null, { current: 1, ttl: d?.ttl || ttl })
71 | }
72 | await trx.commit()
73 | } catch (err) {
74 | await trx.rollback()
75 | // TODO: Handle as desired
76 | fastify.log.error(err)
77 | process.nextTick(cb, err, { current: 0 })
78 | }
79 | }
80 |
81 | KnexStore.prototype.child = function (routeOptions = {}) {
82 | // NOTE: Optionally override and set global: false here for route specific
83 | // options, which then allows you to use `global: true` should you
84 | // wish to during initial registration below.
85 | const options = { ...this.options, ...routeOptions, global: false }
86 | const store = new KnexStore(options)
87 | store.routeKey(routeOptions.routeInfo.method + routeOptions.routeInfo.url)
88 | return store
89 | }
90 |
91 | fastify.register(require('../../fastify-rate-limit'),
92 | {
93 | global: false,
94 | max: 10,
95 | store: KnexStore,
96 | skipOnError: false
97 | }
98 | )
99 |
100 | fastify.get('/', {
101 | config: {
102 | rateLimit: {
103 | max: 10,
104 | timeWindow: '1 minute'
105 | }
106 | }
107 | }, (_req, reply) => {
108 | reply.send({ hello: 'from ... root' })
109 | })
110 |
111 | fastify.get('/private', {
112 | config: {
113 | rateLimit: {
114 | max: 3,
115 | timeWindow: '1 minute'
116 | }
117 | }
118 | }, (_req, reply) => {
119 | reply.send({ hello: 'from ... private' })
120 | })
121 |
122 | fastify.get('/public', (_req, reply) => {
123 | reply.send({ hello: 'from ... public' })
124 | })
125 |
--------------------------------------------------------------------------------
/example/example-knex.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | // Example of a Custom Store using Knex.js ORM for SQLite database
4 | // Below is an example table to store rate limits that must be created
5 | // in the database first
6 | //
7 | // CREATE TABLE "RateLimits" (
8 | // "Route" TEXT,
9 | // "Source" TEXT,
10 | // "Count" INTEGER,
11 | // "TTL" NUMERIC,
12 | // PRIMARY KEY("Source")
13 | // );
14 | //
15 | // CREATE UNIQUE INDEX "idx_uniq_route_source" ON "RateLimits" (Route, Source);
16 | //
17 | const Knex = require('knex')
18 | const fastify = require('fastify')()
19 |
20 | const knex = Knex({
21 | client: 'sqlite3',
22 | connection: {
23 | filename: './db.sqlite'
24 | }
25 | })
26 |
27 | function KnexStore (options) {
28 | this.options = options
29 | this.route = ''
30 | }
31 |
32 | KnexStore.prototype.routeKey = function (route) {
33 | if (route) {
34 | this.route = route
35 | } else {
36 | return route
37 | }
38 | }
39 |
40 | KnexStore.prototype.incr = function (key, cb) {
41 | const now = (new Date()).getTime()
42 | const ttl = now + this.options.timeWindow
43 | knex.transaction(function (trx) {
44 | trx
45 | .where({ Route: this.route, Source: key })
46 | .then(d => {
47 | if (d.TTL > now) {
48 | trx
49 | .raw(`UPDATE RateLimits SET Count = 1 WHERE Route='${this.route}' AND Source='${key}'`)
50 | .then(() => {
51 | cb(null, { current: 1, ttl: d.TTL })
52 | })
53 | .catch(err => {
54 | cb(err, { current: 0 })
55 | })
56 | } else {
57 | trx
58 | .raw(`INSERT INTO RateLimits(Route, Source, Count, TTL) VALUES('${this.route}', '${key}',1,${d.TTL || ttl}) ON CONFLICT(Route, Source) DO UPDATE SET Count=Count+1,TTL=${ttl}`)
59 | .then(() => {
60 | cb(null, { current: d.Count ? d.Count + 1 : 1, ttl: d.TTL || ttl })
61 | })
62 | .catch(err => {
63 | cb(err, { current: 0 })
64 | })
65 | }
66 | })
67 | .catch(err => {
68 | cb(err, { current: 0 })
69 | })
70 | })
71 | }
72 |
73 | KnexStore.prototype.child = function (routeOptions) {
74 | const options = Object.assign(this.options, routeOptions)
75 | const store = new KnexStore(options)
76 | store.routeKey(routeOptions.routeInfo.method + routeOptions.routeInfo.url)
77 | return store
78 | }
79 |
80 | fastify.register(require('../../fastify-rate-limit'),
81 | {
82 | global: false,
83 | max: 10,
84 | store: KnexStore,
85 | skipOnError: false
86 | }
87 | )
88 |
89 | fastify.get('/', {
90 | config: {
91 | rateLimit: {
92 | max: 10,
93 | timeWindow: '1 minute'
94 | }
95 | }
96 | }, (_req, reply) => {
97 | reply.send({ hello: 'from ... root' })
98 | })
99 |
100 | fastify.get('/private', {
101 | config: {
102 | rateLimit: {
103 | max: 3,
104 | timeWindow: '1 minute'
105 | }
106 | }
107 | }, (_req, reply) => {
108 | reply.send({ hello: 'from ... private' })
109 | })
110 |
111 | fastify.get('/public', (_req, reply) => {
112 | reply.send({ hello: 'from ... public' })
113 | })
114 |
115 | fastify.listen({ port: 3000 }, err => {
116 | if (err) throw err
117 | console.log('Server listening at http://localhost:3000')
118 | })
119 |
--------------------------------------------------------------------------------
/example/example-sequelize.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | // Example of a Custom Store using Sequelize ORM for PostgreSQL database
4 |
5 | // Sequelize Migration for "RateLimits" table
6 | //
7 | // module.exports = {
8 | // up: (queryInterface, { TEXT, INTEGER, BIGINT }) => {
9 | // return queryInterface.createTable(
10 | // 'RateLimits',
11 | // {
12 | // Route: {
13 | // type: TEXT,
14 | // allowNull: false
15 | // },
16 | // Source: {
17 | // type: TEXT,
18 | // allowNull: false,
19 | // primaryKey: true
20 | // },
21 | // Count: {
22 | // type: INTEGER,
23 | // allowNull: false
24 | // },
25 | // TTL: {
26 | // type: BIGINT,
27 | // allowNull: false
28 | // }
29 | // },
30 | // {
31 | // freezeTableName: true,
32 | // timestamps: false,
33 | // uniqueKeys: {
34 | // unique_tag: {
35 | // customIndex: true,
36 | // fields: ['Route', 'Source']
37 | // }
38 | // }
39 | // }
40 | // )
41 | // },
42 | // down: queryInterface => {
43 | // return queryInterface.dropTable('RateLimits')
44 | // }
45 | // }
46 |
47 | const fastify = require('fastify')()
48 | const Sequelize = require('sequelize')
49 |
50 | const databaseUri = 'postgres://username:password@localhost:5432/fastify-rate-limit-example'
51 | const sequelize = new Sequelize(databaseUri)
52 | // OR
53 | // const sequelize = new Sequelize('database', 'username', 'password');
54 |
55 | // Sequelize Model for "RateLimits" table
56 | //
57 | const RateLimits = sequelize.define(
58 | 'RateLimits',
59 | {
60 | Route: {
61 | type: Sequelize.TEXT,
62 | allowNull: false
63 | },
64 | Source: {
65 | type: Sequelize.TEXT,
66 | allowNull: false,
67 | primaryKey: true
68 | },
69 | Count: {
70 | type: Sequelize.INTEGER,
71 | allowNull: false
72 | },
73 | TTL: {
74 | type: Sequelize.BIGINT,
75 | allowNull: false
76 | }
77 | },
78 | {
79 | freezeTableName: true,
80 | timestamps: false,
81 | indexes: [
82 | {
83 | unique: true,
84 | fields: ['Route', 'Source']
85 | }
86 | ]
87 | }
88 | )
89 |
90 | function RateLimiterStore (options) {
91 | this.options = options
92 | this.route = ''
93 | }
94 |
95 | RateLimiterStore.prototype.routeKey = function routeKey (route) {
96 | if (route) this.route = route
97 | return route
98 | }
99 |
100 | RateLimiterStore.prototype.incr = async function incr (key, cb) {
101 | const now = new Date().getTime()
102 | const ttl = now + this.options.timeWindow
103 | const cond = { Route: this.route, Source: key }
104 |
105 | const RateLimit = await RateLimits.findOne({ where: cond })
106 |
107 | if (RateLimit && parseInt(RateLimit.TTL, 10) > now) {
108 | try {
109 | await RateLimit.update({ Count: RateLimit.Count + 1 }, cond)
110 | cb(null, {
111 | current: RateLimit.Count + 1,
112 | ttl: RateLimit.TTL
113 | })
114 | } catch (err) {
115 | cb(err, {
116 | current: 0
117 | })
118 | }
119 | } else {
120 | sequelize.query(
121 | `INSERT INTO "RateLimits"("Route", "Source", "Count", "TTL")
122 | VALUES('${this.route}', '${key}', 1,
123 | ${RateLimit?.TTL || ttl})
124 | ON CONFLICT("Route", "Source") DO UPDATE SET "Count"=1, "TTL"=${ttl}`
125 | )
126 | .then(() => {
127 | cb(null, {
128 | current: 1,
129 | ttl: RateLimit?.TTL || ttl
130 | })
131 | })
132 | .catch(err => {
133 | cb(err, {
134 | current: 0
135 | })
136 | })
137 | }
138 | }
139 |
140 | RateLimiterStore.prototype.child = function child (routeOptions = {}) {
141 | const options = Object.assign(this.options, routeOptions)
142 | const store = new RateLimiterStore(options)
143 | store.routeKey(routeOptions.routeInfo.method + routeOptions.routeInfo.url)
144 | return store
145 | }
146 |
147 | fastify.register(require('../../fastify-rate-limit'),
148 | {
149 | global: false,
150 | max: 10,
151 | store: RateLimiterStore,
152 | skipOnError: false
153 | }
154 | )
155 |
156 | fastify.get('/', {
157 | config: {
158 | rateLimit: {
159 | max: 10,
160 | timeWindow: '1 minute'
161 | }
162 | }
163 | }, (_req, reply) => {
164 | reply.send({ hello: 'from ... root' })
165 | })
166 |
167 | fastify.get('/private', {
168 | config: {
169 | rateLimit: {
170 | max: 3,
171 | timeWindow: '1 minute'
172 | }
173 | }
174 | }, (_req, reply) => {
175 | reply.send({ hello: 'from ... private' })
176 | })
177 |
178 | fastify.get('/public', (_req, reply) => {
179 | reply.send({ hello: 'from ... public' })
180 | })
181 |
182 | fastify.listen({ port: 3000 }, err => {
183 | if (err) throw err
184 | console.log('Server listening at http://localhost:3000')
185 | })
186 |
--------------------------------------------------------------------------------
/example/example-simple.mjs:
--------------------------------------------------------------------------------
1 | import fastify from 'fastify'
2 | import fastifyRateLimit from '../index.js'
3 |
4 | const server = fastify()
5 |
6 | await server.register(fastifyRateLimit, {
7 | global: true,
8 | max: 10000,
9 | timeWindow: '1 minute'
10 | })
11 |
12 | server.get('/', (_request, reply) => {
13 | reply.send('Hello, world!')
14 | })
15 |
16 | const start = async () => {
17 | try {
18 | await server.listen({ port: 3000 })
19 | console.log('Server is running on port 3000')
20 | } catch (error) {
21 | console.error('Error starting server:', error)
22 | }
23 | }
24 |
25 | start()
26 |
--------------------------------------------------------------------------------
/example/example.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const Redis = require('ioredis')
4 | const redis = new Redis({
5 | connectionName: 'my-connection-name',
6 | host: 'localhost',
7 | port: 6379,
8 | connectTimeout: 500,
9 | maxRetriesPerRequest: 1
10 | })
11 |
12 | const fastify = require('fastify')()
13 |
14 | fastify.register(require('../../fastify-rate-limit'),
15 | {
16 | global: false,
17 | max: 3000, // default max rate limit
18 | // timeWindow: 1000*60,
19 | // cache: 10000,
20 | allowList: ['127.0.0.2'], // global allowList access ( ACL based on the key from the keyGenerator)
21 | redis, // connection to redis
22 | skipOnError: false // default false
23 | // keyGenerator: function(req) { /* ... */ }, // default (req) => req.raw.ip
24 | })
25 |
26 | fastify.get('/', {
27 | config: {
28 | rateLimit: {
29 | max: 3,
30 | timeWindow: '1 minute'
31 | }
32 | }
33 | }, (_req, reply) => {
34 | reply.send({ hello: 'from ... root' })
35 | })
36 |
37 | fastify.get('/private', {
38 | config: {
39 | rateLimit: {
40 | max: 3,
41 | allowList: ['127.0.2.1', '127.0.3.1'],
42 | timeWindow: '1 minute'
43 | }
44 | }
45 | }, (_req, reply) => {
46 | reply.send({ hello: 'from ... private' })
47 | })
48 |
49 | fastify.get('/public', (_req, reply) => {
50 | reply.send({ hello: 'from ... public' })
51 | })
52 |
53 | fastify.get('/public/sub-rated-1', {
54 | config: {
55 | rateLimit: {
56 | timeWindow: '1 minute',
57 | allowList: ['127.0.2.1'],
58 | onExceeding: function () {
59 | console.log('callback on exceededing ... executed before response to client. req is give as argument')
60 | },
61 | onExceeded: function () {
62 | console.log('callback on exceeded ... to black ip in security group for example, req is give as argument')
63 | }
64 | }
65 | }
66 | }, (_req, reply) => {
67 | reply.send({ hello: 'from sub-rated-1 ... using default max value ... ' })
68 | })
69 |
70 | fastify.get('/public/sub-rated-2', {
71 | config: {
72 | rateLimit: {
73 | max: 3,
74 | timeWindow: '1 minute',
75 | onExceeding: function () {
76 | console.log('callback on exceededing ... executed before response to client. req is give as argument')
77 | },
78 | onExceeded: function () {
79 | console.log('callback on exceeded ... to black ip in security group for example, req is give as argument')
80 | }
81 | }
82 | }
83 | }, (_req, reply) => {
84 | reply.send({ hello: 'from ... sub-rated-2' })
85 | })
86 |
87 | fastify.get('/home', {
88 | config: {
89 | rateLimit: {
90 | max: 200,
91 | timeWindow: '1 minute'
92 | }
93 | }
94 | }, (_req, reply) => {
95 | reply.send({ hello: 'toto' })
96 | })
97 |
98 | fastify.get('/customerrormessage', {
99 | config: {
100 | rateLimit: {
101 | max: 2,
102 | timeWindow: '1 minute',
103 | errorResponseBuilder: (_req, context) => ({ code: 429, timeWindow: context.after, limit: context.max })
104 | }
105 | }
106 | }, (_req, reply) => {
107 | reply.send({ hello: 'toto' })
108 | })
109 |
110 | fastify.listen({ port: 3000 }, err => {
111 | if (err) throw err
112 | console.log('Server listening at http://localhost:3000')
113 | })
114 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const fp = require('fastify-plugin')
4 | const { parse, format } = require('@lukeed/ms')
5 |
6 | const LocalStore = require('./store/LocalStore')
7 | const RedisStore = require('./store/RedisStore')
8 |
9 | const defaultMax = 1000
10 | const defaultTimeWindow = 60000
11 | const defaultHook = 'onRequest'
12 |
13 | const defaultHeaders = {
14 | rateLimit: 'x-ratelimit-limit',
15 | rateRemaining: 'x-ratelimit-remaining',
16 | rateReset: 'x-ratelimit-reset',
17 | retryAfter: 'retry-after'
18 | }
19 |
20 | const draftSpecHeaders = {
21 | rateLimit: 'ratelimit-limit',
22 | rateRemaining: 'ratelimit-remaining',
23 | rateReset: 'ratelimit-reset',
24 | retryAfter: 'retry-after'
25 | }
26 |
27 | const defaultOnFn = () => {}
28 |
29 | const defaultKeyGenerator = (req) => req.ip
30 |
31 | const defaultErrorResponse = (_req, context) => {
32 | const err = new Error(`Rate limit exceeded, retry in ${context.after}`)
33 | err.statusCode = context.statusCode
34 | return err
35 | }
36 |
37 | async function fastifyRateLimit (fastify, settings) {
38 | const globalParams = {
39 | global: (typeof settings.global === 'boolean') ? settings.global : true
40 | }
41 |
42 | if (typeof settings.enableDraftSpec === 'boolean' && settings.enableDraftSpec) {
43 | globalParams.enableDraftSpec = true
44 | globalParams.labels = draftSpecHeaders
45 | } else {
46 | globalParams.enableDraftSpec = false
47 | globalParams.labels = defaultHeaders
48 | }
49 |
50 | globalParams.addHeaders = Object.assign({
51 | [globalParams.labels.rateLimit]: true,
52 | [globalParams.labels.rateRemaining]: true,
53 | [globalParams.labels.rateReset]: true,
54 | [globalParams.labels.retryAfter]: true
55 | }, settings.addHeaders)
56 |
57 | globalParams.addHeadersOnExceeding = Object.assign({
58 | [globalParams.labels.rateLimit]: true,
59 | [globalParams.labels.rateRemaining]: true,
60 | [globalParams.labels.rateReset]: true
61 | }, settings.addHeadersOnExceeding)
62 |
63 | // Global maximum allowed requests
64 | if (Number.isFinite(settings.max) && settings.max >= 0) {
65 | globalParams.max = Math.trunc(settings.max)
66 | } else if (
67 | typeof settings.max === 'function'
68 | ) {
69 | globalParams.max = settings.max
70 | } else {
71 | globalParams.max = defaultMax
72 | }
73 |
74 | // Global time window
75 | if (Number.isFinite(settings.timeWindow) && settings.timeWindow >= 0) {
76 | globalParams.timeWindow = Math.trunc(settings.timeWindow)
77 | } else if (typeof settings.timeWindow === 'string') {
78 | globalParams.timeWindow = parse(settings.timeWindow)
79 | } else if (
80 | typeof settings.timeWindow === 'function'
81 | ) {
82 | globalParams.timeWindow = settings.timeWindow
83 | } else {
84 | globalParams.timeWindow = defaultTimeWindow
85 | }
86 |
87 | globalParams.hook = settings.hook || defaultHook
88 | globalParams.allowList = settings.allowList || settings.whitelist || null
89 | globalParams.ban = Number.isFinite(settings.ban) && settings.ban >= 0 ? Math.trunc(settings.ban) : -1
90 | globalParams.onBanReach = typeof settings.onBanReach === 'function' ? settings.onBanReach : defaultOnFn
91 | globalParams.onExceeding = typeof settings.onExceeding === 'function' ? settings.onExceeding : defaultOnFn
92 | globalParams.onExceeded = typeof settings.onExceeded === 'function' ? settings.onExceeded : defaultOnFn
93 | globalParams.continueExceeding = typeof settings.continueExceeding === 'boolean' ? settings.continueExceeding : false
94 | globalParams.exponentialBackoff = typeof settings.exponentialBackoff === 'boolean' ? settings.exponentialBackoff : false
95 |
96 | globalParams.keyGenerator = typeof settings.keyGenerator === 'function'
97 | ? settings.keyGenerator
98 | : defaultKeyGenerator
99 |
100 | if (typeof settings.errorResponseBuilder === 'function') {
101 | globalParams.errorResponseBuilder = settings.errorResponseBuilder
102 | globalParams.isCustomErrorMessage = true
103 | } else {
104 | globalParams.errorResponseBuilder = defaultErrorResponse
105 | globalParams.isCustomErrorMessage = false
106 | }
107 |
108 | globalParams.skipOnError = typeof settings.skipOnError === 'boolean' ? settings.skipOnError : false
109 |
110 | const pluginComponent = {
111 | rateLimitRan: Symbol('fastify.request.rateLimitRan'),
112 | store: null
113 | }
114 |
115 | if (settings.store) {
116 | const Store = settings.store
117 | pluginComponent.store = new Store(globalParams)
118 | } else {
119 | if (settings.redis) {
120 | pluginComponent.store = new RedisStore(globalParams.continueExceeding, globalParams.exponentialBackoff, settings.redis, settings.nameSpace)
121 | } else {
122 | pluginComponent.store = new LocalStore(globalParams.continueExceeding, globalParams.exponentialBackoff, settings.cache)
123 | }
124 | }
125 |
126 | fastify.decorateRequest(pluginComponent.rateLimitRan, false)
127 |
128 | if (!fastify.hasDecorator('createRateLimit')) {
129 | fastify.decorate('createRateLimit', (options) => {
130 | const args = createLimiterArgs(pluginComponent, globalParams, options)
131 | return (req) => applyRateLimit.apply(this, args.concat(req))
132 | })
133 | }
134 |
135 | if (!fastify.hasDecorator('rateLimit')) {
136 | fastify.decorate('rateLimit', (options) => {
137 | const args = createLimiterArgs(pluginComponent, globalParams, options)
138 | return rateLimitRequestHandler(...args)
139 | })
140 | }
141 |
142 | fastify.addHook('onRoute', (routeOptions) => {
143 | if (routeOptions.config?.rateLimit != null) {
144 | if (typeof routeOptions.config.rateLimit === 'object') {
145 | const newPluginComponent = Object.create(pluginComponent)
146 | const mergedRateLimitParams = mergeParams(globalParams, routeOptions.config.rateLimit, { routeInfo: routeOptions })
147 | newPluginComponent.store = pluginComponent.store.child(mergedRateLimitParams)
148 |
149 | addRouteRateHook(newPluginComponent, mergedRateLimitParams, routeOptions)
150 | } else if (routeOptions.config.rateLimit !== false) {
151 | throw new Error('Unknown value for route rate-limit configuration')
152 | }
153 | } else if (globalParams.global) {
154 | // As the endpoint does not have a custom configuration, use the global one
155 | addRouteRateHook(pluginComponent, globalParams, routeOptions)
156 | }
157 | })
158 | }
159 |
160 | function mergeParams (...params) {
161 | const result = Object.assign({}, ...params)
162 |
163 | if (Number.isFinite(result.timeWindow) && result.timeWindow >= 0) {
164 | result.timeWindow = Math.trunc(result.timeWindow)
165 | } else if (typeof result.timeWindow === 'string') {
166 | result.timeWindow = parse(result.timeWindow)
167 | } else if (typeof result.timeWindow !== 'function') {
168 | result.timeWindow = defaultTimeWindow
169 | }
170 |
171 | if (Number.isFinite(result.max) && result.max >= 0) {
172 | result.max = Math.trunc(result.max)
173 | } else if (typeof result.max !== 'function') {
174 | result.max = defaultMax
175 | }
176 |
177 | if (Number.isFinite(result.ban) && result.ban >= 0) {
178 | result.ban = Math.trunc(result.ban)
179 | } else {
180 | result.ban = -1
181 | }
182 |
183 | if (result.groupId !== undefined && typeof result.groupId !== 'string') {
184 | throw new Error('groupId must be a string')
185 | }
186 |
187 | return result
188 | }
189 |
190 | function createLimiterArgs (pluginComponent, globalParams, options) {
191 | if (typeof options === 'object') {
192 | const newPluginComponent = Object.create(pluginComponent)
193 | const mergedRateLimitParams = mergeParams(globalParams, options, { routeInfo: {} })
194 | newPluginComponent.store = newPluginComponent.store.child(mergedRateLimitParams)
195 | return [newPluginComponent, mergedRateLimitParams]
196 | }
197 |
198 | return [pluginComponent, globalParams]
199 | }
200 |
201 | function addRouteRateHook (pluginComponent, params, routeOptions) {
202 | const hook = params.hook
203 | const hookHandler = rateLimitRequestHandler(pluginComponent, params)
204 | if (Array.isArray(routeOptions[hook])) {
205 | routeOptions[hook].push(hookHandler)
206 | } else if (typeof routeOptions[hook] === 'function') {
207 | routeOptions[hook] = [routeOptions[hook], hookHandler]
208 | } else {
209 | routeOptions[hook] = [hookHandler]
210 | }
211 | }
212 |
213 | async function applyRateLimit (pluginComponent, params, req) {
214 | const { store } = pluginComponent
215 |
216 | // Retrieve the key from the generator (the global one or the one defined in the endpoint)
217 | let key = await params.keyGenerator(req)
218 | const groupId = req.routeOptions.config?.rateLimit?.groupId
219 |
220 | if (groupId) {
221 | key += groupId
222 | }
223 |
224 | // Don't apply any rate limiting if in the allow list
225 | if (params.allowList) {
226 | if (typeof params.allowList === 'function') {
227 | if (await params.allowList(req, key)) {
228 | return {
229 | isAllowed: true,
230 | key
231 | }
232 | }
233 | } else if (params.allowList.indexOf(key) !== -1) {
234 | return {
235 | isAllowed: true,
236 | key
237 | }
238 | }
239 | }
240 |
241 | const max = typeof params.max === 'number' ? params.max : await params.max(req, key)
242 | const timeWindow = typeof params.timeWindow === 'number' ? params.timeWindow : await params.timeWindow(req, key)
243 | let current = 0
244 | let ttl = 0
245 | let ttlInSeconds = 0
246 |
247 | // We increment the rate limit for the current request
248 | try {
249 | const res = await new Promise((resolve, reject) => {
250 | store.incr(key, (err, res) => {
251 | err ? reject(err) : resolve(res)
252 | }, timeWindow, max)
253 | })
254 |
255 | current = res.current
256 | ttl = res.ttl
257 | ttlInSeconds = Math.ceil(res.ttl / 1000)
258 | } catch (err) {
259 | if (!params.skipOnError) {
260 | throw err
261 | }
262 | }
263 |
264 | return {
265 | isAllowed: false,
266 | key,
267 | max,
268 | timeWindow,
269 | remaining: Math.max(0, max - current),
270 | ttl,
271 | ttlInSeconds,
272 | isExceeded: current > max,
273 | isBanned: params.ban !== -1 && current - max > params.ban
274 | }
275 | }
276 |
277 | function rateLimitRequestHandler (pluginComponent, params) {
278 | const { rateLimitRan } = pluginComponent
279 |
280 | return async (req, res) => {
281 | if (req[rateLimitRan]) {
282 | return
283 | }
284 |
285 | req[rateLimitRan] = true
286 |
287 | const rateLimit = await applyRateLimit(pluginComponent, params, req)
288 | if (rateLimit.isAllowed) {
289 | return
290 | }
291 |
292 | const {
293 | key,
294 | max,
295 | remaining,
296 | ttl,
297 | ttlInSeconds,
298 | isExceeded,
299 | isBanned
300 | } = rateLimit
301 |
302 | if (!isExceeded) {
303 | if (params.addHeadersOnExceeding[params.labels.rateLimit]) { res.header(params.labels.rateLimit, max) }
304 | if (params.addHeadersOnExceeding[params.labels.rateRemaining]) { res.header(params.labels.rateRemaining, remaining) }
305 | if (params.addHeadersOnExceeding[params.labels.rateReset]) { res.header(params.labels.rateReset, ttlInSeconds) }
306 |
307 | params.onExceeding(req, key)
308 |
309 | return
310 | }
311 |
312 | params.onExceeded(req, key)
313 |
314 | if (params.addHeaders[params.labels.rateLimit]) { res.header(params.labels.rateLimit, max) }
315 | if (params.addHeaders[params.labels.rateRemaining]) { res.header(params.labels.rateRemaining, 0) }
316 | if (params.addHeaders[params.labels.rateReset]) { res.header(params.labels.rateReset, ttlInSeconds) }
317 | if (params.addHeaders[params.labels.retryAfter]) { res.header(params.labels.retryAfter, ttlInSeconds) }
318 |
319 | const respCtx = {
320 | statusCode: 429,
321 | ban: false,
322 | max,
323 | ttl,
324 | after: format(ttlInSeconds * 1000, true)
325 | }
326 |
327 | if (isBanned) {
328 | respCtx.statusCode = 403
329 | respCtx.ban = true
330 | params.onBanReach(req, key)
331 | }
332 |
333 | throw params.errorResponseBuilder(req, respCtx)
334 | }
335 | }
336 |
337 | module.exports = fp(fastifyRateLimit, {
338 | fastify: '5.x',
339 | name: '@fastify/rate-limit'
340 | })
341 | module.exports.default = fastifyRateLimit
342 | module.exports.fastifyRateLimit = fastifyRateLimit
343 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@fastify/rate-limit",
3 | "version": "10.3.0",
4 | "description": "A low overhead rate limiter for your routes",
5 | "main": "index.js",
6 | "type": "commonjs",
7 | "types": "types/index.d.ts",
8 | "scripts": {
9 | "lint": "eslint",
10 | "lint:fix": "eslint --fix",
11 | "redis": "docker run -p 6379:6379 --name rate-limit-redis -d --rm redis",
12 | "test": "npm run test:unit && npm run test:typescript",
13 | "test:unit": "c8 --100 node --test",
14 | "test:typescript": "tsd"
15 | },
16 | "repository": {
17 | "type": "git",
18 | "url": "git+https://github.com/fastify/fastify-rate-limit.git"
19 | },
20 | "keywords": [
21 | "fastify",
22 | "rate",
23 | "limit"
24 | ],
25 | "author": "Tomas Della Vedova - @delvedor (http://delved.org)",
26 | "contributors": [
27 | {
28 | "name": "Matteo Collina",
29 | "email": "hello@matteocollina.com"
30 | },
31 | {
32 | "name": "Manuel Spigolon",
33 | "email": "behemoth89@gmail.com"
34 | },
35 | {
36 | "name": "Gürgün Dayıoğlu",
37 | "email": "hey@gurgun.day",
38 | "url": "https://heyhey.to/G"
39 | },
40 | {
41 | "name": "Frazer Smith",
42 | "email": "frazer.dev@icloud.com",
43 | "url": "https://github.com/fdawgs"
44 | }
45 | ],
46 | "license": "MIT",
47 | "bugs": {
48 | "url": "https://github.com/fastify/fastify-rate-limit/issues"
49 | },
50 | "homepage": "https://github.com/fastify/fastify-rate-limit#readme",
51 | "funding": [
52 | {
53 | "type": "github",
54 | "url": "https://github.com/sponsors/fastify"
55 | },
56 | {
57 | "type": "opencollective",
58 | "url": "https://opencollective.com/fastify"
59 | }
60 | ],
61 | "devDependencies": {
62 | "@fastify/pre-commit": "^2.1.0",
63 | "@sinonjs/fake-timers": "^14.0.0",
64 | "@types/node": "^22.0.0",
65 | "c8": "^10.1.2",
66 | "eslint": "^9.17.0",
67 | "fastify": "^5.0.0",
68 | "ioredis": "^5.4.1",
69 | "knex": "^3.1.0",
70 | "neostandard": "^0.12.0",
71 | "sqlite3": "^5.1.7",
72 | "tsd": "^0.32.0"
73 | },
74 | "dependencies": {
75 | "@lukeed/ms": "^2.0.2",
76 | "fastify-plugin": "^5.0.0",
77 | "toad-cache": "^3.7.0"
78 | },
79 | "publishConfig": {
80 | "access": "public"
81 | },
82 | "pre-commit": [
83 | "lint",
84 | "test"
85 | ]
86 | }
87 |
--------------------------------------------------------------------------------
/store/LocalStore.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const { LruMap: Lru } = require('toad-cache')
4 |
5 | function LocalStore (continueExceeding, exponentialBackoff, cache = 5000) {
6 | this.continueExceeding = continueExceeding
7 | this.exponentialBackoff = exponentialBackoff
8 | this.lru = new Lru(cache)
9 | }
10 |
11 | LocalStore.prototype.incr = function (ip, cb, timeWindow, max) {
12 | const nowInMs = Date.now()
13 | let current = this.lru.get(ip)
14 |
15 | if (!current) {
16 | // Item doesn't exist
17 | current = { current: 1, ttl: timeWindow, iterationStartMs: nowInMs }
18 | } else if (current.iterationStartMs + timeWindow <= nowInMs) {
19 | // Item has expired
20 | current.current = 1
21 | current.ttl = timeWindow
22 | current.iterationStartMs = nowInMs
23 | } else {
24 | // Item is alive
25 | ++current.current
26 |
27 | // Reset TLL if max has been exceeded and `continueExceeding` is enabled
28 | if (this.continueExceeding && current.current > max) {
29 | current.ttl = timeWindow
30 | current.iterationStartMs = nowInMs
31 | } else if (this.exponentialBackoff && current.current > max) {
32 | // Handle exponential backoff
33 | const backoffExponent = current.current - max - 1
34 | const ttl = timeWindow * (2 ** backoffExponent)
35 | current.ttl = Number.isSafeInteger(ttl) ? ttl : Number.MAX_SAFE_INTEGER
36 | current.iterationStartMs = nowInMs
37 | } else {
38 | current.ttl = timeWindow - (nowInMs - current.iterationStartMs)
39 | }
40 | }
41 |
42 | this.lru.set(ip, current)
43 | cb(null, current)
44 | }
45 |
46 | LocalStore.prototype.child = function (routeOptions) {
47 | return new LocalStore(routeOptions.continueExceeding, routeOptions.exponentialBackoff, routeOptions.cache)
48 | }
49 |
50 | module.exports = LocalStore
51 |
--------------------------------------------------------------------------------
/store/RedisStore.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const lua = `
4 | -- Key to operate on
5 | local key = KEYS[1]
6 | -- Time window for the TTL
7 | local timeWindow = tonumber(ARGV[1])
8 | -- Max requests
9 | local max = tonumber(ARGV[2])
10 | -- Flag to determine if TTL should be reset after exceeding
11 | local continueExceeding = ARGV[3] == 'true'
12 | --Flag to determine if exponential backoff should be applied
13 | local exponentialBackoff = ARGV[4] == 'true'
14 |
15 | --Max safe integer
16 | local MAX_SAFE_INTEGER = (2^53) - 1
17 |
18 | -- Increment the key's value
19 | local current = redis.call('INCR', key)
20 |
21 | if current == 1 or (continueExceeding and current > max) then
22 | redis.call('PEXPIRE', key, timeWindow)
23 | elseif exponentialBackoff and current > max then
24 | local backoffExponent = current - max - 1
25 | timeWindow = math.min(timeWindow * (2 ^ backoffExponent), MAX_SAFE_INTEGER)
26 | redis.call('PEXPIRE', key, timeWindow)
27 | else
28 | timeWindow = redis.call('PTTL', key)
29 | end
30 |
31 | return {current, timeWindow}
32 | `
33 |
34 | function RedisStore (continueExceeding, exponentialBackoff, redis, key = 'fastify-rate-limit-') {
35 | this.continueExceeding = continueExceeding
36 | this.exponentialBackoff = exponentialBackoff
37 | this.redis = redis
38 | this.key = key
39 |
40 | if (!this.redis.rateLimit) {
41 | this.redis.defineCommand('rateLimit', {
42 | numberOfKeys: 1,
43 | lua
44 | })
45 | }
46 | }
47 |
48 | RedisStore.prototype.incr = function (ip, cb, timeWindow, max) {
49 | this.redis.rateLimit(this.key + ip, timeWindow, max, this.continueExceeding, this.exponentialBackoff, (err, result) => {
50 | err ? cb(err, null) : cb(null, { current: result[0], ttl: result[1] })
51 | })
52 | }
53 |
54 | RedisStore.prototype.child = function (routeOptions) {
55 | return new RedisStore(routeOptions.continueExceeding, routeOptions.exponentialBackoff, this.redis, `${this.key}${routeOptions.routeInfo.method}${routeOptions.routeInfo.url}-`)
56 | }
57 |
58 | module.exports = RedisStore
59 |
--------------------------------------------------------------------------------
/test/create-rate-limit.test.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const { test, mock } = require('node:test')
4 | const Fastify = require('fastify')
5 | const rateLimit = require('../index')
6 |
7 | test('With global rate limit options', async t => {
8 | t.plan(8)
9 | const clock = mock.timers
10 | clock.enable(0)
11 | const fastify = Fastify()
12 | await fastify.register(rateLimit, {
13 | global: false,
14 | max: 2,
15 | timeWindow: 1000
16 | })
17 |
18 | const checkRateLimit = fastify.createRateLimit()
19 |
20 | fastify.get('/', async (req, reply) => {
21 | const limit = await checkRateLimit(req)
22 | return limit
23 | })
24 |
25 | let res
26 |
27 | res = await fastify.inject('/')
28 |
29 | t.assert.deepStrictEqual(res.statusCode, 200)
30 | t.assert.deepStrictEqual(res.json(), {
31 | isAllowed: false,
32 | key: '127.0.0.1',
33 | max: 2,
34 | timeWindow: 1000,
35 | remaining: 1,
36 | ttl: 1000,
37 | ttlInSeconds: 1,
38 | isExceeded: false,
39 | isBanned: false
40 | })
41 |
42 | res = await fastify.inject('/')
43 | t.assert.deepStrictEqual(res.statusCode, 200)
44 | t.assert.deepStrictEqual(res.json(), {
45 | isAllowed: false,
46 | key: '127.0.0.1',
47 | max: 2,
48 | timeWindow: 1000,
49 | remaining: 0,
50 | ttl: 1000,
51 | ttlInSeconds: 1,
52 | isExceeded: false,
53 | isBanned: false
54 | })
55 |
56 | res = await fastify.inject('/')
57 | t.assert.deepStrictEqual(res.statusCode, 200)
58 | t.assert.deepStrictEqual(res.json(), {
59 | isAllowed: false,
60 | key: '127.0.0.1',
61 | max: 2,
62 | timeWindow: 1000,
63 | remaining: 0,
64 | ttl: 1000,
65 | ttlInSeconds: 1,
66 | isExceeded: true,
67 | isBanned: false
68 | })
69 |
70 | clock.tick(1100)
71 |
72 | res = await fastify.inject('/')
73 |
74 | t.assert.deepStrictEqual(res.statusCode, 200)
75 | t.assert.deepStrictEqual(res.json(), {
76 | isAllowed: false,
77 | key: '127.0.0.1',
78 | max: 2,
79 | timeWindow: 1000,
80 | remaining: 1,
81 | ttl: 1000,
82 | ttlInSeconds: 1,
83 | isExceeded: false,
84 | isBanned: false
85 | })
86 |
87 | clock.reset()
88 | })
89 |
90 | test('With custom rate limit options', async t => {
91 | t.plan(10)
92 | const clock = mock.timers
93 | clock.enable(0)
94 | const fastify = Fastify()
95 | await fastify.register(rateLimit, {
96 | global: false,
97 | max: 5,
98 | timeWindow: 1000
99 | })
100 |
101 | const checkRateLimit = fastify.createRateLimit({
102 | max: 2,
103 | timeWindow: 1000,
104 | ban: 1
105 | })
106 |
107 | fastify.get('/', async (req, reply) => {
108 | const limit = await checkRateLimit(req)
109 | return limit
110 | })
111 |
112 | let res
113 |
114 | res = await fastify.inject('/')
115 |
116 | t.assert.deepStrictEqual(res.statusCode, 200)
117 | t.assert.deepStrictEqual(res.json(), {
118 | isAllowed: false,
119 | key: '127.0.0.1',
120 | max: 2,
121 | timeWindow: 1000,
122 | remaining: 1,
123 | ttl: 1000,
124 | ttlInSeconds: 1,
125 | isExceeded: false,
126 | isBanned: false
127 | })
128 |
129 | res = await fastify.inject('/')
130 | t.assert.deepStrictEqual(res.statusCode, 200)
131 | t.assert.deepStrictEqual(res.json(), {
132 | isAllowed: false,
133 | key: '127.0.0.1',
134 | max: 2,
135 | timeWindow: 1000,
136 | remaining: 0,
137 | ttl: 1000,
138 | ttlInSeconds: 1,
139 | isExceeded: false,
140 | isBanned: false
141 | })
142 |
143 | // should be exceeded now
144 | res = await fastify.inject('/')
145 | t.assert.deepStrictEqual(res.statusCode, 200)
146 | t.assert.deepStrictEqual(res.json(), {
147 | isAllowed: false,
148 | key: '127.0.0.1',
149 | max: 2,
150 | timeWindow: 1000,
151 | remaining: 0,
152 | ttl: 1000,
153 | ttlInSeconds: 1,
154 | isExceeded: true,
155 | isBanned: false
156 | })
157 |
158 | // should be banned now
159 | res = await fastify.inject('/')
160 | t.assert.deepStrictEqual(res.statusCode, 200)
161 | t.assert.deepStrictEqual(res.json(), {
162 | isAllowed: false,
163 | key: '127.0.0.1',
164 | max: 2,
165 | timeWindow: 1000,
166 | remaining: 0,
167 | ttl: 1000,
168 | ttlInSeconds: 1,
169 | isExceeded: true,
170 | isBanned: true
171 | })
172 |
173 | clock.tick(1100)
174 |
175 | res = await fastify.inject('/')
176 |
177 | t.assert.deepStrictEqual(res.statusCode, 200)
178 | t.assert.deepStrictEqual(res.json(), {
179 | isAllowed: false,
180 | key: '127.0.0.1',
181 | max: 2,
182 | timeWindow: 1000,
183 | remaining: 1,
184 | ttl: 1000,
185 | ttlInSeconds: 1,
186 | isExceeded: false,
187 | isBanned: false
188 | })
189 |
190 | clock.reset()
191 | })
192 |
193 | test('With allow list', async t => {
194 | t.plan(2)
195 | const clock = mock.timers
196 | clock.enable(0)
197 | const fastify = Fastify()
198 | await fastify.register(rateLimit, {
199 | global: false,
200 | max: 5,
201 | timeWindow: 1000
202 | })
203 |
204 | const checkRateLimit = fastify.createRateLimit({
205 | allowList: ['127.0.0.1'],
206 | max: 2,
207 | timeWindow: 1000
208 | })
209 |
210 | fastify.get('/', async (req, reply) => {
211 | const limit = await checkRateLimit(req)
212 | return limit
213 | })
214 |
215 | const res = await fastify.inject('/')
216 |
217 | t.assert.deepStrictEqual(res.statusCode, 200)
218 |
219 | // expect a different return type because isAllowed is true
220 | t.assert.deepStrictEqual(res.json(), {
221 | isAllowed: true,
222 | key: '127.0.0.1'
223 | })
224 | })
225 |
--------------------------------------------------------------------------------
/test/exponential-backoff.test.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | const { test } = require('node:test')
3 | const assert = require('node:assert')
4 | const Fastify = require('fastify')
5 | const rateLimit = require('../index')
6 |
7 | const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
8 |
9 | test('Exponential Backoff', async () => {
10 | const fastify = Fastify()
11 |
12 | // Register rate limit plugin with exponentialBackoff set to true in routeConfig
13 | await fastify.register(rateLimit, { max: 2, timeWindow: 500 })
14 |
15 | fastify.get(
16 | '/expoential-backoff',
17 | {
18 | config: {
19 | rateLimit: {
20 | max: 2,
21 | timeWindow: 500,
22 | exponentialBackoff: true
23 | }
24 | }
25 | },
26 | async () => 'exponential backoff applied!'
27 | )
28 |
29 | // Test
30 | const res = await fastify.inject({ url: '/expoential-backoff', method: 'GET' })
31 | assert.deepStrictEqual(res.statusCode, 200)
32 | assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
33 | assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
34 |
35 | const res2 = await fastify.inject({ url: '/expoential-backoff', method: 'GET' })
36 | assert.deepStrictEqual(res2.statusCode, 200)
37 | assert.deepStrictEqual(res2.headers['x-ratelimit-limit'], '2')
38 | assert.deepStrictEqual(res2.headers['x-ratelimit-remaining'], '0')
39 |
40 | const res3 = await fastify.inject({ url: '/expoential-backoff', method: 'GET' })
41 | assert.deepStrictEqual(res3.statusCode, 429)
42 | assert.deepStrictEqual(res3.headers['x-ratelimit-limit'], '2')
43 | assert.deepStrictEqual(res3.headers['x-ratelimit-remaining'], '0')
44 | assert.deepStrictEqual(
45 | {
46 | statusCode: 429,
47 | error: 'Too Many Requests',
48 | message: 'Rate limit exceeded, retry in 1 second'
49 | },
50 | JSON.parse(res3.payload)
51 | )
52 |
53 | const res4 = await fastify.inject({ url: '/expoential-backoff', method: 'GET' })
54 | assert.deepStrictEqual(res4.statusCode, 429)
55 | assert.deepStrictEqual(res4.headers['x-ratelimit-limit'], '2')
56 | assert.deepStrictEqual(res4.headers['x-ratelimit-remaining'], '0')
57 | assert.deepStrictEqual(
58 | {
59 | statusCode: 429,
60 | error: 'Too Many Requests',
61 | message: 'Rate limit exceeded, retry in 1 second'
62 | },
63 | JSON.parse(res4.payload)
64 | )
65 |
66 | // Wait for the window to reset
67 | await sleep(1000)
68 | const res5 = await fastify.inject({ url: '/expoential-backoff', method: 'GET' })
69 | assert.deepStrictEqual(res5.statusCode, 200)
70 | assert.deepStrictEqual(res5.headers['x-ratelimit-limit'], '2')
71 | assert.deepStrictEqual(res5.headers['x-ratelimit-remaining'], '1')
72 | })
73 |
74 | test('Global Exponential Backoff', async () => {
75 | const fastify = Fastify()
76 |
77 | // Register rate limit plugin with exponentialBackoff set to true in routeConfig
78 | await fastify.register(rateLimit, { max: 2, timeWindow: 500, exponentialBackoff: true })
79 |
80 | fastify.get(
81 | '/expoential-backoff-global',
82 | {
83 | config: {
84 | rateLimit: {
85 | max: 2,
86 | timeWindow: 500
87 | }
88 | }
89 | },
90 | async () => 'exponential backoff applied!'
91 | )
92 |
93 | // Test
94 | let res
95 | res = await fastify.inject({ url: '/expoential-backoff-global', method: 'GET' })
96 | assert.deepStrictEqual(res.statusCode, 200)
97 | assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
98 | assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
99 |
100 | res = await fastify.inject({ url: '/expoential-backoff-global', method: 'GET' })
101 | assert.deepStrictEqual(res.statusCode, 200)
102 | assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
103 | assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
104 |
105 | res = await fastify.inject({ url: '/expoential-backoff-global', method: 'GET' })
106 | assert.deepStrictEqual(res.statusCode, 429)
107 | assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
108 | assert.deepStrictEqual(
109 | {
110 | statusCode: 429,
111 | error: 'Too Many Requests',
112 | message: 'Rate limit exceeded, retry in 1 second'
113 | },
114 | JSON.parse(res.payload)
115 | )
116 |
117 | res = await fastify.inject({ url: '/expoential-backoff-global', method: 'GET' })
118 | assert.deepStrictEqual(res.statusCode, 429)
119 | assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
120 | assert.deepStrictEqual(
121 | {
122 | statusCode: 429,
123 | error: 'Too Many Requests',
124 | message: 'Rate limit exceeded, retry in 1 second'
125 | },
126 | JSON.parse(res.payload)
127 | )
128 |
129 | res = await fastify.inject({ url: '/expoential-backoff-global', method: 'GET' })
130 | assert.deepStrictEqual(res.statusCode, 429)
131 | assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
132 | assert.deepStrictEqual(
133 | {
134 | statusCode: 429,
135 | error: 'Too Many Requests',
136 | message: 'Rate limit exceeded, retry in 2 seconds'
137 | },
138 | JSON.parse(res.payload)
139 | )
140 |
141 | res = await fastify.inject({ url: '/expoential-backoff-global', method: 'GET' })
142 | assert.deepStrictEqual(res.statusCode, 429)
143 | assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
144 | assert.deepStrictEqual(
145 | {
146 | statusCode: 429,
147 | error: 'Too Many Requests',
148 | message: 'Rate limit exceeded, retry in 4 seconds'
149 | },
150 | JSON.parse(res.payload)
151 | )
152 | })
153 |
154 | test('MAx safe Exponential Backoff', async () => {
155 | const fastify = Fastify()
156 |
157 | // Register rate limit plugin with exponentialBackoff set to true in routeConfig
158 | await fastify.register(rateLimit, { max: 2, timeWindow: 500, exponentialBackoff: true })
159 |
160 | fastify.get(
161 | '/expoential-backoff-global',
162 | {
163 | config: {
164 | rateLimit: {
165 | max: 2,
166 | timeWindow: '285421 years'
167 | }
168 | }
169 | },
170 | async () => 'exponential backoff applied!'
171 | )
172 |
173 | // Test
174 | let res
175 | res = await fastify.inject({ url: '/expoential-backoff-global', method: 'GET' })
176 | assert.deepStrictEqual(res.statusCode, 200)
177 | assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
178 | assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
179 |
180 | res = await fastify.inject({ url: '/expoential-backoff-global', method: 'GET' })
181 | assert.deepStrictEqual(res.statusCode, 200)
182 | assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
183 | assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
184 |
185 | res = await fastify.inject({ url: '/expoential-backoff-global', method: 'GET' })
186 | assert.deepStrictEqual(res.statusCode, 429)
187 | assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
188 | assert.deepStrictEqual(
189 | {
190 | statusCode: 429,
191 | error: 'Too Many Requests',
192 | message: 'Rate limit exceeded, retry in 285421 years'
193 | },
194 | JSON.parse(res.payload)
195 | )
196 |
197 | res = await fastify.inject({ url: '/expoential-backoff-global', method: 'GET' })
198 | assert.deepStrictEqual(res.statusCode, 429)
199 | assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
200 | assert.deepStrictEqual(
201 | {
202 | statusCode: 429,
203 | error: 'Too Many Requests',
204 | message: 'Rate limit exceeded, retry in 285421 years'
205 | },
206 | JSON.parse(res.payload)
207 | )
208 |
209 | res = await fastify.inject({ url: '/expoential-backoff-global', method: 'GET' })
210 | assert.deepStrictEqual(res.statusCode, 429)
211 | assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
212 | assert.deepStrictEqual(
213 | {
214 | statusCode: 429,
215 | error: 'Too Many Requests',
216 | message: 'Rate limit exceeded, retry in 285421 years'
217 | },
218 | JSON.parse(res.payload)
219 | )
220 |
221 | res = await fastify.inject({ url: '/expoential-backoff-global', method: 'GET' })
222 | assert.deepStrictEqual(res.statusCode, 429)
223 | assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
224 | assert.deepStrictEqual(
225 | {
226 | statusCode: 429,
227 | error: 'Too Many Requests',
228 | message: 'Rate limit exceeded, retry in 285421 years'
229 | },
230 | JSON.parse(res.payload)
231 | )
232 | })
233 |
--------------------------------------------------------------------------------
/test/github-issues/issue-207.test.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const { test, mock } = require('node:test')
4 | const Fastify = require('fastify')
5 | const rateLimit = require('../../index')
6 |
7 | test('issue #207 - when continueExceeding is true and the store is local then it should reset the rate-limit', async (t) => {
8 | const clock = mock.timers
9 | clock.enable()
10 | const fastify = Fastify()
11 |
12 | await fastify.register(rateLimit, {
13 | global: false
14 | })
15 |
16 | fastify.get(
17 | '/',
18 | {
19 | config: {
20 | rateLimit: {
21 | max: 1,
22 | timeWindow: 5000,
23 | continueExceeding: true
24 | }
25 | }
26 | },
27 | async () => {
28 | return 'hello!'
29 | }
30 | )
31 |
32 | const firstOkResponse = await fastify.inject({
33 | url: '/',
34 | method: 'GET'
35 | })
36 | const firstRateLimitResponse = await fastify.inject({
37 | url: '/',
38 | method: 'GET'
39 | })
40 |
41 | clock.tick(3000)
42 |
43 | const secondRateLimitWithResettingTheRateLimitTimer = await fastify.inject({
44 | url: '/',
45 | method: 'GET'
46 | })
47 |
48 | // after this the total time passed is 6s which WITHOUT `continueExceeding` the next request should be OK
49 | clock.tick(3000)
50 |
51 | const thirdRateLimitWithResettingTheRateLimitTimer = await fastify.inject({
52 | url: '/',
53 | method: 'GET'
54 | })
55 |
56 | // After this the rate limiter should allow for new requests
57 | clock.tick(5000)
58 |
59 | const okResponseAfterRateLimitCompleted = await fastify.inject({
60 | url: '/',
61 | method: 'GET'
62 | })
63 |
64 | t.assert.deepStrictEqual(firstOkResponse.statusCode, 200)
65 |
66 | t.assert.deepStrictEqual(firstRateLimitResponse.statusCode, 429)
67 | t.assert.deepStrictEqual(
68 | firstRateLimitResponse.headers['x-ratelimit-limit'],
69 | '1'
70 | )
71 | t.assert.deepStrictEqual(
72 | firstRateLimitResponse.headers['x-ratelimit-remaining'],
73 | '0'
74 | )
75 | t.assert.deepStrictEqual(
76 | firstRateLimitResponse.headers['x-ratelimit-reset'],
77 | '5'
78 | )
79 |
80 | t.assert.deepStrictEqual(
81 | secondRateLimitWithResettingTheRateLimitTimer.statusCode,
82 | 429
83 | )
84 | t.assert.deepStrictEqual(
85 | secondRateLimitWithResettingTheRateLimitTimer.headers['x-ratelimit-limit'],
86 | '1'
87 | )
88 | t.assert.deepStrictEqual(
89 | secondRateLimitWithResettingTheRateLimitTimer.headers[
90 | 'x-ratelimit-remaining'
91 | ],
92 | '0'
93 | )
94 | t.assert.deepStrictEqual(
95 | secondRateLimitWithResettingTheRateLimitTimer.headers['x-ratelimit-reset'],
96 | '5'
97 | )
98 |
99 | t.assert.deepStrictEqual(
100 | thirdRateLimitWithResettingTheRateLimitTimer.statusCode,
101 | 429
102 | )
103 | t.assert.deepStrictEqual(
104 | thirdRateLimitWithResettingTheRateLimitTimer.headers['x-ratelimit-limit'],
105 | '1'
106 | )
107 | t.assert.deepStrictEqual(
108 | thirdRateLimitWithResettingTheRateLimitTimer.headers[
109 | 'x-ratelimit-remaining'
110 | ],
111 | '0'
112 | )
113 | t.assert.deepStrictEqual(
114 | thirdRateLimitWithResettingTheRateLimitTimer.headers['x-ratelimit-reset'],
115 | '5'
116 | )
117 |
118 | t.assert.deepStrictEqual(okResponseAfterRateLimitCompleted.statusCode, 200)
119 | clock.reset(0)
120 | })
121 |
--------------------------------------------------------------------------------
/test/github-issues/issue-215.test.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const { test, mock } = require('node:test')
4 | const Fastify = require('fastify')
5 | const rateLimit = require('../../index')
6 |
7 | test('issue #215 - when using local store, 2nd user should not be rate limited when the time window is passed for the 1st user', async (t) => {
8 | t.plan(5)
9 | const clock = mock.timers
10 | clock.enable()
11 | const fastify = Fastify()
12 |
13 | await fastify.register(rateLimit, {
14 | global: false
15 | })
16 |
17 | fastify.get(
18 | '/',
19 | {
20 | config: {
21 | rateLimit: {
22 | max: 1,
23 | timeWindow: 5000,
24 | continueExceeding: false
25 | }
26 | }
27 | },
28 | async () => 'hello!'
29 | )
30 |
31 | const user1FirstRequest = await fastify.inject({
32 | url: '/',
33 | method: 'GET',
34 | remoteAddress: '1.1.1.1'
35 | })
36 |
37 | // Waiting for the time to pass to make the 2nd user start in a different start point
38 | clock.tick(3000)
39 |
40 | const user2FirstRequest = await fastify.inject({
41 | url: '/',
42 | method: 'GET',
43 | remoteAddress: '2.2.2.2'
44 | })
45 |
46 | const user2SecondRequestAndShouldBeRateLimited = await fastify.inject({
47 | url: '/',
48 | method: 'GET',
49 | remoteAddress: '2.2.2.2'
50 | })
51 |
52 | // After this the total time passed for the 1st user is 6s and for the 2nd user only 3s
53 | clock.tick(3000)
54 |
55 | const user2ThirdRequestAndShouldStillBeRateLimited = await fastify.inject({
56 | url: '/',
57 | method: 'GET',
58 | remoteAddress: '2.2.2.2'
59 | })
60 |
61 | // After this the total time passed for the 2nd user is 5.1s - he should not be rate limited
62 | clock.tick(2100)
63 |
64 | const user2OkResponseAfterRateLimitCompleted = await fastify.inject({
65 | url: '/',
66 | method: 'GET',
67 | remoteAddress: '2.2.2.2'
68 | })
69 |
70 | t.assert.deepStrictEqual(user1FirstRequest.statusCode, 200)
71 | t.assert.deepStrictEqual(user2FirstRequest.statusCode, 200)
72 |
73 | t.assert.deepStrictEqual(
74 | user2SecondRequestAndShouldBeRateLimited.statusCode,
75 | 429
76 | )
77 | t.assert.deepStrictEqual(
78 | user2ThirdRequestAndShouldStillBeRateLimited.statusCode,
79 | 429
80 | )
81 |
82 | t.assert.deepStrictEqual(
83 | user2OkResponseAfterRateLimitCompleted.statusCode,
84 | 200
85 | )
86 | clock.reset()
87 | })
88 |
--------------------------------------------------------------------------------
/test/github-issues/issue-284.test.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const { test, mock } = require('node:test')
4 | const Fastify = require('fastify')
5 | const rateLimit = require('../../index')
6 |
7 | test("issue #284 - don't set the reply code automatically", async (t) => {
8 | const clock = mock.timers
9 | clock.enable()
10 | const fastify = Fastify()
11 |
12 | await fastify.register(rateLimit, {
13 | global: false
14 | })
15 |
16 | fastify.setErrorHandler((err, _req, res) => {
17 | t.assert.deepStrictEqual(res.statusCode, 200)
18 | t.assert.deepStrictEqual(err.statusCode, 429)
19 |
20 | res.redirect('/')
21 | })
22 |
23 | fastify.get(
24 | '/',
25 | {
26 | config: {
27 | rateLimit: {
28 | max: 1,
29 | timeWindow: 5000,
30 | continueExceeding: true
31 | }
32 | }
33 | },
34 | async () => {
35 | return 'hello!'
36 | }
37 | )
38 |
39 | const firstOkResponse = await fastify.inject({
40 | url: '/',
41 | method: 'GET'
42 | })
43 | const firstRateLimitResponse = await fastify.inject({
44 | url: '/',
45 | method: 'GET'
46 | })
47 |
48 | // After this the rate limiter should allow for new requests
49 | clock.tick(5000)
50 |
51 | const okResponseAfterRateLimitCompleted = await fastify.inject({
52 | url: '/',
53 | method: 'GET'
54 | })
55 |
56 | t.assert.deepStrictEqual(firstOkResponse.statusCode, 200)
57 |
58 | t.assert.deepStrictEqual(firstRateLimitResponse.statusCode, 302)
59 | t.assert.deepStrictEqual(
60 | firstRateLimitResponse.headers['x-ratelimit-limit'],
61 | '1'
62 | )
63 | t.assert.deepStrictEqual(
64 | firstRateLimitResponse.headers['x-ratelimit-remaining'],
65 | '0'
66 | )
67 | t.assert.deepStrictEqual(
68 | firstRateLimitResponse.headers['x-ratelimit-reset'],
69 | '5'
70 | )
71 |
72 | t.assert.deepStrictEqual(okResponseAfterRateLimitCompleted.statusCode, 200)
73 | clock.reset(0)
74 | })
75 |
--------------------------------------------------------------------------------
/test/global-rate-limit.test.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const { test, mock } = require('node:test')
4 | const Fastify = require('fastify')
5 | const rateLimit = require('../index')
6 |
7 | const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
8 |
9 | test('Basic', async (t) => {
10 | t.plan(15)
11 | const clock = mock.timers
12 | clock.enable(0)
13 | const fastify = Fastify()
14 | await fastify.register(rateLimit, { max: 2, timeWindow: 1000 })
15 |
16 | fastify.get('/', async () => 'hello!')
17 |
18 | let res
19 |
20 | res = await fastify.inject('/')
21 |
22 | t.assert.deepStrictEqual(res.statusCode, 200)
23 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
24 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
25 |
26 | res = await fastify.inject('/')
27 |
28 | t.assert.deepStrictEqual(res.statusCode, 200)
29 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
30 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
31 |
32 | res = await fastify.inject('/')
33 |
34 | t.assert.deepStrictEqual(res.statusCode, 429)
35 | t.assert.deepStrictEqual(
36 | res.headers['content-type'],
37 | 'application/json; charset=utf-8'
38 | )
39 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
40 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
41 | t.assert.deepStrictEqual(res.headers['retry-after'], '1')
42 | t.assert.deepStrictEqual(
43 | {
44 | statusCode: 429,
45 | error: 'Too Many Requests',
46 | message: 'Rate limit exceeded, retry in 1 second'
47 | },
48 | JSON.parse(res.payload)
49 | )
50 |
51 | clock.tick(1100)
52 |
53 | res = await fastify.inject('/')
54 |
55 | t.assert.deepStrictEqual(res.statusCode, 200)
56 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
57 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
58 | clock.reset()
59 | })
60 |
61 | test('With text timeWindow', async (t) => {
62 | t.plan(15)
63 | const clock = mock.timers
64 | clock.enable(0)
65 | const fastify = Fastify()
66 | await fastify.register(rateLimit, { max: 2, timeWindow: '1s' })
67 |
68 | fastify.get('/', async () => 'hello!')
69 |
70 | let res
71 |
72 | res = await fastify.inject('/')
73 |
74 | t.assert.deepStrictEqual(res.statusCode, 200)
75 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
76 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
77 |
78 | res = await fastify.inject('/')
79 |
80 | t.assert.deepStrictEqual(res.statusCode, 200)
81 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
82 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
83 |
84 | res = await fastify.inject('/')
85 |
86 | t.assert.deepStrictEqual(res.statusCode, 429)
87 | t.assert.deepStrictEqual(
88 | res.headers['content-type'],
89 | 'application/json; charset=utf-8'
90 | )
91 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
92 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
93 | t.assert.deepStrictEqual(res.headers['retry-after'], '1')
94 | t.assert.deepStrictEqual(
95 | {
96 | statusCode: 429,
97 | error: 'Too Many Requests',
98 | message: 'Rate limit exceeded, retry in 1 second'
99 | },
100 | JSON.parse(res.payload)
101 | )
102 |
103 | clock.tick(1100)
104 |
105 | res = await fastify.inject('/')
106 |
107 | t.assert.deepStrictEqual(res.statusCode, 200)
108 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
109 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
110 | clock.reset()
111 | })
112 |
113 | test('With function timeWindow', async (t) => {
114 | t.plan(15)
115 | const clock = mock.timers
116 | clock.enable(0)
117 | const fastify = Fastify()
118 | await fastify.register(rateLimit, { max: 2, timeWindow: (_, __) => 1000 })
119 |
120 | fastify.get('/', async () => 'hello!')
121 |
122 | let res
123 |
124 | res = await fastify.inject('/')
125 |
126 | t.assert.deepStrictEqual(res.statusCode, 200)
127 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
128 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
129 |
130 | res = await fastify.inject('/')
131 |
132 | t.assert.deepStrictEqual(res.statusCode, 200)
133 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
134 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
135 |
136 | res = await fastify.inject('/')
137 |
138 | t.assert.deepStrictEqual(res.statusCode, 429)
139 | t.assert.deepStrictEqual(
140 | res.headers['content-type'],
141 | 'application/json; charset=utf-8'
142 | )
143 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
144 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
145 | t.assert.deepStrictEqual(res.headers['retry-after'], '1')
146 | t.assert.deepStrictEqual(
147 | {
148 | statusCode: 429,
149 | error: 'Too Many Requests',
150 | message: 'Rate limit exceeded, retry in 1 second'
151 | },
152 | JSON.parse(res.payload)
153 | )
154 |
155 | clock.tick(1100)
156 |
157 | res = await fastify.inject('/')
158 |
159 | t.assert.deepStrictEqual(res.statusCode, 200)
160 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
161 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
162 | clock.reset()
163 | })
164 |
165 | test('When passing NaN to the timeWindow property then the timeWindow should be the default value - 60 seconds', async (t) => {
166 | t.plan(5)
167 | const clock = mock.timers
168 | clock.enable(0)
169 | const defaultTimeWindowInSeconds = '60'
170 |
171 | const fastify = Fastify()
172 | await fastify.register(rateLimit, { max: 1, timeWindow: NaN })
173 |
174 | fastify.get('/', async () => 'hello!')
175 |
176 | let res
177 |
178 | res = await fastify.inject('/')
179 |
180 | t.assert.deepStrictEqual(res.statusCode, 200)
181 | t.assert.deepStrictEqual(
182 | res.headers['x-ratelimit-reset'],
183 | defaultTimeWindowInSeconds
184 | )
185 |
186 | res = await fastify.inject('/')
187 |
188 | t.assert.deepStrictEqual(res.statusCode, 429)
189 |
190 | // Wait for almost 60s to make sure the time limit is right
191 | clock.tick(55 * 1000)
192 |
193 | res = await fastify.inject('/')
194 |
195 | t.assert.deepStrictEqual(res.statusCode, 429)
196 |
197 | // Wait for the seconds that left until the time limit reset
198 | clock.tick(5 * 1000)
199 |
200 | res = await fastify.inject('/')
201 |
202 | t.assert.deepStrictEqual(res.statusCode, 200)
203 | clock.reset()
204 | })
205 |
206 | test('With ips allowList, allowed ips should not result in rate limiting', async (t) => {
207 | t.plan(3)
208 | const fastify = Fastify()
209 | await fastify.register(rateLimit, {
210 | max: 2,
211 | timeWindow: '2s',
212 | allowList: ['127.0.0.1']
213 | })
214 |
215 | fastify.get('/', async () => 'hello!')
216 |
217 | let res
218 |
219 | res = await fastify.inject('/')
220 | t.assert.deepStrictEqual(res.statusCode, 200)
221 |
222 | res = await fastify.inject('/')
223 | t.assert.deepStrictEqual(res.statusCode, 200)
224 |
225 | res = await fastify.inject('/')
226 | t.assert.deepStrictEqual(res.statusCode, 200)
227 | })
228 |
229 | test('With ips allowList, not allowed ips should result in rate limiting', async (t) => {
230 | t.plan(3)
231 | const fastify = Fastify()
232 | await fastify.register(rateLimit, {
233 | max: 2,
234 | timeWindow: '2s',
235 | allowList: ['1.1.1.1']
236 | })
237 |
238 | fastify.get('/', async () => 'hello!')
239 |
240 | let res
241 |
242 | res = await fastify.inject('/')
243 | t.assert.deepStrictEqual(res.statusCode, 200)
244 |
245 | res = await fastify.inject('/')
246 | t.assert.deepStrictEqual(res.statusCode, 200)
247 |
248 | res = await fastify.inject('/')
249 | t.assert.deepStrictEqual(res.statusCode, 429)
250 | })
251 |
252 | test('With ips whitelist', async (t) => {
253 | t.plan(3)
254 | const fastify = Fastify()
255 | await fastify.register(rateLimit, {
256 | max: 2,
257 | timeWindow: '2s',
258 | whitelist: ['127.0.0.1']
259 | })
260 |
261 | fastify.get('/', async () => 'hello!')
262 |
263 | let res
264 |
265 | res = await fastify.inject('/')
266 | t.assert.deepStrictEqual(res.statusCode, 200)
267 |
268 | res = await fastify.inject('/')
269 | t.assert.deepStrictEqual(res.statusCode, 200)
270 |
271 | res = await fastify.inject('/')
272 | t.assert.deepStrictEqual(res.statusCode, 200)
273 | })
274 |
275 | test('With function allowList', async (t) => {
276 | t.plan(18)
277 | const fastify = Fastify()
278 | await fastify.register(rateLimit, {
279 | max: 2,
280 | timeWindow: '2s',
281 | keyGenerator () {
282 | return 42
283 | },
284 | allowList: function (req, key) {
285 | t.assert.ok(req.headers)
286 | t.assert.deepStrictEqual(key, 42)
287 | return req.headers['x-my-header'] !== undefined
288 | }
289 | })
290 |
291 | fastify.get('/', async () => 'hello!')
292 |
293 | const allowListHeader = {
294 | method: 'GET',
295 | url: '/',
296 | headers: {
297 | 'x-my-header': 'FOO BAR'
298 | }
299 | }
300 |
301 | let res
302 |
303 | res = await fastify.inject(allowListHeader)
304 | t.assert.deepStrictEqual(res.statusCode, 200)
305 |
306 | res = await fastify.inject(allowListHeader)
307 | t.assert.deepStrictEqual(res.statusCode, 200)
308 |
309 | res = await fastify.inject(allowListHeader)
310 | t.assert.deepStrictEqual(res.statusCode, 200)
311 |
312 | res = await fastify.inject('/')
313 | t.assert.deepStrictEqual(res.statusCode, 200)
314 |
315 | res = await fastify.inject('/')
316 | t.assert.deepStrictEqual(res.statusCode, 200)
317 |
318 | res = await fastify.inject('/')
319 | t.assert.deepStrictEqual(res.statusCode, 429)
320 | })
321 |
322 | test('With async/await function allowList', async (t) => {
323 | t.plan(18)
324 | const fastify = Fastify()
325 |
326 | await fastify.register(rateLimit, {
327 | max: 2,
328 | timeWindow: '2s',
329 | keyGenerator () {
330 | return 42
331 | },
332 | allowList: async function (req, key) {
333 | await sleep(1)
334 | t.assert.ok(req.headers)
335 | t.assert.deepStrictEqual(key, 42)
336 | return req.headers['x-my-header'] !== undefined
337 | }
338 | })
339 |
340 | fastify.get('/', async () => 'hello!')
341 |
342 | const allowListHeader = {
343 | method: 'GET',
344 | url: '/',
345 | headers: {
346 | 'x-my-header': 'FOO BAR'
347 | }
348 | }
349 |
350 | let res
351 |
352 | res = await fastify.inject(allowListHeader)
353 | t.assert.deepStrictEqual(res.statusCode, 200)
354 |
355 | res = await fastify.inject(allowListHeader)
356 | t.assert.deepStrictEqual(res.statusCode, 200)
357 |
358 | res = await fastify.inject(allowListHeader)
359 | t.assert.deepStrictEqual(res.statusCode, 200)
360 |
361 | res = await fastify.inject('/')
362 | t.assert.deepStrictEqual(res.statusCode, 200)
363 |
364 | res = await fastify.inject('/')
365 | t.assert.deepStrictEqual(res.statusCode, 200)
366 |
367 | res = await fastify.inject('/')
368 | t.assert.deepStrictEqual(res.statusCode, 429)
369 | })
370 |
371 | test('With onExceeding option', async (t) => {
372 | t.plan(5)
373 | const fastify = Fastify()
374 | await fastify.register(rateLimit, {
375 | max: 2,
376 | timeWindow: '2s',
377 | onExceeding: function (req, key) {
378 | if (req && key) t.assert.ok('onExceeding called')
379 | }
380 | })
381 |
382 | fastify.get('/', async () => 'hello!')
383 |
384 | let res
385 |
386 | res = await fastify.inject('/')
387 | t.assert.deepStrictEqual(res.statusCode, 200)
388 |
389 | res = await fastify.inject('/')
390 | t.assert.deepStrictEqual(res.statusCode, 200)
391 |
392 | res = await fastify.inject('/')
393 | t.assert.deepStrictEqual(res.statusCode, 429)
394 | })
395 |
396 | test('With onExceeded option', async (t) => {
397 | t.plan(4)
398 | const fastify = Fastify()
399 | await fastify.register(rateLimit, {
400 | max: 2,
401 | timeWindow: '2s',
402 | onExceeded: function (req, key) {
403 | if (req && key) t.assert.ok('onExceeded called')
404 | }
405 | })
406 |
407 | fastify.get('/', async () => 'hello!')
408 |
409 | let res
410 |
411 | res = await fastify.inject('/')
412 | t.assert.deepStrictEqual(res.statusCode, 200)
413 |
414 | res = await fastify.inject('/')
415 | t.assert.deepStrictEqual(res.statusCode, 200)
416 |
417 | res = await fastify.inject('/')
418 | t.assert.deepStrictEqual(res.statusCode, 429)
419 | })
420 |
421 | test('With onBanReach option', async (t) => {
422 | t.plan(4)
423 | const fastify = Fastify()
424 | await fastify.register(rateLimit, {
425 | max: 1,
426 | ban: 1,
427 | onBanReach: function (req) {
428 | // onBanReach called
429 | t.assert.ok(req)
430 | }
431 | })
432 |
433 | fastify.get('/', async () => 'hello!')
434 |
435 | let res
436 |
437 | res = await fastify.inject('/')
438 | t.assert.deepStrictEqual(res.statusCode, 200)
439 |
440 | res = await fastify.inject('/')
441 | t.assert.deepStrictEqual(res.statusCode, 429)
442 |
443 | res = await fastify.inject('/')
444 | t.assert.deepStrictEqual(res.statusCode, 403)
445 | })
446 |
447 | test('With keyGenerator', async (t) => {
448 | t.plan(19)
449 | const clock = mock.timers
450 | clock.enable(0)
451 | const fastify = Fastify()
452 | await fastify.register(rateLimit, {
453 | max: 2,
454 | timeWindow: 1000,
455 | keyGenerator (req) {
456 | t.assert.deepStrictEqual(req.headers['my-custom-header'], 'random-value')
457 | return req.headers['my-custom-header']
458 | }
459 | })
460 |
461 | fastify.get('/', async () => 'hello!')
462 |
463 | const payload = {
464 | method: 'GET',
465 | url: '/',
466 | headers: {
467 | 'my-custom-header': 'random-value'
468 | }
469 | }
470 |
471 | let res
472 |
473 | res = await fastify.inject(payload)
474 | t.assert.deepStrictEqual(res.statusCode, 200)
475 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
476 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
477 |
478 | res = await fastify.inject(payload)
479 | t.assert.deepStrictEqual(res.statusCode, 200)
480 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
481 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
482 |
483 | res = await fastify.inject(payload)
484 | t.assert.deepStrictEqual(res.statusCode, 429)
485 | t.assert.deepStrictEqual(
486 | res.headers['content-type'],
487 | 'application/json; charset=utf-8'
488 | )
489 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
490 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
491 | t.assert.deepStrictEqual(res.headers['retry-after'], '1')
492 | t.assert.deepStrictEqual(
493 | {
494 | statusCode: 429,
495 | error: 'Too Many Requests',
496 | message: 'Rate limit exceeded, retry in 1 second'
497 | },
498 | JSON.parse(res.payload)
499 | )
500 |
501 | clock.tick(1100)
502 |
503 | res = await fastify.inject(payload)
504 | t.assert.deepStrictEqual(res.statusCode, 200)
505 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
506 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
507 | clock.reset()
508 | })
509 |
510 | test('With async/await keyGenerator', async (t) => {
511 | t.plan(16)
512 | const fastify = Fastify()
513 | await fastify.register(rateLimit, {
514 | max: 1,
515 | timeWindow: 1000,
516 | keyGenerator: async function (req) {
517 | await sleep(1)
518 | t.assert.deepStrictEqual(req.headers['my-custom-header'], 'random-value')
519 | return req.headers['my-custom-header']
520 | }
521 | })
522 |
523 | fastify.get('/', async () => 'hello!')
524 |
525 | const payload = {
526 | method: 'GET',
527 | url: '/',
528 | headers: {
529 | 'my-custom-header': 'random-value'
530 | }
531 | }
532 |
533 | let res
534 |
535 | res = await fastify.inject(payload)
536 | t.assert.deepStrictEqual(res.statusCode, 200)
537 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '1')
538 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
539 |
540 | res = await fastify.inject(payload)
541 | t.assert.deepStrictEqual(res.statusCode, 429)
542 | t.assert.deepStrictEqual(
543 | res.headers['content-type'],
544 | 'application/json; charset=utf-8'
545 | )
546 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '1')
547 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
548 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
549 | t.assert.deepStrictEqual(res.headers['retry-after'], '1')
550 | t.assert.deepStrictEqual(
551 | {
552 | statusCode: 429,
553 | error: 'Too Many Requests',
554 | message: 'Rate limit exceeded, retry in 1 second'
555 | },
556 | JSON.parse(res.payload)
557 | )
558 |
559 | await sleep(1100)
560 |
561 | res = await fastify.inject(payload)
562 | t.assert.deepStrictEqual(res.statusCode, 200)
563 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '1')
564 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
565 | })
566 |
567 | test('With CustomStore', async (t) => {
568 | t.plan(15)
569 |
570 | function CustomStore (options) {
571 | this.options = options
572 | this.current = 0
573 | }
574 |
575 | CustomStore.prototype.incr = function (key, cb) {
576 | const timeWindow = this.options.timeWindow
577 | this.current++
578 | cb(null, { current: this.current, ttl: timeWindow - this.current * 1000 })
579 | }
580 |
581 | CustomStore.prototype.child = function (routeOptions) {
582 | const store = new CustomStore(
583 | Object.assign(this.options, routeOptions.config.rateLimit)
584 | )
585 | return store
586 | }
587 |
588 | const fastify = Fastify()
589 | await fastify.register(rateLimit, {
590 | max: 2,
591 | timeWindow: 10000,
592 | store: CustomStore
593 | })
594 |
595 | fastify.get('/', async () => 'hello!')
596 |
597 | let res
598 |
599 | res = await fastify.inject('/')
600 |
601 | t.assert.deepStrictEqual(res.statusCode, 200)
602 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
603 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
604 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '9')
605 |
606 | res = await fastify.inject('/')
607 |
608 | t.assert.deepStrictEqual(res.statusCode, 200)
609 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
610 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
611 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '8')
612 |
613 | res = await fastify.inject('/')
614 |
615 | t.assert.deepStrictEqual(res.statusCode, 429)
616 | t.assert.deepStrictEqual(
617 | res.headers['content-type'],
618 | 'application/json; charset=utf-8'
619 | )
620 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
621 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
622 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '7')
623 | t.assert.deepStrictEqual(res.headers['retry-after'], '7')
624 | t.assert.deepStrictEqual(
625 | {
626 | statusCode: 429,
627 | error: 'Too Many Requests',
628 | message: 'Rate limit exceeded, retry in 7 seconds'
629 | },
630 | JSON.parse(res.payload)
631 | )
632 | })
633 |
634 | test('does not override the onRequest', async (t) => {
635 | t.plan(4)
636 | const fastify = Fastify()
637 | await fastify.register(rateLimit, {
638 | max: 2,
639 | timeWindow: 1000
640 | })
641 |
642 | fastify.get(
643 | '/',
644 | {
645 | onRequest: function (req, reply, next) {
646 | t.assert.ok('onRequest called')
647 | next()
648 | }
649 | },
650 | async () => 'hello!'
651 | )
652 |
653 | const res = await fastify.inject('/')
654 | t.assert.deepStrictEqual(res.statusCode, 200)
655 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
656 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
657 | })
658 |
659 | test('does not override the onRequest as an array', async (t) => {
660 | t.plan(4)
661 | const fastify = Fastify()
662 | await fastify.register(rateLimit, {
663 | max: 2,
664 | timeWindow: 1000
665 | })
666 |
667 | fastify.get(
668 | '/',
669 | {
670 | onRequest: [
671 | function (req, reply, next) {
672 | t.assert.ok('onRequest called')
673 | next()
674 | }
675 | ]
676 | },
677 | async () => 'hello!'
678 | )
679 |
680 | const res = await fastify.inject('/')
681 |
682 | t.assert.deepStrictEqual(res.statusCode, 200)
683 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
684 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
685 | })
686 |
687 | test('variable max', async (t) => {
688 | t.plan(4)
689 | const fastify = Fastify()
690 | await fastify.register(rateLimit, {
691 | max: (req) => {
692 | t.assert.ok(req)
693 | return +req.headers['secret-max']
694 | },
695 | timeWindow: 1000
696 | })
697 |
698 | fastify.get('/', async () => 'hello')
699 |
700 | const res = await fastify.inject({ url: '/', headers: { 'secret-max': 50 } })
701 |
702 | t.assert.deepStrictEqual(res.statusCode, 200)
703 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '50')
704 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '49')
705 | })
706 |
707 | test('variable max contenders', async (t) => {
708 | t.plan(7)
709 | const fastify = Fastify()
710 | await fastify.register(rateLimit, {
711 | keyGenerator: (req) => req.headers['api-key'],
712 | max: (req, key) => (key === 'pro' ? 3 : 2),
713 | timeWindow: 10000
714 | })
715 |
716 | fastify.get('/', async () => 'hello')
717 |
718 | const requestSequence = [
719 | { headers: { 'api-key': 'pro' }, status: 200, url: '/' },
720 | { headers: { 'api-key': 'pro' }, status: 200, url: '/' },
721 | { headers: { 'api-key': 'pro' }, status: 200, url: '/' },
722 | { headers: { 'api-key': 'pro' }, status: 429, url: '/' },
723 | { headers: { 'api-key': 'NOT' }, status: 200, url: '/' },
724 | { headers: { 'api-key': 'NOT' }, status: 200, url: '/' },
725 | { headers: { 'api-key': 'NOT' }, status: 429, url: '/' }
726 | ]
727 |
728 | for (const item of requestSequence) {
729 | const res = await fastify.inject({ url: item.url, headers: item.headers })
730 | t.assert.deepStrictEqual(res.statusCode, item.status)
731 | }
732 | })
733 |
734 | test('when passing NaN to max variable then it should use the default max - 1000', async (t) => {
735 | t.plan(2002)
736 |
737 | const defaultMax = 1000
738 |
739 | const fastify = Fastify()
740 | await fastify.register(rateLimit, {
741 | max: NaN,
742 | timeWindow: 10000
743 | })
744 |
745 | fastify.get('/', async () => 'hello')
746 |
747 | for (let i = 0; i < defaultMax; i++) {
748 | const res = await fastify.inject('/')
749 | t.assert.deepStrictEqual(res.statusCode, 200)
750 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '1000')
751 | }
752 |
753 | const res = await fastify.inject('/')
754 | t.assert.deepStrictEqual(res.statusCode, 429)
755 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '1000')
756 | })
757 |
758 | test('hide rate limit headers', async (t) => {
759 | t.plan(14)
760 | const clock = mock.timers
761 | clock.enable(0)
762 | const fastify = Fastify()
763 | await fastify.register(rateLimit, {
764 | max: 1,
765 | timeWindow: 1000,
766 | addHeaders: {
767 | 'x-ratelimit-limit': false,
768 | 'x-ratelimit-remaining': false,
769 | 'x-ratelimit-reset': false,
770 | 'retry-after': false
771 | }
772 | })
773 |
774 | fastify.get('/', async () => 'hello')
775 |
776 | let res
777 |
778 | res = await fastify.inject('/')
779 |
780 | t.assert.deepStrictEqual(res.statusCode, 200)
781 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '1')
782 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
783 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
784 |
785 | res = await fastify.inject('/')
786 |
787 | t.assert.deepStrictEqual(res.statusCode, 429)
788 | t.assert.deepStrictEqual(
789 | res.headers['content-type'],
790 | 'application/json; charset=utf-8'
791 | )
792 | t.assert.notStrictEqual(
793 | res.headers['x-ratelimit-limit'],
794 | 'the header must be missing'
795 | )
796 | t.assert.notStrictEqual(
797 | res.headers['x-ratelimit-remaining'],
798 | 'the header must be missing'
799 | )
800 | t.assert.notStrictEqual(
801 | res.headers['x-ratelimit-reset'],
802 | 'the header must be missing'
803 | )
804 | t.assert.notStrictEqual(
805 | res.headers['retry-after'],
806 | 'the header must be missing'
807 | )
808 |
809 | clock.tick(1100)
810 |
811 | res = await fastify.inject('/')
812 | t.assert.deepStrictEqual(res.statusCode, 200)
813 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '1')
814 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
815 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
816 |
817 | clock.reset()
818 | })
819 |
820 | test('hide rate limit headers on exceeding', async (t) => {
821 | t.plan(14)
822 | const clock = mock.timers
823 | clock.enable(0)
824 | const fastify = Fastify()
825 | await fastify.register(rateLimit, {
826 | max: 1,
827 | timeWindow: 1000,
828 | addHeadersOnExceeding: {
829 | 'x-ratelimit-limit': false,
830 | 'x-ratelimit-remaining': false,
831 | 'x-ratelimit-reset': false
832 | }
833 | })
834 |
835 | fastify.get('/', async () => 'hello')
836 |
837 | let res
838 |
839 | res = await fastify.inject('/')
840 |
841 | t.assert.deepStrictEqual(res.statusCode, 200)
842 | t.assert.notStrictEqual(
843 | res.headers['x-ratelimit-limit'],
844 | 'the header must be missing'
845 | )
846 | t.assert.notStrictEqual(
847 | res.headers['x-ratelimit-remaining'],
848 | 'the header must be missing'
849 | )
850 | t.assert.notStrictEqual(
851 | res.headers['x-ratelimit-reset'],
852 | 'the header must be missing'
853 | )
854 |
855 | res = await fastify.inject('/')
856 |
857 | t.assert.deepStrictEqual(res.statusCode, 429)
858 | t.assert.deepStrictEqual(
859 | res.headers['content-type'],
860 | 'application/json; charset=utf-8'
861 | )
862 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '1')
863 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
864 | t.assert.notStrictEqual(res.headers['x-ratelimit-reset'], undefined)
865 | t.assert.deepStrictEqual(res.headers['retry-after'], '1')
866 |
867 | clock.tick(1100)
868 |
869 | res = await fastify.inject('/')
870 |
871 | t.assert.deepStrictEqual(res.statusCode, 200)
872 | t.assert.notStrictEqual(
873 | res.headers['x-ratelimit-limit'],
874 | 'the header must be missing'
875 | )
876 | t.assert.notStrictEqual(
877 | res.headers['x-ratelimit-remaining'],
878 | 'the header must be missing'
879 | )
880 | t.assert.notStrictEqual(
881 | res.headers['x-ratelimit-reset'],
882 | 'the header must be missing'
883 | )
884 | clock.reset()
885 | })
886 |
887 | test('hide rate limit headers at all times', async (t) => {
888 | t.plan(14)
889 | const clock = mock.timers
890 | clock.enable(0)
891 | const fastify = Fastify()
892 | await fastify.register(rateLimit, {
893 | max: 1,
894 | timeWindow: 1000,
895 | addHeaders: {
896 | 'x-ratelimit-limit': false,
897 | 'x-ratelimit-remaining': false,
898 | 'x-ratelimit-reset': false,
899 | 'retry-after': false
900 | },
901 | addHeadersOnExceeding: {
902 | 'x-ratelimit-limit': false,
903 | 'x-ratelimit-remaining': false,
904 | 'x-ratelimit-reset': false
905 | }
906 | })
907 |
908 | fastify.get('/', async () => 'hello')
909 |
910 | let res
911 |
912 | res = await fastify.inject('/')
913 |
914 | t.assert.deepStrictEqual(res.statusCode, 200)
915 | t.assert.notStrictEqual(
916 | res.headers['x-ratelimit-limit'],
917 | 'the header must be missing'
918 | )
919 | t.assert.notStrictEqual(
920 | res.headers['x-ratelimit-remaining'],
921 | 'the header must be missing'
922 | )
923 | t.assert.notStrictEqual(
924 | res.headers['x-ratelimit-reset'],
925 | 'the header must be missing'
926 | )
927 |
928 | res = await fastify.inject('/')
929 |
930 | t.assert.deepStrictEqual(res.statusCode, 429)
931 | t.assert.deepStrictEqual(
932 | res.headers['content-type'],
933 | 'application/json; charset=utf-8'
934 | )
935 | t.assert.notStrictEqual(
936 | res.headers['x-ratelimit-limit'],
937 | 'the header must be missing'
938 | )
939 | t.assert.notStrictEqual(
940 | res.headers['x-ratelimit-remaining'],
941 | 'the header must be missing'
942 | )
943 | t.assert.notStrictEqual(
944 | res.headers['x-ratelimit-reset'],
945 | 'the header must be missing'
946 | )
947 | t.assert.notStrictEqual(
948 | res.headers['retry-after'],
949 | 'the header must be missing'
950 | )
951 |
952 | clock.tick(1100)
953 |
954 | res = await fastify.inject('/')
955 |
956 | t.assert.deepStrictEqual(res.statusCode, 200)
957 | t.assert.notStrictEqual(
958 | res.headers['x-ratelimit-limit'],
959 | 'the header must be missing'
960 | )
961 | t.assert.notStrictEqual(
962 | res.headers['x-ratelimit-remaining'],
963 | 'the header must be missing'
964 | )
965 | t.assert.notStrictEqual(
966 | res.headers['x-ratelimit-reset'],
967 | 'the header must be missing'
968 | )
969 | clock.reset()
970 | })
971 |
972 | test('With ban', async (t) => {
973 | t.plan(3)
974 | const fastify = Fastify()
975 | await fastify.register(rateLimit, {
976 | max: 1,
977 | ban: 1
978 | })
979 |
980 | fastify.get('/', async () => 'hello!')
981 |
982 | let res
983 |
984 | res = await fastify.inject('/')
985 | t.assert.deepStrictEqual(res.statusCode, 200)
986 |
987 | res = await fastify.inject('/')
988 | t.assert.deepStrictEqual(res.statusCode, 429)
989 |
990 | res = await fastify.inject('/')
991 | t.assert.deepStrictEqual(res.statusCode, 403)
992 | })
993 |
994 | test('stops fastify lifecycle after onRequest and before preValidation', async (t) => {
995 | t.plan(4)
996 | const fastify = Fastify()
997 | await fastify.register(rateLimit, { max: 1, timeWindow: 1000 })
998 |
999 | let preValidationCallCount = 0
1000 |
1001 | fastify.get(
1002 | '/',
1003 | {
1004 | preValidation: function (req, reply, next) {
1005 | t.assert.ok('preValidation called only once')
1006 | preValidationCallCount++
1007 | next()
1008 | }
1009 | },
1010 | async () => 'hello!'
1011 | )
1012 |
1013 | let res
1014 |
1015 | res = await fastify.inject('/')
1016 | t.assert.deepStrictEqual(res.statusCode, 200)
1017 |
1018 | res = await fastify.inject('/')
1019 | t.assert.deepStrictEqual(res.statusCode, 429)
1020 | t.assert.deepStrictEqual(preValidationCallCount, 1)
1021 | })
1022 |
1023 | test('With enabled IETF Draft Spec', async (t) => {
1024 | t.plan(16)
1025 |
1026 | const clock = mock.timers
1027 | clock.enable(0)
1028 | const fastify = Fastify()
1029 | await fastify.register(rateLimit, {
1030 | max: 2,
1031 | timeWindow: '1s',
1032 | enableDraftSpec: true,
1033 | errorResponseBuilder: (req, context) => ({
1034 | statusCode: 429,
1035 | error: 'Too Many Requests',
1036 | message: 'Rate limit exceeded, retry in 1 second',
1037 | ttl: context.ttl
1038 | })
1039 | })
1040 |
1041 | fastify.get('/', async () => 'hello!')
1042 |
1043 | let res
1044 |
1045 | res = await fastify.inject('/')
1046 |
1047 | t.assert.deepStrictEqual(res.statusCode, 200)
1048 | t.assert.deepStrictEqual(res.headers['ratelimit-limit'], '2')
1049 | t.assert.deepStrictEqual(res.headers['ratelimit-remaining'], '1')
1050 |
1051 | res = await fastify.inject('/')
1052 |
1053 | t.assert.deepStrictEqual(res.statusCode, 200)
1054 | t.assert.deepStrictEqual(res.headers['ratelimit-limit'], '2')
1055 | t.assert.deepStrictEqual(res.headers['ratelimit-remaining'], '0')
1056 |
1057 | res = await fastify.inject('/')
1058 |
1059 | t.assert.deepStrictEqual(res.statusCode, 429)
1060 | t.assert.deepStrictEqual(
1061 | res.headers['content-type'],
1062 | 'application/json; charset=utf-8'
1063 | )
1064 | t.assert.deepStrictEqual(res.headers['ratelimit-limit'], '2')
1065 | t.assert.deepStrictEqual(res.headers['ratelimit-remaining'], '0')
1066 | t.assert.deepStrictEqual(
1067 | res.headers['ratelimit-reset'],
1068 | res.headers['retry-after']
1069 | )
1070 | const { ttl, ...payload } = JSON.parse(res.payload)
1071 | t.assert.deepStrictEqual(
1072 | res.headers['retry-after'],
1073 | '' + Math.floor(ttl / 1000)
1074 | )
1075 | t.assert.deepStrictEqual(
1076 | {
1077 | statusCode: 429,
1078 | error: 'Too Many Requests',
1079 | message: 'Rate limit exceeded, retry in 1 second'
1080 | },
1081 | payload
1082 | )
1083 |
1084 | clock.tick(1100)
1085 |
1086 | res = await fastify.inject('/')
1087 |
1088 | t.assert.deepStrictEqual(res.statusCode, 200)
1089 | t.assert.deepStrictEqual(res.headers['ratelimit-limit'], '2')
1090 | t.assert.deepStrictEqual(res.headers['ratelimit-remaining'], '1')
1091 | clock.reset()
1092 | })
1093 |
1094 | test('hide IETF draft spec headers', async (t) => {
1095 | t.plan(14)
1096 |
1097 | const clock = mock.timers
1098 | clock.enable(0)
1099 | const fastify = Fastify()
1100 | await fastify.register(rateLimit, {
1101 | max: 1,
1102 | timeWindow: 1000,
1103 | enableDraftSpec: true,
1104 | addHeaders: {
1105 | 'ratelimit-limit': false,
1106 | 'ratelimit-remaining': false,
1107 | 'ratelimit-reset': false,
1108 | 'retry-after': false
1109 | }
1110 | })
1111 |
1112 | fastify.get('/', async () => 'hello')
1113 |
1114 | let res
1115 |
1116 | res = await fastify.inject('/')
1117 |
1118 | t.assert.deepStrictEqual(res.statusCode, 200)
1119 | t.assert.deepStrictEqual(res.headers['ratelimit-limit'], '1')
1120 | t.assert.deepStrictEqual(res.headers['ratelimit-remaining'], '0')
1121 | t.assert.deepStrictEqual(res.headers['ratelimit-reset'], '1')
1122 |
1123 | res = await fastify.inject('/')
1124 |
1125 | t.assert.deepStrictEqual(res.statusCode, 429)
1126 | t.assert.deepStrictEqual(
1127 | res.headers['content-type'],
1128 | 'application/json; charset=utf-8'
1129 | )
1130 | t.assert.notStrictEqual(
1131 | res.headers['ratelimit-limit'],
1132 | 'the header must be missing'
1133 | )
1134 | t.assert.notStrictEqual(
1135 | res.headers['ratelimit-remaining'],
1136 | 'the header must be missing'
1137 | )
1138 | t.assert.notStrictEqual(
1139 | res.headers['ratelimit-reset'],
1140 | 'the header must be missing'
1141 | )
1142 | t.assert.notStrictEqual(
1143 | res.headers['retry-after'],
1144 | 'the header must be missing'
1145 | )
1146 |
1147 | clock.tick(1100)
1148 |
1149 | res = await fastify.inject('/')
1150 |
1151 | t.assert.deepStrictEqual(res.statusCode, 200)
1152 | t.assert.deepStrictEqual(res.headers['ratelimit-limit'], '1')
1153 | t.assert.deepStrictEqual(res.headers['ratelimit-remaining'], '0')
1154 | t.assert.deepStrictEqual(res.headers['ratelimit-reset'], '1')
1155 |
1156 | clock.reset()
1157 | })
1158 |
1159 | test('afterReset and Rate Limit remain the same when enableDraftSpec is enabled', async (t) => {
1160 | t.plan(13)
1161 | const clock = mock.timers
1162 | clock.enable(0)
1163 | const fastify = Fastify()
1164 | await fastify.register(rateLimit, {
1165 | max: 1,
1166 | timeWindow: '10s',
1167 | enableDraftSpec: true
1168 | })
1169 |
1170 | fastify.get('/', async () => 'hello!')
1171 |
1172 | const res = await fastify.inject('/')
1173 |
1174 | t.assert.deepStrictEqual(res.statusCode, 200)
1175 | t.assert.deepStrictEqual(res.headers['ratelimit-limit'], '1')
1176 | t.assert.deepStrictEqual(res.headers['ratelimit-remaining'], '0')
1177 |
1178 | clock.tick(500)
1179 | await retry('10')
1180 |
1181 | clock.tick(1000)
1182 | await retry('9')
1183 |
1184 | async function retry (timeLeft) {
1185 | const res = await fastify.inject('/')
1186 |
1187 | t.assert.deepStrictEqual(res.statusCode, 429)
1188 | t.assert.deepStrictEqual(res.headers['ratelimit-limit'], '1')
1189 | t.assert.deepStrictEqual(res.headers['ratelimit-remaining'], '0')
1190 | t.assert.deepStrictEqual(res.headers['ratelimit-reset'], timeLeft)
1191 | t.assert.deepStrictEqual(
1192 | res.headers['ratelimit-reset'],
1193 | res.headers['retry-after']
1194 | )
1195 | }
1196 | clock.reset()
1197 | })
1198 |
1199 | test('Before async in "max"', async () => {
1200 | const fastify = Fastify()
1201 | await fastify.register(rateLimit, {
1202 | keyGenerator: (req) => req.headers['api-key'],
1203 | max: async (req, key) => requestSequence(key),
1204 | timeWindow: 10000
1205 | })
1206 |
1207 | await fastify.get('/', async () => 'hello')
1208 |
1209 | const requestSequence = async (key) => ((await key) === 'pro' ? 5 : 2)
1210 | })
1211 |
1212 | test('exposeHeadRoutes', async (t) => {
1213 | const fastify = Fastify({
1214 | exposeHeadRoutes: true
1215 | })
1216 | await fastify.register(rateLimit, {
1217 | max: 10,
1218 | timeWindow: 1000
1219 | })
1220 | fastify.get('/', async () => 'hello!')
1221 |
1222 | const res = await fastify.inject({
1223 | url: '/',
1224 | method: 'GET'
1225 | })
1226 |
1227 | const resHead = await fastify.inject({
1228 | url: '/',
1229 | method: 'HEAD'
1230 | })
1231 |
1232 | t.assert.deepStrictEqual(res.statusCode, 200, 'GET: Response status code')
1233 | t.assert.deepStrictEqual(
1234 | res.headers['x-ratelimit-limit'],
1235 | '10',
1236 | 'GET: x-ratelimit-limit header (global rate limit)'
1237 | )
1238 | t.assert.deepStrictEqual(
1239 | res.headers['x-ratelimit-remaining'],
1240 | '9',
1241 | 'GET: x-ratelimit-remaining header (global rate limit)'
1242 | )
1243 |
1244 | t.assert.deepStrictEqual(
1245 | resHead.statusCode,
1246 | 200,
1247 | 'HEAD: Response status code'
1248 | )
1249 | t.assert.deepStrictEqual(
1250 | resHead.headers['x-ratelimit-limit'],
1251 | '10',
1252 | 'HEAD: x-ratelimit-limit header (global rate limit)'
1253 | )
1254 | t.assert.deepStrictEqual(
1255 | resHead.headers['x-ratelimit-remaining'],
1256 | '8',
1257 | 'HEAD: x-ratelimit-remaining header (global rate limit)'
1258 | )
1259 | })
1260 |
1261 | test('When continue exceeding is on (Local)', async (t) => {
1262 | const fastify = Fastify()
1263 |
1264 | await fastify.register(rateLimit, {
1265 | max: 1,
1266 | timeWindow: 5000,
1267 | continueExceeding: true
1268 | })
1269 |
1270 | fastify.get('/', async () => 'hello!')
1271 |
1272 | const first = await fastify.inject({
1273 | url: '/',
1274 | method: 'GET'
1275 | })
1276 | const second = await fastify.inject({
1277 | url: '/',
1278 | method: 'GET'
1279 | })
1280 |
1281 | t.assert.deepStrictEqual(first.statusCode, 200)
1282 |
1283 | t.assert.deepStrictEqual(second.statusCode, 429)
1284 | t.assert.deepStrictEqual(second.headers['x-ratelimit-limit'], '1')
1285 | t.assert.deepStrictEqual(second.headers['x-ratelimit-remaining'], '0')
1286 | t.assert.deepStrictEqual(second.headers['x-ratelimit-reset'], '5')
1287 | })
1288 |
1289 | test('on preHandler hook', async (t) => {
1290 | const fastify = Fastify()
1291 |
1292 | await fastify.register(rateLimit, {
1293 | max: 1,
1294 | timeWindow: 10000,
1295 | hook: 'preHandler',
1296 | keyGenerator (req) {
1297 | return req.userId || req.ip
1298 | }
1299 | })
1300 |
1301 | fastify.decorateRequest('userId', '')
1302 | fastify.addHook('preHandler', async (req) => {
1303 | const { userId } = req.query
1304 | if (userId) {
1305 | req.userId = userId
1306 | }
1307 | })
1308 |
1309 | fastify.get('/', async () => 'fastify is awesome !')
1310 |
1311 | const send = (userId) => {
1312 | let query
1313 | if (userId) {
1314 | query = { userId }
1315 | }
1316 | return fastify.inject({
1317 | url: '/',
1318 | method: 'GET',
1319 | query
1320 | })
1321 | }
1322 | const first = await send()
1323 | const second = await send()
1324 | const third = await send('123')
1325 | const fourth = await send('123')
1326 | const fifth = await send('234')
1327 |
1328 | t.assert.deepStrictEqual(first.statusCode, 200)
1329 | t.assert.deepStrictEqual(second.statusCode, 429)
1330 | t.assert.deepStrictEqual(third.statusCode, 200)
1331 | t.assert.deepStrictEqual(fourth.statusCode, 429)
1332 | t.assert.deepStrictEqual(fifth.statusCode, 200)
1333 | })
1334 |
1335 | test('ban directly', async (t) => {
1336 | t.plan(15)
1337 |
1338 | const clock = mock.timers
1339 | clock.enable(0)
1340 |
1341 | const fastify = Fastify()
1342 | await fastify.register(rateLimit, { max: 2, ban: 0, timeWindow: '1s' })
1343 |
1344 | fastify.get('/', async () => 'hello!')
1345 |
1346 | let res
1347 |
1348 | res = await fastify.inject('/')
1349 |
1350 | t.assert.deepStrictEqual(res.statusCode, 200)
1351 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
1352 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
1353 |
1354 | res = await fastify.inject('/')
1355 |
1356 | t.assert.deepStrictEqual(res.statusCode, 200)
1357 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
1358 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
1359 |
1360 | res = await fastify.inject('/')
1361 |
1362 | t.assert.deepStrictEqual(res.statusCode, 403)
1363 | t.assert.deepStrictEqual(
1364 | res.headers['content-type'],
1365 | 'application/json; charset=utf-8'
1366 | )
1367 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
1368 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
1369 | t.assert.deepStrictEqual(res.headers['retry-after'], '1')
1370 | t.assert.deepStrictEqual(
1371 | {
1372 | statusCode: 403,
1373 | error: 'Forbidden',
1374 | message: 'Rate limit exceeded, retry in 1 second'
1375 | },
1376 | JSON.parse(res.payload)
1377 | )
1378 |
1379 | clock.tick(1100)
1380 |
1381 | res = await fastify.inject('/')
1382 |
1383 | t.assert.deepStrictEqual(res.statusCode, 200)
1384 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
1385 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
1386 | clock.reset()
1387 | })
1388 |
1389 | test('wrong timewindow', async (t) => {
1390 | t.plan(15)
1391 |
1392 | const clock = mock.timers
1393 | clock.enable(0)
1394 |
1395 | const fastify = Fastify()
1396 | await fastify.register(rateLimit, { max: 2, ban: 0, timeWindow: '1s' })
1397 |
1398 | fastify.get(
1399 | '/',
1400 | {
1401 | config: {
1402 | rateLimit: {
1403 | timeWindow: -5
1404 | }
1405 | }
1406 | },
1407 | async () => 'hello!'
1408 | )
1409 |
1410 | let res
1411 |
1412 | res = await fastify.inject('/')
1413 |
1414 | t.assert.deepStrictEqual(res.statusCode, 200)
1415 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
1416 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
1417 |
1418 | res = await fastify.inject('/')
1419 |
1420 | t.assert.deepStrictEqual(res.statusCode, 200)
1421 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
1422 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
1423 |
1424 | res = await fastify.inject('/')
1425 |
1426 | t.assert.deepStrictEqual(res.statusCode, 403)
1427 | t.assert.deepStrictEqual(
1428 | res.headers['content-type'],
1429 | 'application/json; charset=utf-8'
1430 | )
1431 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
1432 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
1433 | t.assert.deepStrictEqual(res.headers['retry-after'], '60')
1434 | t.assert.deepStrictEqual(
1435 | {
1436 | statusCode: 403,
1437 | error: 'Forbidden',
1438 | message: 'Rate limit exceeded, retry in 1 minute'
1439 | },
1440 | JSON.parse(res.payload)
1441 | )
1442 |
1443 | clock.tick(1100)
1444 |
1445 | res = await fastify.inject('/')
1446 |
1447 | t.assert.deepStrictEqual(res.statusCode, 403)
1448 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
1449 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
1450 | clock.reset()
1451 | })
1452 |
--------------------------------------------------------------------------------
/test/group-rate-limit.test.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | const { test } = require('node:test')
3 | const assert = require('node:assert')
4 | const Fastify = require('fastify')
5 | const rateLimit = require('../index')
6 |
7 | const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
8 |
9 | test('GroupId from routeConfig', async () => {
10 | const fastify = Fastify()
11 |
12 | // Register rate limit plugin with groupId in routeConfig
13 | await fastify.register(rateLimit, { max: 2, timeWindow: 500 })
14 |
15 | fastify.get(
16 | '/routeWithGroupId',
17 | {
18 | config: {
19 | rateLimit: {
20 | max: 2,
21 | timeWindow: 500,
22 | groupId: 'group1' // groupId specified in routeConfig
23 | }
24 | }
25 | },
26 | async () => 'hello from route with groupId!'
27 | )
28 |
29 | // Test: Request should have the correct groupId in response
30 | const res = await fastify.inject({ url: '/routeWithGroupId', method: 'GET' })
31 | assert.deepStrictEqual(res.statusCode, 200)
32 | assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
33 | assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
34 | })
35 |
36 | test('GroupId from routeOptions', async () => {
37 | const fastify = Fastify()
38 |
39 | // Register rate limit plugin with groupId in routeOptions
40 | await fastify.register(rateLimit, { max: 2, timeWindow: 500 })
41 |
42 | fastify.get(
43 | '/routeWithGroupIdFromOptions',
44 | {
45 | config: {
46 | rateLimit: {
47 | max: 2,
48 | timeWindow: 500
49 | // groupId not specified here
50 | }
51 | }
52 | },
53 | async () => 'hello from route with groupId from options!'
54 | )
55 |
56 | // Test: Request should have the correct groupId from routeOptions
57 | const res = await fastify.inject({ url: '/routeWithGroupIdFromOptions', method: 'GET' })
58 | assert.deepStrictEqual(res.statusCode, 200)
59 | assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
60 | assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
61 | })
62 |
63 | test('No groupId provided', async () => {
64 | const fastify = Fastify()
65 |
66 | // Register rate limit plugin without groupId
67 | await fastify.register(rateLimit, { max: 2, timeWindow: 500 })
68 |
69 | // Route without groupId
70 | fastify.get(
71 | '/noGroupId',
72 | {
73 | config: {
74 | rateLimit: {
75 | max: 2,
76 | timeWindow: 500
77 | }
78 | }
79 | },
80 | async () => 'hello from no groupId route!'
81 | )
82 |
83 | let res
84 |
85 | // Test without groupId
86 | res = await fastify.inject({ url: '/noGroupId', method: 'GET' })
87 | assert.deepStrictEqual(res.statusCode, 200)
88 | assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
89 | assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
90 |
91 | res = await fastify.inject({ url: '/noGroupId', method: 'GET' })
92 | assert.deepStrictEqual(res.statusCode, 200)
93 | assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
94 | assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
95 |
96 | res = await fastify.inject({ url: '/noGroupId', method: 'GET' })
97 | assert.deepStrictEqual(res.statusCode, 429)
98 | assert.deepStrictEqual(
99 | res.headers['content-type'],
100 | 'application/json; charset=utf-8'
101 | )
102 | assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
103 | assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
104 | assert.deepStrictEqual(res.headers['retry-after'], '1')
105 | assert.deepStrictEqual(
106 | {
107 | statusCode: 429,
108 | error: 'Too Many Requests',
109 | message: 'Rate limit exceeded, retry in 1 second'
110 | },
111 | JSON.parse(res.payload)
112 | )
113 | })
114 |
115 | test('With multiple routes and custom groupId', async () => {
116 | const fastify = Fastify()
117 |
118 | // Register rate limit plugin
119 | await fastify.register(rateLimit, { max: 2, timeWindow: 500 })
120 |
121 | // Route 1 with groupId 'group1'
122 | fastify.get(
123 | '/route1',
124 | {
125 | config: {
126 | rateLimit: {
127 | max: 2,
128 | timeWindow: 500,
129 | groupId: 'group1'
130 | }
131 | }
132 | },
133 | async () => 'hello from route 1!'
134 | )
135 |
136 | // Route 2 with groupId 'group2'
137 | fastify.get(
138 | '/route2',
139 | {
140 | config: {
141 | rateLimit: {
142 | max: 2,
143 | timeWindow: 1000,
144 | groupId: 'group2'
145 | }
146 | }
147 | },
148 | async () => 'hello from route 2!'
149 | )
150 |
151 | let res
152 |
153 | // Test Route 1
154 | res = await fastify.inject({ url: '/route1', method: 'GET' })
155 | assert.deepStrictEqual(res.statusCode, 200)
156 | assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
157 | assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
158 |
159 | res = await fastify.inject({ url: '/route1', method: 'GET' })
160 | assert.deepStrictEqual(res.statusCode, 200)
161 | assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
162 | assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
163 |
164 | res = await fastify.inject({ url: '/route1', method: 'GET' })
165 | assert.deepStrictEqual(res.statusCode, 429)
166 | assert.deepStrictEqual(
167 | res.headers['content-type'],
168 | 'application/json; charset=utf-8'
169 | )
170 | assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
171 | assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
172 | assert.deepStrictEqual(res.headers['retry-after'], '1')
173 | assert.deepStrictEqual(
174 | {
175 | statusCode: 429,
176 | error: 'Too Many Requests',
177 | message: 'Rate limit exceeded, retry in 1 second'
178 | },
179 | JSON.parse(res.payload)
180 | )
181 |
182 | // Test Route 2
183 | res = await fastify.inject({ url: '/route2', method: 'GET' })
184 | assert.deepStrictEqual(res.statusCode, 200)
185 | assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
186 | assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
187 |
188 | res = await fastify.inject({ url: '/route2', method: 'GET' })
189 | assert.deepStrictEqual(res.statusCode, 200)
190 | assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
191 | assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
192 |
193 | res = await fastify.inject({ url: '/route2', method: 'GET' })
194 | assert.deepStrictEqual(res.statusCode, 429)
195 | assert.deepStrictEqual(
196 | res.headers['content-type'],
197 | 'application/json; charset=utf-8'
198 | )
199 | assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
200 | assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
201 | assert.deepStrictEqual(res.headers['retry-after'], '1')
202 | assert.deepStrictEqual(
203 | {
204 | statusCode: 429,
205 | error: 'Too Many Requests',
206 | message: 'Rate limit exceeded, retry in 1 second'
207 | },
208 | JSON.parse(res.payload)
209 | )
210 |
211 | // Wait for the window to reset
212 | await sleep(1000)
213 |
214 | // After reset, Route 1 should succeed again
215 | res = await fastify.inject({ url: '/route1', method: 'GET' })
216 | assert.deepStrictEqual(res.statusCode, 200)
217 | assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
218 | assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
219 |
220 | // Route 2 should also succeed after the reset
221 | res = await fastify.inject({ url: '/route2', method: 'GET' })
222 | assert.deepStrictEqual(res.statusCode, 200)
223 | assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
224 | assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
225 | })
226 |
227 | test('Invalid groupId type', async () => {
228 | const fastify = Fastify()
229 |
230 | // Register rate limit plugin with a route having an invalid groupId
231 | await fastify.register(rateLimit, { max: 2, timeWindow: 1000 })
232 |
233 | try {
234 | fastify.get(
235 | '/invalidGroupId',
236 | {
237 | config: {
238 | rateLimit: {
239 | max: 2,
240 | timeWindow: 1000,
241 | groupId: 123 // Invalid groupId type
242 | }
243 | }
244 | },
245 | async () => 'hello with invalid groupId!'
246 | )
247 | assert.fail('should throw')
248 | console.log('HER')
249 | } catch (err) {
250 | assert.deepStrictEqual(err.message, 'groupId must be a string')
251 | }
252 | })
253 |
--------------------------------------------------------------------------------
/test/local-store-close.test.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const { test } = require('node:test')
4 | const Fastify = require('fastify')
5 | const rateLimit = require('../index')
6 |
7 | test('Fastify close on local store', async (t) => {
8 | t.plan(1)
9 | const fastify = Fastify()
10 | await fastify.register(rateLimit, { max: 2, timeWindow: 1000 })
11 | let counter = 1
12 | fastify.addHook('onClose', (_instance, done) => {
13 | counter++
14 | done()
15 | })
16 | await fastify.close()
17 | t.assert.deepStrictEqual(counter, 2)
18 | })
19 |
--------------------------------------------------------------------------------
/test/not-found-handler-rate-limited.test.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const { test } = require('node:test')
4 | const Fastify = require('fastify')
5 | const rateLimit = require('../index')
6 |
7 | test('Set not found handler can be rate limited', async (t) => {
8 | t.plan(18)
9 |
10 | const fastify = Fastify()
11 |
12 | await fastify.register(rateLimit, { max: 2, timeWindow: 1000 })
13 | t.assert.ok(fastify.rateLimit)
14 |
15 | fastify.setNotFoundHandler(
16 | {
17 | preHandler: fastify.rateLimit()
18 | },
19 | function (_request, reply) {
20 | t.assert.ok('Error handler has been called')
21 | reply.status(404).send(new Error('Not found'))
22 | }
23 | )
24 |
25 | let res
26 | res = await fastify.inject('/not-found')
27 | t.assert.deepStrictEqual(res.statusCode, 404)
28 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
29 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
30 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
31 |
32 | res = await fastify.inject('/not-found')
33 | t.assert.deepStrictEqual(res.statusCode, 404)
34 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
35 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
36 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
37 |
38 | res = await fastify.inject('/not-found')
39 | t.assert.deepStrictEqual(res.statusCode, 429)
40 | t.assert.deepStrictEqual(
41 | res.headers['content-type'],
42 | 'application/json; charset=utf-8'
43 | )
44 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
45 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
46 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
47 | t.assert.deepStrictEqual(res.headers['retry-after'], '1')
48 | t.assert.deepStrictEqual(JSON.parse(res.payload), {
49 | statusCode: 429,
50 | error: 'Too Many Requests',
51 | message: 'Rate limit exceeded, retry in 1 second'
52 | })
53 | })
54 |
55 | test('Set not found handler can be rate limited with specific options', async (t) => {
56 | t.plan(28)
57 |
58 | const fastify = Fastify()
59 |
60 | await fastify.register(rateLimit, { max: 2, timeWindow: 1000 })
61 | t.assert.ok(fastify.rateLimit)
62 |
63 | fastify.setNotFoundHandler(
64 | {
65 | preHandler: fastify.rateLimit({
66 | max: 4,
67 | timeWindow: 2000
68 | })
69 | },
70 | function (_request, reply) {
71 | t.assert.ok('Error handler has been called')
72 | reply.status(404).send(new Error('Not found'))
73 | }
74 | )
75 |
76 | let res
77 | res = await fastify.inject('/not-found')
78 | t.assert.deepStrictEqual(res.statusCode, 404)
79 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '4')
80 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '3')
81 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '2')
82 |
83 | res = await fastify.inject('/not-found')
84 | t.assert.deepStrictEqual(res.statusCode, 404)
85 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '4')
86 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '2')
87 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '2')
88 |
89 | res = await fastify.inject('/not-found')
90 | t.assert.deepStrictEqual(res.statusCode, 404)
91 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '4')
92 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
93 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '2')
94 |
95 | res = await fastify.inject('/not-found')
96 | t.assert.deepStrictEqual(res.statusCode, 404)
97 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '4')
98 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
99 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '2')
100 |
101 | res = await fastify.inject('/not-found')
102 | t.assert.deepStrictEqual(res.statusCode, 429)
103 | t.assert.deepStrictEqual(
104 | res.headers['content-type'],
105 | 'application/json; charset=utf-8'
106 | )
107 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '4')
108 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
109 | t.assert.deepStrictEqual(res.headers['retry-after'], '2')
110 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '2')
111 | t.assert.deepStrictEqual(JSON.parse(res.payload), {
112 | statusCode: 429,
113 | error: 'Too Many Requests',
114 | message: 'Rate limit exceeded, retry in 2 seconds'
115 | })
116 | })
117 |
--------------------------------------------------------------------------------
/test/redis-rate-limit.test.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const { test, describe } = require('node:test')
4 | const Redis = require('ioredis')
5 | const Fastify = require('fastify')
6 | const rateLimit = require('../index')
7 |
8 | const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
9 |
10 | const REDIS_HOST = '127.0.0.1'
11 |
12 | describe('Global rate limit', () => {
13 | test('With redis store', async (t) => {
14 | t.plan(21)
15 | const fastify = Fastify()
16 | const redis = await new Redis({ host: REDIS_HOST })
17 | await fastify.register(rateLimit, {
18 | max: 2,
19 | timeWindow: 1000,
20 | redis
21 | })
22 |
23 | fastify.get('/', async () => 'hello!')
24 |
25 | let res
26 |
27 | res = await fastify.inject('/')
28 | t.assert.strictEqual(res.statusCode, 200)
29 | t.assert.ok(res)
30 | t.assert.strictEqual(res.statusCode, 200)
31 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
32 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
33 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
34 |
35 | res = await fastify.inject('/')
36 | t.assert.deepStrictEqual(res.statusCode, 200)
37 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
38 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
39 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
40 |
41 | await sleep(100)
42 |
43 | res = await fastify.inject('/')
44 | t.assert.deepStrictEqual(res.statusCode, 429)
45 | t.assert.deepStrictEqual(
46 | res.headers['content-type'],
47 | 'application/json; charset=utf-8'
48 | )
49 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
50 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
51 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
52 | t.assert.deepStrictEqual(res.headers['retry-after'], '1')
53 | t.assert.deepStrictEqual(
54 | {
55 | statusCode: 429,
56 | error: 'Too Many Requests',
57 | message: 'Rate limit exceeded, retry in 1 second'
58 | },
59 | JSON.parse(res.payload)
60 | )
61 |
62 | // Not using fake timers here as we use an external Redis that would not be effected by this
63 | await sleep(1100)
64 |
65 | res = await fastify.inject('/')
66 |
67 | t.assert.deepStrictEqual(res.statusCode, 200)
68 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
69 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
70 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
71 |
72 | await redis.flushall()
73 | await redis.quit()
74 | })
75 |
76 | test('With redis store (ban)', async (t) => {
77 | t.plan(19)
78 | const fastify = Fastify()
79 | const redis = await new Redis({ host: REDIS_HOST })
80 | await fastify.register(rateLimit, {
81 | max: 1,
82 | ban: 1,
83 | timeWindow: 1000,
84 | redis
85 | })
86 |
87 | fastify.get('/', async () => 'hello!')
88 |
89 | let res
90 |
91 | res = await fastify.inject('/')
92 | t.assert.deepStrictEqual(res.statusCode, 200)
93 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '1')
94 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
95 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
96 |
97 | res = await fastify.inject('/')
98 | t.assert.deepStrictEqual(res.statusCode, 429)
99 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '1')
100 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
101 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
102 |
103 | res = await fastify.inject('/')
104 | t.assert.deepStrictEqual(res.statusCode, 403)
105 | t.assert.deepStrictEqual(
106 | res.headers['content-type'],
107 | 'application/json; charset=utf-8'
108 | )
109 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '1')
110 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
111 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
112 | t.assert.deepStrictEqual(res.headers['retry-after'], '1')
113 | t.assert.deepStrictEqual(
114 | {
115 | statusCode: 403,
116 | error: 'Forbidden',
117 | message: 'Rate limit exceeded, retry in 1 second'
118 | },
119 | JSON.parse(res.payload)
120 | )
121 |
122 | // Not using fake timers here as we use an external Redis that would not be effected by this
123 | await sleep(1100)
124 |
125 | res = await fastify.inject('/')
126 |
127 | t.assert.deepStrictEqual(res.statusCode, 200)
128 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '1')
129 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
130 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
131 |
132 | await redis.flushall()
133 | await redis.quit()
134 | })
135 |
136 | test('Skip on redis error', async (t) => {
137 | t.plan(9)
138 | const fastify = Fastify()
139 | const redis = await new Redis({ host: REDIS_HOST })
140 | await fastify.register(rateLimit, {
141 | max: 2,
142 | timeWindow: 1000,
143 | redis,
144 | skipOnError: true
145 | })
146 |
147 | fastify.get('/', async () => 'hello!')
148 |
149 | let res
150 |
151 | res = await fastify.inject('/')
152 | t.assert.deepStrictEqual(res.statusCode, 200)
153 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
154 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
155 |
156 | await redis.flushall()
157 | await redis.quit()
158 |
159 | res = await fastify.inject('/')
160 | t.assert.deepStrictEqual(res.statusCode, 200)
161 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
162 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '2')
163 |
164 | res = await fastify.inject('/')
165 | t.assert.deepStrictEqual(res.statusCode, 200)
166 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
167 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '2')
168 | })
169 |
170 | test('Throw on redis error', async (t) => {
171 | t.plan(5)
172 | const fastify = Fastify()
173 | const redis = await new Redis({ host: REDIS_HOST })
174 | await fastify.register(rateLimit, {
175 | max: 2,
176 | timeWindow: 1000,
177 | redis,
178 | skipOnError: false
179 | })
180 |
181 | fastify.get('/', async () => 'hello!')
182 |
183 | let res
184 |
185 | res = await fastify.inject('/')
186 | t.assert.deepStrictEqual(res.statusCode, 200)
187 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
188 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
189 |
190 | await redis.flushall()
191 | await redis.quit()
192 |
193 | res = await fastify.inject('/')
194 | t.assert.deepStrictEqual(res.statusCode, 500)
195 | t.assert.deepStrictEqual(
196 | res.body,
197 | '{"statusCode":500,"error":"Internal Server Error","message":"Connection is closed."}'
198 | )
199 | })
200 |
201 | test('When continue exceeding is on (Redis)', async (t) => {
202 | const fastify = Fastify()
203 | const redis = await new Redis({ host: REDIS_HOST })
204 |
205 | await fastify.register(rateLimit, {
206 | redis,
207 | max: 1,
208 | timeWindow: 5000,
209 | continueExceeding: true
210 | })
211 |
212 | fastify.get('/', async () => 'hello!')
213 |
214 | const first = await fastify.inject({
215 | url: '/',
216 | method: 'GET'
217 | })
218 | const second = await fastify.inject({
219 | url: '/',
220 | method: 'GET'
221 | })
222 |
223 | t.assert.deepStrictEqual(first.statusCode, 200)
224 |
225 | t.assert.deepStrictEqual(second.statusCode, 429)
226 | t.assert.deepStrictEqual(second.headers['x-ratelimit-limit'], '1')
227 | t.assert.deepStrictEqual(second.headers['x-ratelimit-remaining'], '0')
228 | t.assert.deepStrictEqual(second.headers['x-ratelimit-reset'], '5')
229 |
230 | await redis.flushall()
231 | await redis.quit()
232 | })
233 |
234 | test('Redis with continueExceeding should not always return the timeWindow as ttl', async (t) => {
235 | t.plan(19)
236 | const fastify = Fastify()
237 | const redis = await new Redis({ host: REDIS_HOST })
238 | await fastify.register(rateLimit, {
239 | max: 2,
240 | timeWindow: 3000,
241 | continueExceeding: true,
242 | redis
243 | })
244 |
245 | fastify.get('/', async () => 'hello!')
246 |
247 | let res
248 |
249 | res = await fastify.inject('/')
250 | t.assert.deepStrictEqual(res.statusCode, 200)
251 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
252 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
253 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '3')
254 |
255 | // After this sleep, we should not see `x-ratelimit-reset === 3` anymore
256 | await sleep(1000)
257 |
258 | res = await fastify.inject('/')
259 | t.assert.deepStrictEqual(res.statusCode, 200)
260 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
261 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
262 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '2')
263 |
264 | res = await fastify.inject('/')
265 | t.assert.deepStrictEqual(res.statusCode, 429)
266 | t.assert.deepStrictEqual(
267 | res.headers['content-type'],
268 | 'application/json; charset=utf-8'
269 | )
270 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
271 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
272 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '3')
273 | t.assert.deepStrictEqual(res.headers['retry-after'], '3')
274 | t.assert.deepStrictEqual(
275 | {
276 | statusCode: 429,
277 | error: 'Too Many Requests',
278 | message: 'Rate limit exceeded, retry in 3 seconds'
279 | },
280 | JSON.parse(res.payload)
281 | )
282 |
283 | // Not using fake timers here as we use an external Redis that would not be effected by this
284 | await sleep(1000)
285 |
286 | res = await fastify.inject('/')
287 |
288 | t.assert.deepStrictEqual(res.statusCode, 429)
289 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
290 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
291 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '3')
292 |
293 | await redis.flushall()
294 | await redis.quit()
295 | })
296 |
297 | test('When use a custom nameSpace', async (t) => {
298 | const fastify = Fastify()
299 | const redis = await new Redis({ host: REDIS_HOST })
300 |
301 | await fastify.register(rateLimit, {
302 | max: 2,
303 | timeWindow: 1000,
304 | redis,
305 | nameSpace: 'my-namespace:',
306 | keyGenerator: (req) => req.headers['x-my-header']
307 | })
308 |
309 | fastify.get('/', async () => 'hello!')
310 |
311 | const allowListHeader = {
312 | method: 'GET',
313 | url: '/',
314 | headers: {
315 | 'x-my-header': 'custom name space'
316 | }
317 | }
318 |
319 | let res
320 |
321 | res = await fastify.inject(allowListHeader)
322 | t.assert.deepStrictEqual(res.statusCode, 200)
323 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
324 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
325 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
326 |
327 | res = await fastify.inject(allowListHeader)
328 | t.assert.deepStrictEqual(res.statusCode, 200)
329 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
330 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
331 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
332 |
333 | res = await fastify.inject(allowListHeader)
334 | t.assert.deepStrictEqual(res.statusCode, 429)
335 | t.assert.deepStrictEqual(
336 | res.headers['content-type'],
337 | 'application/json; charset=utf-8'
338 | )
339 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
340 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
341 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
342 | t.assert.deepStrictEqual(res.headers['retry-after'], '1')
343 | t.assert.deepStrictEqual(
344 | {
345 | statusCode: 429,
346 | error: 'Too Many Requests',
347 | message: 'Rate limit exceeded, retry in 1 second'
348 | },
349 | JSON.parse(res.payload)
350 | )
351 |
352 | // Not using fake timers here as we use an external Redis that would not be effected by this
353 | await sleep(1100)
354 |
355 | res = await fastify.inject(allowListHeader)
356 |
357 | t.assert.deepStrictEqual(res.statusCode, 200)
358 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
359 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
360 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
361 |
362 | await redis.flushall()
363 | await redis.quit()
364 | })
365 |
366 | test('With redis store and exponential backoff', async (t) => {
367 | t.plan(20)
368 | const fastify = Fastify()
369 | const redis = await new Redis({ host: REDIS_HOST })
370 | await fastify.register(rateLimit, {
371 | max: 2,
372 | timeWindow: 1000,
373 | redis,
374 | exponentialBackoff: true
375 | })
376 |
377 | fastify.get('/', async () => 'hello!')
378 |
379 | let res
380 |
381 | res = await fastify.inject('/')
382 | t.assert.deepStrictEqual(res.statusCode, 200)
383 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
384 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
385 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
386 |
387 | res = await fastify.inject('/')
388 | t.assert.deepStrictEqual(res.statusCode, 200)
389 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
390 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
391 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
392 |
393 | // First attempt over the limit should have the normal timeWindow (1000ms)
394 | res = await fastify.inject('/')
395 | t.assert.deepStrictEqual(res.statusCode, 429)
396 | t.assert.deepStrictEqual(
397 | res.headers['content-type'],
398 | 'application/json; charset=utf-8'
399 | )
400 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
401 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
402 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
403 | t.assert.deepStrictEqual(res.headers['retry-after'], '1')
404 | t.assert.deepStrictEqual(
405 | {
406 | statusCode: 429,
407 | error: 'Too Many Requests',
408 | message: 'Rate limit exceeded, retry in 1 second'
409 | },
410 | JSON.parse(res.payload)
411 | )
412 |
413 | // Second attempt over the limit should have doubled timeWindow (2000ms)
414 | res = await fastify.inject('/')
415 | t.assert.deepStrictEqual(res.statusCode, 429)
416 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
417 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
418 | t.assert.deepStrictEqual(res.headers['retry-after'], '2')
419 | t.assert.deepStrictEqual(
420 | {
421 | statusCode: 429,
422 | error: 'Too Many Requests',
423 | message: 'Rate limit exceeded, retry in 2 seconds'
424 | },
425 | JSON.parse(res.payload)
426 | )
427 |
428 | await redis.flushall()
429 | await redis.quit()
430 | })
431 | })
432 |
433 | describe('Route rate limit', () => {
434 | test('With redis store', async t => {
435 | t.plan(19)
436 | const fastify = Fastify()
437 | const redis = new Redis({ host: REDIS_HOST })
438 | await fastify.register(rateLimit, {
439 | global: false,
440 | redis
441 | })
442 |
443 | fastify.get('/', {
444 | config: {
445 | rateLimit: {
446 | max: 2,
447 | timeWindow: 1000
448 | },
449 | someOtherPlugin: {
450 | someValue: 1
451 | }
452 | }
453 | }, async () => 'hello!')
454 |
455 | let res
456 |
457 | res = await fastify.inject('/')
458 | t.assert.strictEqual(res.statusCode, 200)
459 | t.assert.strictEqual(res.headers['x-ratelimit-limit'], '2')
460 | t.assert.strictEqual(res.headers['x-ratelimit-remaining'], '1')
461 | t.assert.strictEqual(res.headers['x-ratelimit-reset'], '1')
462 |
463 | res = await fastify.inject('/')
464 | t.assert.strictEqual(res.statusCode, 200)
465 | t.assert.strictEqual(res.headers['x-ratelimit-limit'], '2')
466 | t.assert.strictEqual(res.headers['x-ratelimit-remaining'], '0')
467 | t.assert.strictEqual(res.headers['x-ratelimit-reset'], '1')
468 |
469 | res = await fastify.inject('/')
470 | t.assert.strictEqual(res.statusCode, 429)
471 | t.assert.strictEqual(res.headers['content-type'], 'application/json; charset=utf-8')
472 | t.assert.strictEqual(res.headers['x-ratelimit-limit'], '2')
473 | t.assert.strictEqual(res.headers['x-ratelimit-remaining'], '0')
474 | t.assert.strictEqual(res.headers['x-ratelimit-reset'], '1')
475 | t.assert.strictEqual(res.headers['retry-after'], '1')
476 | t.assert.deepStrictEqual({
477 | statusCode: 429,
478 | error: 'Too Many Requests',
479 | message: 'Rate limit exceeded, retry in 1 second'
480 | }, JSON.parse(res.payload))
481 |
482 | // Not using fake timers here as we use an external Redis that would not be effected by this
483 | await sleep(1100)
484 |
485 | res = await fastify.inject('/')
486 | t.assert.strictEqual(res.statusCode, 200)
487 | t.assert.strictEqual(res.headers['x-ratelimit-limit'], '2')
488 | t.assert.strictEqual(res.headers['x-ratelimit-remaining'], '1')
489 | t.assert.strictEqual(res.headers['x-ratelimit-reset'], '1')
490 |
491 | await redis.flushall()
492 | await redis.quit()
493 | })
494 |
495 | test('Throw on redis error', async (t) => {
496 | t.plan(6)
497 | const fastify = Fastify()
498 | const redis = new Redis({ host: REDIS_HOST })
499 | await fastify.register(rateLimit, {
500 | redis,
501 | global: false
502 | })
503 |
504 | fastify.get(
505 | '/',
506 | {
507 | config: {
508 | rateLimit: {
509 | max: 2,
510 | timeWindow: 1000,
511 | skipOnError: false
512 | }
513 | }
514 | },
515 | async () => 'hello!'
516 | )
517 |
518 | let res
519 |
520 | res = await fastify.inject('/')
521 | t.assert.deepStrictEqual(res.statusCode, 200)
522 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
523 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
524 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
525 |
526 | await redis.flushall()
527 | await redis.quit()
528 |
529 | res = await fastify.inject('/')
530 | t.assert.deepStrictEqual(res.statusCode, 500)
531 | t.assert.deepStrictEqual(
532 | res.body,
533 | '{"statusCode":500,"error":"Internal Server Error","message":"Connection is closed."}'
534 | )
535 | })
536 |
537 | test('Skip on redis error', async (t) => {
538 | t.plan(9)
539 | const fastify = Fastify()
540 | const redis = new Redis({ host: REDIS_HOST })
541 | await fastify.register(rateLimit, {
542 | redis,
543 | global: false
544 | })
545 |
546 | fastify.get(
547 | '/',
548 | {
549 | config: {
550 | rateLimit: {
551 | max: 2,
552 | timeWindow: 1000,
553 | skipOnError: true
554 | }
555 | }
556 | },
557 | async () => 'hello!'
558 | )
559 |
560 | let res
561 |
562 | res = await fastify.inject('/')
563 | t.assert.deepStrictEqual(res.statusCode, 200)
564 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
565 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
566 |
567 | await redis.flushall()
568 | await redis.quit()
569 |
570 | res = await fastify.inject('/')
571 | t.assert.deepStrictEqual(res.statusCode, 200)
572 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
573 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '2')
574 |
575 | res = await fastify.inject('/')
576 | t.assert.deepStrictEqual(res.statusCode, 200)
577 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
578 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '2')
579 | })
580 |
581 | test('When continue exceeding is on (Redis)', async (t) => {
582 | const fastify = Fastify()
583 | const redis = await new Redis({ host: REDIS_HOST })
584 |
585 | await fastify.register(rateLimit, {
586 | global: false,
587 | redis
588 | })
589 |
590 | fastify.get(
591 | '/',
592 | {
593 | config: {
594 | rateLimit: {
595 | timeWindow: 5000,
596 | max: 1,
597 | continueExceeding: true
598 | }
599 | }
600 | },
601 | async () => 'hello!'
602 | )
603 |
604 | const first = await fastify.inject({
605 | url: '/',
606 | method: 'GET'
607 | })
608 | const second = await fastify.inject({
609 | url: '/',
610 | method: 'GET'
611 | })
612 |
613 | t.assert.deepStrictEqual(first.statusCode, 200)
614 |
615 | t.assert.deepStrictEqual(second.statusCode, 429)
616 | t.assert.deepStrictEqual(second.headers['x-ratelimit-limit'], '1')
617 | t.assert.deepStrictEqual(second.headers['x-ratelimit-remaining'], '0')
618 | t.assert.deepStrictEqual(second.headers['x-ratelimit-reset'], '5')
619 |
620 | await redis.flushall()
621 | await redis.quit()
622 | })
623 |
624 | test('When continue exceeding is off under route (Redis)', async (t) => {
625 | const fastify = Fastify()
626 | const redis = await new Redis({ host: REDIS_HOST })
627 |
628 | await fastify.register(rateLimit, {
629 | global: false,
630 | continueExceeding: true,
631 | redis
632 | })
633 |
634 | fastify.get(
635 | '/',
636 | {
637 | config: {
638 | rateLimit: {
639 | timeWindow: 5000,
640 | max: 1,
641 | continueExceeding: false
642 | }
643 | }
644 | },
645 | async () => 'hello!'
646 | )
647 |
648 | const first = await fastify.inject({
649 | url: '/',
650 | method: 'GET'
651 | })
652 | const second = await fastify.inject({
653 | url: '/',
654 | method: 'GET'
655 | })
656 |
657 | await sleep(2000)
658 |
659 | const third = await fastify.inject({
660 | url: '/',
661 | method: 'GET'
662 | })
663 |
664 | t.assert.deepStrictEqual(first.statusCode, 200)
665 |
666 | t.assert.deepStrictEqual(second.statusCode, 429)
667 | t.assert.deepStrictEqual(second.headers['x-ratelimit-limit'], '1')
668 | t.assert.deepStrictEqual(second.headers['x-ratelimit-remaining'], '0')
669 | t.assert.deepStrictEqual(second.headers['x-ratelimit-reset'], '5')
670 |
671 | t.assert.deepStrictEqual(third.statusCode, 429)
672 | t.assert.deepStrictEqual(third.headers['x-ratelimit-limit'], '1')
673 | t.assert.deepStrictEqual(third.headers['x-ratelimit-remaining'], '0')
674 | t.assert.deepStrictEqual(third.headers['x-ratelimit-reset'], '3')
675 |
676 | await redis.flushall()
677 | await redis.quit()
678 | })
679 |
680 | test('Route-specific exponential backoff with redis store', async (t) => {
681 | t.plan(17)
682 | const fastify = Fastify()
683 | const redis = await new Redis({ host: REDIS_HOST })
684 | await fastify.register(rateLimit, {
685 | global: false,
686 | redis
687 | })
688 |
689 | fastify.get('/', {
690 | config: {
691 | rateLimit: {
692 | max: 1,
693 | timeWindow: 1000,
694 | exponentialBackoff: true
695 | }
696 | }
697 | }, async () => 'hello!')
698 |
699 | let res
700 |
701 | res = await fastify.inject('/')
702 | t.assert.deepStrictEqual(res.statusCode, 200)
703 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '1')
704 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
705 | t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
706 |
707 | // First attempt over the limit should have the normal timeWindow (1000ms)
708 | res = await fastify.inject('/')
709 | t.assert.deepStrictEqual(res.statusCode, 429)
710 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '1')
711 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
712 | t.assert.deepStrictEqual(res.headers['retry-after'], '1')
713 | t.assert.deepStrictEqual(
714 | {
715 | statusCode: 429,
716 | error: 'Too Many Requests',
717 | message: 'Rate limit exceeded, retry in 1 second'
718 | },
719 | JSON.parse(res.payload)
720 | )
721 |
722 | // Second attempt over the limit should have doubled timeWindow (2000ms)
723 | res = await fastify.inject('/')
724 | t.assert.deepStrictEqual(res.statusCode, 429)
725 | t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '1')
726 | t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
727 | t.assert.deepStrictEqual(res.headers['retry-after'], '2')
728 | t.assert.deepStrictEqual(
729 | {
730 | statusCode: 429,
731 | error: 'Too Many Requests',
732 | message: 'Rate limit exceeded, retry in 2 seconds'
733 | },
734 | JSON.parse(res.payload)
735 | )
736 |
737 | // Third attempt over the limit should have quadrupled timeWindow (4000ms)
738 | res = await fastify.inject('/')
739 | t.assert.deepStrictEqual(res.statusCode, 429)
740 | t.assert.deepStrictEqual(res.headers['retry-after'], '4')
741 | t.assert.deepStrictEqual(
742 | {
743 | statusCode: 429,
744 | error: 'Too Many Requests',
745 | message: 'Rate limit exceeded, retry in 4 seconds'
746 | },
747 | JSON.parse(res.payload)
748 | )
749 |
750 | await redis.flushall()
751 | await redis.quit()
752 | })
753 | })
754 |
--------------------------------------------------------------------------------
/types/index.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | import {
4 | ContextConfigDefault,
5 | FastifyPluginCallback,
6 | FastifyRequest,
7 | FastifySchema,
8 | preHandlerAsyncHookHandler,
9 | RouteGenericInterface,
10 | RouteOptions
11 | } from 'fastify'
12 |
13 | declare module 'fastify' {
14 | interface FastifyInstance {
15 | createRateLimit(options?: fastifyRateLimit.CreateRateLimitOptions): (req: FastifyRequest) => Promise<
16 | | {
17 | isAllowed: true
18 | key: string
19 | }
20 | | {
21 | isAllowed: false
22 | key: string
23 | max: number
24 | timeWindow: number
25 | remaining: number
26 | ttl: number
27 | ttlInSeconds: number
28 | isExceeded: boolean
29 | isBanned: boolean
30 | }
31 | >
32 |
33 | rateLimit<
34 | RouteGeneric extends RouteGenericInterface = RouteGenericInterface,
35 | ContextConfig = ContextConfigDefault,
36 | SchemaCompiler extends FastifySchema = FastifySchema
37 | >(options?: fastifyRateLimit.RateLimitOptions): preHandlerAsyncHookHandler<
38 | RawServer,
39 | RawRequest,
40 | RawReply,
41 | RouteGeneric,
42 | ContextConfig,
43 | SchemaCompiler,
44 | TypeProvider,
45 | Logger
46 | >;
47 | }
48 | interface FastifyContextConfig {
49 | rateLimit?: fastifyRateLimit.RateLimitOptions | false;
50 | }
51 | }
52 |
53 | type FastifyRateLimit = FastifyPluginCallback
54 |
55 | declare namespace fastifyRateLimit {
56 |
57 | export interface FastifyRateLimitOptions { }
58 |
59 | export interface errorResponseBuilderContext {
60 | statusCode: number;
61 | ban: boolean;
62 | after: string;
63 | max: number;
64 | ttl: number;
65 | }
66 |
67 | export interface FastifyRateLimitStoreCtor {
68 | new(options: FastifyRateLimitOptions): FastifyRateLimitStore;
69 | }
70 |
71 | export interface FastifyRateLimitStore {
72 | incr(
73 | key: string,
74 | callback: (
75 | error: Error | null,
76 | result?: { current: number; ttl: number }
77 | ) => void
78 | ): void;
79 | child(
80 | routeOptions: RouteOptions & { path: string; prefix: string }
81 | ): FastifyRateLimitStore;
82 | }
83 |
84 | interface DefaultAddHeaders {
85 | 'x-ratelimit-limit'?: boolean;
86 | 'x-ratelimit-remaining'?: boolean;
87 | 'x-ratelimit-reset'?: boolean;
88 | 'retry-after'?: boolean;
89 | }
90 |
91 | interface DraftSpecAddHeaders {
92 | 'ratelimit-limit'?: boolean;
93 | 'ratelimit-remaining'?: boolean;
94 | 'ratelimit-reset'?: boolean;
95 | 'retry-after'?: boolean;
96 | }
97 |
98 | interface DefaultAddHeadersOnExceeding {
99 | 'x-ratelimit-limit'?: boolean;
100 | 'x-ratelimit-remaining'?: boolean;
101 | 'x-ratelimit-reset'?: boolean;
102 | }
103 |
104 | interface DraftSpecAddHeadersOnExceeding {
105 | 'ratelimit-limit'?: boolean;
106 | 'ratelimit-remaining'?: boolean;
107 | 'ratelimit-reset'?: boolean;
108 | }
109 |
110 | export interface CreateRateLimitOptions {
111 | store?: FastifyRateLimitStoreCtor;
112 | skipOnError?: boolean;
113 | max?:
114 | | number
115 | | ((req: FastifyRequest, key: string) => number)
116 | | ((req: FastifyRequest, key: string) => Promise);
117 | timeWindow?:
118 | | number
119 | | string
120 | | ((req: FastifyRequest, key: string) => number)
121 | | ((req: FastifyRequest, key: string) => Promise);
122 | /**
123 | * @deprecated Use `allowList` property
124 | */
125 | whitelist?: string[] | ((req: FastifyRequest, key: string) => boolean);
126 | allowList?: string[] | ((req: FastifyRequest, key: string) => boolean | Promise);
127 | keyGenerator?: (req: FastifyRequest) => string | number | Promise;
128 | ban?: number;
129 | }
130 |
131 | export type RateLimitHook =
132 | | 'onRequest'
133 | | 'preParsing'
134 | | 'preValidation'
135 | | 'preHandler'
136 |
137 | export interface RateLimitOptions extends CreateRateLimitOptions {
138 | hook?: RateLimitHook;
139 | cache?: number;
140 | continueExceeding?: boolean;
141 | onBanReach?: (req: FastifyRequest, key: string) => void;
142 | groupId?: string;
143 | errorResponseBuilder?: (
144 | req: FastifyRequest,
145 | context: errorResponseBuilderContext
146 | ) => object;
147 | enableDraftSpec?: boolean;
148 | onExceeding?: (req: FastifyRequest, key: string) => void;
149 | onExceeded?: (req: FastifyRequest, key: string) => void;
150 | exponentialBackoff?: boolean;
151 |
152 | }
153 |
154 | export interface RateLimitPluginOptions extends RateLimitOptions {
155 | global?: boolean;
156 | cache?: number;
157 | redis?: any;
158 | nameSpace?: string;
159 | addHeaders?: DefaultAddHeaders | DraftSpecAddHeaders;
160 | addHeadersOnExceeding?:
161 | | DefaultAddHeadersOnExceeding
162 | | DraftSpecAddHeadersOnExceeding;
163 | }
164 | export const fastifyRateLimit: FastifyRateLimit
165 | export { fastifyRateLimit as default }
166 | }
167 |
168 | declare function fastifyRateLimit (...params: Parameters): ReturnType
169 | export = fastifyRateLimit
170 |
--------------------------------------------------------------------------------
/types/index.test-d.ts:
--------------------------------------------------------------------------------
1 | import fastify, {
2 | FastifyInstance,
3 | FastifyRequest,
4 | preHandlerAsyncHookHandler,
5 | RequestGenericInterface,
6 | RouteOptions
7 | } from 'fastify'
8 | import * as http2 from 'node:http2'
9 | import IORedis from 'ioredis'
10 | import pino from 'pino'
11 | import fastifyRateLimit, {
12 | CreateRateLimitOptions,
13 | errorResponseBuilderContext,
14 | FastifyRateLimitOptions,
15 | FastifyRateLimitStore,
16 | RateLimitPluginOptions
17 | } from '..'
18 | import { expectAssignable, expectType } from 'tsd'
19 |
20 | class CustomStore implements FastifyRateLimitStore {
21 | options: FastifyRateLimitOptions
22 |
23 | constructor (options: FastifyRateLimitOptions) {
24 | this.options = options
25 | }
26 |
27 | incr (
28 | _key: string,
29 | _callback: (
30 | error: Error | null,
31 | result?: { current: number; ttl: number }
32 | ) => void
33 | ) {}
34 |
35 | child (_routeOptions: RouteOptions & { path: string; prefix: string }) {
36 | return ({})
37 | }
38 | }
39 |
40 | const appWithImplicitHttp = fastify()
41 | const options1: RateLimitPluginOptions = {
42 | global: true,
43 | max: 3,
44 | timeWindow: 5000,
45 | cache: 10000,
46 | allowList: ['127.0.0.1'],
47 | redis: new IORedis({ host: '127.0.0.1' }),
48 | skipOnError: true,
49 | ban: 10,
50 | continueExceeding: false,
51 | keyGenerator: (req: FastifyRequest) => req.ip,
52 | groupId: '42',
53 | errorResponseBuilder: (
54 | req: FastifyRequest,
55 | context: errorResponseBuilderContext
56 | ) => {
57 | if (context.ban) {
58 | return {
59 | statusCode: 403,
60 | error: 'Forbidden',
61 | message: `You can not access this service as you have sent too many requests that exceed your rate limit. Your IP: ${req.ip} and Limit: ${context.max}`,
62 | }
63 | } else {
64 | return {
65 | statusCode: 429,
66 | error: 'Too Many Requests',
67 | message: `You hit the rate limit, please slow down! You can retry in ${context.after}`,
68 | }
69 | }
70 | },
71 | addHeadersOnExceeding: {
72 | 'x-ratelimit-limit': false,
73 | 'x-ratelimit-remaining': false,
74 | 'x-ratelimit-reset': false
75 | },
76 | addHeaders: {
77 | 'x-ratelimit-limit': false,
78 | 'x-ratelimit-remaining': false,
79 | 'x-ratelimit-reset': false,
80 | 'retry-after': false
81 | },
82 | onExceeding: (_req: FastifyRequest, _key: string) => ({}),
83 | onExceeded: (_req: FastifyRequest, _key: string) => ({}),
84 | onBanReach: (_req: FastifyRequest, _key: string) => ({})
85 | }
86 | const options2: RateLimitPluginOptions = {
87 | global: true,
88 | max: (_req: FastifyRequest, _key: string) => 42,
89 | allowList: (_req: FastifyRequest, _key: string) => false,
90 | timeWindow: 5000,
91 | hook: 'preParsing'
92 | }
93 |
94 | const options3: RateLimitPluginOptions = {
95 | global: true,
96 | max: (_req: FastifyRequest, _key: string) => 42,
97 | timeWindow: 5000,
98 | store: CustomStore,
99 | hook: 'preValidation'
100 | }
101 |
102 | const options4: RateLimitPluginOptions = {
103 | global: true,
104 | max: (_req: FastifyRequest, _key: string) => Promise.resolve(42),
105 | timeWindow: 5000,
106 | store: CustomStore,
107 | hook: 'preHandler'
108 | }
109 |
110 | const options5: RateLimitPluginOptions = {
111 | max: 3,
112 | timeWindow: 5000,
113 | cache: 10000,
114 | redis: new IORedis({ host: '127.0.0.1' }),
115 | nameSpace: 'my-namespace'
116 | }
117 |
118 | const options6: RateLimitPluginOptions = {
119 | global: true,
120 | allowList: async (_req, _key) => true,
121 | keyGenerator: async (_req) => '',
122 | timeWindow: 5000,
123 | store: CustomStore,
124 | hook: 'preHandler'
125 | }
126 |
127 | const options7: RateLimitPluginOptions = {
128 | global: true,
129 | max: (_req: FastifyRequest, _key: string) => 42,
130 | timeWindow: (_req: FastifyRequest, _key: string) => 5000,
131 | store: CustomStore,
132 | hook: 'preValidation'
133 | }
134 |
135 | const options8: RateLimitPluginOptions = {
136 | global: true,
137 | max: (_req: FastifyRequest, _key: string) => 42,
138 | timeWindow: (_req: FastifyRequest, _key: string) => Promise.resolve(5000),
139 | store: CustomStore,
140 | hook: 'preValidation'
141 | }
142 |
143 | const options9: RateLimitPluginOptions = {
144 | global: true,
145 | max: (_req: FastifyRequest, _key: string) => Promise.resolve(42),
146 | timeWindow: (_req: FastifyRequest, _key: string) => 5000,
147 | store: CustomStore,
148 | hook: 'preValidation',
149 | exponentialBackoff: true
150 | }
151 |
152 | appWithImplicitHttp.register(fastifyRateLimit, options1)
153 | appWithImplicitHttp.register(fastifyRateLimit, options2)
154 | appWithImplicitHttp.register(fastifyRateLimit, options5)
155 | appWithImplicitHttp.register(fastifyRateLimit, options9)
156 |
157 | appWithImplicitHttp.register(fastifyRateLimit, options3).then(() => {
158 | expectType(appWithImplicitHttp.rateLimit())
159 | expectType(appWithImplicitHttp.rateLimit(options1))
160 | expectType(appWithImplicitHttp.rateLimit(options2))
161 | expectType(appWithImplicitHttp.rateLimit(options3))
162 | expectType(appWithImplicitHttp.rateLimit(options4))
163 | expectType(appWithImplicitHttp.rateLimit(options5))
164 | expectType(appWithImplicitHttp.rateLimit(options6))
165 | expectType(appWithImplicitHttp.rateLimit(options7))
166 | expectType(appWithImplicitHttp.rateLimit(options8))
167 | expectType(appWithImplicitHttp.rateLimit(options9))
168 | // The following test is dependent on https://github.com/fastify/fastify/pull/2929
169 | // appWithImplicitHttp.setNotFoundHandler({
170 | // preHandler: appWithImplicitHttp.rateLimit()
171 | // }, function (request:FastifyRequest, reply: FastifyReply) {
172 | // reply.status(404).send(new Error('Not found'))
173 | // })
174 | })
175 |
176 | appWithImplicitHttp.get('/', { config: { rateLimit: { max: 10, timeWindow: '60s' } } }, () => { return 'limited' })
177 |
178 | const appWithHttp2: FastifyInstance<
179 | http2.Http2Server,
180 | http2.Http2ServerRequest,
181 | http2.Http2ServerResponse
182 | > = fastify({ http2: true })
183 |
184 | appWithHttp2.register(fastifyRateLimit, options1)
185 | appWithHttp2.register(fastifyRateLimit, options2)
186 | appWithHttp2.register(fastifyRateLimit, options3)
187 | appWithHttp2.register(fastifyRateLimit, options5)
188 | appWithHttp2.register(fastifyRateLimit, options6)
189 | appWithHttp2.register(fastifyRateLimit, options7)
190 | appWithHttp2.register(fastifyRateLimit, options8)
191 | appWithHttp2.register(fastifyRateLimit, options9)
192 |
193 | appWithHttp2.get('/public', {
194 | config: {
195 | rateLimit: false
196 | }
197 | }, (_request, reply) => {
198 | reply.send({ hello: 'from ... public' })
199 | })
200 |
201 | expectAssignable({
202 | statusCode: 429,
203 | ban: true,
204 | after: '123',
205 | max: 1000,
206 | ttl: 123
207 | })
208 |
209 | const appWithCustomLogger = fastify({
210 | loggerInstance: pino(),
211 | }).withTypeProvider()
212 |
213 | appWithCustomLogger.register(fastifyRateLimit, options1)
214 |
215 | appWithCustomLogger.route({
216 | method: 'GET',
217 | url: '/',
218 | preHandler: appWithCustomLogger.rateLimit({}),
219 | handler: () => {},
220 | })
221 |
222 | const options10: CreateRateLimitOptions = {
223 | store: CustomStore,
224 | skipOnError: true,
225 | max: 0,
226 | timeWindow: 5000,
227 | allowList: ['127.0.0.1'],
228 | keyGenerator: (req: FastifyRequest) => req.ip,
229 | ban: 10
230 | }
231 |
232 | appWithImplicitHttp.register(fastifyRateLimit, { global: false })
233 | const checkRateLimit = appWithImplicitHttp.createRateLimit(options10)
234 | appWithImplicitHttp.route({
235 | method: 'GET',
236 | url: '/',
237 | handler: async (req, _reply) => {
238 | const limit = await checkRateLimit(req)
239 | expectType<{
240 | isAllowed: true;
241 | key: string;
242 | } | {
243 | isAllowed: false;
244 | key: string;
245 | max: number;
246 | timeWindow: number;
247 | remaining: number;
248 | ttl: number;
249 | ttlInSeconds: number;
250 | isExceeded: boolean;
251 | isBanned: boolean;
252 | }>(limit)
253 | },
254 | })
255 |
256 | const options11: CreateRateLimitOptions = {
257 | max: (_req: FastifyRequest, _key: string) => 42,
258 | timeWindow: '10s',
259 | allowList: (_req: FastifyRequest) => true,
260 | keyGenerator: (_req: FastifyRequest) => 42,
261 | }
262 |
263 | const options12: CreateRateLimitOptions = {
264 | max: (_req: FastifyRequest, _key: string) => Promise.resolve(42),
265 | timeWindow: (_req: FastifyRequest, _key: string) => 5000,
266 | allowList: (_req: FastifyRequest) => Promise.resolve(true),
267 | keyGenerator: (_req: FastifyRequest) => Promise.resolve(42),
268 | }
269 |
270 | const options13: CreateRateLimitOptions = {
271 | timeWindow: (_req: FastifyRequest, _key: string) => Promise.resolve(5000),
272 | keyGenerator: (_req: FastifyRequest) => Promise.resolve('key'),
273 | }
274 |
275 | expectType(appWithImplicitHttp.rateLimit(options11))
276 | expectType(appWithImplicitHttp.rateLimit(options12))
277 | expectType(appWithImplicitHttp.rateLimit(options13))
278 |
--------------------------------------------------------------------------------