Skip to content

feat(backend): IP-based rate limiting middleware#14

Merged
GRACENOBLE merged 2 commits into
mainfrom
backend.rate-limiting
Jun 15, 2026
Merged

feat(backend): IP-based rate limiting middleware#14
GRACENOBLE merged 2 commits into
mainfrom
backend.rate-limiting

Conversation

@GRACENOBLE

@GRACENOBLE GRACENOBLE commented Jun 15, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds RateLimit(rps float64, burst int) gin.HandlerFunc in internal/transport/middleware/ratelimit.go — per-IP token-bucket limiter using golang.org/x/time/rate; returns HTTP 429 when the bucket is exhausted
  • Wires the middleware into RegisterRoutes() after recovery/logger, before CORS
  • Controlled entirely by env vars — set RATE_LIMIT_RPS=0 or omit to disable (safe default for local dev)
  • Fixes stale source paths across backend/docs/ and adds backend/docs/middleware.md
  • Updates CLAUDE.md workflow + hard rules to require .env.example updates alongside any new env vars

Configuration

RATE_LIMIT_RPS=100      # requests/sec per IP (0 or unset = disabled)
RATE_LIMIT_BURST=500    # burst capacity (defaults to RPS×5 when omitted)

Test plan

  • TestRateLimit_Disabled — RPS=0 passes all requests through
  • TestRateLimit_AllowsUnderLimit — requests within burst capacity return 200
  • TestRateLimit_BlocksOverLimit — second request with burst=1 returns 429
  • TestRateLimit_PerIP — exhausting one IP does not affect another IP's limit
  • go vet ./... clean
  • pnpm lint, pnpm build pass
  • Mobile lint + unit tests pass

Summary by CodeRabbit

Release Notes

  • New Features

    • Added rate limiting with configurable requests-per-second and burst settings via environment variables
    • Added Redis configuration support
  • Documentation

    • Updated middleware, routing, and environment variable documentation
  • Chores

    • Added dependency for rate limiting functionality

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.
@github-actions github-actions Bot added area: backend Go REST API type: chore Cleanup or maintenance tasks labels Jun 15, 2026
@coderabbitai

coderabbitai Bot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@GRACENOBLE, we couldn't start this review because you've reached your PR review rate limit.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 92f75317-97cc-4bf8-8ab7-93765a2d58df

📥 Commits

Reviewing files that changed from the base of the PR and between b533f6a and e707c44.

📒 Files selected for processing (4)
  • backend/docs/environment.md
  • backend/docs/routing.md
  • backend/internal/transport/middleware/ratelimit.go
  • backend/internal/transport/middleware/ratelimit_test.go
📝 Walkthrough

Walkthrough

Adds a per-IP token-bucket rate limiting middleware (RateLimit(rps, burst)) using golang.org/x/time/rate. The Config struct gains RateLimitRPS/RateLimitBurst fields parsed from new env vars; RegisterRoutes accepts and installs them. Documentation (middleware.md, routing.md, environment.md) and CLAUDE.md hygiene rules are updated accordingly.

Changes

Rate Limiting Feature End-to-End

Layer / File(s) Summary
RateLimit middleware implementation and tests
backend/internal/transport/middleware/ratelimit.go, backend/internal/transport/middleware/ratelimit_test.go, backend/go.mod
Adds RateLimit(rps, burst) gin.HandlerFunc with a per-IP mutex-protected rate.Limiter map. rps<=0 is a no-op; otherwise aborts with HTTP 429 and JSON on exhaustion. Test suite covers disabled mode, under-limit, over-limit, and per-IP isolation. golang.org/x/time added as a direct dependency.
Config fields, env parsing, and server wiring
backend/internal/bootstrap/bootstrap.go, backend/internal/server/server.go, backend/internal/transport/handlers/routes.go, backend/.env.example
Config gains RateLimitRPS/RateLimitBurst; loadConfig() reads RATE_LIMIT_RPS/RATE_LIMIT_BURST and auto-derives burst. NewServer passes the values to RegisterRoutes, whose signature expands from no-arg to (rps float64, burst int) and installs the middleware. .env.example adds REDIS_URL, RATE_LIMIT_RPS, and RATE_LIMIT_BURST placeholders.
Docs and CLAUDE.md env-var rule
backend/docs/middleware.md, backend/docs/routing.md, backend/docs/error-handling.md, backend/docs/environment.md, backend/docs/_index.md, CLAUDE.md
New middleware.md documents registration order, Logger, and RateLimit behavior. routing.md reflects gin.New() with conditional logger selection. error-handling.md notes explicit gin.Recovery() in RegisterRoutes(). environment.md adds ENV and rate-limit variable entries. _index.md adds the middleware topic row and refreshes source paths. CLAUDE.md adds an Env vars workflow step and a hard rule for .env.example documentation.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • GRACENOBLE/fullstack-template#11: Modifies the same RegisterRoutes function in routes.go, adding Swagger UI mounting and conditional logging — the same router configuration site this PR extends with RateLimit wiring.

Suggested labels

area: backend, dependencies, documentation

🐇 A token for you, a token for me,
Per-IP buckets flowing so free!
RPS parsed from .env with care,
Burst defaults computed — how fair!
The limiter map guards every gate,
429 for those who just can't wait. 🎯

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely summarizes the main change: introducing IP-based rate limiting middleware, which is the primary feature across all modified files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch backend.rate-limiting

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 | 🟠 Major

Move 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.md registration order from:

  1. Recovery + logger
  2. Rate limiter
  3. CORS

To:

  1. Recovery + logger
  2. CORS
  3. 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

📥 Commits

Reviewing files that changed from the base of the PR and between a08a756 and b533f6a.

⛔ Files ignored due to path filters (1)
  • backend/go.sum is excluded by !**/*.sum
📒 Files selected for processing (13)
  • CLAUDE.md
  • backend/.env.example
  • backend/docs/_index.md
  • backend/docs/environment.md
  • backend/docs/error-handling.md
  • backend/docs/middleware.md
  • backend/docs/routing.md
  • backend/go.mod
  • backend/internal/bootstrap/bootstrap.go
  • backend/internal/server/server.go
  • backend/internal/transport/handlers/routes.go
  • backend/internal/transport/middleware/ratelimit.go
  • backend/internal/transport/middleware/ratelimit_test.go

Comment thread backend/.env.example
Comment thread backend/docs/environment.md Outdated
Comment thread backend/docs/routing.md
Comment thread backend/internal/bootstrap/bootstrap.go
Comment thread backend/internal/transport/middleware/ratelimit.go Outdated
Comment thread backend/internal/transport/middleware/ratelimit.go Outdated
- 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
@GRACENOBLE GRACENOBLE merged commit 6fc9fc1 into main Jun 15, 2026
3 checks passed
@GRACENOBLE GRACENOBLE deleted the backend.rate-limiting branch June 15, 2026 06:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: backend Go REST API type: chore Cleanup or maintenance tasks

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant