Skip to content

feat: add increment option to createRateLimit#449

Open
Betouss wants to merge 2 commits into
fastify:mainfrom
Betouss:feat/create-rate-limit-increment-option
Open

feat: add increment option to createRateLimit#449
Betouss wants to merge 2 commits into
fastify:mainfrom
Betouss:feat/create-rate-limit-increment-option

Conversation

@Betouss
Copy link
Copy Markdown

@Betouss Betouss commented Jun 5, 2026

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

@
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
@
@Betouss Betouss changed the title @ feat: add increment option to createRateLimit Jun 5, 2026
@Betouss
Copy link
Copy Markdown
Author

Betouss commented Jun 5, 2026

Implements #420 as discussed. @jean-michelet happy to adjust anything.

@jean-michelet
Copy link
Copy Markdown
Member

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

@jean-michelet jean-michelet requested a review from a team June 6, 2026 07:17
Copy link
Copy Markdown

@ilteoood ilteoood left a comment

Choose a reason for hiding this comment

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

I have a single question at the moment. What comes next is an AI review. It could make mistakes.

Comment thread store/LocalStore.js Outdated
cb(null, current)
}

LocalStore.prototype.read = function (ip, cb, timeWindow) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Why the signature of the read function is different between the local store and the redis store?

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.

Same point, both read methods now share the incr signature (ip, cb, timeWindow, max), with JSDoc documenting the contract. Fixed in 9dc865c.

Comment thread store/LocalStore.js
LocalStore.prototype.read = function (ip, cb, timeWindow) {
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.

Comment thread store/RedisStore.js

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] })
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.

Comment thread index.js
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.

Comment thread store/RedisStore.js
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.

clock.reset()
})

test('With { increment: false } it reads the state without consuming it', async t => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

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.

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.

Comment thread index.js
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.

Comment thread store/RedisStore.js Outdated
-- Read the counter without mutating it
local current = redis.call('GET', key)

if current == false then
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
if current == false then
if current == nil then

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.

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.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.

Comment thread store/LocalStore.js Outdated
Comment on lines +46 to +59
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 })
}
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.

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.

Comment thread index.js
let ttlInSeconds = 0

// We increment the rate limit for the current request
const storeMethod = callOptions?.increment === false ? 'read' : 'incr'
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.

Comment thread README.md Outdated
- `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.
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.

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
@Betouss Betouss requested review from gurgunday and ilteoood June 6, 2026 13:40
@Betouss
Copy link
Copy Markdown
Author

Betouss commented Jun 6, 2026

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, npm run test:unit (100% coverage, incl. Redis), and tstyche types.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a way to check the status of rate limiters without incrementing them

5 participants