Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 75 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ A low overhead rate limiter for your routes.
npm i @fastify/rate-limit
```

If you want to use the built-in Valkey support, prefer [`@fastify/valkey-glide`](https://github.com/fastify/fastify-valkey-glide) in Fastify applications. It manages the GLIDE client lifecycle and gives you a resolved client to pass into `@fastify/rate-limit`.

```
npm i @fastify/valkey-glide @valkey/valkey-glide
```

If you do not want the Fastify integration layer, you can use [`@valkey/valkey-glide`](https://github.com/valkey-io/valkey-glide) directly instead:

```
npm i @valkey/valkey-glide
```

### Compatibility

| Plugin version | Fastify version |
Expand Down Expand Up @@ -79,6 +91,59 @@ The response will have some additional headers:
|`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

### Using `@fastify/valkey-glide`

For Fastify applications, prefer [`@fastify/valkey-glide`](https://github.com/fastify/fastify-valkey-glide) and pass the resolved client into `@fastify/rate-limit`.

```js
import Fastify from 'fastify'
import rateLimit from '@fastify/rate-limit'
import fastifyValkey from '@fastify/valkey-glide'

const fastify = Fastify()

await fastify.register(fastifyValkey, {
namespace: 'rateLimit',
addresses: [{ host: '127.0.0.1', port: 6379 }]
})

await fastify.register(rateLimit, {
max: 100,
timeWindow: '1 minute',
valkey: fastify.valkey.rateLimit
})
```

When using `@fastify/valkey-glide`, register it first so the resolved client is already available when `@fastify/rate-limit` is registered.

### Using Valkey GLIDE directly

If you want to manage the client yourself, you can use a standalone or cluster [`@valkey/valkey-glide`](https://github.com/valkey-io/valkey-glide) client with the `valkey` option.

```js
import Fastify from 'fastify'
import rateLimit from '@fastify/rate-limit'
import { GlideClient } from '@valkey/valkey-glide'

const fastify = Fastify()

const valkey = await GlideClient.createClient({
addresses: [{ host: '127.0.0.1', port: 6379 }]
})

fastify.addHook('onClose', async () => {
valkey.close()
})

await fastify.register(rateLimit, {
max: 100,
timeWindow: '1 minute',
valkey
})
```

For cluster mode, create a `GlideClusterClient` instead and pass it to the same `valkey` option. The built-in `ValkeyStore` uses a single-key script, so it is compatible with GLIDE's same-slot requirement for script execution.


### Preventing guessing of URLS through 404s

Expand Down Expand Up @@ -123,6 +188,7 @@ await fastify.register(import('@fastify/rate-limit'), {
cache: 10000, // default 5000
allowList: ['127.0.0.1'], // default []
redis: new Redis({ host: '127.0.0.1' }), // default null
valkey: valkeyClient, // default null
nameSpace: 'teste-ratelimit-', // default is 'fastify-rate-limit-'
continueExceeding: true, // default false
skipOnError: true, // default false
Expand Down Expand Up @@ -150,7 +216,8 @@ await fastify.register(import('@fastify/rate-limit'), {
- `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-'
- `valkey`: use a resolved Valkey GLIDE client. In Fastify applications, prefer obtaining this from [`@fastify/valkey-glide`](https://github.com/fastify/fastify-valkey-glide). You can also pass a direct [`@valkey/valkey-glide`](https://github.com/valkey-io/valkey-glide) `GlideClient` or `GlideClusterClient`. `@valkey/valkey-glide` is an optional peer dependency and only required when this option is used.
- `nameSpace`: choose which prefix to use in the external store key, 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).
Expand All @@ -165,6 +232,8 @@ await fastify.register(import('@fastify/rate-limit'), {
- `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.

`store`, `redis`, and `valkey` are mutually exclusive. Use only one storage option per plugin registration.

`keyGenerator` example usage:
```js
await fastify.register(import('@fastify/rate-limit'), {
Expand Down Expand Up @@ -517,6 +586,11 @@ If `isAllowed` is `false` the object also contains these additional properties:
- `isExceeded`: `true` if the limit was exceeded.
- `isBanned`: `true` if the request was banned according to the `ban` option.

### Examples of Valkey Clients

- [`@fastify/valkey-glide` integration](./example/example-fastify-valkey-glide.mjs)
- [Direct GLIDE client](./example/example-valkey-direct.mjs)

### 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:
Expand Down
33 changes: 33 additions & 0 deletions example/example-fastify-valkey-glide.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import Fastify from 'fastify'
import fastifyRateLimit from '../index.js'
import fastifyValkey from '@fastify/valkey-glide'

const fastify = Fastify()

await fastify.register(fastifyValkey, {
namespace: 'rateLimit',
addresses: [{ host: '127.0.0.1', port: 6379 }],
// clientMode: 'cluster'
})

await fastify.register(fastifyRateLimit, {
global: false,
max: 100,
timeWindow: '1 minute',
valkey: fastify.valkey.rateLimit,
nameSpace: 'fastify-rate-limit:'
})

fastify.get('/', {
config: {
rateLimit: {
max: 3,
timeWindow: '1 minute'
}
}
}, async () => {
return { hello: '@fastify/valkey-glide' }
})

await fastify.listen({ port: 3000 })
console.log('Server listening at http://localhost:3000')
35 changes: 35 additions & 0 deletions example/example-valkey-direct.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import Fastify from 'fastify'
import fastifyRateLimit from '../index.js'
import { GlideClient } from '@valkey/valkey-glide'

const fastify = Fastify()

const valkey = await GlideClient.createClient({
addresses: [{ host: '127.0.0.1', port: 6379 }]
})

fastify.addHook('onClose', async () => {
valkey.close()
})

await fastify.register(fastifyRateLimit, {
global: false,
max: 100,
timeWindow: '1 minute',
valkey,
nameSpace: 'fastify-rate-limit:'
})

fastify.get('/', {
config: {
rateLimit: {
max: 3,
timeWindow: '1 minute'
}
}
}, async () => {
return { hello: 'valkey direct client' }
})

await fastify.listen({ port: 3000 })
console.log('Server listening at http://localhost:3000')
17 changes: 16 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const { parse, format } = require('@lukeed/ms')

const LocalStore = require('./store/LocalStore')
const RedisStore = require('./store/RedisStore')
const ValkeyStore = require('./store/ValkeyStore')

const defaultMax = 1000
const defaultTimeWindow = 60000
Expand Down Expand Up @@ -35,6 +36,8 @@ const defaultErrorResponse = (_req, context) => {
}

async function fastifyRateLimit (fastify, settings) {
validateStoreOptions(settings)

const globalParams = {
global: (typeof settings.global === 'boolean') ? settings.global : true
}
Expand Down Expand Up @@ -116,7 +119,9 @@ async function fastifyRateLimit (fastify, settings) {
const Store = settings.store
pluginComponent.store = new Store(globalParams)
} else {
if (settings.redis) {
if (settings.valkey) {
pluginComponent.store = new ValkeyStore(globalParams.continueExceeding, globalParams.exponentialBackoff, settings.valkey, settings.nameSpace)
} 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)
Expand Down Expand Up @@ -157,6 +162,16 @@ async function fastifyRateLimit (fastify, settings) {
})
}

function validateStoreOptions (settings) {
if (settings.store && (settings.redis || settings.valkey)) {
throw new Error('store cannot be used together with redis or valkey')
}

if (settings.redis && settings.valkey) {
throw new Error('redis and valkey cannot be used together')
}
}

function mergeParams (...params) {
const result = Object.assign({}, ...params)

Expand Down
12 changes: 11 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"scripts": {
"lint": "eslint",
"lint:fix": "eslint --fix",
"redis": "docker run -p 6379:6379 --name rate-limit-redis -d --rm redis",
"redis": "docker run -p 6379:6379 --name rate-limit-redis -d --rm redis:latest",
"valkey": "docker run -p 6380:6379 --name rate-limit-valkey -d --rm valkey/valkey:latest",
"test": "npm run test:unit && npm run test:typescript",
"test:unit": "c8 --100 node --test",
"test:typescript": "tsd"
Expand Down Expand Up @@ -61,6 +62,7 @@
"devDependencies": {
"@sinonjs/fake-timers": "^15.0.0",
"@types/node": "^25.0.3",
"@valkey/valkey-glide": "^2.0.0",
"c8": "^11.0.0",
"eslint": "^9.17.0",
"fastify": "^5.0.0",
Expand All @@ -75,6 +77,14 @@
"fastify-plugin": "^5.0.0",
"toad-cache": "^3.7.0"
},
"peerDependencies": {
"@valkey/valkey-glide": "^2.0.0"
},
"peerDependenciesMeta": {
"@valkey/valkey-glide": {
"optional": true
}
},
"publishConfig": {
"access": "public"
}
Expand Down
86 changes: 86 additions & 0 deletions store/ValkeyStore.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
'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}
`

let cachedScriptCtor = null
let cachedScript = null

function getScriptCtor () {
if (ValkeyStore.Script) {
return ValkeyStore.Script
}

try {
return require('@valkey/valkey-glide').Script
} catch (err) {
err.message = 'Valkey support requires @valkey/valkey-glide to be installed'
throw err
Comment on lines +45 to +46
}
}

function getRateLimitScript () {
const Script = getScriptCtor()

if (cachedScriptCtor !== Script || cachedScript === null) {
cachedScriptCtor = Script
cachedScript = new Script(lua)
}

return cachedScript
}

function ValkeyStore (continueExceeding, exponentialBackoff, valkey, key = 'fastify-rate-limit-') {
this.continueExceeding = continueExceeding
this.exponentialBackoff = exponentialBackoff
this.valkey = valkey
this.key = key
this.script = getRateLimitScript()
}

ValkeyStore.prototype.incr = function (ip, cb, timeWindow, max) {
this.valkey.invokeScript(this.script, {
keys: [this.key + ip],
args: [String(timeWindow), String(max), String(this.continueExceeding), String(this.exponentialBackoff)]
}).then((result) => {
cb(null, { current: Number(result[0]), ttl: Number(result[1]) })
}, (err) => {
cb(err, null)
})
}

ValkeyStore.prototype.child = function (routeOptions) {
return new ValkeyStore(routeOptions.continueExceeding, routeOptions.exponentialBackoff, this.valkey, `${this.key}${routeOptions.routeInfo.method}${routeOptions.routeInfo.url}-`)
}

ValkeyStore.Script = null

module.exports = ValkeyStore
Loading