Skip to content

Commit

Permalink
Merge next into master (#373)
Browse files Browse the repository at this point in the history
* Updating for v5 (#338)

* ci: add node 21 to test matrix and remove node 14 and 16

* package.json update for v5

* merge main to next (#348)

* Update README.md to reflect #307 (#342)

* Redis should work with ban (#336)

* Redis should work with ban

* fix ttl

* simplify

* format

* remove warning

* move property

* add test

* fix test

* fix test

* fix test

* still fix the same test

* make it non-breaking

* make it non-breaking

* make it non-breaking

* make it non-breaking

* perf: add ban back

* Bumped v8.1.0

Signed-off-by: Matteo Collina <hello@matteocollina.com>

* Bumped v9.0.0

Signed-off-by: Matteo Collina <hello@matteocollina.com>

* Fix in-memory cache bug when there is only one entry (#345)

* Bumped v9.0.1

---------

Signed-off-by: Matteo Collina <hello@matteocollina.com>
Co-authored-by: Trevor Lund <89029310+tlund101@users.noreply.github.com>
Co-authored-by: Matteo Collina <hello@matteocollina.com>
Co-authored-by: Igor Savin <iselwin@gmail.com>

* Revert "merge main to next (#348)"

This reverts commit 5446122.

* revert workflow update as its causing issues (#350)

* revert: handle ban in store (#347)

* remove ban handling from store

* don't use negation

* add undef

* don't pass ban to promis

* fix accidental explicit checks

* fix accidental line change

* inline ban

* Workflow v4 (#354)

* refactor: make RedisStore default key a default parameter (#356)

* refactor redis parameters and throw if no instance was provided

* revert error

* Add function support for timeWindow (#357)

* feat: dynamic time window support

* fix: proper usage of timeWindowString

---------

Co-authored-by: lukas <git@lukaselsner.de>

* update docs (#360)

* fix: remove unnecessary parameter timeWindow (#363)

* refactor: consistent option handling (#365)

* small refactor in option handling

* simplify

* simplify

* perf: pregenerate `timeWindow` string when possible and use `noop` as default function (#364)

* pregenerate timeWindowString if possible

* simplify

* simplify

* noop

* simplify

* simple-example

* rename

* update for v5 (#370)

* update for v5

* update for v5

* update for v5

* update for v5

* update for v5

* update for v5

* update for v5

* update for v5

* update for v5

* update for v5

* update for v5

* update for v5

* update for v5

* update for v5

* update fastify deps

---------

Signed-off-by: Matteo Collina <hello@matteocollina.com>
Co-authored-by: Eliphaz Bouye <53824344+Eliphaz-Bouye@users.noreply.github.com>
Co-authored-by: Gürgün Dayıoğlu <gurgun.dayioglu@icloud.com>
Co-authored-by: Trevor Lund <89029310+tlund101@users.noreply.github.com>
Co-authored-by: Matteo Collina <hello@matteocollina.com>
Co-authored-by: Igor Savin <iselwin@gmail.com>
Co-authored-by: lukas <mindrunner@users.noreply.github.com>
Co-authored-by: lukas <git@lukaselsner.de>
Co-authored-by: Gürgün Dayıoğlu <hey@gurgun.day>
  • Loading branch information
9 people authored Jul 10, 2024
1 parent 595a627 commit ad026a8
Show file tree
Hide file tree
Showing 12 changed files with 278 additions and 176 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
permissions:
contents: write
pull-requests: write
uses: fastify/workflows/.github/workflows/plugins-ci-redis.yml@v3
uses: fastify/workflows/.github/workflows/plugins-ci-redis.yml@v5.0.0
with:
license-check: true
lint: true
7 changes: 1 addition & 6 deletions .taprc
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
jobs: 1

branches: 96
functions: 100
lines: 100
statements: 98

disable-coverage: true
files:
- test/**/*.test.js
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ await fastify.register(import('@fastify/rate-limit'), {
- `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 or as a string (in the [`ms`](https://github.com/zeit/ms) format)
- `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 a 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/master/example/example.js).
Expand Down
25 changes: 25 additions & 0 deletions example/example-simple.mjs
Original file line number Diff line number Diff line change
@@ -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()
89 changes: 53 additions & 36 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ const draftSpecHeaders = {
retryAfter: 'retry-after'
}

const defaultOnFn = () => {}

const defaultKeyGenerator = (req) => req.ip

const defaultErrorResponse = (req, context) => {
Expand Down Expand Up @@ -59,23 +61,35 @@ async function fastifyRateLimit (fastify, settings) {
}, settings.addHeadersOnExceeding)

// Global maximum allowed requests
globalParams.max = ((typeof settings.max === 'number' && Number.isFinite(settings.max) && (settings.max = Math.trunc(settings.max)) >= 0) || typeof settings.max === 'function')
? settings.max
: defaultMax
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
globalParams.timeWindow = typeof settings.timeWindow === 'string'
? ms.parse(settings.timeWindow)
: typeof settings.timeWindow === 'number' && Number.isFinite(settings.timeWindow) && settings.timeWindow >= 0
? Math.trunc(settings.timeWindow)
: defaultTimeWindow
if (Number.isFinite(settings.timeWindow) && settings.timeWindow >= 0) {
globalParams.timeWindow = Math.trunc(settings.timeWindow)
} else if (typeof settings.timeWindow === 'string') {
globalParams.timeWindow = ms.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 = typeof settings.ban === 'number' && Number.isFinite(settings.ban) && settings.ban >= 0 ? Math.trunc(settings.ban) : -1
globalParams.onBanReach = typeof settings.onBanReach === 'function' ? settings.onBanReach : null
globalParams.onExceeding = typeof settings.onExceeding === 'function' ? settings.onExceeding : null
globalParams.onExceeded = typeof settings.onExceeded === 'function' ? settings.onExceeded : 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.keyGenerator = typeof settings.keyGenerator === 'function'
Expand All @@ -102,9 +116,9 @@ async function fastifyRateLimit (fastify, settings) {
pluginComponent.store = new Store(globalParams)
} else {
if (settings.redis) {
pluginComponent.store = new RedisStore(settings.redis, globalParams.timeWindow, settings.continueExceeding, settings.nameSpace || 'fastify-rate-limit-')
pluginComponent.store = new RedisStore(globalParams.continueExceeding, settings.redis, settings.nameSpace)
} else {
pluginComponent.store = new LocalStore(settings.cache, globalParams.timeWindow, settings.continueExceeding)
pluginComponent.store = new LocalStore(globalParams.continueExceeding, settings.cache)
}
}

Expand Down Expand Up @@ -143,21 +157,21 @@ async function fastifyRateLimit (fastify, settings) {
function mergeParams (...params) {
const result = Object.assign({}, ...params)

if (typeof result.timeWindow === 'string') {
result.timeWindow = ms.parse(result.timeWindow)
} else if (typeof result.timeWindow === 'number' && Number.isFinite(result.timeWindow) && result.timeWindow >= 0) {
if (Number.isFinite(result.timeWindow) && result.timeWindow >= 0) {
result.timeWindow = Math.trunc(result.timeWindow)
} else {
} else if (typeof result.timeWindow === 'string') {
result.timeWindow = ms.parse(result.timeWindow)
} else if (typeof result.timeWindow !== 'function') {
result.timeWindow = defaultTimeWindow
}

if (typeof result.max === 'number' && Number.isFinite(result.max) && result.max >= 0) {
if (Number.isFinite(result.max) && result.max >= 0) {
result.max = Math.trunc(result.max)
} else if (typeof result.max !== 'function') {
result.max = defaultMax
}

if (typeof result.ban === 'number' && Number.isFinite(result.ban) && result.ban >= 0) {
if (Number.isFinite(result.ban) && result.ban >= 0) {
result.ban = Math.trunc(result.ban)
} else {
result.ban = -1
Expand All @@ -180,7 +194,11 @@ function addRouteRateHook (pluginComponent, params, routeOptions) {

function rateLimitRequestHandler (pluginComponent, params) {
const { rateLimitRan, store } = pluginComponent
const timeWindowString = ms.format(params.timeWindow, true)

let timeWindowString
if (typeof params.timeWindow === 'number') {
timeWindowString = ms.format(params.timeWindow, true)
}

return async (req, res) => {
if (req[rateLimitRan]) {
Expand All @@ -204,58 +222,57 @@ function rateLimitRequestHandler (pluginComponent, params) {
}

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 timeLeftInSeconds = 0
let ban = false
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)
}, max, params.ban)
}, timeWindow, max)
})

current = res.current
ttl = res.ttl
ban = res.ban ?? (params.ban !== -1 && current - max > params.ban)
ttlInSeconds = Math.ceil(res.ttl / 1000)
} catch (err) {
if (!params.skipOnError) {
throw err
}
}

timeLeftInSeconds = Math.ceil(ttl / 1000)

if (current <= max) {
if (params.addHeadersOnExceeding[params.labels.rateLimit]) { res.header(params.labels.rateLimit, max) }
if (params.addHeadersOnExceeding[params.labels.rateRemaining]) { res.header(params.labels.rateRemaining, max - current) }
if (params.addHeadersOnExceeding[params.labels.rateReset]) { res.header(params.labels.rateReset, timeLeftInSeconds) }
if (params.addHeadersOnExceeding[params.labels.rateReset]) { res.header(params.labels.rateReset, ttlInSeconds) }

params.onExceeding?.(req, key)
params.onExceeding(req, key)

return
}

params.onExceeded?.(req, key)
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, timeLeftInSeconds) }
if (params.addHeaders[params.labels.retryAfter]) { res.header(params.labels.retryAfter, timeLeftInSeconds) }
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,
ban: false,
max,
ttl,
after: timeWindowString
after: timeWindowString ?? ms.format(timeWindow, true)
}

if (ban) {
if (params.ban !== -1 && current - max > params.ban) {
respCtx.statusCode = 403
params.onBanReach?.(req, key)
respCtx.ban = true
params.onBanReach(req, key)
}

throw params.errorResponseBuilder(req, respCtx)
Expand Down
26 changes: 13 additions & 13 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,21 +29,21 @@
},
"homepage": "https://github.com/fastify/fastify-rate-limit#readme",
"devDependencies": {
"@fastify/pre-commit": "^2.0.2",
"@sinonjs/fake-timers": "^11.0.0",
"@types/node": "^20.1.1",
"fastify": "^4.7.0",
"ioredis": "^5.0.5",
"knex": "^3.0.1",
"sqlite3": "^5.0.2",
"standard": "^17.0.0",
"tap": "^16.0.0",
"tsd": "^0.31.0"
"@fastify/pre-commit": "^2.1.0",
"@sinonjs/fake-timers": "^11.2.2",
"@types/node": "^20.14.10",
"fastify": "^5.0.0-alpha.3",
"ioredis": "^5.4.1",
"knex": "^3.1.0",
"sqlite3": "^5.1.7",
"standard": "^17.1.0",
"tap": "20.0.3",
"tsd": "^0.31.1"
},
"dependencies": {
"@lukeed/ms": "^2.0.1",
"fastify-plugin": "^4.0.0",
"toad-cache": "^3.3.1"
"@lukeed/ms": "^2.0.2",
"fastify-plugin": "^5.0.0-pre.fv5.1",
"toad-cache": "^3.7.0"
},
"publishConfig": {
"access": "public"
Expand Down
24 changes: 9 additions & 15 deletions store/LocalStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,48 +2,42 @@

const { LruMap: Lru } = require('toad-cache')

function LocalStore (cache = 5000, timeWindow, continueExceeding) {
this.lru = new Lru(cache)
this.timeWindow = timeWindow
function LocalStore (continueExceeding, cache = 5000) {
this.continueExceeding = continueExceeding
this.lru = new Lru(cache)
}

LocalStore.prototype.incr = function (ip, cb, max, ban) {
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: this.timeWindow, ban: false, iterationStartMs: nowInMs }
} else if (current.iterationStartMs + this.timeWindow <= nowInMs) {
current = { current: 1, ttl: timeWindow, iterationStartMs: nowInMs }
} else if (current.iterationStartMs + timeWindow <= nowInMs) {
// Item has expired
current.current = 1
current.ttl = this.timeWindow
current.ban = false
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 = this.timeWindow
current.ttl = timeWindow
current.iterationStartMs = nowInMs
} else {
current.ttl = this.timeWindow - (nowInMs - current.iterationStartMs)
current.ttl = timeWindow - (nowInMs - current.iterationStartMs)
}
}

if (ban !== -1 && !current.ban && current.current - max > ban) {
current.ban = true
}

this.lru.set(ip, current)
cb(null, current)
}

LocalStore.prototype.child = function (routeOptions) {
return new LocalStore(routeOptions.cache, routeOptions.timeWindow, routeOptions.continueExceeding)
return new LocalStore(routeOptions.continueExceeding, routeOptions.cache)
}

module.exports = LocalStore
19 changes: 8 additions & 11 deletions store/RedisStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,8 @@ const lua = `
local timeWindow = tonumber(ARGV[1])
-- Max requests
local max = tonumber(ARGV[2])
-- Ban after this number is exceeded
local ban = tonumber(ARGV[3])
-- Flag to determine if TTL should be reset after exceeding
local continueExceeding = ARGV[4] == 'true'
local continueExceeding = ARGV[3] == 'true'
-- Increment the key's value
local current = redis.call('INCR', key)
Expand All @@ -24,13 +22,12 @@ const lua = `
ttl = timeWindow
end
return {current, ttl, ban ~= -1 and current - max > ban}
return {current, ttl}
`

function RedisStore (redis, timeWindow, continueExceeding, key) {
this.redis = redis
this.timeWindow = timeWindow
function RedisStore (continueExceeding, redis, key = 'fastify-rate-limit-') {
this.continueExceeding = continueExceeding
this.redis = redis
this.key = key

if (!this.redis.rateLimit) {
Expand All @@ -41,14 +38,14 @@ function RedisStore (redis, timeWindow, continueExceeding, key) {
}
}

RedisStore.prototype.incr = function (ip, cb, max, ban) {
this.redis.rateLimit(this.key + ip, this.timeWindow, max, ban, this.continueExceeding, (err, result) => {
err ? cb(err, null) : cb(null, { current: result[0], ttl: result[1], ban: result[2] })
RedisStore.prototype.incr = function (ip, cb, timeWindow, max) {
this.redis.rateLimit(this.key + ip, timeWindow, max, this.continueExceeding, (err, result) => {
err ? cb(err, null) : cb(null, { current: result[0], ttl: result[1] })
})
}

RedisStore.prototype.child = function (routeOptions) {
return new RedisStore(this.redis, routeOptions.timeWindow, routeOptions.continueExceeding, this.key + routeOptions.routeInfo.method + routeOptions.routeInfo.url + '-')
return new RedisStore(routeOptions.continueExceeding, this.redis, `${this.key}${routeOptions.routeInfo.method}${routeOptions.routeInfo.url}-`)
}

module.exports = RedisStore
Loading

0 comments on commit ad026a8

Please sign in to comment.