Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merge next into master #373

Merged
merged 20 commits into from
Jul 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1b9e201
Updating for v5 (#338)
eliphazbouye Nov 4, 2023
e5929ae
Merge branch 'master' of https://github.com/fastify/fastify-rate-limi…
gurgunday Nov 5, 2023
5446122
merge main to next (#348)
gurgunday Dec 13, 2023
ca230b8
Revert "merge main to next (#348)"
gurgunday Dec 13, 2023
7c61f50
Merge branch master of https://github.com/fastify/fastify-rate-limit …
gurgunday Dec 13, 2023
3a035c6
revert workflow update as its causing issues (#350)
gurgunday Dec 13, 2023
fe1ca6f
revert: handle ban in store (#347)
gurgunday Dec 17, 2023
68dbc8c
Merge branch 'master' of https://github.com/fastify/fastify-rate-limi…
gurgunday Dec 18, 2023
4aeb4c4
Merge branch 'master' of https://github.com/fastify/fastify-rate-limi…
gurgunday Dec 19, 2023
2dbf3c0
Merge branch 'master' of https://github.com/fastify/fastify-rate-limi…
gurgunday Jan 1, 2024
81276c3
Workflow v4 (#354)
gurgunday Jan 1, 2024
a5f104e
refactor: make RedisStore default key a default parameter (#356)
gurgunday Jan 8, 2024
9fc0a86
Add function support for timeWindow (#357)
mindrunner Jan 28, 2024
a06ec84
update docs (#360)
gurgunday Jan 29, 2024
3fed277
fix: remove unnecessary parameter timeWindow (#363)
gurgunday Feb 23, 2024
20d426b
refactor: consistent option handling (#365)
gurgunday Feb 24, 2024
aa83957
perf: pregenerate `timeWindow` string when possible and use `noop` as…
gurgunday Feb 24, 2024
7a9b632
Merge branch 'master' of https://github.com/fastify/fastify-rate-limi…
gurgunday Jul 6, 2024
bee84c8
update for v5 (#370)
gurgunday Jul 6, 2024
17bc97e
update fastify deps
jsumners Jul 10, 2024
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
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