slowapi as the sole HTTP rate limit library; keep Redis abuse counters¶
Renumbered from 0002, which collided with 0002-docker-publish-job-dag; that one was added first and kept the number.
Status¶
Accepted
Context¶
Auth carried two overlapping stacks: scaffold fastapi-limiter (Redis FastAPILimiter init in lifespan, never applied to routes) and slowapi (live @limiter.limit on auth endpoints, default in-memory storage). We consolidated on slowapi only, removed fastapi-limiter, unified on one shared Limiter in app/core/rate_limit.py, and use Redis storage_uri from service_settings.redis_url outside testing so HTTP rate limits are shared across workers. Hand-rolled Redis abuse counters for registration/resend-verification stay as a separate control. Dual libraries and a phantom Redis limiter path were higher risk than keeping the already-enforcing slowapi stack; folding abuse counters into slowapi remains a follow-up, not part of this change.
The shared limiter originally keyed every request by client address. Users behind a shared NAT then competed for one quota, and an authenticated account could rotate addresses to evade limits. Issue #100 asked to key by authenticated user when the request already has one. ADR 0011 decision 8 / #203 had to land first, so the address fallback is a real per-client bucket rather than the proxy's address.
Decision¶
- slowapi is the only HTTP rate limit library. Unused
fastapi-limiterinit is not restored. Abuse counters stay separate. - The shared limiter's
key_func(rate_limit_key) is the single place the key is chosen. It returnsuser:{id}when the request already carries an authenticated identity, andip:{client address}otherwise. The prefixes keep a user identifier and a raw address from producing the same string. - Identity comes from already-established authentication state.
get_current_userrecords the user onrequest.stateafter the existing token and allowlist checks succeed. The key function reads that attribute; it does not decode or validate tokens. A Bearer header with no established identity is treated as anonymous. - The address fallback uses the same client-address reader as the rest of the application (
get_client_ip, corrected byProxyHeadersMiddlewareforTRUSTED_PROXIESmembers). A second notion of client address is how rate limiting, the audit trail, and origin-network detection would drift apart. - Existing limit strings stay as they are. Anonymous auth endpoints (login, access-token, register, password-reset request) do not call
get_current_user, so they remain address-keyed at5/minute/3/hour. - Redis
storage_uriis unchanged. Workers share whatever keysrate_limit_keyreturns.
Consequences¶
- Users behind a shared NAT no longer share one HTTP rate-limit quota once they are authenticated.
- An abusive authenticated account cannot reset its quota by rotating addresses.
- Changing the key namespace resets in-flight Redis buckets once (a one-time cutover).
- Route-level
@limiter.limitruns after FastAPI dependencies, which is what makes established identity visible to the key function. Default limits applied bySlowAPIMiddlewarewould not see it; this project does not set default limits.
Smoke (manual)¶
With the API up (non-testing mode), burst six POST /api/v1/auth/access-token form requests from the same client within one minute. Expect HTTP 429, JSON status: "error", message: "Rate limit exceeded", error code rate_limit, and slowapi rate-limit response headers when injected (X-RateLimit-* / Retry-After as applicable).