🐛 fix(limiter): correct fixed-window hit credit on skipped requests - #4422
Conversation
When SkipSuccessfulRequests/SkipFailedRequests credits a hit back after the handler runs, the fixed-window strategy decremented currHits unconditionally and bumped a stale local counter for the X-RateLimit-Remaining header. If the window rolled over (or another request reset the entry) between the increment and the credit, currHits could underflow below zero, inflating the next window's allowance, and the header reported a value disconnected from stored state. Mirror the sliding-window semantics: only credit the hit when the entry still belongs to the same window and currHits is positive, and recompute remaining from the fresh entry instead of incrementing the stale local. https://claude.ai/code/session_015UgiFK9CxncaL1ZbevmkJ6
Sessions acquired via Store.Get are pooled and must be released per the documented contract (Session.Release), but two paths never did: - csrf sessionManager.getRaw/setRaw/delRaw acquired a session from the store (when no session middleware is active) and dropped it without Release, so every CSRF request with session storage lost the pooled object. - the session middleware skipped saveSession — the only place the session was released — for destroyed sessions, so every Destroy() (e.g. logout) leaked the Session out of the pool. https://claude.ai/code/session_015UgiFK9CxncaL1ZbevmkJ6
The previous comparison only handled a single entity tag and required the weak indicator to match between client and server: - 'If-None-Match: W/"a", "b"' against ETag '"a"' returned 200 instead of 304 because the whole list was compared as one tag. - a strong client tag never matched a weak server tag (and vice versa unless the list happened to substring-match), violating the weak comparison REQUIRED for If-None-Match (RFC 9110 §8.8.3.2). - 'If-None-Match: *' was not recognized. - strong matching relied on bytes.Contains substring semantics. Parse the header as a comma-separated entity-tag list, compare each tag with weak comparison (ignoring W/ prefixes, requiring quoted tags), and support the wildcard. Also emit the ETag header on 304 responses as required by RFC 9110 §15.4.5. https://claude.ai/code/session_015UgiFK9CxncaL1ZbevmkJ6
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughThis PR fixes resource lifecycle and correctness across middlewares: CSRF and session middleware properly release pooled resources, ETag matching implements RFC 9110 weak-tag comparison with new parsing helpers and tests, and rate limiters prevent negative remaining values across window boundaries and skip-success credit-back scenarios. ChangesMiddleware Resource Lifecycle & RFC Compliance Fixes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 inconclusive)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4422 +/- ##
==========================================
+ Coverage 91.50% 91.52% +0.02%
==========================================
Files 134 134
Lines 13472 13497 +25
==========================================
+ Hits 12327 12353 +26
+ Misses 727 725 -2
- Partials 418 419 +1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Switch bytes.TrimSpace to the generic utils.TrimSpace and replace the wildcard bytes.Equal with a direct byte check. Tag bytes still use bytes.Equal: utils only provides EqualFold, and entity-tag comparison is octet-by-octet per RFC 9110. https://claude.ai/code/session_015UgiFK9CxncaL1ZbevmkJ6
ReneWerner87
left a comment
There was a problem hiding this comment.
Reviewed with focus on correctness, concurrency, and RFC semantics. All four fixes are real and correctly implemented: the fixed-window credit guard closes a genuine currHits underflow (rate-limit bypass), the csrf/session Release calls fix session pool leaks without double-release risk, and the new If-None-Match handling matches RFC 9110 §8.8.3.2. Affected suites pass, including with -race.
A few remarks inline. Two notes that do not fit a specific line:
- Behavior changes worth a changelog entry:
If-None-Match: *now returns 304 (was 200), a strong client tag now matches a weak server tag, and the ETag header is now also sent on 304 responses. All RFC-correct, but observable. - Pre-existing, not for this PR: the fixed-window skip path still emits the pre-handler
X-RateLimit-Resetvalue, while the sliding window recomputes it post-handler.
Addresses review feedback on the fixed-window hit-credit fix: - Add Test_Limiter_Fixed_Window_SkipSuccessfulRequests_DoesNotCreditNextWindow, a deterministic regression test analogous to the sliding-window one. It fails on the pre-fix code (slow skipped request wrongly credits the rolled window) and passes with the window guard. - Clamp X-RateLimit-Remaining to 0 in both fixed and sliding strategies so a skip-credit path never emits a negative value to header-parsing clients. - Document the comma-in-opaque-tag fail-open limitation in etag isNoneMatch. - Document why csrf getRaw may return token.Raw after Release. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@ReneWerner87 the linter is failing on this one |
CI's golangci-lint restored a stale cache whose revive results predated this function gaining its 'secure' scheme filter, so nolintlint reported the //nolint:revive directive as unused even though revive's flag-parameter rule fires on a fresh analysis. Move the directive inline onto the function-declaration line (the exact position of the finding) and touch the file so the linter re-analyzes it instead of reusing the stale cached result. Verified with a clean golangci-lint cache: revive flags the parameter and the directive suppresses it, leaving 0 issues. https://claude.ai/code/session_015UgiFK9CxncaL1ZbevmkJ6
Add a unit test table for the unexported etagWeakMatch helper, exercising the weak/strong cross-comparison matrix and the malformed-tag rejection branches (unquoted or empty client/server tags). These guard clauses are unreachable through the middleware because Fiber always emits well-formed quoted ETags, so a direct test is the appropriate way to cover them. https://claude.ai/code/session_015UgiFK9CxncaL1ZbevmkJ6
Should be good now. |
When SkipSuccessfulRequests/SkipFailedRequests credits a hit back after
the handler runs, the fixed-window strategy decremented currHits
unconditionally and bumped a stale local counter for the
X-RateLimit-Remaining header. If the window rolled over (or another
request reset the entry) between the increment and the credit, currHits
could underflow below zero, inflating the next window's allowance, and
the header reported a value disconnected from stored state.
Mirror the sliding-window semantics: only credit the hit when the entry
still belongs to the same window and currHits is positive, and recompute
remaining from the fresh entry instead of incrementing the stale local.