Skip to content
Closed
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
42 changes: 38 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,33 @@ 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

### Valkey

Use [`@fastify/valkey-glide`](https://github.com/fastify/fastify-valkey-glide) to share a GLIDE client with the rate limiter:

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

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

const fastify = Fastify()

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

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

Register `@fastify/valkey-glide` first so its client is available. Install `@valkey/valkey-glide` at the application root, as above, so both plugins use the same GLIDE installation. The `valkey` option accepts both standalone and cluster clients, including a caller-owned `GlideClient` or `GlideClusterClient`.

### Preventing guessing of URLS through 404s

Expand Down Expand Up @@ -151,7 +178,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-redisport-host-options) 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/blob/main/example/example.js).
- `nameSpace`: choose which prefix to use in the redis, default is 'fastify-rate-limit-'
- `valkey`: use a `GlideClient` or `GlideClusterClient` from [`@valkey/valkey-glide`](https://github.com/valkey-io/valkey-glide) instead of `redis`. In Fastify applications, [`@fastify/valkey-glide`](https://github.com/fastify/fastify-valkey-glide) can manage this client.
- `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 Down Expand Up @@ -545,8 +573,8 @@ fastify.post('/login', async (request, reply) => {
A few things to keep in mind when using `{ increment: false }`:

- **It is a non-mutating snapshot.** A peek never increments the counter, resets the window, or advances the `ban`/`continueExceeding`/`exponentialBackoff` side effects that a real request triggers. The returned `isExceeded`/`isBanned` therefore reflect the *current* counters, but a peek will not, by itself, escalate a ban or extend a backoff window.
- **`ttl` reflects the store's current window.** With the Redis store, `ttl` is the raw server `PTTL` (the same value `incr` reports), so it can exceed the configured `timeWindow` when `continueExceeding`/`exponentialBackoff` has extended it.
- **Custom stores must implement `read`.** The flag relies on a non-mutating `read(ip, cb, timeWindow, max)` method, which mirrors the `incr` signature. The built-in local and Redis stores provide it; a custom store that does not will throw a clear error when called with `{ increment: false }`.
- **`ttl` reflects the store's current window.** With the Redis and Valkey stores, `ttl` is the raw server `PTTL` (the same value `incr` reports), so it can exceed the configured `timeWindow` when `continueExceeding`/`exponentialBackoff` has extended it.
- **Custom stores must implement `read`.** The flag relies on a non-mutating `read(ip, cb, timeWindow, max)` method, which mirrors the `incr` signature. The built-in local, Redis, and Valkey stores provide it; a custom store that does not will throw a clear error when called with `{ increment: false }`.

### Examples of Custom Store

Expand All @@ -569,11 +597,17 @@ The response will have the following headers if `enableDraftSpec` is `true`:
|`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:
To run tests locally with Redis:
```
npm run redis
```

To include the Valkey integration test:
```
npm run valkey
VALKEY_HOST=127.0.0.1 npm test
```

<a name="license"></a>
## License

Expand Down
13 changes: 9 additions & 4 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,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 @@ -145,12 +146,16 @@ async function fastifyRateLimit (fastify, settings) {
if (settings.store) {
const Store = settings.store
pluginComponent.store = new Store(globalParams)
} else {
} else if (settings.valkey) {
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)
throw new Error('redis and valkey cannot be used together')
}

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)
}

fastify.decorateRequest(pluginComponent.rateLimitRan, false)
Expand Down
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"lint": "eslint",
"lint:fix": "eslint --fix",
"redis": "docker run -p 6379:6379 --name rate-limit-redis -d --rm redis",
"valkey": "docker run -p 6379: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": "tstyche"
Expand Down Expand Up @@ -61,6 +62,7 @@
"devDependencies": {
"@sinonjs/fake-timers": "^15.0.0",
"@types/node": "^26.0.1",
"@valkey/valkey-glide": "^2.0.1",
"c8": "^12.0.0",
"eslint": "^9.17.0",
"fastify": "^5.0.0",
Expand All @@ -76,6 +78,14 @@
"ip-address": "^10.2.0",
"toad-cache": "^3.7.0"
},
"peerDependencies": {
"@valkey/valkey-glide": "^2.0.1"
},
"peerDependenciesMeta": {
"@valkey/valkey-glide": {
"optional": true
}
},
"publishConfig": {
"access": "public"
}
Expand Down
55 changes: 1 addition & 54 deletions store/RedisStore.js
Original file line number Diff line number Diff line change
@@ -1,59 +1,6 @@
'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}
`

const luaRead = `
-- Key to operate on
local key = KEYS[1]

-- Read the counter without mutating it
local current = redis.call('GET', key)

-- A missing key returns false from redis.call (and nil from redis.pcall);
-- "not current" covers both, so the clean-state branch is robust either way.
if not current then
-- Key doesn't exist: clean state
return {0, 0}
end

-- Read the remaining TTL in milliseconds
local ttl = redis.call('PTTL', key)
if ttl < 0 then
-- -2 (no key) or -1 (no expiry): report no active window
ttl = 0
end

return {tonumber(current), ttl}
`
const { increment: lua, read: luaRead } = require('./lua')

function RedisStore (continueExceeding, exponentialBackoff, redis, key = 'fastify-rate-limit-') {
this.continueExceeding = continueExceeding
Expand Down
65 changes: 65 additions & 0 deletions store/ValkeyStore.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
'use strict'

const lua = require('./lua')

// GLIDE stores script sources in a native registry. Keep the two fixed scripts
// alive for this module's lifetime so every store can recover from cache misses.
let sharedScripts

function createScripts () {
let Script

try {
Script = require('@valkey/valkey-glide').Script
} catch (cause) {
throw new Error('Valkey support requires @valkey/valkey-glide to be installed', { cause })
}

return {
increment: new Script(lua.increment),
read: new Script(lua.read)
}
}

function getScripts () {
if (!sharedScripts) {
sharedScripts = createScripts()
}

return sharedScripts
}

function invokeScript (valkey, script, options, cb) {
valkey.invokeScript(script, options).then((result) => {
cb(null, { current: Number(result[0]), ttl: Number(result[1]) })
}, (err) => {
cb(err, null)
})
}

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

ValkeyStore.prototype.incr = function (ip, cb, timeWindow, max) {
invokeScript(this.valkey, this.scripts.increment, {
keys: [this.key + ip],
args: [String(timeWindow), String(max), String(this.continueExceeding), String(this.exponentialBackoff)]
}, cb)
}

ValkeyStore.prototype.read = function (ip, cb, timeWindow, max) {
invokeScript(this.valkey, this.scripts.read, {
keys: [this.key + ip]
}, cb)
}

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

module.exports = ValkeyStore
58 changes: 58 additions & 0 deletions store/lua.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
'use strict'

const increment = `
-- 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}
`

const read = `
-- Key to operate on
local key = KEYS[1]

-- Read the counter without mutating it
local current = redis.call('GET', key)

-- A missing key returns false from redis.call (and nil from redis.pcall);
-- "not current" covers both, so the clean-state branch is robust either way.
if not current then
-- Key doesn't exist: clean state
return {0, 0}
end

-- Read the remaining TTL in milliseconds
local ttl = redis.call('PTTL', key)
if ttl < 0 then
-- -2 (no key) or -1 (no expiry): report no active window
ttl = 0
end

return {tonumber(current), ttl}
`

module.exports = { increment, read }
Loading