feat: add increment option to createRateLimit#449
Conversation
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 fastify#420
@
|
Implements #420 as discussed. @jean-michelet happy to adjust anything. |
|
Thx, I am not familiar with the source code of this plugin so I prefer your work to be reviewed by someone else from @fastify/plugins |
ilteoood
left a comment
There was a problem hiding this comment.
I have a single question at the moment. What comes next is an AI review. It could make mistakes.
| cb(null, current) | ||
| } | ||
|
|
||
| LocalStore.prototype.read = function (ip, cb, timeWindow) { |
There was a problem hiding this comment.
Why the signature of the read function is different between the local store and the redis store?
There was a problem hiding this comment.
Same point, both read methods now share the incr signature (ip, cb, timeWindow, max), with JSDoc documenting the contract. Fixed in 9dc865c.
| LocalStore.prototype.read = function (ip, cb, timeWindow) { | ||
| const nowInMs = Date.now() | ||
| const current = this.lru.get(ip) | ||
|
|
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
|
||
| 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] }) |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
| let ttlInSeconds = 0 | ||
|
|
||
| // We increment the rate limit for the current request | ||
| const storeMethod = callOptions?.increment === false ? 'read' : 'incr' |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| end | ||
|
|
||
| -- Read the remaining TTL in milliseconds | ||
| local ttl = redis.call('PTTL', key) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| clock.reset() | ||
| }) | ||
|
|
||
| test('With { increment: false } it reads the state without consuming it', async t => { |
There was a problem hiding this comment.
The new test covers the default config but doesn't exercise continueExceeding: true (or exponentialBackoff: true). With incr, both options mutate the TTL when the limit is exceeded (LocalStore.incr resets iterationStartMs and ttl; the Redis incr Lua calls PEXPIRE again). With read, neither mutation happens — which is the whole point — but that also means a peek after exceeding will report the original window's remaining TTL, not a reset one. Worth pinning down behaviorally with a dedicated test, since the asymmetry between incr and read on the TTL is the kind of thing that bites you in production.
There was a problem hiding this comment.
Added dedicated tests for both continueExceeding: true and exponentialBackoff: true with { increment: false }. They pin the behavior: a peek inside the active window reports the exceeded count and the base-window ttl (without escalating backoff), and reports a clean state once the window elapses: matching incr's expiry detection. 9dc865c.
| try { | ||
| const res = await new Promise((resolve, reject) => { | ||
| store.incr(key, (err, res) => { | ||
| store[storeMethod](key, (err, res) => { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| -- Read the counter without mutating it | ||
| local current = redis.call('GET', key) | ||
|
|
||
| if current == false then |
There was a problem hiding this comment.
| if current == false then | |
| if current == nil then |
There was a problem hiding this comment.
Thanks! redis.call('GET') returns false (not nil) for a missing key, so == nil wouldn't match it. I went with if not current then, which is robust to both false (redis.call) and nil (redis.pcall) and reads cleanly. Let me know if you'd rather have an explicit == false. 9dc865c.
There was a problem hiding this comment.
Pull request overview
This PR adds an increment call option to the limiter function returned by fastify.createRateLimit(), enabling consumers to “peek” at current rate-limit state without consuming a request (via { increment: false }). This aligns with the manual rate-limiter use case described in #420 (e.g., checking status before processing, only incrementing on specific outcomes like failed logins).
Changes:
- Extend
createRateLimit()to accept an optional second argument{ increment?: boolean }and route it into the limiter logic. - Add non-mutating “read” support to the built-in stores (Redis + local) and update docs + types/tests accordingly.
- Add unit/integration tests validating the non-incrementing behavior for local and Redis stores.
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
index.js |
Adds callOptions support and switches store method between incr vs read based on { increment: false }. |
store/LocalStore.js |
Introduces read() for the in-memory store (non-mutating lookup). |
store/RedisStore.js |
Introduces rateLimitRead Lua command + read() for Redis store (non-mutating lookup). |
test/create-rate-limit.test.js |
Adds tests for { increment: false } behavior with the local store. |
test/redis-rate-limit.test.js |
Adds tests for { increment: false } behavior and error handling with Redis. |
README.md |
Documents the new optional { increment } argument and shows a login example. |
types/index.d.ts |
Updates createRateLimit() function type to accept the optional call options. |
types/index.tst.ts |
Adds a type test asserting the limiter is callable with { increment: false }. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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 }) | ||
| } |
There was a problem hiding this comment.
read uses the same window-expiry check as incr (iterationStartMs + timeWindow), so it doesn't expire earlier than incr or let clients bypass the window. I added tests under exponentialBackoff showing a peek still reports the exceeded count while the window is active, and only reports a clean state once incr would also reset. The reported ttl is the base window by design (a peek doesn't escalate backoff), now documented. 9dc865c.
| let ttlInSeconds = 0 | ||
|
|
||
| // We increment the rate limit for the current request | ||
| const storeMethod = callOptions?.increment === false ? 'read' : 'incr' |
There was a problem hiding this comment.
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.
| - `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. |
There was a problem hiding this comment.
Updated: the docs now show { increment?: boolean }, clarify it defaults to incrementing, and note that custom stores must implement read. 9dc865c.
Address review feedback on PR fastify#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 fastify#420
|
Thanks for the detailed review, @ilteoood @gurgunday! Addressed everything in 9dc865c — replies inline on each thread. Two points are judgment calls (snapshot semantics for ban/backoff, and raw vs clamped Redis TTL); I explained my reasoning inline and I'm happy to adjust either if you'd prefer. All checks green locally: lint, |
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
Checklist
npm run test && npm run benchmark --if-presentand the Code of conduct