DocDoeAI / app /core /rate_limiter.py
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
3bcdb36
Raw
History Blame Contribute Delete
5.13 kB
"""Rate limiting that is correct across multiple instances.
Two windows:
* per-minute burst control (fixed 60s bucket)
* per-day quota
When Redis is configured (``REDIS_URL``), counters live in Redis so every
replica shares one view β€” horizontally correct. Without Redis, an in-process
fallback is used (correct on a single instance). Same public API either way.
"""
from __future__ import annotations
import threading
import time
from collections import deque
from app.core.redis_client import get_redis
_PER_MINUTE_WINDOW = 60.0
_PER_USER_MINUTE_LIMIT = 20 # authenticated, user-keyed
_PER_IP_MINUTE_LIMIT = 30 # unauthenticated, IP-keyed
# Auth endpoints (login + signup) get a much tighter per-IP cap to slow down
# password brute-force and credential-stuffing attempts. 10/min β‰ˆ 600/hour per
# IP is enough for a normal user retrying a few times but stops a dictionary
# attack cold. Applies regardless of the larger generic /chat etc. window.
_PER_IP_AUTH_MINUTE_LIMIT = 10
# Support submissions (bug reports + feedback) get a tight dedicated cap so a
# script cannot flood the table, while a genuine student can still file a few
# in a row. Keyed per-user when signed in, per-IP when anonymous, in its own
# bucket so it never shares state with (or locks out) normal study traffic.
_PER_SUPPORT_MINUTE_LIMIT = 5
_DAY_SECONDS = 86_400
# ── In-memory fallback state ────────────────────────────────────────────────
_daily_store: dict[str, dict] = {}
_daily_lock = threading.Lock()
_DAILY_STORE_MAX = 10_000
_minute_store: dict[str, deque] = {}
_minute_lock = threading.Lock()
def _minute_limit_for(key: str) -> int:
return _PER_USER_MINUTE_LIMIT if key.startswith("user:") else _PER_IP_MINUTE_LIMIT
def over_per_minute(key: str, now: float | None = None, path: str | None = None) -> bool:
"""Return True if the per-minute limit is exceeded (and do not record).
The optional ``path`` argument lets the caller apply a tighter cap on
sensitive endpoints (e.g. /auth/login, /auth/signup). Auth requests are
tracked in a SEPARATE bucket so they don't share state with general usage β€”
that way a user's normal chat traffic can't accidentally lock them out of
signing in, and an attacker can't exhaust their general budget to disable
the brute-force guard.
"""
now = now or time.time()
is_auth = path in {
"/auth/login",
"/auth/signup",
"/auth/forgot-password",
"/auth/reset-password",
}
is_support = path is not None and path.startswith("/support")
if is_auth:
limit = _PER_IP_AUTH_MINUTE_LIMIT
bucket_ns = "auth"
elif is_support:
limit = _PER_SUPPORT_MINUTE_LIMIT
bucket_ns = "support"
else:
limit = _minute_limit_for(key)
bucket_ns = "gen"
client = get_redis()
if client is not None:
try:
bucket = int(now // _PER_MINUTE_WINDOW)
rkey = f"rl:min:{bucket_ns}:{key}:{bucket}"
count = client.incr(rkey)
if count == 1:
client.expire(rkey, int(_PER_MINUTE_WINDOW) + 5)
return count > limit
except Exception:
pass # fall through to in-memory on any Redis error
return _over_per_minute_memory(f"{bucket_ns}:{key}", now, limit)
def _over_per_minute_memory(key: str, now: float, limit: int) -> bool:
cutoff = now - _PER_MINUTE_WINDOW
with _minute_lock:
dq = _minute_store.setdefault(key, deque())
while dq and dq[0] <= cutoff:
dq.popleft()
if len(dq) >= limit:
return True
dq.append(now)
return False
def over_daily(key: str, daily_limit: int, now: float | None = None) -> bool:
"""Return True if the daily quota is exhausted. Increments on allow."""
now = now or time.time()
client = get_redis()
if client is not None:
try:
day = int(now // _DAY_SECONDS)
rkey = f"rl:day:{key}:{day}"
count = client.incr(rkey)
if count == 1:
client.expire(rkey, _DAY_SECONDS + 60)
return count > daily_limit
except Exception:
pass
return _over_daily_memory(key, daily_limit, now)
def _over_daily_memory(key: str, daily_limit: int, now: float) -> bool:
with _daily_lock:
_evict_expired(now)
entry = _daily_store.get(key)
if entry is None or now > entry["reset_at"]:
entry = {"count": 0, "reset_at": now + _DAY_SECONDS}
_daily_store[key] = entry
if entry["count"] >= daily_limit:
return True
entry["count"] += 1
return False
def _evict_expired(now: float) -> None:
if len(_daily_store) < _DAILY_STORE_MAX:
return
for k in [k for k, v in _daily_store.items() if now > v.get("reset_at", 0)]:
del _daily_store[k]
if len(_daily_store) >= _DAILY_STORE_MAX:
for k in list(_daily_store.keys())[: _DAILY_STORE_MAX // 2]:
del _daily_store[k]