Skip to content

fix(service): keep fractional refill and sub-second windows in the Redis rate limiter store - #4375

Open
NitinKumar004 wants to merge 2 commits into
gofr-dev:developmentfrom
NitinKumar004:fix/redis-rate-limiter-refill
Open

NitinKumar004 wants to merge 2 commits into
gofr-dev:developmentfrom
NitinKumar004:fix/redis-rate-limiter-refill

Conversation

@NitinKumar004

Copy link
Copy Markdown
Contributor

The Redis token bucket script floored each refill to whole tokens but reset last_refill on every call. Any key polled faster than one token interval therefore never refilled, e.g. 30/min polled every 500ms, or 10/s polled every 50ms.

The Go caller also passed the window as whole seconds:

  • a 500ms window became 0, the refill rate became infinite, and limiting was effectively disabled;
  • a 1500ms window allowed 1.5x the configured rate.

Changes (Redis path only)

  • The Lua script keeps fractional tokens and refills continuously from the elapsed nanoseconds. The refill clock only moves forward, so pods with lagging clocks cannot re-credit the same elapsed time.
  • The window is passed to the script in nanoseconds (config.Window.Nanoseconds()), so sub-second and non-integer windows are honored.
  • Allow delegates to an unexported allowAt(now) so tests can pin time. No public API changes.

Compatibility: the key (gofr:ratelimit:<key>), its fields (tokens, last_refill) and their units (ns) are unchanged. Old and new pods can share keys during a rolling deploy, and rollback needs no migration.

Tests

  • New table-driven TestRedisRateLimiterStore_AllowAt runs the Lua script against miniredis with pinned timestamps. Previously the script was only covered by redismock argument matching.
  • It covers steady polling, a sub-second window, a non-integer window, partial refill carry-over, the burst cap, clock skew, keys written by the previous script, and the EXPIRE TTL.
  • The same rows also pass against a real Redis 7.
  • This doesn't overlap fix(service): stop the local rate limiter panicking below 1 request/second #4361, which changes the local in-memory bucket in the same file; the two diffs touch different line ranges and names.

Fixes #4363

NitinKumar004 and others added 2 commits September 24, 2026 16:03
…dis rate limiter store

The Redis token bucket script floored each refill to whole tokens but
reset last_refill on every call, so keys polled faster than one token
interval never refilled. The Go caller also passed the window in whole
seconds, so a 500ms window became 0 (limiting effectively disabled) and
a 1500ms window allowed 1.5x the configured rate.

Refill continuously from elapsed nanoseconds with a forward-only clock,
pass the window in nanoseconds, and add an unexported allowAt seam so
the script can be tested with pinned time.

Fixes gofr-dev#4363

@Umang01-hash Umang01-hash left a comment

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.

LGTM. Went through the script math, the tests, and ran it end-to-end against a real Redis 7. Thanks @NitinKumar004.

Bug is real (verified): the old script floored refill to whole tokens and reset last_refill every call, so a key polled faster than one token-interval never accumulated; and the Go caller passed the window as whole seconds, so a 500ms window became 0 (infinite rate → limiting disabled) and 1500ms gave 1.5x. Fixes #4363.

Fix is correct:

  • Continuous fractional refill tokens += (now-last_refill)*requests/window_ns, capped at burst; retry_after = ceil((1-tokens)*window_ns/requests/1e6) ms. Hand-checked the 30/min-polled-500ms and clock-skew sequences — the expected values are genuinely right.
  • Clock-forward-only guard (if now > last_refill) means a lagging pod can't re-credit elapsed time and last_refill never moves backward.
  • No public API change (allowAt unexported); key/fields/units unchanged, so rolling deploy + rollback are safe (the old-format-key row proves it).

Real Redis 7 E2E (miniredis's Lua differs from real Redis, so I EVAL'd the actual script): 5/s polled every 100ms → {1,0} {0,100} {1,0} {0,100} {1,0}, TTL 600 — the #4363 scenario now refills correctly on real Redis.

Tests are non-vacuous — reintroducing integer flooring fails 6 cases; dropping the clock guard fails only the lagging-clock case. gofmt/vet clean, golangci-lint --new-from-rev 0 issues, full service suite green under -race. Window<=0/Requests<=0 are defaulted by Validate(), so window_ns can't be zero.

Re: #4361 (same file, local bucket): package-scope names are disjoint — redisBucketStep/runRedisBucketSteps and a method allowAt on *RedisRateLimiterStore vs *tokenBucket — so no redeclaration on merge, just a normal rebase. Good to merge.

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.

Redis rate limiter store never refills under steady traffic, and a sub-second Window disables it

2 participants