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

Added exponential backoff #387

Merged
merged 10 commits into from
Nov 15, 2024
Merged
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ await fastify.register(import('@fastify/rate-limit'), {
- `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).
- `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
- `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/master/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
Expand All @@ -160,6 +160,7 @@ await fastify.register(import('@fastify/rate-limit'), {
- `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
Expand Down
9 changes: 7 additions & 2 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ async function fastifyRateLimit (fastify, settings) {
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
Expand All @@ -116,9 +117,9 @@ async function fastifyRateLimit (fastify, settings) {
pluginComponent.store = new Store(globalParams)
} else {
if (settings.redis) {
pluginComponent.store = new RedisStore(globalParams.continueExceeding, settings.redis, settings.nameSpace)
pluginComponent.store = new RedisStore(globalParams.continueExceeding, globalParams.exponentialBackoff, settings.redis, settings.nameSpace)
} else {
pluginComponent.store = new LocalStore(globalParams.continueExceeding, settings.cache)
pluginComponent.store = new LocalStore(globalParams.continueExceeding, globalParams.exponentialBackoff, settings.cache)
}
}

Expand Down Expand Up @@ -252,6 +253,10 @@ function rateLimitRequestHandler (pluginComponent, params) {
current = res.current
ttl = res.ttl
ttlInSeconds = Math.ceil(res.ttl / 1000)

if (params.exponentialBackoff) {
timeWindowString = ms.format(ttl, true)
}
} catch (err) {
if (!params.skipOnError) {
throw err
Expand Down
11 changes: 9 additions & 2 deletions store/LocalStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

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

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

Expand All @@ -27,6 +28,12 @@ LocalStore.prototype.incr = function (ip, cb, timeWindow, max) {
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)
}
Expand All @@ -37,7 +44,7 @@ LocalStore.prototype.incr = function (ip, cb, timeWindow, max) {
}

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

module.exports = LocalStore
19 changes: 16 additions & 3 deletions store/RedisStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ const lua = `
-- 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)

Expand All @@ -20,13 +26,20 @@ const lua = `
if ttl == -1 or (continueExceeding and current > max) then
redis.call('PEXPIRE', key, timeWindow)
ttl = timeWindow

-- If the key is new or if its incremented value has exceeded the max value and exponential backoff is enabled then set its TTL
elseif exponentialBackoff and current > max then
local backoffExponent = current - max - 1
ttl = math.min(timeWindow * (2.0 ^ backoffExponent), MAX_SAFE_INTEGER)
redis.call('PEXPIRE', key, ttl)
end

return {current, ttl}
`

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

Expand All @@ -39,13 +52,13 @@ function RedisStore (continueExceeding, redis, key = 'fastify-rate-limit-') {
}

RedisStore.prototype.incr = function (ip, cb, timeWindow, max) {
this.redis.rateLimit(this.key + ip, timeWindow, max, this.continueExceeding, (err, result) => {
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, this.redis, `${this.key}${routeOptions.routeInfo.method}${routeOptions.routeInfo.url}-`)
return new RedisStore(routeOptions.continueExceeding, routeOptions.exponentialBackoff, this.redis, `${this.key}${routeOptions.routeInfo.method}${routeOptions.routeInfo.url}-`)
}

module.exports = RedisStore
232 changes: 232 additions & 0 deletions test/exponential-backoff.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
'use strict'
const { test } = require('node:test')
const assert = require('assert')
const Fastify = require('fastify')
const rateLimit = require('../index')

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))

test('Exponential Backoff', async (t) => {
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 (req, reply) => '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 500 ms'
},
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 (t) => {
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 (req, reply) => '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 500 ms'
},
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 (t) => {
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 (req, reply) => '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)
)
})
2 changes: 2 additions & 0 deletions types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ declare namespace fastifyRateLimit {
enableDraftSpec?: boolean;
onExceeding?: (req: FastifyRequest, key: string) => void;
onExceeded?: (req: FastifyRequest, key: string) => void;
exponentialBackoff?: boolean;
aniketcodes marked this conversation as resolved.
Show resolved Hide resolved

}

export interface RateLimitPluginOptions extends RateLimitOptions {
Expand Down
Loading