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
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,35 @@ 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 }`. 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' });

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 };
});
```

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:
Expand Down
16 changes: 12 additions & 4 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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))
})
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -244,10 +244,18 @@ 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'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the read path the counter is read-only, but the return block further down still computes isBanned: params.ban !== -1 && current - max > params.ban (line ~273). Since read doesn't trigger the ban machinery, a banned IP peeked with { increment: false } will report isAllowed: false with the current counter — not isBanned: true. This is arguably a deliberate "peek doesn't apply policy" choice, but it's a footgun: a caller peeking to decide whether to consume may incorrectly treat a banned IP as healthy. Either reflect ban state on read (read from a separate ban store / counter) or document the limitation explicitly in the README.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed this deserves to be explicit. I treated { increment: false } as a deliberate non-mutating snapshot: it reports current counters (so isExceeded/isBanned reflect existing state) but never triggers or advances ban/backoff. Reflecting live ban escalation would need a separate ban store the plugin doesn't keep, so I documented the snapshot semantics in the README rather than half-implementing it. Happy to change the behavior if you'd prefer. 9dc865c.

Copy link
Copy Markdown
Author

@Betouss Betouss Jun 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call, added a fast-fail with a clear error message when { increment: false } is used with a store that doesn't implement read, plus a test and a README note. 9dc865c.


// `{ 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.incr(key, (err, res) => {
store[storeMethod](key, (err, res) => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once storeMethod === 'read', the function still computes isBanned, isExceeded, etc. from current on the return path — fine, those are still meaningful for the caller. But the incr-side invariants those flags rely on (ban accumulation, continueExceeding TTL reset, exponentialBackoff window expansion) are never applied on read, so the isBanned/isExceeded semantics for a peek are subtly different from a real request. Not a bug, but a small short-circuit opportunity: when storeMethod === 'read', you can skip the current - max > params.ban check (ban can't have been triggered by a read) and the README/return shape should make this explicit. Also worth considering: avoid passing timeWindow and max to read at all on the read path, since they're unused by the current implementations.

Copy link
Copy Markdown
Author

@Betouss Betouss Jun 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The signature is now uniform with incr, and each store ignores what it doesn't need. I kept isBanned/isExceeded in the return shape (still meaningful as a current-state snapshot) and documented that read doesn't advance ban/backoff. I left the ban computation in rather than short-circuiting, to keep the return shape identical between a peek and a real request, but happy to short-circuit if you prefer. 9dc865c.

err ? reject(err) : resolve(res)
}, timeWindow, max)
})
Expand Down
33 changes: 33 additions & 0 deletions store/LocalStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,39 @@ LocalStore.prototype.incr = function (ip, cb, timeWindow, max) {
cb(null, current)
}

/**
* 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Signature inconsistency: this takes (ip, cb, timeWindow) while RedisStore.prototype.read takes (ip, cb). The incr contract is uniform across both stores ((ip, cb, timeWindow, max)), so the asymmetry on read means any third-party store author has to guess. Either align both signatures and ignore unused args, or document the contract (ideally as JSDoc on the store base) so custom stores can implement read correctly.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. I aligned both stores to read(ip, cb, timeWindow, max), matching the incr contract, and added JSDoc on both read methods documenting it: same signature as incr, non-mutating, and an implementation may ignore the args it doesn't need. Fixed in 9dc865c.

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)
}
Expand Down
51 changes: 51 additions & 0 deletions store/RedisStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,30 @@ 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)

-- 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

-- Read the remaining TTL in milliseconds
local ttl = redis.call('PTTL', key)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This script reports whatever PTTL says, without clamping to the configured timeWindow. Concretely: if the key exists with a TTL larger than the configured timeWindow (e.g. a previously larger window, or a different timeWindow configured by an earlier process), read will return a ttl bigger than the configured timeWindow — the caller will think the window is still alive when incr would have reset it. incr resets the window via PEXPIRE, so the two paths can disagree on the same key. Either clamp ttl to timeWindow here, or document that read reports the raw server state and may diverge from the configured window.

Copy link
Copy Markdown
Author

@Betouss Betouss Jun 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked into this: the existing incr Lua already returns the raw PTTL on its alive path and PEXPIREs beyond timeWindow under continueExceeding/exponentialBackoff. So read returning raw PTTL is actually consistent with incr, clamping to timeWindow would make read diverge from incr under backoff. I documented that read reports the raw server TTL. Glad to clamp instead if you'd rather. 9dc865c.

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
Expand All @@ -43,6 +67,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) {
Expand All @@ -51,6 +82,26 @@ RedisStore.prototype.incr = function (ip, cb, timeWindow, max) {
})
}

/**
* 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] })
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the comment on LocalStore.prototype.read: this omits timeWindow from the signature, breaking the symmetry with incr and with the LocalStore counterpart. If the intent is "we don't need timeWindow here because PTTL is the source of truth", that's fine — but the contract for custom store authors should be explicit (uniform signature, with unused args allowed and ignored).

Copy link
Copy Markdown
Author

@Betouss Betouss Jun 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aligned with the above: RedisStore.prototype.read is now read(ip, cb, timeWindow, max) too (it ignores timeWindow/max since PTTL is the source of truth), with JSDoc noting it. 9dc865c.

})
}

RedisStore.prototype.child = function (routeOptions) {
return new RedisStore(routeOptions.continueExceeding, routeOptions.exponentialBackoff, this.redis, `${this.key}${routeOptions.routeInfo.method}${routeOptions.routeInfo.url}-`)
}
Expand Down
Loading