"""Shared rate-limiter instance + client-IP key function. WHY THIS MODULE EXISTS (BUG-02): slowapi's default `get_remote_address` keys on `request.client.host` — the immediate TCP peer. In production the backend sits behind the Next.js proxy (app/api/proxy/[...path]/route.ts), and uvicorn is launched WITHOUT `--proxy-headers` (see entrypoint.sh), so `request.client.host` is the proxy server's IP for EVERY request. That collapses all users into a single rate-limit bucket — the proxy gets throttled, real per-user limiting never happens. The frontend proxy relays the real client IP in `x-forwarded-for` (falling back to `x-real-ip`). This key function reads those headers explicitly so the limiter buckets per end-user. All routers MUST import THIS `limiter` (not construct their own) so every decorated route shares one instance and one storage backend, and `app.state.limiter` matches the decorators. SECURITY NOTE: A client that can reach the backend directly (bypassing the proxy) could spoof `x-forwarded-for` to mint a fresh bucket per request. That is inherent to any XFF-based limiting and is acceptable here because the backend is only meant to be reachable via the proxy. If the backend is ever exposed publicly, restrict trust to the known proxy IP (uvicorn `--forwarded-allow-ips`). """ from __future__ import annotations from slowapi import Limiter from starlette.requests import Request def client_ip_key(request: Request) -> str: """Best-effort real client IP: x-forwarded-for[0] → x-real-ip → peer.""" xff = request.headers.get("x-forwarded-for") if xff: # Left-most entry is the original client; subsequent hops are proxies. first = xff.split(",")[0].strip() if first: return first xri = request.headers.get("x-real-ip") if xri and xri.strip(): return xri.strip() return request.client.host if request.client else "unknown" # Single shared limiter. default_limits applies to any route without an # explicit @limiter.limit(...) decorator. limiter = Limiter(key_func=client_ip_key, default_limits=["60/minute"])