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 5 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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 10 additions & 3 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 @@ -24,7 +25,13 @@ LocalStore.prototype.incr = function (ip, cb, timeWindow, max) {
++current.current

// Reset TLL if max has been exceeded and `continueExceeding` is enabled
aniketcodes marked this conversation as resolved.
Show resolved Hide resolved
if (this.continueExceeding && current.current > max) {
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 if (this.continueExceeding && current.current > max) {
current.ttl = timeWindow
current.iterationStartMs = nowInMs
} else {
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
20 changes: 17 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 @@ -21,12 +27,20 @@ const lua = `
redis.call('PEXPIRE', key, timeWindow)
ttl = timeWindow
end

-- If the key is new or if its incremented value has exceeded the max value and exponential backoff is enabled then set its TTL
if ttl == -1 or (exponentialBackoff and current > max) then
local backoffExponent = current - max - 1
ttl = math.min(timeWindow * (2 ^ 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 +53,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