From 05575b62438c5c54c0342821108c39f7b3bf3cde Mon Sep 17 00:00:00 2001 From: luizroberto <105612296+Luiz-Honorato@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:40:13 -0300 Subject: [PATCH 1/2] @ feat: add increment option to createRateLimit Allow consumers of fastify.createRateLimit() to check the rate limit status without consuming a request, by passing { increment: false } as an optional second argument to the limiter function. Closes #420 @ --- README.md | 23 ++++++++ index.js | 8 +-- store/LocalStore.js | 15 +++++ store/RedisStore.js | 35 ++++++++++++ test/create-rate-limit.test.js | 100 +++++++++++++++++++++++++++++++++ test/redis-rate-limit.test.js | 68 ++++++++++++++++++++++ types/index.d.ts | 2 +- types/index.tst.ts | 3 + 8 files changed, 249 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a0732d3..da66ad2 100644 --- a/README.md +++ b/README.md @@ -517,6 +517,29 @@ If `isAllowed` is `false` the object also contains these additional properties: - `isExceeded`: `true` if the limit was exceeded. - `isBanned`: `true` if the request was banned according to the `ban` option. +The limiter function accepts an optional second argument `{ increment: boolean }`. When `increment` is `false`, the current rate limit status is returned **without consuming a request**. This is useful when a limit should only be enforced on certain outcomes (e.g. failed login attempts) while still checking the status before processing. + +```js +const checkRateLimit = fastify.createRateLimit({ max: 5, timeWindow: '1 minute' }); + +fastify.post('/login', async (request, reply) => { + // Peek at the current status without consuming a request + const status = await checkRateLimit(request, { increment: false }); + if (status.isExceeded) { + return reply.code(429).send({ error: 'Too many attempts' }); + } + + const success = await tryLogin(request.body); + if (!success) { + // Only consume a request when the login fails + await checkRateLimit(request); + return reply.code(401).send({ error: 'Invalid credentials' }); + } + + return { ok: true }; +}); +``` + ### Examples of Custom Store These examples show an overview of the `store` feature and you should take inspiration from it and tweak as you need: diff --git a/index.js b/index.js index a77dad1..745bc2b 100644 --- a/index.js +++ b/index.js @@ -128,7 +128,7 @@ async function fastifyRateLimit (fastify, settings) { if (!fastify.hasDecorator('createRateLimit')) { fastify.decorate('createRateLimit', (options) => { const args = createLimiterArgs(pluginComponent, globalParams, options) - return (req) => applyRateLimit.apply(this, args.concat(req)) + return (req, callOptions) => applyRateLimit.apply(this, args.concat(req, callOptions)) }) } @@ -210,7 +210,7 @@ function addRouteRateHook (pluginComponent, params, routeOptions) { } } -async function applyRateLimit (pluginComponent, params, req) { +async function applyRateLimit (pluginComponent, params, req, callOptions) { const { store } = pluginComponent // Retrieve the key from the generator (the global one or the one defined in the endpoint) @@ -244,10 +244,10 @@ async function applyRateLimit (pluginComponent, params, req) { let ttl = 0 let ttlInSeconds = 0 - // We increment the rate limit for the current request + const storeMethod = callOptions?.increment === false ? 'read' : 'incr' try { const res = await new Promise((resolve, reject) => { - store.incr(key, (err, res) => { + store[storeMethod](key, (err, res) => { err ? reject(err) : resolve(res) }, timeWindow, max) }) diff --git a/store/LocalStore.js b/store/LocalStore.js index f465b65..7251456 100644 --- a/store/LocalStore.js +++ b/store/LocalStore.js @@ -43,6 +43,21 @@ LocalStore.prototype.incr = function (ip, cb, timeWindow, max) { cb(null, current) } +LocalStore.prototype.read = function (ip, cb, timeWindow) { + const nowInMs = Date.now() + const current = this.lru.get(ip) + + if (!current || current.iterationStartMs + timeWindow <= nowInMs) { + // Item doesn't exist or has expired: report a clean state without mutating + cb(null, { current: 0, ttl: 0 }) + return + } + + // Item is alive: report the current state without mutating + const ttl = timeWindow - (nowInMs - current.iterationStartMs) + cb(null, { current: current.current, ttl }) +} + LocalStore.prototype.child = function (routeOptions) { return new LocalStore(routeOptions.continueExceeding, routeOptions.exponentialBackoff, routeOptions.cache) } diff --git a/store/RedisStore.js b/store/RedisStore.js index a3501d0..3e7da00 100644 --- a/store/RedisStore.js +++ b/store/RedisStore.js @@ -31,6 +31,28 @@ const lua = ` return {current, timeWindow} ` +const luaRead = ` + -- Key to operate on + local key = KEYS[1] + + -- Read the counter without mutating it + local current = redis.call('GET', key) + + if current == false then + -- Key doesn't exist: clean state + return {0, 0} + end + + -- Read the remaining TTL in milliseconds + local ttl = redis.call('PTTL', key) + if ttl < 0 then + -- -2 (no key) or -1 (no expiry): report no active window + ttl = 0 + end + + return {tonumber(current), ttl} +` + function RedisStore (continueExceeding, exponentialBackoff, redis, key = 'fastify-rate-limit-') { this.continueExceeding = continueExceeding this.exponentialBackoff = exponentialBackoff @@ -43,6 +65,13 @@ function RedisStore (continueExceeding, exponentialBackoff, redis, key = 'fastif lua }) } + + if (!this.redis.rateLimitRead) { + this.redis.defineCommand('rateLimitRead', { + numberOfKeys: 1, + lua: luaRead + }) + } } RedisStore.prototype.incr = function (ip, cb, timeWindow, max) { @@ -51,6 +80,12 @@ RedisStore.prototype.incr = function (ip, cb, timeWindow, max) { }) } +RedisStore.prototype.read = function (ip, cb) { + this.redis.rateLimitRead(this.key + ip, (err, result) => { + err ? cb(err, null) : cb(null, { current: result[0], ttl: result[1] }) + }) +} + RedisStore.prototype.child = function (routeOptions) { return new RedisStore(routeOptions.continueExceeding, routeOptions.exponentialBackoff, this.redis, `${this.key}${routeOptions.routeInfo.method}${routeOptions.routeInfo.url}-`) } diff --git a/test/create-rate-limit.test.js b/test/create-rate-limit.test.js index 9069669..ee80398 100644 --- a/test/create-rate-limit.test.js +++ b/test/create-rate-limit.test.js @@ -221,4 +221,104 @@ test('With allow list', async t => { isAllowed: true, key: '127.0.0.1' }) + + clock.reset() +}) + +test('With { increment: false } it reads the state without consuming it', async t => { + t.plan(10) + const clock = mock.timers + clock.enable(0) + const fastify = Fastify() + await fastify.register(rateLimit, { + global: false, + max: 2, + timeWindow: 1000 + }) + + const checkRateLimit = fastify.createRateLimit() + + fastify.get('/peek', async (req) => checkRateLimit(req, { increment: false })) + fastify.get('/consume', async (req) => checkRateLimit(req)) + + let res + + // Peek before any request: clean state, nothing consumed + res = await fastify.inject('/peek') + t.assert.deepStrictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.json(), { + isAllowed: false, + key: '127.0.0.1', + max: 2, + timeWindow: 1000, + remaining: 2, + ttl: 0, + ttlInSeconds: 0, + isExceeded: false, + isBanned: false + }) + + // Consume one request + res = await fastify.inject('/consume') + t.assert.deepStrictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.json(), { + isAllowed: false, + key: '127.0.0.1', + max: 2, + timeWindow: 1000, + remaining: 1, + ttl: 1000, + ttlInSeconds: 1, + isExceeded: false, + isBanned: false + }) + + // Peek again: reads the active window without consuming (remaining stays 1) + res = await fastify.inject('/peek') + t.assert.deepStrictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.json(), { + isAllowed: false, + key: '127.0.0.1', + max: 2, + timeWindow: 1000, + remaining: 1, + ttl: 1000, + ttlInSeconds: 1, + isExceeded: false, + isBanned: false + }) + + // Consume again: now the limit is reached + res = await fastify.inject('/consume') + t.assert.deepStrictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.json(), { + isAllowed: false, + key: '127.0.0.1', + max: 2, + timeWindow: 1000, + remaining: 0, + ttl: 1000, + ttlInSeconds: 1, + isExceeded: false, + isBanned: false + }) + + // After the window expires, peek reports a clean state again + clock.tick(1100) + + res = await fastify.inject('/peek') + t.assert.deepStrictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.json(), { + isAllowed: false, + key: '127.0.0.1', + max: 2, + timeWindow: 1000, + remaining: 2, + ttl: 0, + ttlInSeconds: 0, + isExceeded: false, + isBanned: false + }) + + clock.reset() }) diff --git a/test/redis-rate-limit.test.js b/test/redis-rate-limit.test.js index f631ab7..df103b8 100644 --- a/test/redis-rate-limit.test.js +++ b/test/redis-rate-limit.test.js @@ -428,6 +428,74 @@ describe('Global rate limit', () => { await redis.flushall() await redis.quit() }) + + test('With { increment: false } it reads the state without consuming it', async (t) => { + t.plan(5) + const fastify = Fastify() + const redis = await new Redis({ host: REDIS_HOST }) + await redis.flushall() + await fastify.register(rateLimit, { + global: false, + max: 2, + timeWindow: 1000, + redis + }) + + const checkRateLimit = fastify.createRateLimit() + + fastify.get('/peek', async (req) => checkRateLimit(req, { increment: false })) + fastify.get('/consume', async (req) => checkRateLimit(req)) + + let res + + // Peek before any request: clean state, nothing consumed + res = await fastify.inject('/peek') + t.assert.deepStrictEqual(res.json().remaining, 2) + + // Consume one request + res = await fastify.inject('/consume') + t.assert.deepStrictEqual(res.json().remaining, 1) + + // Peek again: reads the active window without consuming (remaining stays 1) + res = await fastify.inject('/peek') + t.assert.deepStrictEqual(res.json().remaining, 1) + + // Consume again: the limit is now reached + res = await fastify.inject('/consume') + t.assert.deepStrictEqual(res.json().remaining, 0) + + // Peek once more: still 0, state was not mutated + res = await fastify.inject('/peek') + t.assert.deepStrictEqual(res.json().remaining, 0) + + await redis.flushall() + await redis.quit() + }) + + test('With { increment: false } it throws on redis error', async (t) => { + t.plan(2) + const fastify = Fastify() + const redis = await new Redis({ host: REDIS_HOST }) + await fastify.register(rateLimit, { + global: false, + max: 2, + timeWindow: 1000, + redis + }) + + const checkRateLimit = fastify.createRateLimit() + fastify.get('/peek', async (req) => checkRateLimit(req, { increment: false })) + + await redis.flushall() + await redis.quit() + + const res = await fastify.inject('/peek') + t.assert.deepStrictEqual(res.statusCode, 500) + t.assert.deepStrictEqual( + res.body, + '{"statusCode":500,"error":"Internal Server Error","message":"Connection is closed."}' + ) + }) }) describe('Route rate limit', () => { diff --git a/types/index.d.ts b/types/index.d.ts index abe7dd7..9d31050 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -12,7 +12,7 @@ import { declare module 'fastify' { interface FastifyInstance { - createRateLimit(options?: fastifyRateLimit.CreateRateLimitOptions): (req: FastifyRequest) => Promise< + createRateLimit(options?: fastifyRateLimit.CreateRateLimitOptions): (req: FastifyRequest, callOptions?: { increment?: boolean }) => Promise< | { isAllowed: true key: string diff --git a/types/index.tst.ts b/types/index.tst.ts index cfa7fc0..c972a55 100644 --- a/types/index.tst.ts +++ b/types/index.tst.ts @@ -302,6 +302,9 @@ appWithImplicitHttp.route({ isBanned: boolean; } >() + + // The limiter function accepts an optional second argument + expect(checkRateLimit).type.toBeCallableWith(req, { increment: false }) } }) From 9dc865c99b3b06c0c6c2746ff32cc1e5ce011fb3 Mon Sep 17 00:00:00 2001 From: luizroberto <105612296+Luiz-Honorato@users.noreply.github.com> Date: Sat, 6 Jun 2026 10:31:42 -0300 Subject: [PATCH 2/2] harden createRateLimit increment option per review Address review feedback on PR #449: - Align both stores' read to the incr signature (ip, cb, timeWindow, max) - Fail fast with a clear error when { increment: false } is used with a custom store that does not implement read - Use if not current then in the Redis read Lua so the clean-state branch is robust to both false and nil - Document the snapshot semantics - Add tests for continueExceeding and exponentialBackoff peeks Refs #420 --- README.md | 8 +- index.js | 8 ++ store/LocalStore.js | 20 ++++- store/RedisStore.js | 20 ++++- test/create-rate-limit.test.js | 146 +++++++++++++++++++++++++++++++++ 5 files changed, 198 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index da66ad2..41af0d3 100644 --- a/README.md +++ b/README.md @@ -517,7 +517,7 @@ If `isAllowed` is `false` the object also contains these additional properties: - `isExceeded`: `true` if the limit was exceeded. - `isBanned`: `true` if the request was banned according to the `ban` option. -The limiter function accepts an optional second argument `{ increment: boolean }`. When `increment` is `false`, the current rate limit status is returned **without consuming a request**. This is useful when a limit should only be enforced on certain outcomes (e.g. failed login attempts) while still checking the status before processing. +The limiter function accepts an optional second argument `{ increment?: boolean }`. The `increment` flag defaults to `true`, so omitting the argument keeps the original behavior (the request is consumed). When `increment` is `false`, the current rate limit status is returned **without consuming a request**. This is useful when a limit should only be enforced on certain outcomes (e.g. failed login attempts) while still checking the status before processing. ```js const checkRateLimit = fastify.createRateLimit({ max: 5, timeWindow: '1 minute' }); @@ -540,6 +540,12 @@ fastify.post('/login', async (request, reply) => { }); ``` +A few things to keep in mind when using `{ increment: false }`: + +- **It is a non-mutating snapshot.** A peek never increments the counter, resets the window, or advances the `ban`/`continueExceeding`/`exponentialBackoff` side effects that a real request triggers. The returned `isExceeded`/`isBanned` therefore reflect the *current* counters, but a peek will not, by itself, escalate a ban or extend a backoff window. +- **`ttl` reflects the store's current window.** With the Redis store, `ttl` is the raw server `PTTL` (the same value `incr` reports), so it can exceed the configured `timeWindow` when `continueExceeding`/`exponentialBackoff` has extended it. +- **Custom stores must implement `read`.** The flag relies on a non-mutating `read(ip, cb, timeWindow, max)` method, which mirrors the `incr` signature. The built-in local and Redis stores provide it; a custom store that does not will throw a clear error when called with `{ increment: false }`. + ### Examples of Custom Store These examples show an overview of the `store` feature and you should take inspiration from it and tweak as you need: diff --git a/index.js b/index.js index 745bc2b..45bcdce 100644 --- a/index.js +++ b/index.js @@ -245,6 +245,14 @@ async function applyRateLimit (pluginComponent, params, req, callOptions) { let ttlInSeconds = 0 const storeMethod = callOptions?.increment === false ? 'read' : 'incr' + + // `{ increment: false }` requires the store to implement a non-mutating + // `read` method. Custom stores may not, so fail fast with a clear message + // instead of a cryptic "store[storeMethod] is not a function" TypeError. + if (storeMethod === 'read' && typeof store.read !== 'function') { + throw new Error('The configured rate-limit store does not implement a `read` method, which is required to use `{ increment: false }`') + } + try { const res = await new Promise((resolve, reject) => { store[storeMethod](key, (err, res) => { diff --git a/store/LocalStore.js b/store/LocalStore.js index 7251456..8124616 100644 --- a/store/LocalStore.js +++ b/store/LocalStore.js @@ -43,7 +43,25 @@ LocalStore.prototype.incr = function (ip, cb, timeWindow, max) { cb(null, current) } -LocalStore.prototype.read = function (ip, cb, timeWindow) { +/** + * Read the current rate-limit state for `ip` without mutating it. + * + * Stores expose `read` with the same argument contract as `incr` + * (`ip, cb, timeWindow, max`) so the two are interchangeable; an + * implementation may ignore the arguments it does not need (`max` here). + * + * `read` is a non-mutating snapshot: it never increments the counter, resets + * the window, or advances the `continueExceeding`/`exponentialBackoff`/`ban` + * side effects that `incr` applies. It mirrors `incr`'s window-expiry + * detection, so a peek and a real request agree on whether the window is + * still active. + * + * @param {string} ip + * @param {(err: Error | null, res: { current: number, ttl: number }) => void} cb + * @param {number} timeWindow + * @param {number} [max] + */ +LocalStore.prototype.read = function (ip, cb, timeWindow, max) { const nowInMs = Date.now() const current = this.lru.get(ip) diff --git a/store/RedisStore.js b/store/RedisStore.js index 3e7da00..5cb3dbc 100644 --- a/store/RedisStore.js +++ b/store/RedisStore.js @@ -38,7 +38,9 @@ const luaRead = ` -- Read the counter without mutating it local current = redis.call('GET', key) - if current == false then + -- A missing key returns false from redis.call (and nil from redis.pcall); + -- "not current" covers both, so the clean-state branch is robust either way. + if not current then -- Key doesn't exist: clean state return {0, 0} end @@ -80,7 +82,21 @@ RedisStore.prototype.incr = function (ip, cb, timeWindow, max) { }) } -RedisStore.prototype.read = function (ip, cb) { +/** + * Read the current rate-limit state for `ip` without mutating it. + * + * Same argument contract as `incr` (`ip, cb, timeWindow, max`); the Redis + * implementation only needs the key, so `timeWindow`/`max` are ignored. The + * reported `ttl` is the raw server `PTTL` — the same source `incr` returns on + * its alive path — so it may exceed the configured `timeWindow` when + * `continueExceeding`/`exponentialBackoff` extended it. + * + * @param {string} ip + * @param {(err: Error | null, res: { current: number, ttl: number }) => void} cb + * @param {number} [timeWindow] + * @param {number} [max] + */ +RedisStore.prototype.read = function (ip, cb, timeWindow, max) { this.redis.rateLimitRead(this.key + ip, (err, result) => { err ? cb(err, null) : cb(null, { current: result[0], ttl: result[1] }) }) diff --git a/test/create-rate-limit.test.js b/test/create-rate-limit.test.js index ee80398..17f739e 100644 --- a/test/create-rate-limit.test.js +++ b/test/create-rate-limit.test.js @@ -322,3 +322,149 @@ test('With { increment: false } it reads the state without consuming it', async clock.reset() }) + +test('With { increment: false } and continueExceeding the peek mirrors the active window', async t => { + t.plan(4) + const clock = mock.timers + clock.enable(0) + const fastify = Fastify() + await fastify.register(rateLimit, { + global: false, + max: 1, + timeWindow: 1000, + continueExceeding: true + }) + + const checkRateLimit = fastify.createRateLimit() + fastify.get('/peek', async (req) => checkRateLimit(req, { increment: false })) + fastify.get('/consume', async (req) => checkRateLimit(req)) + + let res + + await fastify.inject('/consume') // current = 1 + clock.tick(100) + await fastify.inject('/consume') // current = 2 -> continueExceeding resets the window + clock.tick(100) + + // Peek inside the active window: shows the exceeded state, does not reset it + res = await fastify.inject('/peek') + t.assert.deepStrictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.json(), { + isAllowed: false, + key: '127.0.0.1', + max: 1, + timeWindow: 1000, + remaining: 0, + ttl: 900, + ttlInSeconds: 1, + isExceeded: true, + isBanned: false + }) + + // Once the window elapses, the peek reports a clean state again (matching incr) + clock.tick(1000) + res = await fastify.inject('/peek') + t.assert.deepStrictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.json(), { + isAllowed: false, + key: '127.0.0.1', + max: 1, + timeWindow: 1000, + remaining: 1, + ttl: 0, + ttlInSeconds: 0, + isExceeded: false, + isBanned: false + }) + + clock.reset() +}) + +test('With { increment: false } and exponentialBackoff the peek reports the base window without escalating it', async t => { + t.plan(4) + const clock = mock.timers + clock.enable(0) + const fastify = Fastify() + await fastify.register(rateLimit, { + global: false, + max: 1, + timeWindow: 1000, + exponentialBackoff: true + }) + + const checkRateLimit = fastify.createRateLimit() + fastify.get('/peek', async (req) => checkRateLimit(req, { increment: false })) + fastify.get('/consume', async (req) => checkRateLimit(req)) + + let res + + await fastify.inject('/consume') // current = 1 + clock.tick(100) + await fastify.inject('/consume') // current = 2 -> backoff window doubles + clock.tick(100) + await fastify.inject('/consume') // current = 3 -> backoff window doubles again + clock.tick(100) + + // Peek reports the current count and the base-window ttl, without escalating + // the backoff window the way a real (incrementing) request would. + res = await fastify.inject('/peek') + t.assert.deepStrictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.json(), { + isAllowed: false, + key: '127.0.0.1', + max: 1, + timeWindow: 1000, + remaining: 0, + ttl: 900, + ttlInSeconds: 1, + isExceeded: true, + isBanned: false + }) + + // Once the base window elapses, the peek reports a clean state + clock.tick(1000) + res = await fastify.inject('/peek') + t.assert.deepStrictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.json(), { + isAllowed: false, + key: '127.0.0.1', + max: 1, + timeWindow: 1000, + remaining: 1, + ttl: 0, + ttlInSeconds: 0, + isExceeded: false, + isBanned: false + }) + + clock.reset() +}) + +test('With { increment: false } it throws a clear error when the store has no read method', async t => { + t.plan(2) + + class NoReadStore { + incr (ip, cb, timeWindow) { + cb(null, { current: 1, ttl: timeWindow }) + } + + child () { + return this + } + } + + const fastify = Fastify() + await fastify.register(rateLimit, { + global: false, + max: 2, + timeWindow: 1000, + store: NoReadStore + }) + + const checkRateLimit = fastify.createRateLimit() + fastify.get('/peek', async (req) => checkRateLimit(req, { increment: false })) + + const res = await fastify.inject('/peek') + t.assert.deepStrictEqual(res.statusCode, 500) + t.assert.ok(res.json().message.includes('read')) +})