├── .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 | [![CI](https://github.com/fastify/fastify-rate-limit/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/fastify/fastify-rate-limit/actions/workflows/ci.yml) 4 | [![NPM version](https://img.shields.io/npm/v/@fastify/rate-limit.svg?style=flat)](https://www.npmjs.com/package/@fastify/rate-limit) 5 | [![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-brightgreen?style=flat)](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 | --------------------------------------------------------------------------------