feat(backend): IP-based rate limiting middleware#14
Conversation
Adds a token-bucket rate limiter per client IP via golang.org/x/time/rate. Controlled by RATE_LIMIT_RPS and RATE_LIMIT_BURST env vars; disabled when RPS is unset or zero. Returns HTTP 429 when the per-IP bucket is exhausted. Also fixes stale source paths across backend/docs/ and adds middleware.md. Updates CLAUDE.md to require .env.example updates alongside new env vars.
|
Warning Review limit reached
More reviews will be available in 46 minutes and 46 seconds. Learn how PR review limits work. Your organization has run out of usage credits. Purchase more credits in the billing tab to continue. ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (4)
📝 WalkthroughWalkthroughAdds a per-IP token-bucket rate limiting middleware ( ChangesRate Limiting Feature End-to-End
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Suggested labels
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ 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 |
There was a problem hiding this comment.
Actionable comments posted: 6
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
backend/internal/transport/handlers/routes.go (1)
27-34:⚠️ Potential issue | 🟠 MajorMove CORS middleware before rate limiting.
When the rate limiter aborts with 429, it stops the middleware chain and prevents CORS middleware from running. This causes browsers to receive rate limit responses without CORS headers, resulting in opaque CORS errors instead of readable 429 payloads.
Update
backend/docs/middleware.mdregistration order from:
- Recovery + logger
- Rate limiter
- CORS
To:
- Recovery + logger
- CORS
- Rate limiter
Suggested fix
- r.Use(middleware.RateLimit(rps, burst)) - r.Use(cors.New(cors.Config{ AllowOrigins: []string{"http://localhost:3000"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}, AllowHeaders: []string{"Accept", "Authorization", "Content-Type"}, AllowCredentials: true, })) + r.Use(middleware.RateLimit(rps, burst))🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@backend/internal/transport/handlers/routes.go` around lines 27 - 34, The rate limiting middleware is currently registered before CORS middleware in the handler chain. When rate limiter aborts with a 429 response, it stops the middleware chain before CORS middleware can run, causing rate limit responses to lack CORS headers and appear as opaque CORS errors to browsers. Swap the order of the two middleware registrations so that the cors.New middleware call executes before the middleware.RateLimit call, ensuring CORS headers are applied even when rate limiting aborts. Additionally, update backend/docs/middleware.md to document the correct registration order (Recovery/logger first, then CORS, then rate limiter).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/.env.example`:
- Around line 11-12: The RATE_LIMIT_RPS and RATE_LIMIT_BURST environment
variables in the .env.example file are set to non-zero values (100 and 500
respectively), which enables rate limiting by default when developers copy this
file. Change both RATE_LIMIT_RPS and RATE_LIMIT_BURST to use safe placeholder
values of 0 instead, ensuring rate limiting is disabled by default and teams
must explicitly opt-in by setting actual values when needed.
In `@backend/docs/environment.md`:
- Line 33: The `RATE_LIMIT_BURST` documentation in the environment table states
"RPS * 5" as the default behavior, but the actual implementation in bootstrap.go
uses integer truncation before multiplying by 5. Update the documentation for
the `RATE_LIMIT_BURST` row to accurately describe the integer truncation
behavior (e.g., "int(RPS) * 5" or similar wording that clarifies the order of
operations), or alternatively adjust the code in bootstrap.go to perform the
multiplication before truncation to match the documented behavior exactly.
Choose whichever approach aligns with the intended design intent.
In `@backend/docs/routing.md`:
- Around line 52-65: The documentation snippet in backend/docs/routing.md shows
an outdated RegisterRoutes() call that lacks the rate-limiting parameters now
required by the actual implementation. Update the example to reflect the current
function signature by including the rps and burst parameters in the
RegisterRoutes() call. Additionally, ensure the middleware chain documentation
reflects the current implementation, including any rate-limit middleware that is
now part of the routing setup to match what the code actually does at runtime.
In `@backend/internal/bootstrap/bootstrap.go`:
- Around line 130-134: The code at this location swallows parse errors from
strconv.ParseFloat and strconv.Atoi by discarding them with underscore
assignments, violating the coding guideline to return errors up the stack.
Additionally, the burst derivation logic can result in burst=0 when 0 < rps < 1
(since int(0.5) truncates to 0), effectively blocking all requests. Capture the
parse errors from both ParseFloat and Atoi calls, validate them, and return an
error if parsing fails. Additionally, fix the burst derivation calculation to
ensure burst is never zero when rps is positive (for example, use a minimum
value calculation or a different formula that handles fractional rps values
correctly).
In `@backend/internal/transport/middleware/ratelimit.go`:
- Around line 19-30: The limiters map initialized on line 19 stores rate
limiters indefinitely for each unique client IP without any cleanup mechanism,
causing unbounded memory growth. Add a TTL or eviction strategy for stale IP
entries by implementing periodic cleanup (such as removing entries that haven't
been accessed within a certain time window) or by tracking creation/access
timestamps for each limiter entry in the map and removing entries that exceed
the TTL threshold. Ensure that the mutex protection is maintained during any
cleanup operations on the limiters map, and consider triggering cleanup either
periodically via a background goroutine or opportunistically during getLimiter
function calls.
- Around line 26-27: The burst parameter passed to rate.NewLimiter at line 26
can be zero when derived from fractional rps configurations, which would block
all requests. Before creating the limiter with rate.NewLimiter(rate.Limit(rps),
burst), clamp the burst value to ensure it is at least 1 by adding a conditional
check that sets burst to 1 if it is less than 1.
---
Outside diff comments:
In `@backend/internal/transport/handlers/routes.go`:
- Around line 27-34: The rate limiting middleware is currently registered before
CORS middleware in the handler chain. When rate limiter aborts with a 429
response, it stops the middleware chain before CORS middleware can run, causing
rate limit responses to lack CORS headers and appear as opaque CORS errors to
browsers. Swap the order of the two middleware registrations so that the
cors.New middleware call executes before the middleware.RateLimit call, ensuring
CORS headers are applied even when rate limiting aborts. Additionally, update
backend/docs/middleware.md to document the correct registration order
(Recovery/logger first, then CORS, then rate limiter).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: d22dc1c8-c99c-4e86-a953-7b2b9dc7702d
⛔ Files ignored due to path filters (1)
backend/go.sumis excluded by!**/*.sum
📒 Files selected for processing (13)
CLAUDE.mdbackend/.env.examplebackend/docs/_index.mdbackend/docs/environment.mdbackend/docs/error-handling.mdbackend/docs/middleware.mdbackend/docs/routing.mdbackend/go.modbackend/internal/bootstrap/bootstrap.gobackend/internal/server/server.gobackend/internal/transport/handlers/routes.gobackend/internal/transport/middleware/ratelimit.gobackend/internal/transport/middleware/ratelimit_test.go
- Clamp burst to >=1 so fractional RATE_LIMIT_RPS (e.g. 0.1) never blocks all traffic (int truncation produced burst=0 previously) - Evict stale per-IP limiters every 5 minutes to bound memory growth - Add TestRateLimit_FractionalRPS_AllowsFirst to cover the burst clamp - Update routing.md snippet to reflect RegisterRoutes(rps, burst) signature - Clarify RATE_LIMIT_BURST env doc to document int truncation and min-1 clamp
Summary
RateLimit(rps float64, burst int) gin.HandlerFuncininternal/transport/middleware/ratelimit.go— per-IP token-bucket limiter usinggolang.org/x/time/rate; returns HTTP 429 when the bucket is exhaustedRegisterRoutes()after recovery/logger, before CORSRATE_LIMIT_RPS=0or omit to disable (safe default for local dev)backend/docs/and addsbackend/docs/middleware.mdCLAUDE.mdworkflow + hard rules to require.env.exampleupdates alongside any new env varsConfiguration
Test plan
TestRateLimit_Disabled— RPS=0 passes all requests throughTestRateLimit_AllowsUnderLimit— requests within burst capacity return 200TestRateLimit_BlocksOverLimit— second request with burst=1 returns 429TestRateLimit_PerIP— exhausting one IP does not affect another IP's limitgo vet ./...cleanpnpm lint,pnpm buildpassSummary by CodeRabbit
Release Notes
New Features
Documentation
Chores