Skip to content

feat(api): add per-client IP rate limiting#17

Merged
jmgilman merged 1 commit into
masterfrom
feat/rate-limiting
Jun 24, 2026
Merged

feat(api): add per-client IP rate limiting#17
jmgilman merged 1 commit into
masterfrom
feat/rate-limiting

Conversation

@jmgilman

Copy link
Copy Markdown
Contributor

Summary

Adds per-client rate limiting, on by default, mirroring the repo's port + adapter + composition-root + enable-flag idiom (like todo.Repository and the authz tier). It is 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.

Design (decisions confirmed with the maintainer)

  • Scope: per client IP, pre-auth. Keyed by the spoof-safe IP the existing ClientIP middleware resolves (honoring --trusted-proxy-header). Keying is a pluggable KeyFunc; the default is adapterhttp.ClientIPKeyFunc — swap it for a principal-based key to limit authenticated callers instead.
  • Backend: in-process token bucket + documented seam. The shipped ratelimit.InMemory adapter uses golang.org/x/time/rate with per-key buckets evicted after an idle period (a janitor goroutine the App stops on shutdown). The ratelimit.Limiter port is the seam for a distributed (e.g. Redis) limiter — implement Allow and wire it in app.go; the middleware and key function are unchanged.
  • Headers: Retry-After (RFC 9110) on the 429. The IETF RateLimit/RateLimit-Policy structured-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

  • New internal/ratelimit: Limiter port + Decision (limiter.go), in-process token-bucket adapter (memory.go), and the Huma Middleware taking a router-agnostic KeyFunc (middleware.go). Imports huma only — not chi.
  • internal/adapter/http: ClientIPKeyFunc (the chi/humachi-specific key extraction stays in the transport adapter); RouterDeps.InstallRateLimit is called before InstallAuthz.
  • internal/app/app.go: builds the limiter when enabled, wires the install hook, and stops the limiter on shutdown (mirrors closePool).
  • internal/config: --rate-limit-enabled (default true), --rate-limit-rps (10), --rate-limit-burst (20), with validation.

Testing

  • Unit (internal/ratelimit): token-bucket burst→deny→refill, per-key independence, Stop idempotency; middleware allow/deny with the RFC 9457 429 + Retry-After, per-client keying, and disabled-passthrough.
  • Functional (internal/app): TestAppWiringRateLimits drives the fully composed handler with a burst of one and proves the limiter is wired, runs before auth (the second request is 429, not the 403 a denied caller would get), and that /healthz is never limited.
  • moon run root:check green; moon run root:test-integration green against postgres:17-alpine (the authz e2e disables rate limiting as an orthogonal concern; rate limiting has its own tests).
  • The OpenAPI spec is unchanged: 429 is cross-cutting middleware, not a per-operation response (same treatment as the authz 401/403), so openapi-check stays green with no regen.

🤖 Generated with Claude Code

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>
@jmgilman jmgilman merged commit 867662f into master Jun 24, 2026
7 checks passed
@jmgilman jmgilman deleted the feat/rate-limiting branch June 24, 2026 19:06
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.

1 participant