Skip to content
Open
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
9 changes: 9 additions & 0 deletions store/LocalStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,15 @@ LocalStore.prototype.read = function (ip, cb, timeWindow, max) {
}

LocalStore.prototype.child = function (routeOptions) {
if (routeOptions.groupId) {
// Share the parent's LRU so routes in the same group collide on the same key
const child = Object.create(LocalStore.prototype)
child.continueExceeding = routeOptions.continueExceeding
child.exponentialBackoff = routeOptions.exponentialBackoff
child.lru = this.lru // reuse parent's cache, not a new one
return child
}
// fallback: existing per-route behavior — fresh LRU
return new LocalStore(routeOptions.continueExceeding, routeOptions.exponentialBackoff, routeOptions.cache)
}

Expand Down
11 changes: 10 additions & 1 deletion store/RedisStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,16 @@ RedisStore.prototype.read = function (ip, cb, timeWindow, max) {
}

RedisStore.prototype.child = function (routeOptions) {
return new RedisStore(routeOptions.continueExceeding, routeOptions.exponentialBackoff, this.redis, `${this.key}${routeOptions.routeInfo.method}${routeOptions.routeInfo.url}-`)
const namespace = routeOptions.groupId
? routeOptions.groupId
: routeOptions.routeInfo.method + routeOptions.routeInfo.url

return new RedisStore(
routeOptions.continueExceeding,
routeOptions.exponentialBackoff,
this.redis,
`${this.key}${namespace}-`
)
}

module.exports = RedisStore
32 changes: 32 additions & 0 deletions test/group-rate-limit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -250,3 +250,35 @@ test('Invalid groupId type', async () => {
assert.deepStrictEqual(err.message, 'groupId must be a string')
}
})

test('two routes sharing one groupId share the counter', async (t) => {
const app = Fastify()
t.after(() => app.close())
await app.register(rateLimit, { max: 100, timeWindow: '1 minute' })

const config = { rateLimit: { max: 3, timeWindow: '1 minute', groupId: 'OTP' } }
app.get('/otp/send', { config }, async () => 'sent')
app.get('/otp/resend', { config }, async () => 'resent')

const urls = ['/otp/send', '/otp/send', '/otp/send', '/otp/resend']
let lastRes
for (const url of urls) {
lastRes = await app.inject({ url })
}
t.assert.strictEqual(lastRes.statusCode, 429)
})

test('two routes without groupId keep separate counters', async (t) => {
const app = Fastify()
t.after(() => app.close())
await app.register(rateLimit, { max: 100, timeWindow: '1 minute' })

const config = { rateLimit: { max: 3, timeWindow: '1 minute' } } // no groupId
app.get('/route-a', { config }, async () => 'a')
app.get('/route-b', { config }, async () => 'b')

const res1 = await app.inject({ url: '/route-a' })
const res2 = await app.inject({ url: '/route-b' })
t.assert.strictEqual(res1.statusCode, 200)
t.assert.strictEqual(res2.statusCode, 200)
})
Loading