Aktueller Stand

This commit is contained in:
2026-01-22 22:22:48 +01:00
parent 33e2bc61e2
commit fa5f3808bb
169 changed files with 58567 additions and 25460 deletions

View File

@@ -0,0 +1,5 @@
# Set the default behavior, in case people don't have core.autocrlf set
* text=auto
# Require Unix line endings
* text eol=lf

View File

@@ -0,0 +1,13 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
open-pull-requests-limit: 10
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "monthly"
open-pull-requests-limit: 10

View File

@@ -0,0 +1,21 @@
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 15
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 7
# Issues with these labels will never be considered stale
exemptLabels:
- "discussion"
- "feature request"
- "bug"
- "help wanted"
- "plugin suggestion"
- "good first issue"
# Label to use when marking an issue as stale
staleLabel: stale
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions.
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false

View File

@@ -0,0 +1,24 @@
name: CI
on:
push:
paths-ignore:
- 'docs/**'
- '*.md'
pull_request:
paths-ignore:
- 'docs/**'
- '*.md'
permissions:
contents: read
jobs:
test:
permissions:
contents: write
pull-requests: write
uses: fastify/workflows/.github/workflows/plugins-ci-redis.yml@v5
with:
license-check: true
lint: true

21
backend/node_modules/@fastify/rate-limit/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 Fastify
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

550
backend/node_modules/@fastify/rate-limit/README.md generated vendored Normal file
View File

