| """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 |
| _PER_IP_MINUTE_LIMIT = 30 |
| |
| |
| |
| |
| _PER_IP_AUTH_MINUTE_LIMIT = 10 |
| |
| |
| |
| |
| _PER_SUPPORT_MINUTE_LIMIT = 5 |
| _DAY_SECONDS = 86_400 |
|
|
| |
| _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 |
| 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] |
|
|