feat(api): add per-client IP rate limiting#17
Merged
Conversation
Rate limit the API per client IP, on by default, as a Huma middleware installed before authentication so an over-limit request is rejected with an RFC 9457 429 + Retry-After before it reaches the credential store, shielding the auth path and database from anonymous floods. The infrastructure routes (/healthz, /readyz, /metrics) bypass Huma and are never limited. The shipped limiter is in-process (per-key token bucket via golang.org/x/time/rate, with idle-evicted buckets) behind a new ratelimit.Limiter port — the seam for a distributed (for example, Redis-backed) limiter. Keying is a pluggable KeyFunc whose default is the spoof-safe resolved client IP; swap it to limit authenticated principals instead. Config: --rate-limit-enabled (default true), --rate-limit-rps (default 10), --rate-limit-burst (default 20). Retry-After (RFC 9110) is the throttle signal; the IETF RateLimit structured-field headers are still a draft and map only loosely onto a token bucket, so they are left as a documented enhancement. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds per-client rate limiting, on by default, mirroring the repo's port + adapter + composition-root + enable-flag idiom (like
todo.Repositoryand the authz tier). It is a Huma middleware installed before authentication, so an over-limit request is rejected with an RFC 9457429+Retry-Afterbefore it reaches the credential store — shielding the auth path and database from anonymous floods. The infrastructure routes (/healthz,/readyz,/metrics) bypass Huma and are never limited.Design (decisions confirmed with the maintainer)
--trusted-proxy-header). Keying is a pluggableKeyFunc; the default isadapterhttp.ClientIPKeyFunc— swap it for a principal-based key to limit authenticated callers instead.ratelimit.InMemoryadapter usesgolang.org/x/time/ratewith per-key buckets evicted after an idle period (a janitor goroutine the App stops on shutdown). Theratelimit.Limiterport is the seam for a distributed (e.g. Redis) limiter — implementAllowand wire it inapp.go; the middleware and key function are unchanged.Retry-After(RFC 9110) on the 429. The IETFRateLimit/RateLimit-Policystructured-field headers are still a draft and map only loosely onto a token bucket, so they are left as a documented enhancement rather than shipping an approximate mapping in a template.Layering
internal/ratelimit:Limiterport +Decision(limiter.go), in-process token-bucket adapter (memory.go), and the HumaMiddlewaretaking a router-agnosticKeyFunc(middleware.go). Importshumaonly — not chi.internal/adapter/http:ClientIPKeyFunc(the chi/humachi-specific key extraction stays in the transport adapter);RouterDeps.InstallRateLimitis called beforeInstallAuthz.internal/app/app.go: builds the limiter when enabled, wires the install hook, and stops the limiter on shutdown (mirrorsclosePool).internal/config:--rate-limit-enabled(defaulttrue),--rate-limit-rps(10),--rate-limit-burst(20), with validation.Testing
internal/ratelimit): token-bucket burst→deny→refill, per-key independence,Stopidempotency; middleware allow/deny with the RFC 9457 429 +Retry-After, per-client keying, and disabled-passthrough.internal/app):TestAppWiringRateLimitsdrives the fully composed handler with a burst of one and proves the limiter is wired, runs before auth (the second request is429, not the403a denied caller would get), and that/healthzis never limited.moon run root:checkgreen;moon run root:test-integrationgreen againstpostgres:17-alpine(the authz e2e disables rate limiting as an orthogonal concern; rate limiting has its own tests).openapi-checkstays green with no regen.🤖 Generated with Claude Code