@@ -0,0 +1,550 @@
# @fastify/rate-limit
[![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)
[![NPM version](https://img.shields.io/npm/v/@fastify/rate-limit.svg?style=flat)](https://www.npmjs.com/package/@fastify/rate-limit)
[![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-brightgreen?style=flat)](https://github.com/neostandard/neostandard)
A low overhead rate limiter for your routes.
## Install
```
npm i @fastify/rate-limit
```
### Compatibility
| Plugin version | Fastify version |
| -------------- | -------------------- |
| `>=10.x` | `^5.x` |
| `>=7.x <10.x` | `^4.x` |
| `>=3.x <7.x` | `^3.x` |
| `>=2.x <7.x` | `^2.x` |
| `^1.x` | `^1.x` |
Please note that if a Fastify version is out of support, then so are the corresponding versions of this plugin
in the table above.
See [Fastify's LTS policy](https://github.com/fastify/fastify/blob/main/docs/Reference/LTS.md) for more details.
## Usage
Register the plugin and, if required, pass some custom options.<br>
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.
```js
import Fastify from 'fastify'
const fastify = Fastify()
await fastify.register(import('@fastify/rate-limit'), {
max: 100,
timeWindow: '1 minute'
})
fastify.get('/', (request, reply) => {
reply.send({ hello: 'world' })
})
fastify.listen({ port: 3000 }, err => {
if (err) throw err
console.log('Server listening at http://localhost:3000')
})
```
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`:
```js
{
statusCode: 429,
error: 'Too Many Requests',
message: 'Rate limit exceeded, retry in 1 minute'
}
```
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):
```js
fastify.setErrorHandler(function (error, request, reply) {
if (error.statusCode === 429) {
reply.code(429)
error.message = 'You hit the rate limit! Slow down please!'
}
reply.send(error)
})
```
The response will have some additional headers:
| Header | Description |
|--------|-------------|
|`x-ratelimit-limit` | how many requests the client can make
|`x-ratelimit-remaining` | how many requests remain to the client in the timewindow
|`x-ratelimit-reset` | how many seconds must pass before the rate limit resets
|`retry-after` | if the max has been reached, the seconds the client must wait before they can make new requests
### Preventing guessing of URLS through 404s
An attacker could search for valid URLs if your 404 error handling is not rate limited.
To rate limit your 404 response, you can use a custom handler:
```js
const fastify = Fastify()
await fastify.register(rateLimit, { global: true, max: 2, timeWindow: 1000 })
fastify.setNotFoundHandler({
preHandler: fastify.rateLimit()
}, function (request, reply) {
reply.code(404).send({ hello: 'world' })
})
```
Note that you can customize the behavior of the preHandler in the same way you would for specific routes:
```js
const fastify = Fastify()
await fastify.register(rateLimit, { global: true, max: 2, timeWindow: 1000 })
fastify.setNotFoundHandler({
preHandler: fastify.rateLimit({
max: 4,
timeWindow: 500
})
}, function (request, reply) {
reply.code(404).send({ hello: 'world' })
})
```
### Options
You can pass the following options during the plugin registration:
```js
await fastify.register(import('@fastify/rate-limit'), {
global : false, // default true
max: 3, // default 1000
ban: 2, // default -1
timeWindow: 5000, // default 1000 * 60
hook: 'preHandler', // default 'onRequest'
cache: 10000, // default 5000
allowList: ['127.0.0.1'], // default []
redis: new Redis({ host: '127.0.0.1' }), // default null
nameSpace: 'teste-ratelimit-', // default is 'fastify-rate-limit-'
continueExceeding: true, // default false
skipOnError: true, // default false
keyGenerator: function (request) { /* ... */ }, // default (request) => request.ip
errorResponseBuilder: function (request, context) { /* ... */},
enableDraftSpec: true, // default false. Uses IEFT draft header standard
addHeadersOnExceeding: { // default show all the response headers when rate limit is not reached
'x-ratelimit-limit': true,
'x-ratelimit-remaining': true,
'x-ratelimit-reset': true
},
addHeaders: { // default show all the response headers when rate limit is reached
'x-ratelimit-limit': true,
'x-ratelimit-remaining': true,
'x-ratelimit-reset': true,
'retry-after': true
}
})
```
- `global` : indicates if the plugin should apply rate limiting to all routes within the encapsulation scope.
- `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.
- `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.
- `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.
- `cache`: this plugin internally uses an LRU cache to handle the clients, you can change the size of the cache with this option
- `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.
- `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).<br> **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).
- `nameSpace`: choose which prefix to use in the redis, default is 'fastify-rate-limit-'
- `continueExceeding`: Renew user limitation when user sends a request to the server when still limited. This will take priority over `exponentialBackoff`
- `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
- `skipOnError`: if `true` it will skip errors generated by the storage (e.g. redis not reachable).
- `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
- `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`.
- `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}``})`
- `addHeadersOnExceeding`: define which headers should be added in the response when the limit is not reached. Defaults all the headers will be shown
- `addHeaders`: define which headers should be added in the response when the limit is reached. Defaults all the headers will be shown
- `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).
- `onExceeding`: callback that will be executed before request limit has been reached.
- `onExceeded`: callback that will be executed after request limit has been reached.
- `onBanReach`: callback that will be executed when the ban limit has been reached.
- `exponentialBackoff`: Renew user limitation exponentially when user sends a request to the server when still limited.
`keyGenerator` example usage:
```js
await fastify.register(import('@fastify/rate-limit'), {
/* ... */
keyGenerator: function (request) {
return request.headers['x-real-ip'] // nginx
|| request.headers['x-client-ip'] // apache
|| request.headers['x-forwarded-for'] // use this only if you trust the header
|| request.session.username // you can limit based on any session value
|| request.ip // fallback to default
}
})
```
Variable `max` example usage:
```js
// In the same timeWindow, the max value can change based on request and/or key like this
fastify.register(rateLimit, {
/* ... */
keyGenerator (request) { return request.headers['service-key'] },
max: async (request, key) => { return key === 'pro' ? 3 : 2 },
timeWindow: 1000
})
```
`errorResponseBuilder` example usage:
```js
await fastify.register(import('@fastify/rate-limit'), {
/* ... */
errorResponseBuilder: function (request, context) {
return {
statusCode: 429,
error: 'Too Many Requests',
message: `I only allow ${context.max} requests per ${context.after} to this Website. Try again soon.`,
date: Date.now(),
expiresIn: context.ttl // milliseconds
}
}
})
```
Dynamic `allowList` example usage:
```js
await fastify.register(import('@fastify/rate-limit'), {
/* ... */
allowList: function (request, key) {
return request.headers['x-app-client-id'] === 'internal-usage'
}
})
```
Custom `hook` example usage (after authentication):
```js
await fastify.register(import('@fastify/rate-limit'), {
hook: 'preHandler',
keyGenerator: function (request) {
return request.userId || request.ip
}
})
fastify.decorateRequest('userId', '')
fastify.addHook('preHandler', async function (request) {
const { userId } = request.query
if (userId) {
request.userId = userId
}
})
```
Custom `store` example usage:
NOTE: The ```timeWindow``` will always be passed as the numeric value in milliseconds into the store's constructor.
```js
function CustomStore (options) {
this.options = options
this.current = 0
}
CustomStore.prototype.incr = function (key, cb) {
const timeWindow = this.options.timeWindow
this.current++
cb(null, { current: this.current, ttl: timeWindow - (this.current * 1000) })
}
CustomStore.prototype.child = function (routeOptions) {
// We create a merged copy of the current parent parameters with the specific
// route parameters and pass them into the child store.
const childParams = Object.assign(this.options, routeOptions)
const store = new CustomStore(childParams)
// Here is where you may want to do some custom calls on the store with the information
// in routeOptions first...
// store.setSubKey(routeOptions.method + routeOptions.url)
return store
}
await fastify.register(import('@fastify/rate-limit'), {
/* ... */
store: CustomStore
})
```
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:
- `routeInfo`: The configuration of the route including `method`, `url`, `path`, and the full route `config`
Custom `onExceeding` example usage:
```js
await fastify.register(import('@fastify/rate-limit'), {
/* */
onExceeding: function (req, key) {
console.log('callback on exceeding ... executed before response to client')
}
})
```
Custom `onExceeded` example usage:
```js
await fastify.register(import('@fastify/rate-limit'), {
/* */
onExceeded: function (req, key) {
console.log('callback on exceeded ... executed before response to client')
}
})
```
Custom `onBanReach` example usage:
```js
await fastify.register(import('@fastify/rate-limit'), {
/* */
ban: 10,
onBanReach: function (req, key) {
console.log('callback on exceeded ban limit')
}
})
```
### Options on the endpoint itself
Rate limiting can also be configured at the route level, applying the configuration independently.
For example the `allowList` if configured:
- on plugin registration will affect all endpoints within the encapsulation scope
- on route declaration will affect only the targeted endpoint
The global allowlist is configured when registering it with `fastify.register(...)`.
The endpoint allowlist is set on the endpoint directly with the `{ config : { rateLimit : { allowList : [] } } }` object.
ACL checking is performed based on the value of the key from the `keyGenerator`.
In this example, we are checking the IP address, but it could be an allowlist of specific user identifiers (like JWT or tokens):
```js
import Fastify from 'fastify'
const fastify = Fastify()
await fastify.register(import('@fastify/rate-limit'),
{
global : false, // don't apply these settings to all the routes of the context
max: 3000, // default global max rate limit
allowList: ['192.168.0.10'], // global allowlist access.
redis: redis, // custom connection to redis
})
// add a limited route with this configuration plus the global one
fastify.get('/', {
config: {
rateLimit: {
max: 3,
timeWindow: '1 minute'
}
}
}, (request, reply) => {
reply.send({ hello: 'from ... root' })
})
// add a limited route with this configuration plus the global one
fastify.get('/private', {
config: {
rateLimit: {
max: 3,
timeWindow: '1 minute'
}
}
}, (request, reply) => {
reply.send({ hello: 'from ... private' })
})
// this route doesn't have any rate limit
fastify.get('/public', (request, reply) => {
reply.send({ hello: 'from ... public' })
})
// add a limited route with this configuration plus the global one
fastify.get('/public/sub-rated-1', {
config: {
rateLimit: {
timeWindow: '1 minute',
allowList: ['127.0.0.1'],
onExceeding: function (request, key) {
console.log('callback on exceeding ... executed before response to client')
},
onExceeded: function (request, key) {
console.log('callback on exceeded ... to black ip in security group for example, request is give as argument')
}
}
}
}, (request, reply) => {
reply.send({ hello: 'from sub-rated-1 ... using default max value ... ' })
})
// group routes and add a rate limit
fastify.get('/otp/send', {
config: {
rateLimit: {
max: 3,
timeWindow: '1 minute',
groupId:"OTP"
}
}
}, (request, reply) => {
reply.send({ hello: 'from ... grouped rate limit' })
})
fastify.get('/otp/resend', {
config: {
rateLimit: {
max: 3,
timeWindow: '1 minute',
groupId:"OTP"
}
}
}, (request, reply) => {
reply.send({ hello: 'from ... grouped rate limit' })
})
```
In the route creation you can override the same settings of the plugin registration plus the following additional options:
- `onExceeding` : callback that will be executed each time a request is made to a route that is rate-limited
- `onExceeded` : callback that will be executed when a user reaches the maximum number of tries. Can be useful to blacklist clients
You may also want to set a global rate limiter and then disable it on some routes:
```js
import Fastify from 'fastify'
const fastify = Fastify()
await fastify.register(import('@fastify/rate-limit'), {
max: 100,
timeWindow: '1 minute'
})
// add a limited route with global config
fastify.get('/', (request, reply) => {
reply.send({ hello: 'from ... rate limited root' })
})
// this route doesn't have any rate limit
fastify.get('/public', {
config: {
rateLimit: false
}
}, (request, reply) => {
reply.send({ hello: 'from ... public' })
})
// add a limited route with global config and different max
fastify.get('/private', {
config: {
rateLimit: {
max: 9
}
}
}, (request, reply) => {
reply.send({ hello: 'from ... private and more limited' })
})
```
### Manual Rate Limit
A custom limiter function can be created with `fastify.createRateLimit()`, which is handy when needing to integrate with
technologies like [GraphQL](https://graphql.org/) or [tRPC](https://trpc.io/). This function uses the global [options](#options) set
during plugin registration, but you can override options such as `store`, `skipOnError`, `max`, `timeWindow`,
`allowList`, `keyGenerator`, and `ban`.
Example usage:
```js
import Fastify from 'fastify'
const fastify = Fastify()
// register with global options
await fastify.register(import('@fastify/rate-limit'), {
global : false,
max: 100,
timeWindow: '1 minute'
})
// checkRateLimit will use the global options provided above when called
const checkRateLimit = fastify.createRateLimit();
fastify.get("/", async (request, reply) => {
// manually check the rate limit (using global options)
const limit = await checkRateLimit(request);
if(!limit.isAllowed && limit.isExceeded) {
return reply.code(429).send("Limit exceeded");
}
return reply.send("Hello world");
});
// override global max option
const checkCustomRateLimit = fastify.createRateLimit({ max: 100 });
fastify.get("/custom", async (request, reply) => {
// manually check the rate limit (using global options and overridden max option)
const limit = await checkCustomRateLimit(request);
// manually handle limit exceedance
if(!limit.isAllowed && limit.isExceeded) {
return reply.code(429).send("Limit exceeded");
}
return reply.send("Hello world");
});
```
A custom limiter function created with `fastify.createRateLimit()` only requires a `FastifyRequest` as the first parameter:
```js
const checkRateLimit = fastify.createRateLimit();
const limit = await checkRateLimit(request);
```
The returned `limit` is an object containing the following properties for the `request` passed to `checkRateLimit`.
- `isAllowed`: if `true`, the request was excluded from rate limiting according to the configured `allowList`.
- `key`: the generated key as returned by the `keyGenerator` function.
If `isAllowed` is `false` the object also contains these additional properties:
- `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`.
- `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.
- `remaining`: the remaining amount of requests before the limit is exceeded.
- `ttl`: the remaining time until the limit will be reset in milliseconds.
- `ttlInSeconds`: `ttl` in seconds.
- `isExceeded`: `true` if the limit was exceeded.
- `isBanned`: `true` if the request was banned according to the `ban` option.
### Examples of Custom Store
These examples show an overview of the `store` feature and you should take inspiration from it and tweak as you need:
- [Knex-SQLite](./example/example-knex.js)
- [Knex-MySQL](./example/example-knex-mysql.js)
- [Sequelize-PostgreSQL](./example/example-sequelize.js)
### IETF Draft Spec Headers
The response will have the following headers if `enableDraftSpec` is `true`:
| Header | Description |
|--------|-------------|
|`ratelimit-limit` | how many requests the client can make
|`ratelimit-remaining` | how many requests remain to the client in the timewindow
|`ratelimit-reset` | how many seconds must pass before the rate limit resets
|`retry-after` | contains the same value in time as `ratelimit-reset`
### Contribute
To run tests locally, you need a Redis instance that you can launch with this command:
```
npm run redis
```
<a name="license"></a>
## License
Licensed under [MIT](./LICENSE).

View File

@@ -0,0 +1,6 @@
'use strict'
module.exports = require('neostandard')({
ignores: require('neostandard').resolveIgnoresFromGitignore(),
ts: true
})

View File

@@ -0,0 +1,116 @@
'use strict'
import Redis from 'ioredis'
import Fastify from 'fastify'
const redis = new Redis({
enableAutoPipelining: true,
connectionName: 'my-connection-name',
host: 'localhost',
port: 6379,
connectTimeout: 500,
maxRetriesPerRequest: 1
})
const fastify = Fastify()
await fastify.register(import('../index.js'),
{
global: false,
max: 3000, // default max rate limit
// timeWindow: 1000*60,
// cache: 10000,
allowList: ['127.0.0.2'], // global allowList access ( ACL based on the key from the keyGenerator)
redis, // connection to redis
skipOnError: false // default false
// keyGenerator: function(req) { /* ... */ }, // default (req) => req.raw.ip
})
fastify.get('/', {
config: {
rateLimit: {
max: 3,
timeWindow: '1 minute'
}
}
}, (_req, reply) => {
reply.send({ hello: 'from ... root' })
})
fastify.get('/private', {
config: {
rateLimit: {
max: 3,
allowList: ['127.0.2.1', '127.0.3.1'],
timeWindow: '1 minute'
}
}
}, (_req, reply) => {
reply.send({ hello: 'from ... private' })
})
fastify.get('/public', (_req, reply) => {
reply.send({ hello: 'from ... public' })
})
fastify.get('/public/sub-rated-1', {
config: {
rateLimit: {
timeWindow: '1 minute',
allowList: ['127.0.2.1'],
onExceeding: function () {
console.log('callback on exceededing ... executed before response to client. req is give as argument')
},
onExceeded: function () {
console.log('callback on exceeded ... to black ip in security group for example, req is give as argument')
}
}
}
}, (_req, reply) => {
reply.send({ hello: 'from sub-rated-1 ... using default max value ... ' })
})
fastify.get('/public/sub-rated-2', {
config: {
rateLimit: {
max: 3,
timeWindow: '1 minute',
onExceeding: function () {
console.log('callback on exceededing ... executed before response to client. req is give as argument')
},
onExceeded: function () {
console.log('callback on exceeded ... to black ip in security group for example, req is give as argument')
}
}
}
}, (_req, reply) => {
reply.send({ hello: 'from ... sub-rated-2' })
})
fastify.get('/home', {
config: {
rateLimit: {
max: 200,
timeWindow: '1 minute'
}
}
}, (_req, reply) => {
reply.send({ hello: 'toto' })
})
fastify.get('/customerrormessage', {
config: {
rateLimit: {
max: 2,
timeWindow: '1 minute',
errorResponseBuilder: (_req, context) => ({ code: 429, timeWindow: context.after, limit: context.max })
}
}
}, (_req, reply) => {
reply.send({ hello: 'toto' })
})
fastify.listen({ port: 3000 }, err => {
if (err) throw err
console.log('Server listening at http://localhost:3000')
})

View File

@@ -0,0 +1,124 @@
'use strict'
/* eslint-disable no-undef */
// Example of a custom store using Knex.js and MySQL.
//
// Assumes you have access to a configured knex object.
//
// Note that the rate check should place a read lock on the row.
// For MySQL see:
// https://dev.mysql.com/doc/refman/8.0/en/innodb-locking-reads.html
// https://blog.nodeswat.com/concurrency-mysql-and-node-js-a-journey-of-discovery-31281e53572e
//
// Below is an example table to store rate limits that must be created
// in the database first.
//
// exports.up = async knex => {
// await knex.schema.createTable('rate_limits', table => {
// table.string('source').notNullable()
// table.string('route').notNullable()
// table.integer('count').unsigned()
// table.bigInteger ('ttl')
// table.primary(['route', 'source'])
// })
// }
//
// exports.down = async knex => {
// await knex.schema.dropTable('rate_limits')
// }
//
// CREATE TABLE `rate_limits` (
// `source` varchar(255) NOT NULL,
// `route` varchar(255) NOT NULL,
// `count` int unsigned DEFAULT NULL,
// `ttl` int unsigned DEFAULT NULL,
// PRIMARY KEY (`route`,`source`)
// ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
function KnexStore (options) {
this.options = options
this.route = ''
}
KnexStore.prototype.routeKey = function (route) {
if (route) this.route = route
return route
}
KnexStore.prototype.incr = async function (key, cb) {
const now = (new Date()).getTime()
const ttl = now + this.options.timeWindow
const max = this.options.max
const cond = { route: this.route, source: key }
const trx = await knex.transaction()
try {
// NOTE: MySQL syntax FOR UPDATE for read lock on counter stats in row
const row = await trx('rate_limits')
.whereRaw('route = ? AND source = ? FOR UPDATE', [cond.route || '', cond.source]) // Create read lock
const d = row[0]
if (d && d.ttl > now) {
// Optimization - no need to UPDATE if max has been reached.
if (d.count < max) {
await trx
.raw('UPDATE rate_limits SET count = ? WHERE route = ? AND source = ?', [d.count + 1, cond.route, key])
}
// If we were already at max no need to UPDATE but we must still send d.count + 1 to trigger rate limit.
process.nextTick(cb, null, { current: d.count + 1, ttl: d.ttl })
} else {
await trx
.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])
process.nextTick(cb, null, { current: 1, ttl: d?.ttl || ttl })
}
await trx.commit()
} catch (err) {
await trx.rollback()
// TODO: Handle as desired
fastify.log.error(err)
process.nextTick(cb, err, { current: 0 })
}
}
KnexStore.prototype.child = function (routeOptions = {}) {
// NOTE: Optionally override and set global: false here for route specific
// options, which then allows you to use `global: true` should you
// wish to during initial registration below.
const options = { ...this.options, ...routeOptions, global: false }
const store = new KnexStore(options)
store.routeKey(routeOptions.routeInfo.method + routeOptions.routeInfo.url)
return store
}
fastify.register(require('../../fastify-rate-limit'),
{
global: false,
max: 10,
store: KnexStore,
skipOnError: false
}
)
fastify.get('/', {
config: {
rateLimit: {
max: 10,
timeWindow: '1 minute'
}
}
}, (_req, reply) => {
reply.send({ hello: 'from ... root' })
})
fastify.get('/private', {
config: {
rateLimit: {
max: 3,
timeWindow: '1 minute'
}
}
}, (_req, reply) => {
reply.send({ hello: 'from ... private' })
})
fastify.get('/public', (_req, reply) => {
reply.send({ hello: 'from ... public' })
})

View File

@@ -0,0 +1,118 @@
'use strict'
// Example of a Custom Store using Knex.js ORM for SQLite database
// Below is an example table to store rate limits that must be created
// in the database first
//
// CREATE TABLE "RateLimits" (
// "Route" TEXT,
// "Source" TEXT,
// "Count" INTEGER,
// "TTL" NUMERIC,
// PRIMARY KEY("Source")
// );
//
// CREATE UNIQUE INDEX "idx_uniq_route_source" ON "RateLimits" (Route, Source);
//
const Knex = require('knex')
const fastify = require('fastify')()
const knex = Knex({
client: 'sqlite3',
connection: {
filename: './db.sqlite'
}
})
function KnexStore (options) {
this.options = options
this.route = ''
}
KnexStore.prototype.routeKey = function (route) {
if (route) {
this.route = route
} else {
return route
}
}
KnexStore.prototype.incr = function (key, cb) {
const now = (new Date()).getTime()
const ttl = now + this.options.timeWindow
knex.transaction(function (trx) {
trx
.where({ Route: this.route, Source: key })
.then(d => {
if (d.TTL > now) {
trx
.raw(`UPDATE RateLimits SET Count = 1 WHERE Route='${this.route}' AND Source='${key}'`)
.then(() => {
cb(null, { current: 1, ttl: d.TTL })
})
.catch(err => {
cb(err, { current: 0 })
})
} else {
trx
.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}`)
.then(() => {
cb(null, { current: d.Count ? d.Count + 1 : 1, ttl: d.TTL || ttl })
})
.catch(err => {
cb(err, { current: 0 })
})
}
})
.catch(err => {
cb(err, { current: 0 })
})
})
}
KnexStore.prototype.child = function (routeOptions) {
const options = Object.assign(this.options, routeOptions)
const store = new KnexStore(options)
store.routeKey(routeOptions.routeInfo.method + routeOptions.routeInfo.url)
return store
}
fastify.register(require('../../fastify-rate-limit'),
{
global: false,
max: 10,
store: KnexStore,
skipOnError: false
}
)
fastify.get('/', {
config: {
rateLimit: {
max: 10,
timeWindow: '1 minute'
}
}
}, (_req, reply) => {
reply.send({ hello: 'from ... root' })
})
fastify.get('/private', {
config: {
rateLimit: {
max: 3,
timeWindow: '1 minute'
}
}
}, (_req, reply) => {
reply.send({ hello: 'from ... private' })
})
fastify.get('/public', (_req, reply) => {
reply.send({ hello: 'from ... public' })
})
fastify.listen({ port: 3000 }, err => {
if (err) throw err
console.log('Server listening at http://localhost:3000')
})

View File

@@ -0,0 +1,185 @@
'use strict'
// Example of a Custom Store using Sequelize ORM for PostgreSQL database
// Sequelize Migration for "RateLimits" table
//
// module.exports = {
// up: (queryInterface, { TEXT, INTEGER, BIGINT }) => {
// return queryInterface.createTable(
// 'RateLimits',
// {
// Route: {
// type: TEXT,
// allowNull: false
// },
// Source: {
// type: TEXT,
// allowNull: false,
// primaryKey: true
// },
// Count: {
// type: INTEGER,
// allowNull: false
// },
// TTL: {
// type: BIGINT,
// allowNull: false
// }
// },
// {
// freezeTableName: true,
// timestamps: false,
// uniqueKeys: {
// unique_tag: {
// customIndex: true,
// fields: ['Route', 'Source']
// }
// }
// }
// )
// },
// down: queryInterface => {
// return queryInterface.dropTable('RateLimits')
// }
// }
const fastify = require('fastify')()
const Sequelize = require('sequelize')
const databaseUri = 'postgres://username:password@localhost:5432/fastify-rate-limit-example'
const sequelize = new Sequelize(databaseUri)
// OR
// const sequelize = new Sequelize('database', 'username', 'password');
// Sequelize Model for "RateLimits" table
//
const RateLimits = sequelize.define(
'RateLimits',
{
Route: {
type: Sequelize.TEXT,
allowNull: false
},
Source: {
type: Sequelize.TEXT,
allowNull: false,
primaryKey: true
},
Count: {
type: Sequelize.INTEGER,
allowNull: false
},
TTL: {
type: Sequelize.BIGINT,
allowNull: false
}
},
{
freezeTableName: true,
timestamps: false,
indexes: [
{
unique: true,
fields: ['Route', 'Source']
}
]
}
)
function RateLimiterStore (options) {
this.options = options
this.route = ''
}
RateLimiterStore.prototype.routeKey = function routeKey (route) {
if (route) this.route = route
return route
}
RateLimiterStore.prototype.incr = async function incr (key, cb) {
const now = new Date().getTime()
const ttl = now + this.options.timeWindow
const cond = { Route: this.route, Source: key }
const RateLimit = await RateLimits.findOne({ where: cond })
if (RateLimit && parseInt(RateLimit.TTL, 10) > now) {
try {
await RateLimit.update({ Count: RateLimit.Count + 1 }, cond)
cb(null, {
current: RateLimit.Count + 1,
ttl: RateLimit.TTL
})
} catch (err) {
cb(err, {
current: 0
})
}
} else {
sequelize.query(
`INSERT INTO "RateLimits"("Route", "Source", "Count", "TTL")
VALUES('${this.route}', '${key}', 1,
${RateLimit?.TTL || ttl})
ON CONFLICT("Route", "Source") DO UPDATE SET "Count"=1, "TTL"=${ttl}`
)
.then(() => {
cb(null, {
current: 1,
ttl: RateLimit?.TTL || ttl
})
})
.catch(err => {
cb(err, {
current: 0
})
})
}
}
RateLimiterStore.prototype.child = function child (routeOptions = {}) {
const options = Object.assign(this.options, routeOptions)
const store = new RateLimiterStore(options)
store.routeKey(routeOptions.routeInfo.method + routeOptions.routeInfo.url)
return store
}
fastify.register(require('../../fastify-rate-limit'),
{
global: false,
max: 10,
store: RateLimiterStore,
skipOnError: false
}
)
fastify.get('/', {
config: {
rateLimit: {
max: 10,
timeWindow: '1 minute'
}
}
}, (_req, reply) => {
reply.send({ hello: 'from ... root' })
})
fastify.get('/private', {
config: {
rateLimit: {
max: 3,
timeWindow: '1 minute'
}
}
}, (_req, reply) => {
reply.send({ hello: 'from ... private' })
})
fastify.get('/public', (_req, reply) => {
reply.send({ hello: 'from ... public' })
})
fastify.listen({ port: 3000 }, err => {
if (err) throw err
console.log('Server listening at http://localhost:3000')
})

View File

@@ -0,0 +1,25 @@
import fastify from 'fastify'
import fastifyRateLimit from '../index.js'
const server = fastify()
await server.register(fastifyRateLimit, {
global: true,
max: 10000,
timeWindow: '1 minute'
})
server.get('/', (_request, reply) => {
reply.send('Hello, world!')
})
const start = async () => {
try {
await server.listen({ port: 3000 })
console.log('Server is running on port 3000')
} catch (error) {
console.error('Error starting server:', error)
}
}
start()

View File

@@ -0,0 +1,113 @@
'use strict'
const Redis = require('ioredis')
const redis = new Redis({
connectionName: 'my-connection-name',
host: 'localhost',
port: 6379,
connectTimeout: 500,
maxRetriesPerRequest: 1
})
const fastify = require('fastify')()
fastify.register(require('../../fastify-rate-limit'),
{
global: false,
max: 3000, // default max rate limit
// timeWindow: 1000*60,
// cache: 10000,
allowList: ['127.0.0.2'], // global allowList access ( ACL based on the key from the keyGenerator)
redis, // connection to redis
skipOnError: false // default false
// keyGenerator: function(req) { /* ... */ }, // default (req) => req.raw.ip
})
fastify.get('/', {
config: {
rateLimit: {
max: 3,
timeWindow: '1 minute'
}
}
}, (_req, reply) => {
reply.send({ hello: 'from ... root' })
})
fastify.get('/private', {
config: {
rateLimit: {
max: 3,
allowList: ['127.0.2.1', '127.0.3.1'],
timeWindow: '1 minute'
}
}
}, (_req, reply) => {
reply.send({ hello: 'from ... private' })
})
fastify.get('/public', (_req, reply) => {
reply.send({ hello: 'from ... public' })
})
fastify.get('/public/sub-rated-1', {
config: {
rateLimit: {
timeWindow: '1 minute',
allowList: ['127.0.2.1'],
onExceeding: function () {
console.log('callback on exceededing ... executed before response to client. req is give as argument')
},
onExceeded: function () {
console.log('callback on exceeded ... to black ip in security group for example, req is give as argument')
}
}
}
}, (_req, reply) => {
reply.send({ hello: 'from sub-rated-1 ... using default max value ... ' })
})
fastify.get('/public/sub-rated-2', {
config: {
rateLimit: {
max: 3,
timeWindow: '1 minute',
onExceeding: function () {
console.log('callback on exceededing ... executed before response to client. req is give as argument')
},
onExceeded: function () {
console.log('callback on exceeded ... to black ip in security group for example, req is give as argument')
}
}
}
}, (_req, reply) => {
reply.send({ hello: 'from ... sub-rated-2' })
})
fastify.get('/home', {
config: {
rateLimit: {
max: 200,
timeWindow: '1 minute'
}
}
}, (_req, reply) => {
reply.send({ hello: 'toto' })
})
fastify.get('/customerrormessage', {
config: {
rateLimit: {
max: 2,
timeWindow: '1 minute',
errorResponseBuilder: (_req, context) => ({ code: 429, timeWindow: context.after, limit: context.max })
}
}
}, (_req, reply) => {
reply.send({ hello: 'toto' })
})
fastify.listen({ port: 3000 }, err => {
if (err) throw err
console.log('Server listening at http://localhost:3000')
})

342
backend/node_modules/@fastify/rate-limit/index.js generated vendored Normal file
View File

@@ -0,0 +1,342 @@
'use strict'
const fp = require('fastify-plugin')
const { parse, format } = require('@lukeed/ms')
const LocalStore = require('./store/LocalStore')
const RedisStore = require('./store/RedisStore')
const defaultMax = 1000
const defaultTimeWindow = 60000
const defaultHook = 'onRequest'
const defaultHeaders = {
rateLimit: 'x-ratelimit-limit',
rateRemaining: 'x-ratelimit-remaining',
rateReset: 'x-ratelimit-reset',
retryAfter: 'retry-after'
}
const draftSpecHeaders = {
rateLimit: 'ratelimit-limit',
rateRemaining: 'ratelimit-remaining',
rateReset: 'ratelimit-reset',
retryAfter: 'retry-after'
}
const defaultOnFn = () => {}
const defaultKeyGenerator = (req) => req.ip
const defaultErrorResponse = (_req, context) => {
const err = new Error(`Rate limit exceeded, retry in ${context.after}`)
err.statusCode = context.statusCode
return err
}
async function fastifyRateLimit (fastify, settings) {
const globalParams = {
global: (typeof settings.global === 'boolean') ? settings.global : true
}
if (typeof settings.enableDraftSpec === 'boolean' && settings.enableDraftSpec) {
globalParams.enableDraftSpec = true
globalParams.labels = draftSpecHeaders
} else {
globalParams.enableDraftSpec = false
globalParams.labels = defaultHeaders
}
globalParams.addHeaders = Object.assign({
[globalParams.labels.rateLimit]: true,
[globalParams.labels.rateRemaining]: true,
[globalParams.labels.rateReset]: true,
[globalParams.labels.retryAfter]: true
}, settings.addHeaders)
globalParams.addHeadersOnExceeding = Object.assign({
[globalParams.labels.rateLimit]: true,
[globalParams.labels.rateRemaining]: true,
[globalParams.labels.rateReset]: true
}, settings.addHeadersOnExceeding)
// Global maximum allowed requests
if (Number.isFinite(settings.max) && settings.max >= 0) {
globalParams.max = Math.trunc(settings.max)
} else if (
typeof settings.max === 'function'
) {
globalParams.max = settings.max
} else {
globalParams.max = defaultMax
}
// Global time window
if (Number.isFinite(settings.timeWindow) && settings.timeWindow >= 0) {
globalParams.timeWindow = Math.trunc(settings.timeWindow)
} else if (typeof settings.timeWindow === 'string') {
globalParams.timeWindow = parse(settings.timeWindow)
} else if (
typeof settings.timeWindow === 'function'
) {
globalParams.timeWindow = settings.timeWindow
} else {
globalParams.timeWindow = defaultTimeWindow
}
globalParams.hook = settings.hook || defaultHook
globalParams.allowList = settings.allowList || settings.whitelist || null
globalParams.ban = Number.isFinite(settings.ban) && settings.ban >= 0 ? Math.trunc(settings.ban) : -1
globalParams.onBanReach = typeof settings.onBanReach === 'function' ? settings.onBanReach : defaultOnFn
globalParams.onExceeding = typeof settings.onExceeding === 'function' ? settings.onExceeding : defaultOnFn
globalParams.onExceeded = typeof settings.onExceeded === 'function' ? settings.onExceeded : defaultOnFn
globalParams.continueExceeding = typeof settings.continueExceeding === 'boolean' ? settings.continueExceeding : false
globalParams.exponentialBackoff = typeof settings.exponentialBackoff === 'boolean' ? settings.exponentialBackoff : false
globalParams.keyGenerator = typeof settings.keyGenerator === 'function'
? settings.keyGenerator
: defaultKeyGenerator
if (typeof settings.errorResponseBuilder === 'function') {
globalParams.errorResponseBuilder = settings.errorResponseBuilder
globalParams.isCustomErrorMessage = true
} else {
globalParams.errorResponseBuilder = defaultErrorResponse
globalParams.isCustomErrorMessage = false
}
globalParams.skipOnError = typeof settings.skipOnError === 'boolean' ? settings.skipOnError : false
const pluginComponent = {
rateLimitRan: Symbol('fastify.request.rateLimitRan'),
store: null
}
if (settings.store) {
const Store = settings.store
pluginComponent.store = new Store(globalParams)
} else {
if (settings.redis) {
pluginComponent.store = new RedisStore(globalParams.continueExceeding, globalParams.exponentialBackoff, settings.redis, settings.nameSpace)
} else {
pluginComponent.store = new LocalStore(globalParams.continueExceeding, globalParams.exponentialBackoff, settings.cache)
}
}
fastify.decorateRequest(pluginComponent.rateLimitRan, false)
if (!fastify.hasDecorator('createRateLimit')) {
fastify.decorate('createRateLimit', (options) => {
const args = createLimiterArgs(pluginComponent, globalParams, options)
return (req) => applyRateLimit.apply(this, args.concat(req))
})
}
if (!fastify.hasDecorator('rateLimit')) {
fastify.decorate('rateLimit', (options) => {
const args = createLimiterArgs(pluginComponent, globalParams, options)
return rateLimitRequestHandler(...args)
})
}
fastify.addHook('onRoute', (routeOptions) => {
if (routeOptions.config?.rateLimit != null) {
if (typeof routeOptions.config.rateLimit === 'object') {
const newPluginComponent = Object.create(pluginComponent)
const mergedRateLimitParams = mergeParams(globalParams, routeOptions.config.rateLimit, { routeInfo: routeOptions })
newPluginComponent.store = pluginComponent.store.child(mergedRateLimitParams)
addRouteRateHook(newPluginComponent, mergedRateLimitParams, routeOptions)
} else if (routeOptions.config.rateLimit !== false) {
throw new Error('Unknown value for route rate-limit configuration')
}
} else if (globalParams.global) {
// As the endpoint does not have a custom configuration, use the global one
addRouteRateHook(pluginComponent, globalParams, routeOptions)
}
})
}
function mergeParams (...params) {
const result = Object.assign({}, ...params)
if (Number.isFinite(result.timeWindow) && result.timeWindow >= 0) {
result.timeWindow = Math.trunc(result.timeWindow)
} else if (typeof result.timeWindow === 'string') {
result.timeWindow = parse(result.timeWindow)
} else if (typeof result.timeWindow !== 'function') {
result.timeWindow = defaultTimeWindow
}
if (Number.isFinite(result.max) && result.max >= 0) {
result.max = Math.trunc(result.max)
} else if (typeof result.max !== 'function') {
result.max = defaultMax
}
if (Number.isFinite(result.ban) && result.ban >= 0) {
result.ban = Math.trunc(result.ban)
} else {
result.ban = -1
}
if (result.groupId !== undefined && typeof result.groupId !== 'string') {
throw new Error('groupId must be a string')
}
return result
}
function createLimiterArgs (pluginComponent, globalParams, options) {
if (typeof options === 'object') {
const newPluginComponent = Object.create(pluginComponent)
const mergedRateLimitParams = mergeParams(globalParams, options, { routeInfo: {} })
newPluginComponent.store = newPluginComponent.store.child(mergedRateLimitParams)
return [newPluginComponent, mergedRateLimitParams]
}
return [pluginComponent, globalParams]
}
function addRouteRateHook (pluginComponent, params, routeOptions) {
const hook = params.hook
const hookHandler = rateLimitRequestHandler(pluginComponent, params)
if (Array.isArray(routeOptions[hook])) {
routeOptions[hook].push(hookHandler)
} else if (typeof routeOptions[hook] === 'function') {
routeOptions[hook] = [routeOptions[hook], hookHandler]
} else {
routeOptions[hook] = [hookHandler]
}
}
async function applyRateLimit (pluginComponent, params, req) {
const { store } = pluginComponent
// Retrieve the key from the generator (the global one or the one defined in the endpoint)
let key = await params.keyGenerator(req)
const groupId = req.routeOptions.config?.rateLimit?.groupId
if (groupId) {
key += groupId
}
// Don't apply any rate limiting if in the allow list
if (params.allowList) {
if (typeof params.allowList === 'function') {
if (await params.allowList(req, key)) {
return {
isAllowed: true,
key
}
}
} else if (params.allowList.indexOf(key) !== -1) {
return {
isAllowed: true,
key
}
}
}
const max = typeof params.max === 'number' ? params.max : await params.max(req, key)
const timeWindow = typeof params.timeWindow === 'number' ? params.timeWindow : await params.timeWindow(req, key)
let current = 0
let ttl = 0
let ttlInSeconds = 0
// We increment the rate limit for the current request
try {
const res = await new Promise((resolve, reject) => {
store.incr(key, (err, res) => {
err ? reject(err) : resolve(res)
}, timeWindow, max)
})
current = res.current
ttl = res.ttl
ttlInSeconds = Math.ceil(res.ttl / 1000)
} catch (err) {
if (!params.skipOnError) {
throw err
}
}
return {
isAllowed: false,
key,
max,
timeWindow,
remaining: Math.max(0, max - current),
ttl,
ttlInSeconds,
isExceeded: current > max,
isBanned: params.ban !== -1 && current - max > params.ban
}
}
function rateLimitRequestHandler (pluginComponent, params) {
const { rateLimitRan } = pluginComponent
return async (req, res) => {
if (req[rateLimitRan]) {
return
}
req[rateLimitRan] = true
const rateLimit = await applyRateLimit(pluginComponent, params, req)
if (rateLimit.isAllowed) {
return
}
const {
key,
max,
remaining,
ttl,
ttlInSeconds,
isExceeded,
isBanned
} = rateLimit
if (!isExceeded) {
if (params.addHeadersOnExceeding[params.labels.rateLimit]) { res.header(params.labels.rateLimit, max) }
if (params.addHeadersOnExceeding[params.labels.rateRemaining]) { res.header(params.labels.rateRemaining, remaining) }
if (params.addHeadersOnExceeding[params.labels.rateReset]) { res.header(params.labels.rateReset, ttlInSeconds) }
params.onExceeding(req, key)
return
}
params.onExceeded(req, key)
if (params.addHeaders[params.labels.rateLimit]) { res.header(params.labels.rateLimit, max) }
if (params.addHeaders[params.labels.rateRemaining]) { res.header(params.labels.rateRemaining, 0) }
if (params.addHeaders[params.labels.rateReset]) { res.header(params.labels.rateReset, ttlInSeconds) }
if (params.addHeaders[params.labels.retryAfter]) { res.header(params.labels.retryAfter, ttlInSeconds) }
const respCtx = {
statusCode: 429,
ban: false,
max,
ttl,
after: format(ttlInSeconds * 1000, true)
}
if (isBanned) {
respCtx.statusCode = 403
respCtx.ban = true
params.onBanReach(req, key)
}
throw params.errorResponseBuilder(req, respCtx)
}
}
module.exports = fp(fastifyRateLimit, {
fastify: '5.x',
name: '@fastify/rate-limit'
})
module.exports.default = fastifyRateLimit
module.exports.fastifyRateLimit = fastifyRateLimit

86
backend/node_modules/@fastify/rate-limit/package.json generated vendored Normal file
View File

@@ -0,0 +1,86 @@
{
"name": "@fastify/rate-limit",
"version": "10.3.0",
"description": "A low overhead rate limiter for your routes",
"main": "index.js",
"type": "commonjs",
"types": "types/index.d.ts",
"scripts": {
"lint": "eslint",
"lint:fix": "eslint --fix",
"redis": "docker run -p 6379:6379 --name rate-limit-redis -d --rm redis",
"test": "npm run test:unit && npm run test:typescript",
"test:unit": "c8 --100 node --test",
"test:typescript": "tsd"
},
"repository": {
"type": "git",
"url": "git+https://github.com/fastify/fastify-rate-limit.git"
},
"keywords": [
"fastify",
"rate",
"limit"
],
"author": "Tomas Della Vedova - @delvedor (http://delved.org)",
"contributors": [
{
"name": "Matteo Collina",
"email": "hello@matteocollina.com"
},
{
"name": "Manuel Spigolon",
"email": "behemoth89@gmail.com"
},
{
"name": "Gürgün Dayıoğlu",
"email": "hey@gurgun.day",
"url": "https://heyhey.to/G"
},
{
"name": "Frazer Smith",
"email": "frazer.dev@icloud.com",
"url": "https://github.com/fdawgs"
}
],
"license": "MIT",
"bugs": {
"url": "https://github.com/fastify/fastify-rate-limit/issues"
},
"homepage": "https://github.com/fastify/fastify-rate-limit#readme",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"devDependencies": {
"@fastify/pre-commit": "^2.1.0",
"@sinonjs/fake-timers": "^14.0.0",
"@types/node": "^22.0.0",
"c8": "^10.1.2",
"eslint": "^9.17.0",
"fastify": "^5.0.0",
"ioredis": "^5.4.1",
"knex": "^3.1.0",
"neostandard": "^0.12.0",
"sqlite3": "^5.1.7",
"tsd": "^0.32.0"
},
"dependencies": {
"@lukeed/ms": "^2.0.2",
"fastify-plugin": "^5.0.0",
"toad-cache": "^3.7.0"
},
"publishConfig": {
"access": "public"
},
"pre-commit": [
"lint",
"test"
]
}

View File

@@ -0,0 +1,50 @@
'use strict'
const { LruMap: Lru } = require('toad-cache')
function LocalStore (continueExceeding, exponentialBackoff, cache = 5000) {
this.continueExceeding = continueExceeding
this.exponentialBackoff = exponentialBackoff
this.lru = new Lru(cache)
}
LocalStore.prototype.incr = function (ip, cb, timeWindow, max) {
const nowInMs = Date.now()
let current = this.lru.get(ip)
if (!current) {
// Item doesn't exist
current = { current: 1, ttl: timeWindow, iterationStartMs: nowInMs }
} else if (current.iterationStartMs + timeWindow <= nowInMs) {
// Item has expired
current.current = 1
current.ttl = timeWindow
current.iterationStartMs = nowInMs
} else {
// Item is alive
++current.current
// Reset TLL if max has been exceeded and `continueExceeding` is enabled
if (this.continueExceeding && current.current > max) {
current.ttl = timeWindow
current.iterationStartMs = nowInMs
} else if (this.exponentialBackoff && current.current > max) {
// Handle exponential backoff
const backoffExponent = current.current - max - 1
const ttl = timeWindow * (2 ** backoffExponent)
current.ttl = Number.isSafeInteger(ttl) ? ttl : Number.MAX_SAFE_INTEGER
current.iterationStartMs = nowInMs
} else {
current.ttl = timeWindow - (nowInMs - current.iterationStartMs)
}
}
this.lru.set(ip, current)
cb(null, current)
}
LocalStore.prototype.child = function (routeOptions) {
return new LocalStore(routeOptions.continueExceeding, routeOptions.exponentialBackoff, routeOptions.cache)
}
module.exports = LocalStore

View File

@@ -0,0 +1,58 @@
'use strict'
const lua = `
-- Key to operate on
local key = KEYS[1]
-- Time window for the TTL
local timeWindow = tonumber(ARGV[1])
-- Max requests
local max = tonumber(ARGV[2])
-- Flag to determine if TTL should be reset after exceeding
local continueExceeding = ARGV[3] == 'true'
--Flag to determine if exponential backoff should be applied
local exponentialBackoff = ARGV[4] == 'true'
--Max safe integer
local MAX_SAFE_INTEGER = (2^53) - 1
-- Increment the key's value
local current = redis.call('INCR', key)
if current == 1 or (continueExceeding and current > max) then
redis.call('PEXPIRE', key, timeWindow)
elseif exponentialBackoff and current > max then
local backoffExponent = current - max - 1
timeWindow = math.min(timeWindow * (2 ^ backoffExponent), MAX_SAFE_INTEGER)
redis.call('PEXPIRE', key, timeWindow)
else
timeWindow = redis.call('PTTL', key)
end
return {current, timeWindow}
`
function RedisStore (continueExceeding, exponentialBackoff, redis, key = 'fastify-rate-limit-') {
this.continueExceeding = continueExceeding
this.exponentialBackoff = exponentialBackoff
this.redis = redis
this.key = key
if (!this.redis.rateLimit) {
this.redis.defineCommand('rateLimit', {
numberOfKeys: 1,
lua
})
}
}
RedisStore.prototype.incr = function (ip, cb, timeWindow, max) {
this.redis.rateLimit(this.key + ip, timeWindow, max, this.continueExceeding, this.exponentialBackoff, (err, result) => {
err ? cb(err, null) : cb(null, { current: result[0], ttl: result[1] })
})
}
RedisStore.prototype.child = function (routeOptions) {
return new RedisStore(routeOptions.continueExceeding, routeOptions.exponentialBackoff, this.redis, `${this.key}${routeOptions.routeInfo.method}${routeOptions.routeInfo.url}-`)
}
module.exports = RedisStore

View File

@@ -0,0 +1,224 @@
'use strict'
const { test, mock } = require('node:test')
const Fastify = require('fastify')
const rateLimit = require('../index')
test('With global rate limit options', async t => {
t.plan(8)
const clock = mock.timers
clock.enable(0)
const fastify = Fastify()
await fastify.register(rateLimit, {
global: false,
max: 2,
timeWindow: 1000
})
const checkRateLimit = fastify.createRateLimit()
fastify.get('/', async (req, reply) => {
const limit = await checkRateLimit(req)
return limit
})
let res
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 200)
t.assert.deepStrictEqual(res.json(), {
isAllowed: false,
key: '127.0.0.1',
max: 2,
timeWindow: 1000,
remaining: 1,
ttl: 1000,
ttlInSeconds: 1,
isExceeded: false,
isBanned: false
})
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 200)
t.assert.deepStrictEqual(res.json(), {
isAllowed: false,
key: '127.0.0.1',
max: 2,
timeWindow: 1000,
remaining: 0,
ttl: 1000,
ttlInSeconds: 1,
isExceeded: false,
isBanned: false
})
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 200)
t.assert.deepStrictEqual(res.json(), {
isAllowed: false,
key: '127.0.0.1',
max: 2,
timeWindow: 1000,
remaining: 0,
ttl: 1000,
ttlInSeconds: 1,
isExceeded: true,
isBanned: false
})
clock.tick(1100)
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 200)
t.assert.deepStrictEqual(res.json(), {
isAllowed: false,
key: '127.0.0.1',
max: 2,
timeWindow: 1000,
remaining: 1,
ttl: 1000,
ttlInSeconds: 1,
isExceeded: false,
isBanned: false
})
clock.reset()
})
test('With custom rate limit options', async t => {
t.plan(10)
const clock = mock.timers
clock.enable(0)
const fastify = Fastify()
await fastify.register(rateLimit, {
global: false,
max: 5,
timeWindow: 1000
})
const checkRateLimit = fastify.createRateLimit({
max: 2,
timeWindow: 1000,
ban: 1
})
fastify.get('/', async (req, reply) => {
const limit = await checkRateLimit(req)
return limit
})
let res
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 200)
t.assert.deepStrictEqual(res.json(), {
isAllowed: false,
key: '127.0.0.1',
max: 2,
timeWindow: 1000,
remaining: 1,
ttl: 1000,
ttlInSeconds: 1,
isExceeded: false,
isBanned: false
})
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 200)
t.assert.deepStrictEqual(res.json(), {
isAllowed: false,
key: '127.0.0.1',
max: 2,
timeWindow: 1000,
remaining: 0,
ttl: 1000,
ttlInSeconds: 1,
isExceeded: false,
isBanned: false
})
// should be exceeded now
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 200)
t.assert.deepStrictEqual(res.json(), {
isAllowed: false,
key: '127.0.0.1',
max: 2,
timeWindow: 1000,
remaining: 0,
ttl: 1000,
ttlInSeconds: 1,
isExceeded: true,
isBanned: false
})
// should be banned now
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 200)
t.assert.deepStrictEqual(res.json(), {
isAllowed: false,
key: '127.0.0.1',
max: 2,
timeWindow: 1000,
remaining: 0,
ttl: 1000,
ttlInSeconds: 1,
isExceeded: true,
isBanned: true
})
clock.tick(1100)
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 200)
t.assert.deepStrictEqual(res.json(), {
isAllowed: false,
key: '127.0.0.1',
max: 2,
timeWindow: 1000,
remaining: 1,
ttl: 1000,
ttlInSeconds: 1,
isExceeded: false,
isBanned: false
})
clock.reset()
})
test('With allow list', async t => {
t.plan(2)
const clock = mock.timers
clock.enable(0)
const fastify = Fastify()
await fastify.register(rateLimit, {
global: false,
max: 5,
timeWindow: 1000
})
const checkRateLimit = fastify.createRateLimit({
allowList: ['127.0.0.1'],
max: 2,
timeWindow: 1000
})
fastify.get('/', async (req, reply) => {
const limit = await checkRateLimit(req)
return limit
})
const res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 200)
// expect a different return type because isAllowed is true
t.assert.deepStrictEqual(res.json(), {
isAllowed: true,
key: '127.0.0.1'
})
})

View File

@@ -0,0 +1,232 @@
'use strict'
const { test } = require('node:test')
const assert = require('node:assert')
const Fastify = require('fastify')
const rateLimit = require('../index')
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
test('Exponential Backoff', async () => {
const fastify = Fastify()
// Register rate limit plugin with exponentialBackoff set to true in routeConfig
await fastify.register(rateLimit, { max: 2, timeWindow: 500 })
fastify.get(
'/expoential-backoff',
{
config: {
rateLimit: {
max: 2,
timeWindow: 500,
exponentialBackoff: true
}
}
},
async () => 'exponential backoff applied!'
)
// Test
const res = await fastify.inject({ url: '/expoential-backoff', method: 'GET' })
assert.deepStrictEqual(res.statusCode, 200)
assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
const res2 = await fastify.inject({ url: '/expoential-backoff', method: 'GET' })
assert.deepStrictEqual(res2.statusCode, 200)
assert.deepStrictEqual(res2.headers['x-ratelimit-limit'], '2')
assert.deepStrictEqual(res2.headers['x-ratelimit-remaining'], '0')
const res3 = await fastify.inject({ url: '/expoential-backoff', method: 'GET' })
assert.deepStrictEqual(res3.statusCode, 429)
assert.deepStrictEqual(res3.headers['x-ratelimit-limit'], '2')
assert.deepStrictEqual(res3.headers['x-ratelimit-remaining'], '0')
assert.deepStrictEqual(
{
statusCode: 429,
error: 'Too Many Requests',
message: 'Rate limit exceeded, retry in 1 second'
},
JSON.parse(res3.payload)
)
const res4 = await fastify.inject({ url: '/expoential-backoff', method: 'GET' })
assert.deepStrictEqual(res4.statusCode, 429)
assert.deepStrictEqual(res4.headers['x-ratelimit-limit'], '2')
assert.deepStrictEqual(res4.headers['x-ratelimit-remaining'], '0')
assert.deepStrictEqual(
{
statusCode: 429,
error: 'Too Many Requests',
message: 'Rate limit exceeded, retry in 1 second'
},
JSON.parse(res4.payload)
)
// Wait for the window to reset
await sleep(1000)
const res5 = await fastify.inject({ url: '/expoential-backoff', method: 'GET' })
assert.deepStrictEqual(res5.statusCode, 200)
assert.deepStrictEqual(res5.headers['x-ratelimit-limit'], '2')
assert.deepStrictEqual(res5.headers['x-ratelimit-remaining'], '1')
})
test('Global Exponential Backoff', async () => {
const fastify = Fastify()
// Register rate limit plugin with exponentialBackoff set to true in routeConfig
await fastify.register(rateLimit, { max: 2, timeWindow: 500, exponentialBackoff: true })
fastify.get(
'/expoential-backoff-global',
{
config: {
rateLimit: {
max: 2,
timeWindow: 500
}
}
},
async () => 'exponential backoff applied!'
)
// Test
let res
res = await fastify.inject({ url: '/expoential-backoff-global', method: 'GET' })
assert.deepStrictEqual(res.statusCode, 200)
assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
res = await fastify.inject({ url: '/expoential-backoff-global', method: 'GET' })
assert.deepStrictEqual(res.statusCode, 200)
assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
res = await fastify.inject({ url: '/expoential-backoff-global', method: 'GET' })
assert.deepStrictEqual(res.statusCode, 429)
assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
assert.deepStrictEqual(
{
statusCode: 429,
error: 'Too Many Requests',
message: 'Rate limit exceeded, retry in 1 second'
},
JSON.parse(res.payload)
)
res = await fastify.inject({ url: '/expoential-backoff-global', method: 'GET' })
assert.deepStrictEqual(res.statusCode, 429)
assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
assert.deepStrictEqual(
{
statusCode: 429,
error: 'Too Many Requests',
message: 'Rate limit exceeded, retry in 1 second'
},
JSON.parse(res.payload)
)
res = await fastify.inject({ url: '/expoential-backoff-global', method: 'GET' })
assert.deepStrictEqual(res.statusCode, 429)
assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
assert.deepStrictEqual(
{
statusCode: 429,
error: 'Too Many Requests',
message: 'Rate limit exceeded, retry in 2 seconds'
},
JSON.parse(res.payload)
)
res = await fastify.inject({ url: '/expoential-backoff-global', method: 'GET' })
assert.deepStrictEqual(res.statusCode, 429)
assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
assert.deepStrictEqual(
{
statusCode: 429,
error: 'Too Many Requests',
message: 'Rate limit exceeded, retry in 4 seconds'
},
JSON.parse(res.payload)
)
})
test('MAx safe Exponential Backoff', async () => {
const fastify = Fastify()
// Register rate limit plugin with exponentialBackoff set to true in routeConfig
await fastify.register(rateLimit, { max: 2, timeWindow: 500, exponentialBackoff: true })
fastify.get(
'/expoential-backoff-global',
{
config: {
rateLimit: {
max: 2,
timeWindow: '285421 years'
}
}
},
async () => 'exponential backoff applied!'
)
// Test
let res
res = await fastify.inject({ url: '/expoential-backoff-global', method: 'GET' })
assert.deepStrictEqual(res.statusCode, 200)
assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
res = await fastify.inject({ url: '/expoential-backoff-global', method: 'GET' })
assert.deepStrictEqual(res.statusCode, 200)
assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
res = await fastify.inject({ url: '/expoential-backoff-global', method: 'GET' })
assert.deepStrictEqual(res.statusCode, 429)
assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
assert.deepStrictEqual(
{
statusCode: 429,
error: 'Too Many Requests',
message: 'Rate limit exceeded, retry in 285421 years'
},
JSON.parse(res.payload)
)
res = await fastify.inject({ url: '/expoential-backoff-global', method: 'GET' })
assert.deepStrictEqual(res.statusCode, 429)
assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
assert.deepStrictEqual(
{
statusCode: 429,
error: 'Too Many Requests',
message: 'Rate limit exceeded, retry in 285421 years'
},
JSON.parse(res.payload)
)
res = await fastify.inject({ url: '/expoential-backoff-global', method: 'GET' })
assert.deepStrictEqual(res.statusCode, 429)
assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
assert.deepStrictEqual(
{
statusCode: 429,
error: 'Too Many Requests',
message: 'Rate limit exceeded, retry in 285421 years'
},
JSON.parse(res.payload)
)
res = await fastify.inject({ url: '/expoential-backoff-global', method: 'GET' })
assert.deepStrictEqual(res.statusCode, 429)
assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
assert.deepStrictEqual(
{
statusCode: 429,
error: 'Too Many Requests',
message: 'Rate limit exceeded, retry in 285421 years'
},
JSON.parse(res.payload)
)
})

View File

@@ -0,0 +1,120 @@
'use strict'
const { test, mock } = require('node:test')
const Fastify = require('fastify')
const rateLimit = require('../../index')
test('issue #207 - when continueExceeding is true and the store is local then it should reset the rate-limit', async (t) => {
const clock = mock.timers
clock.enable()
const fastify = Fastify()
await fastify.register(rateLimit, {
global: false
})
fastify.get(
'/',
{
config: {
rateLimit: {
max: 1,
timeWindow: 5000,
continueExceeding: true
}
}
},
async () => {
return 'hello!'
}
)
const firstOkResponse = await fastify.inject({
url: '/',
method: 'GET'
})
const firstRateLimitResponse = await fastify.inject({
url: '/',
method: 'GET'
})
clock.tick(3000)
const secondRateLimitWithResettingTheRateLimitTimer = await fastify.inject({
url: '/',
method: 'GET'
})
// after this the total time passed is 6s which WITHOUT `continueExceeding` the next request should be OK
clock.tick(3000)
const thirdRateLimitWithResettingTheRateLimitTimer = await fastify.inject({
url: '/',
method: 'GET'
})
// After this the rate limiter should allow for new requests
clock.tick(5000)
const okResponseAfterRateLimitCompleted = await fastify.inject({
url: '/',
method: 'GET'
})
t.assert.deepStrictEqual(firstOkResponse.statusCode, 200)
t.assert.deepStrictEqual(firstRateLimitResponse.statusCode, 429)
t.assert.deepStrictEqual(
firstRateLimitResponse.headers['x-ratelimit-limit'],
'1'
)
t.assert.deepStrictEqual(
firstRateLimitResponse.headers['x-ratelimit-remaining'],
'0'
)
t.assert.deepStrictEqual(
firstRateLimitResponse.headers['x-ratelimit-reset'],
'5'
)
t.assert.deepStrictEqual(
secondRateLimitWithResettingTheRateLimitTimer.statusCode,
429
)
t.assert.deepStrictEqual(
secondRateLimitWithResettingTheRateLimitTimer.headers['x-ratelimit-limit'],
'1'
)
t.assert.deepStrictEqual(
secondRateLimitWithResettingTheRateLimitTimer.headers[
'x-ratelimit-remaining'
],
'0'
)
t.assert.deepStrictEqual(
secondRateLimitWithResettingTheRateLimitTimer.headers['x-ratelimit-reset'],
'5'
)
t.assert.deepStrictEqual(
thirdRateLimitWithResettingTheRateLimitTimer.statusCode,
429
)
t.assert.deepStrictEqual(
thirdRateLimitWithResettingTheRateLimitTimer.headers['x-ratelimit-limit'],
'1'
)
t.assert.deepStrictEqual(
thirdRateLimitWithResettingTheRateLimitTimer.headers[
'x-ratelimit-remaining'
],
'0'
)
t.assert.deepStrictEqual(
thirdRateLimitWithResettingTheRateLimitTimer.headers['x-ratelimit-reset'],
'5'
)
t.assert.deepStrictEqual(okResponseAfterRateLimitCompleted.statusCode, 200)
clock.reset(0)
})

View File

@@ -0,0 +1,87 @@
'use strict'
const { test, mock } = require('node:test')
const Fastify = require('fastify')
const rateLimit = require('../../index')
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) => {
t.plan(5)
const clock = mock.timers
clock.enable()
const fastify = Fastify()
await fastify.register(rateLimit, {
global: false
})
fastify.get(
'/',
{
config: {
rateLimit: {
max: 1,
timeWindow: 5000,
continueExceeding: false
}
}
},
async () => 'hello!'
)
const user1FirstRequest = await fastify.inject({
url: '/',
method: 'GET',
remoteAddress: '1.1.1.1'
})
// Waiting for the time to pass to make the 2nd user start in a different start point
clock.tick(3000)
const user2FirstRequest = await fastify.inject({
url: '/',
method: 'GET',
remoteAddress: '2.2.2.2'
})
const user2SecondRequestAndShouldBeRateLimited = await fastify.inject({
url: '/',
method: 'GET',
remoteAddress: '2.2.2.2'
})
// After this the total time passed for the 1st user is 6s and for the 2nd user only 3s
clock.tick(3000)
const user2ThirdRequestAndShouldStillBeRateLimited = await fastify.inject({
url: '/',
method: 'GET',
remoteAddress: '2.2.2.2'
})
// After this the total time passed for the 2nd user is 5.1s - he should not be rate limited
clock.tick(2100)
const user2OkResponseAfterRateLimitCompleted = await fastify.inject({
url: '/',
method: 'GET',
remoteAddress: '2.2.2.2'
})
t.assert.deepStrictEqual(user1FirstRequest.statusCode, 200)
t.assert.deepStrictEqual(user2FirstRequest.statusCode, 200)
t.assert.deepStrictEqual(
user2SecondRequestAndShouldBeRateLimited.statusCode,
429
)
t.assert.deepStrictEqual(
user2ThirdRequestAndShouldStillBeRateLimited.statusCode,
429
)
t.assert.deepStrictEqual(
user2OkResponseAfterRateLimitCompleted.statusCode,
200
)
clock.reset()
})

View File

@@ -0,0 +1,74 @@
'use strict'
const { test, mock } = require('node:test')
const Fastify = require('fastify')
const rateLimit = require('../../index')
test("issue #284 - don't set the reply code automatically", async (t) => {
const clock = mock.timers
clock.enable()
const fastify = Fastify()
await fastify.register(rateLimit, {
global: false
})
fastify.setErrorHandler((err, _req, res) => {
t.assert.deepStrictEqual(res.statusCode, 200)
t.assert.deepStrictEqual(err.statusCode, 429)
res.redirect('/')
})
fastify.get(
'/',
{
config: {
rateLimit: {
max: 1,
timeWindow: 5000,
continueExceeding: true
}
}
},
async () => {
return 'hello!'
}
)
const firstOkResponse = await fastify.inject({
url: '/',
method: 'GET'
})
const firstRateLimitResponse = await fastify.inject({
url: '/',
method: 'GET'
})
// After this the rate limiter should allow for new requests
clock.tick(5000)
const okResponseAfterRateLimitCompleted = await fastify.inject({
url: '/',
method: 'GET'
})
t.assert.deepStrictEqual(firstOkResponse.statusCode, 200)
t.assert.deepStrictEqual(firstRateLimitResponse.statusCode, 302)
t.assert.deepStrictEqual(
firstRateLimitResponse.headers['x-ratelimit-limit'],
'1'
)
t.assert.deepStrictEqual(
firstRateLimitResponse.headers['x-ratelimit-remaining'],
'0'
)
t.assert.deepStrictEqual(
firstRateLimitResponse.headers['x-ratelimit-reset'],
'5'
)
t.assert.deepStrictEqual(okResponseAfterRateLimitCompleted.statusCode, 200)
clock.reset(0)
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,252 @@
'use strict'
const { test } = require('node:test')
const assert = require('node:assert')
const Fastify = require('fastify')
const rateLimit = require('../index')
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
test('GroupId from routeConfig', async () => {
const fastify = Fastify()
// Register rate limit plugin with groupId in routeConfig
await fastify.register(rateLimit, { max: 2, timeWindow: 500 })
fastify.get(
'/routeWithGroupId',
{
config: {
rateLimit: {
max: 2,
timeWindow: 500,
groupId: 'group1' // groupId specified in routeConfig
}
}
},
async () => 'hello from route with groupId!'
)
// Test: Request should have the correct groupId in response
const res = await fastify.inject({ url: '/routeWithGroupId', method: 'GET' })
assert.deepStrictEqual(res.statusCode, 200)
assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
})
test('GroupId from routeOptions', async () => {
const fastify = Fastify()
// Register rate limit plugin with groupId in routeOptions
await fastify.register(rateLimit, { max: 2, timeWindow: 500 })
fastify.get(
'/routeWithGroupIdFromOptions',
{
config: {
rateLimit: {
max: 2,
timeWindow: 500
// groupId not specified here
}
}
},
async () => 'hello from route with groupId from options!'
)
// Test: Request should have the correct groupId from routeOptions
const res = await fastify.inject({ url: '/routeWithGroupIdFromOptions', method: 'GET' })
assert.deepStrictEqual(res.statusCode, 200)
assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
})
test('No groupId provided', async () => {
const fastify = Fastify()
// Register rate limit plugin without groupId
await fastify.register(rateLimit, { max: 2, timeWindow: 500 })
// Route without groupId
fastify.get(
'/noGroupId',
{
config: {
rateLimit: {
max: 2,
timeWindow: 500
}
}
},
async () => 'hello from no groupId route!'
)
let res
// Test without groupId
res = await fastify.inject({ url: '/noGroupId', method: 'GET' })
assert.deepStrictEqual(res.statusCode, 200)
assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
res = await fastify.inject({ url: '/noGroupId', method: 'GET' })
assert.deepStrictEqual(res.statusCode, 200)
assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
res = await fastify.inject({ url: '/noGroupId', method: 'GET' })
assert.deepStrictEqual(res.statusCode, 429)
assert.deepStrictEqual(
res.headers['content-type'],
'application/json; charset=utf-8'
)
assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
assert.deepStrictEqual(res.headers['retry-after'], '1')
assert.deepStrictEqual(
{
statusCode: 429,
error: 'Too Many Requests',
message: 'Rate limit exceeded, retry in 1 second'
},
JSON.parse(res.payload)
)
})
test('With multiple routes and custom groupId', async () => {
const fastify = Fastify()
// Register rate limit plugin
await fastify.register(rateLimit, { max: 2, timeWindow: 500 })
// Route 1 with groupId 'group1'
fastify.get(
'/route1',
{
config: {
rateLimit: {
max: 2,
timeWindow: 500,
groupId: 'group1'
}
}
},
async () => 'hello from route 1!'
)
// Route 2 with groupId 'group2'
fastify.get(
'/route2',
{
config: {
rateLimit: {
max: 2,
timeWindow: 1000,
groupId: 'group2'
}
}
},
async () => 'hello from route 2!'
)
let res
// Test Route 1
res = await fastify.inject({ url: '/route1', method: 'GET' })
assert.deepStrictEqual(res.statusCode, 200)
assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
res = await fastify.inject({ url: '/route1', method: 'GET' })
assert.deepStrictEqual(res.statusCode, 200)
assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
res = await fastify.inject({ url: '/route1', method: 'GET' })
assert.deepStrictEqual(res.statusCode, 429)
assert.deepStrictEqual(
res.headers['content-type'],
'application/json; charset=utf-8'
)
assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
assert.deepStrictEqual(res.headers['retry-after'], '1')
assert.deepStrictEqual(
{
statusCode: 429,
error: 'Too Many Requests',
message: 'Rate limit exceeded, retry in 1 second'
},
JSON.parse(res.payload)
)
// Test Route 2
res = await fastify.inject({ url: '/route2', method: 'GET' })
assert.deepStrictEqual(res.statusCode, 200)
assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
res = await fastify.inject({ url: '/route2', method: 'GET' })
assert.deepStrictEqual(res.statusCode, 200)
assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
res = await fastify.inject({ url: '/route2', method: 'GET' })
assert.deepStrictEqual(res.statusCode, 429)
assert.deepStrictEqual(
res.headers['content-type'],
'application/json; charset=utf-8'
)
assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
assert.deepStrictEqual(res.headers['retry-after'], '1')
assert.deepStrictEqual(
{
statusCode: 429,
error: 'Too Many Requests',
message: 'Rate limit exceeded, retry in 1 second'
},
JSON.parse(res.payload)
)
// Wait for the window to reset
await sleep(1000)
// After reset, Route 1 should succeed again
res = await fastify.inject({ url: '/route1', method: 'GET' })
assert.deepStrictEqual(res.statusCode, 200)
assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
// Route 2 should also succeed after the reset
res = await fastify.inject({ url: '/route2', method: 'GET' })
assert.deepStrictEqual(res.statusCode, 200)
assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
})
test('Invalid groupId type', async () => {
const fastify = Fastify()
// Register rate limit plugin with a route having an invalid groupId
await fastify.register(rateLimit, { max: 2, timeWindow: 1000 })
try {
fastify.get(
'/invalidGroupId',
{
config: {
rateLimit: {
max: 2,
timeWindow: 1000,
groupId: 123 // Invalid groupId type
}
}
},
async () => 'hello with invalid groupId!'
)
assert.fail('should throw')
console.log('HER')
} catch (err) {
assert.deepStrictEqual(err.message, 'groupId must be a string')
}
})

View File

@@ -0,0 +1,18 @@
'use strict'
const { test } = require('node:test')
const Fastify = require('fastify')
const rateLimit = require('../index')
test('Fastify close on local store', async (t) => {
t.plan(1)
const fastify = Fastify()
await fastify.register(rateLimit, { max: 2, timeWindow: 1000 })
let counter = 1
fastify.addHook('onClose', (_instance, done) => {
counter++
done()
})
await fastify.close()
t.assert.deepStrictEqual(counter, 2)
})

View File

@@ -0,0 +1,116 @@
'use strict'
const { test } = require('node:test')
const Fastify = require('fastify')
const rateLimit = require('../index')
test('Set not found handler can be rate limited', async (t) => {
t.plan(18)
const fastify = Fastify()
await fastify.register(rateLimit, { max: 2, timeWindow: 1000 })
t.assert.ok(fastify.rateLimit)
fastify.setNotFoundHandler(
{
preHandler: fastify.rateLimit()
},
function (_request, reply) {
t.assert.ok('Error handler has been called')
reply.status(404).send(new Error('Not found'))
}
)
let res
res = await fastify.inject('/not-found')
t.assert.deepStrictEqual(res.statusCode, 404)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
res = await fastify.inject('/not-found')
t.assert.deepStrictEqual(res.statusCode, 404)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
res = await fastify.inject('/not-found')
t.assert.deepStrictEqual(res.statusCode, 429)
t.assert.deepStrictEqual(
res.headers['content-type'],
'application/json; charset=utf-8'
)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
t.assert.deepStrictEqual(res.headers['retry-after'], '1')
t.assert.deepStrictEqual(JSON.parse(res.payload), {
statusCode: 429,
error: 'Too Many Requests',
message: 'Rate limit exceeded, retry in 1 second'
})
})
test('Set not found handler can be rate limited with specific options', async (t) => {
t.plan(28)
const fastify = Fastify()
await fastify.register(rateLimit, { max: 2, timeWindow: 1000 })
t.assert.ok(fastify.rateLimit)
fastify.setNotFoundHandler(
{
preHandler: fastify.rateLimit({
max: 4,
timeWindow: 2000
})
},
function (_request, reply) {
t.assert.ok('Error handler has been called')
reply.status(404).send(new Error('Not found'))
}
)
let res
res = await fastify.inject('/not-found')
t.assert.deepStrictEqual(res.statusCode, 404)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '4')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '3')
t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '2')
res = await fastify.inject('/not-found')
t.assert.deepStrictEqual(res.statusCode, 404)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '4')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '2')
t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '2')
res = await fastify.inject('/not-found')
t.assert.deepStrictEqual(res.statusCode, 404)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '4')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '2')
res = await fastify.inject('/not-found')
t.assert.deepStrictEqual(res.statusCode, 404)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '4')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '2')
res = await fastify.inject('/not-found')
t.assert.deepStrictEqual(res.statusCode, 429)
t.assert.deepStrictEqual(
res.headers['content-type'],
'application/json; charset=utf-8'
)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '4')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
t.assert.deepStrictEqual(res.headers['retry-after'], '2')
t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '2')
t.assert.deepStrictEqual(JSON.parse(res.payload), {
statusCode: 429,
error: 'Too Many Requests',
message: 'Rate limit exceeded, retry in 2 seconds'
})
})

View File

@@ -0,0 +1,753 @@
'use strict'
const { test, describe } = require('node:test')
const Redis = require('ioredis')
const Fastify = require('fastify')
const rateLimit = require('../index')
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const REDIS_HOST = '127.0.0.1'
describe('Global rate limit', () => {
test('With redis store', async (t) => {
t.plan(21)
const fastify = Fastify()
const redis = await new Redis({ host: REDIS_HOST })
await fastify.register(rateLimit, {
max: 2,
timeWindow: 1000,
redis
})
fastify.get('/', async () => 'hello!')
let res
res = await fastify.inject('/')
t.assert.strictEqual(res.statusCode, 200)
t.assert.ok(res)
t.assert.strictEqual(res.statusCode, 200)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 200)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
await sleep(100)
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 429)
t.assert.deepStrictEqual(
res.headers['content-type'],
'application/json; charset=utf-8'
)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
t.assert.deepStrictEqual(res.headers['retry-after'], '1')
t.assert.deepStrictEqual(
{
statusCode: 429,
error: 'Too Many Requests',
message: 'Rate limit exceeded, retry in 1 second'
},
JSON.parse(res.payload)
)
// Not using fake timers here as we use an external Redis that would not be effected by this
await sleep(1100)
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 200)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
await redis.flushall()
await redis.quit()
})
test('With redis store (ban)', async (t) => {
t.plan(19)
const fastify = Fastify()
const redis = await new Redis({ host: REDIS_HOST })
await fastify.register(rateLimit, {
max: 1,
ban: 1,
timeWindow: 1000,
redis
})
fastify.get('/', async () => 'hello!')
let res
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 200)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '1')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 429)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '1')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 403)
t.assert.deepStrictEqual(
res.headers['content-type'],
'application/json; charset=utf-8'
)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '1')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
t.assert.deepStrictEqual(res.headers['retry-after'], '1')
t.assert.deepStrictEqual(
{
statusCode: 403,
error: 'Forbidden',
message: 'Rate limit exceeded, retry in 1 second'
},
JSON.parse(res.payload)
)
// Not using fake timers here as we use an external Redis that would not be effected by this
await sleep(1100)
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 200)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '1')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
await redis.flushall()
await redis.quit()
})
test('Skip on redis error', async (t) => {
t.plan(9)
const fastify = Fastify()
const redis = await new Redis({ host: REDIS_HOST })
await fastify.register(rateLimit, {
max: 2,
timeWindow: 1000,
redis,
skipOnError: true
})
fastify.get('/', async () => 'hello!')
let res
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 200)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
await redis.flushall()
await redis.quit()
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 200)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '2')
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 200)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '2')
})
test('Throw on redis error', async (t) => {
t.plan(5)
const fastify = Fastify()
const redis = await new Redis({ host: REDIS_HOST })
await fastify.register(rateLimit, {
max: 2,
timeWindow: 1000,
redis,
skipOnError: false
})
fastify.get('/', async () => 'hello!')
let res
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 200)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
await redis.flushall()
await redis.quit()
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 500)
t.assert.deepStrictEqual(
res.body,
'{"statusCode":500,"error":"Internal Server Error","message":"Connection is closed."}'
)
})
test('When continue exceeding is on (Redis)', async (t) => {
const fastify = Fastify()
const redis = await new Redis({ host: REDIS_HOST })
await fastify.register(rateLimit, {
redis,
max: 1,
timeWindow: 5000,
continueExceeding: true
})
fastify.get('/', async () => 'hello!')
const first = await fastify.inject({
url: '/',
method: 'GET'
})
const second = await fastify.inject({
url: '/',
method: 'GET'
})
t.assert.deepStrictEqual(first.statusCode, 200)
t.assert.deepStrictEqual(second.statusCode, 429)
t.assert.deepStrictEqual(second.headers['x-ratelimit-limit'], '1')
t.assert.deepStrictEqual(second.headers['x-ratelimit-remaining'], '0')
t.assert.deepStrictEqual(second.headers['x-ratelimit-reset'], '5')
await redis.flushall()
await redis.quit()
})
test('Redis with continueExceeding should not always return the timeWindow as ttl', async (t) => {
t.plan(19)
const fastify = Fastify()
const redis = await new Redis({ host: REDIS_HOST })
await fastify.register(rateLimit, {
max: 2,
timeWindow: 3000,
continueExceeding: true,
redis
})
fastify.get('/', async () => 'hello!')
let res
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 200)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '3')
// After this sleep, we should not see `x-ratelimit-reset === 3` anymore
await sleep(1000)
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 200)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '2')
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 429)
t.assert.deepStrictEqual(
res.headers['content-type'],
'application/json; charset=utf-8'
)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '3')
t.assert.deepStrictEqual(res.headers['retry-after'], '3')
t.assert.deepStrictEqual(
{
statusCode: 429,
error: 'Too Many Requests',
message: 'Rate limit exceeded, retry in 3 seconds'
},
JSON.parse(res.payload)
)
// Not using fake timers here as we use an external Redis that would not be effected by this
await sleep(1000)
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 429)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '3')
await redis.flushall()
await redis.quit()
})
test('When use a custom nameSpace', async (t) => {
const fastify = Fastify()
const redis = await new Redis({ host: REDIS_HOST })
await fastify.register(rateLimit, {
max: 2,
timeWindow: 1000,
redis,
nameSpace: 'my-namespace:',
keyGenerator: (req) => req.headers['x-my-header']
})
fastify.get('/', async () => 'hello!')
const allowListHeader = {
method: 'GET',
url: '/',
headers: {
'x-my-header': 'custom name space'
}
}
let res
res = await fastify.inject(allowListHeader)
t.assert.deepStrictEqual(res.statusCode, 200)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
res = await fastify.inject(allowListHeader)
t.assert.deepStrictEqual(res.statusCode, 200)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
res = await fastify.inject(allowListHeader)
t.assert.deepStrictEqual(res.statusCode, 429)
t.assert.deepStrictEqual(
res.headers['content-type'],
'application/json; charset=utf-8'
)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
t.assert.deepStrictEqual(res.headers['retry-after'], '1')
t.assert.deepStrictEqual(
{
statusCode: 429,
error: 'Too Many Requests',
message: 'Rate limit exceeded, retry in 1 second'
},
JSON.parse(res.payload)
)
// Not using fake timers here as we use an external Redis that would not be effected by this
await sleep(1100)
res = await fastify.inject(allowListHeader)
t.assert.deepStrictEqual(res.statusCode, 200)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
await redis.flushall()
await redis.quit()
})
test('With redis store and exponential backoff', async (t) => {
t.plan(20)
const fastify = Fastify()
const redis = await new Redis({ host: REDIS_HOST })
await fastify.register(rateLimit, {
max: 2,
timeWindow: 1000,
redis,
exponentialBackoff: true
})
fastify.get('/', async () => 'hello!')
let res
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 200)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 200)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
// First attempt over the limit should have the normal timeWindow (1000ms)
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 429)
t.assert.deepStrictEqual(
res.headers['content-type'],
'application/json; charset=utf-8'
)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
t.assert.deepStrictEqual(res.headers['retry-after'], '1')
t.assert.deepStrictEqual(
{
statusCode: 429,
error: 'Too Many Requests',
message: 'Rate limit exceeded, retry in 1 second'
},
JSON.parse(res.payload)
)
// Second attempt over the limit should have doubled timeWindow (2000ms)
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 429)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
t.assert.deepStrictEqual(res.headers['retry-after'], '2')
t.assert.deepStrictEqual(
{
statusCode: 429,
error: 'Too Many Requests',
message: 'Rate limit exceeded, retry in 2 seconds'
},
JSON.parse(res.payload)
)
await redis.flushall()
await redis.quit()
})
})
describe('Route rate limit', () => {
test('With redis store', async t => {
t.plan(19)
const fastify = Fastify()
const redis = new Redis({ host: REDIS_HOST })
await fastify.register(rateLimit, {
global: false,
redis
})
fastify.get('/', {
config: {
rateLimit: {
max: 2,
timeWindow: 1000
},
someOtherPlugin: {
someValue: 1
}
}
}, async () => 'hello!')
let res
res = await fastify.inject('/')
t.assert.strictEqual(res.statusCode, 200)
t.assert.strictEqual(res.headers['x-ratelimit-limit'], '2')
t.assert.strictEqual(res.headers['x-ratelimit-remaining'], '1')
t.assert.strictEqual(res.headers['x-ratelimit-reset'], '1')
res = await fastify.inject('/')
t.assert.strictEqual(res.statusCode, 200)
t.assert.strictEqual(res.headers['x-ratelimit-limit'], '2')
t.assert.strictEqual(res.headers['x-ratelimit-remaining'], '0')
t.assert.strictEqual(res.headers['x-ratelimit-reset'], '1')
res = await fastify.inject('/')
t.assert.strictEqual(res.statusCode, 429)
t.assert.strictEqual(res.headers['content-type'], 'application/json; charset=utf-8')
t.assert.strictEqual(res.headers['x-ratelimit-limit'], '2')
t.assert.strictEqual(res.headers['x-ratelimit-remaining'], '0')
t.assert.strictEqual(res.headers['x-ratelimit-reset'], '1')
t.assert.strictEqual(res.headers['retry-after'], '1')
t.assert.deepStrictEqual({
statusCode: 429,
error: 'Too Many Requests',
message: 'Rate limit exceeded, retry in 1 second'
}, JSON.parse(res.payload))
// Not using fake timers here as we use an external Redis that would not be effected by this
await sleep(1100)
res = await fastify.inject('/')
t.assert.strictEqual(res.statusCode, 200)
t.assert.strictEqual(res.headers['x-ratelimit-limit'], '2')
t.assert.strictEqual(res.headers['x-ratelimit-remaining'], '1')
t.assert.strictEqual(res.headers['x-ratelimit-reset'], '1')
await redis.flushall()
await redis.quit()
})
test('Throw on redis error', async (t) => {
t.plan(6)
const fastify = Fastify()
const redis = new Redis({ host: REDIS_HOST })
await fastify.register(rateLimit, {
redis,
global: false
})
fastify.get(
'/',
{
config: {
rateLimit: {
max: 2,
timeWindow: 1000,
skipOnError: false
}
}
},
async () => 'hello!'
)
let res
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 200)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
await redis.flushall()
await redis.quit()
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 500)
t.assert.deepStrictEqual(
res.body,
'{"statusCode":500,"error":"Internal Server Error","message":"Connection is closed."}'
)
})
test('Skip on redis error', async (t) => {
t.plan(9)
const fastify = Fastify()
const redis = new Redis({ host: REDIS_HOST })
await fastify.register(rateLimit, {
redis,
global: false
})
fastify.get(
'/',
{
config: {
rateLimit: {
max: 2,
timeWindow: 1000,
skipOnError: true
}
}
},
async () => 'hello!'
)
let res
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 200)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1')
await redis.flushall()
await redis.quit()
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 200)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '2')
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 200)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '2')
})
test('When continue exceeding is on (Redis)', async (t) => {
const fastify = Fastify()
const redis = await new Redis({ host: REDIS_HOST })
await fastify.register(rateLimit, {
global: false,
redis
})
fastify.get(
'/',
{
config: {
rateLimit: {
timeWindow: 5000,
max: 1,
continueExceeding: true
}
}
},
async () => 'hello!'
)
const first = await fastify.inject({
url: '/',
method: 'GET'
})
const second = await fastify.inject({
url: '/',
method: 'GET'
})
t.assert.deepStrictEqual(first.statusCode, 200)
t.assert.deepStrictEqual(second.statusCode, 429)
t.assert.deepStrictEqual(second.headers['x-ratelimit-limit'], '1')
t.assert.deepStrictEqual(second.headers['x-ratelimit-remaining'], '0')
t.assert.deepStrictEqual(second.headers['x-ratelimit-reset'], '5')
await redis.flushall()
await redis.quit()
})
test('When continue exceeding is off under route (Redis)', async (t) => {
const fastify = Fastify()
const redis = await new Redis({ host: REDIS_HOST })
await fastify.register(rateLimit, {
global: false,
continueExceeding: true,
redis
})
fastify.get(
'/',
{
config: {
rateLimit: {
timeWindow: 5000,
max: 1,
continueExceeding: false
}
}
},
async () => 'hello!'
)
const first = await fastify.inject({
url: '/',
method: 'GET'
})
const second = await fastify.inject({
url: '/',
method: 'GET'
})
await sleep(2000)
const third = await fastify.inject({
url: '/',
method: 'GET'
})
t.assert.deepStrictEqual(first.statusCode, 200)
t.assert.deepStrictEqual(second.statusCode, 429)
t.assert.deepStrictEqual(second.headers['x-ratelimit-limit'], '1')
t.assert.deepStrictEqual(second.headers['x-ratelimit-remaining'], '0')
t.assert.deepStrictEqual(second.headers['x-ratelimit-reset'], '5')
t.assert.deepStrictEqual(third.statusCode, 429)
t.assert.deepStrictEqual(third.headers['x-ratelimit-limit'], '1')
t.assert.deepStrictEqual(third.headers['x-ratelimit-remaining'], '0')
t.assert.deepStrictEqual(third.headers['x-ratelimit-reset'], '3')
await redis.flushall()
await redis.quit()
})
test('Route-specific exponential backoff with redis store', async (t) => {
t.plan(17)
const fastify = Fastify()
const redis = await new Redis({ host: REDIS_HOST })
await fastify.register(rateLimit, {
global: false,
redis
})
fastify.get('/', {
config: {
rateLimit: {
max: 1,
timeWindow: 1000,
exponentialBackoff: true
}
}
}, async () => 'hello!')
let res
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 200)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '1')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1')
// First attempt over the limit should have the normal timeWindow (1000ms)
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 429)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '1')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
t.assert.deepStrictEqual(res.headers['retry-after'], '1')
t.assert.deepStrictEqual(
{
statusCode: 429,
error: 'Too Many Requests',
message: 'Rate limit exceeded, retry in 1 second'
},
JSON.parse(res.payload)
)
// Second attempt over the limit should have doubled timeWindow (2000ms)
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 429)
t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '1')
t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0')
t.assert.deepStrictEqual(res.headers['retry-after'], '2')
t.assert.deepStrictEqual(
{
statusCode: 429,
error: 'Too Many Requests',
message: 'Rate limit exceeded, retry in 2 seconds'
},
JSON.parse(res.payload)
)
// Third attempt over the limit should have quadrupled timeWindow (4000ms)
res = await fastify.inject('/')
t.assert.deepStrictEqual(res.statusCode, 429)
t.assert.deepStrictEqual(res.headers['retry-after'], '4')
t.assert.deepStrictEqual(
{
statusCode: 429,
error: 'Too Many Requests',
message: 'Rate limit exceeded, retry in 4 seconds'
},
JSON.parse(res.payload)
)
await redis.flushall()
await redis.quit()
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,169 @@
/// <reference types='node' />
import {
ContextConfigDefault,
FastifyPluginCallback,
FastifyRequest,
FastifySchema,
preHandlerAsyncHookHandler,
RouteGenericInterface,
RouteOptions
} from 'fastify'
declare module 'fastify' {
interface FastifyInstance<RawServer, RawRequest, RawReply, Logger, TypeProvider> {
createRateLimit(options?: fastifyRateLimit.CreateRateLimitOptions): (req: FastifyRequest) => Promise<
| {
isAllowed: true
key: string
}
| {
isAllowed: false
key: string
max: number
timeWindow: number
remaining: number
ttl: number
ttlInSeconds: number
isExceeded: boolean
isBanned: boolean
}
>
rateLimit<
RouteGeneric extends RouteGenericInterface = RouteGenericInterface,
ContextConfig = ContextConfigDefault,
SchemaCompiler extends FastifySchema = FastifySchema
>(options?: fastifyRateLimit.RateLimitOptions): preHandlerAsyncHookHandler<
RawServer,
RawRequest,
RawReply,
RouteGeneric,
ContextConfig,
SchemaCompiler,
TypeProvider,
Logger
>;
}
interface FastifyContextConfig {
rateLimit?: fastifyRateLimit.RateLimitOptions | false;
}
}
type FastifyRateLimit = FastifyPluginCallback<fastifyRateLimit.RateLimitPluginOptions>
declare namespace fastifyRateLimit {
export interface FastifyRateLimitOptions { }
export interface errorResponseBuilderContext {
statusCode: number;
ban: boolean;
after: string;
max: number;
ttl: number;
}
export interface FastifyRateLimitStoreCtor {
new(options: FastifyRateLimitOptions): FastifyRateLimitStore;
}
export interface FastifyRateLimitStore {
incr(
key: string,
callback: (
error: Error | null,
result?: { current: number; ttl: number }
) => void
): void;
child(
routeOptions: RouteOptions & { path: string; prefix: string }
): FastifyRateLimitStore;
}
interface DefaultAddHeaders {
'x-ratelimit-limit'?: boolean;
'x-ratelimit-remaining'?: boolean;
'x-ratelimit-reset'?: boolean;
'retry-after'?: boolean;
}
interface DraftSpecAddHeaders {
'ratelimit-limit'?: boolean;
'ratelimit-remaining'?: boolean;
'ratelimit-reset'?: boolean;
'retry-after'?: boolean;
}
interface DefaultAddHeadersOnExceeding {
'x-ratelimit-limit'?: boolean;
'x-ratelimit-remaining'?: boolean;
'x-ratelimit-reset'?: boolean;
}
interface DraftSpecAddHeadersOnExceeding {
'ratelimit-limit'?: boolean;
'ratelimit-remaining'?: boolean;
'ratelimit-reset'?: boolean;
}
export interface CreateRateLimitOptions {
store?: FastifyRateLimitStoreCtor;
skipOnError?: boolean;
max?:
| number
| ((req: FastifyRequest, key: string) => number)
| ((req: FastifyRequest, key: string) => Promise<number>);
timeWindow?:
| number
| string
| ((req: FastifyRequest, key: string) => number)
| ((req: FastifyRequest, key: string) => Promise<number>);
/**
* @deprecated Use `allowList` property
*/
whitelist?: string[] | ((req: FastifyRequest, key: string) => boolean);
allowList?: string[] | ((req: FastifyRequest, key: string) => boolean | Promise<boolean>);
keyGenerator?: (req: FastifyRequest) => string | number | Promise<string | number>;
ban?: number;
}
export type RateLimitHook =
| 'onRequest'
| 'preParsing'
| 'preValidation'
| 'preHandler'
export interface RateLimitOptions extends CreateRateLimitOptions {
hook?: RateLimitHook;
cache?: number;
continueExceeding?: boolean;
onBanReach?: (req: FastifyRequest, key: string) => void;
groupId?: string;
errorResponseBuilder?: (
req: FastifyRequest,
context: errorResponseBuilderContext
) => object;
enableDraftSpec?: boolean;
onExceeding?: (req: FastifyRequest, key: string) => void;
onExceeded?: (req: FastifyRequest, key: string) => void;
exponentialBackoff?: boolean;
}
export interface RateLimitPluginOptions extends RateLimitOptions {
global?: boolean;
cache?: number;
redis?: any;
nameSpace?: string;
addHeaders?: DefaultAddHeaders | DraftSpecAddHeaders;
addHeadersOnExceeding?:
| DefaultAddHeadersOnExceeding
| DraftSpecAddHeadersOnExceeding;
}
export const fastifyRateLimit: FastifyRateLimit
export { fastifyRateLimit as default }
}
declare function fastifyRateLimit (...params: Parameters<FastifyRateLimit>): ReturnType<FastifyRateLimit>
export = fastifyRateLimit

View File

@@ -0,0 +1,277 @@
import fastify, {
FastifyInstance,
FastifyRequest,
preHandlerAsyncHookHandler,
RequestGenericInterface,
RouteOptions
} from 'fastify'
import * as http2 from 'node:http2'
import IORedis from 'ioredis'
import pino from 'pino'
import fastifyRateLimit, {
CreateRateLimitOptions,
errorResponseBuilderContext,
FastifyRateLimitOptions,
FastifyRateLimitStore,
RateLimitPluginOptions
} from '..'
import { expectAssignable, expectType } from 'tsd'
class CustomStore implements FastifyRateLimitStore {
options: FastifyRateLimitOptions
constructor (options: FastifyRateLimitOptions) {
this.options = options
}
incr (
_key: string,
_callback: (
error: Error | null,
result?: { current: number; ttl: number }
) => void
) {}
child (_routeOptions: RouteOptions & { path: string; prefix: string }) {
return <CustomStore>(<FastifyRateLimitOptions>{})
}
}
const appWithImplicitHttp = fastify()
const options1: RateLimitPluginOptions = {
global: true,
max: 3,
timeWindow: 5000,
cache: 10000,
allowList: ['127.0.0.1'],
redis: new IORedis({ host: '127.0.0.1' }),
skipOnError: true,
ban: 10,
continueExceeding: false,
keyGenerator: (req: FastifyRequest<RequestGenericInterface>) => req.ip,
groupId: '42',
errorResponseBuilder: (
req: FastifyRequest<RequestGenericInterface>,
context: errorResponseBuilderContext
) => {
if (context.ban) {
return {
statusCode: 403,
error: 'Forbidden',
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}`,
}
} else {
return {
statusCode: 429,
error: 'Too Many Requests',
message: `You hit the rate limit, please slow down! You can retry in ${context.after}`,
}
}
},
addHeadersOnExceeding: {
'x-ratelimit-limit': false,
'x-ratelimit-remaining': false,
'x-ratelimit-reset': false
},
addHeaders: {
'x-ratelimit-limit': false,
'x-ratelimit-remaining': false,
'x-ratelimit-reset': false,
'retry-after': false
},
onExceeding: (_req: FastifyRequest<RequestGenericInterface>, _key: string) => ({}),
onExceeded: (_req: FastifyRequest<RequestGenericInterface>, _key: string) => ({}),
onBanReach: (_req: FastifyRequest<RequestGenericInterface>, _key: string) => ({})
}
const options2: RateLimitPluginOptions = {
global: true,
max: (_req: FastifyRequest<RequestGenericInterface>, _key: string) => 42,
allowList: (_req: FastifyRequest<RequestGenericInterface>, _key: string) => false,
timeWindow: 5000,
hook: 'preParsing'
}
const options3: RateLimitPluginOptions = {
global: true,
max: (_req: FastifyRequest<RequestGenericInterface>, _key: string) => 42,
timeWindow: 5000,
store: CustomStore,
hook: 'preValidation'
}
const options4: RateLimitPluginOptions = {
global: true,
max: (_req: FastifyRequest<RequestGenericInterface>, _key: string) => Promise.resolve(42),
timeWindow: 5000,
store: CustomStore,
hook: 'preHandler'
}
const options5: RateLimitPluginOptions = {
max: 3,
timeWindow: 5000,
cache: 10000,
redis: new IORedis({ host: '127.0.0.1' }),
nameSpace: 'my-namespace'
}
const options6: RateLimitPluginOptions = {
global: true,
allowList: async (_req, _key) => true,
keyGenerator: async (_req) => '',
timeWindow: 5000,
store: CustomStore,
hook: 'preHandler'
}
const options7: RateLimitPluginOptions = {
global: true,
max: (_req: FastifyRequest<RequestGenericInterface>, _key: string) => 42,
timeWindow: (_req: FastifyRequest<RequestGenericInterface>, _key: string) => 5000,
store: CustomStore,
hook: 'preValidation'
}
const options8: RateLimitPluginOptions = {
global: true,
max: (_req: FastifyRequest<RequestGenericInterface>, _key: string) => 42,
timeWindow: (_req: FastifyRequest<RequestGenericInterface>, _key: string) => Promise.resolve(5000),
store: CustomStore,
hook: 'preValidation'
}
const options9: RateLimitPluginOptions = {
global: true,
max: (_req: FastifyRequest<RequestGenericInterface>, _key: string) => Promise.resolve(42),
timeWindow: (_req: FastifyRequest<RequestGenericInterface>, _key: string) => 5000,
store: CustomStore,
hook: 'preValidation',
exponentialBackoff: true
}
appWithImplicitHttp.register(fastifyRateLimit, options1)
appWithImplicitHttp.register(fastifyRateLimit, options2)
appWithImplicitHttp.register(fastifyRateLimit, options5)
appWithImplicitHttp.register(fastifyRateLimit, options9)
appWithImplicitHttp.register(fastifyRateLimit, options3).then(() => {
expectType<preHandlerAsyncHookHandler>(appWithImplicitHttp.rateLimit())
expectType<preHandlerAsyncHookHandler>(appWithImplicitHttp.rateLimit(options1))
expectType<preHandlerAsyncHookHandler>(appWithImplicitHttp.rateLimit(options2))
expectType<preHandlerAsyncHookHandler>(appWithImplicitHttp.rateLimit(options3))
expectType<preHandlerAsyncHookHandler>(appWithImplicitHttp.rateLimit(options4))
expectType<preHandlerAsyncHookHandler>(appWithImplicitHttp.rateLimit(options5))
expectType<preHandlerAsyncHookHandler>(appWithImplicitHttp.rateLimit(options6))
expectType<preHandlerAsyncHookHandler>(appWithImplicitHttp.rateLimit(options7))
expectType<preHandlerAsyncHookHandler>(appWithImplicitHttp.rateLimit(options8))
expectType<preHandlerAsyncHookHandler>(appWithImplicitHttp.rateLimit(options9))
// The following test is dependent on https://github.com/fastify/fastify/pull/2929
// appWithImplicitHttp.setNotFoundHandler({
// preHandler: appWithImplicitHttp.rateLimit()
// }, function (request:FastifyRequest<RequestGenericInterface>, reply: FastifyReply<ReplyGenericInterface>) {
// reply.status(404).send(new Error('Not found'))
// })
})
appWithImplicitHttp.get('/', { config: { rateLimit: { max: 10, timeWindow: '60s' } } }, () => { return 'limited' })
const appWithHttp2: FastifyInstance<
http2.Http2Server,
http2.Http2ServerRequest,
http2.Http2ServerResponse
> = fastify({ http2: true })
appWithHttp2.register(fastifyRateLimit, options1)
appWithHttp2.register(fastifyRateLimit, options2)
appWithHttp2.register(fastifyRateLimit, options3)
appWithHttp2.register(fastifyRateLimit, options5)
appWithHttp2.register(fastifyRateLimit, options6)
appWithHttp2.register(fastifyRateLimit, options7)
appWithHttp2.register(fastifyRateLimit, options8)
appWithHttp2.register(fastifyRateLimit, options9)
appWithHttp2.get('/public', {
config: {
rateLimit: false
}
}, (_request, reply) => {
reply.send({ hello: 'from ... public' })
})
expectAssignable<errorResponseBuilderContext>({
statusCode: 429,
ban: true,
after: '123',
max: 1000,
ttl: 123
})
const appWithCustomLogger = fastify({
loggerInstance: pino(),
}).withTypeProvider()
appWithCustomLogger.register(fastifyRateLimit, options1)
appWithCustomLogger.route({
method: 'GET',
url: '/',
preHandler: appWithCustomLogger.rateLimit({}),
handler: () => {},
})
const options10: CreateRateLimitOptions = {
store: CustomStore,
skipOnError: true,
max: 0,
timeWindow: 5000,
allowList: ['127.0.0.1'],
keyGenerator: (req: FastifyRequest<RequestGenericInterface>) => req.ip,
ban: 10
}
appWithImplicitHttp.register(fastifyRateLimit, { global: false })
const checkRateLimit = appWithImplicitHttp.createRateLimit(options10)
appWithImplicitHttp.route({
method: 'GET',
url: '/',
handler: async (req, _reply) => {
const limit = await checkRateLimit(req)
expectType<{
isAllowed: true;
key: string;
} | {
isAllowed: false;
key: string;
max: number;
timeWindow: number;
remaining: number;
ttl: number;
ttlInSeconds: number;
isExceeded: boolean;
isBanned: boolean;
}>(limit)
},
})
const options11: CreateRateLimitOptions = {
max: (_req: FastifyRequest<RequestGenericInterface>, _key: string) => 42,
timeWindow: '10s',
allowList: (_req: FastifyRequest<RequestGenericInterface>) => true,
keyGenerator: (_req: FastifyRequest<RequestGenericInterface>) => 42,
}
const options12: CreateRateLimitOptions = {
max: (_req: FastifyRequest<RequestGenericInterface>, _key: string) => Promise.resolve(42),
timeWindow: (_req: FastifyRequest<RequestGenericInterface>, _key: string) => 5000,
allowList: (_req: FastifyRequest<RequestGenericInterface>) => Promise.resolve(true),
keyGenerator: (_req: FastifyRequest<RequestGenericInterface>) => Promise.resolve(42),
}
const options13: CreateRateLimitOptions = {
timeWindow: (_req: FastifyRequest<RequestGenericInterface>, _key: string) => Promise.resolve(5000),
keyGenerator: (_req: FastifyRequest<RequestGenericInterface>) => Promise.resolve('key'),
}
expectType<preHandlerAsyncHookHandler>(appWithImplicitHttp.rateLimit(options11))
expectType<preHandlerAsyncHookHandler>(appWithImplicitHttp.rateLimit(options12))
expectType<preHandlerAsyncHookHandler>(appWithImplicitHttp.rateLimit(options13))