"""Lightweight rate limiting for the Space's live-solve path. The Space serves precomputed QAOA results instantly, but it also runs the pure-Python classical solvers (brute-force, greedy) live so visitors can confirm the served numbers are real. Those solves are cheap (milliseconds), but a public endpoint still warrants a guard against rapid-fire hammering. Two layers: 1. **Per-IP rate limit** - at most one live solve per ``window_seconds`` per visitor IP (read from ``x-forwarded-for``, set by HF's reverse proxy). 2. **Global daily cap** - a generous ceiling on live solves per UTC day across all visitors, persisted to JSON when a writable dir is available. There is no IBM-quota guard here (this Space has no hardware path). Verdicts are returned as a dataclass so the Gradio layer stays thin and the logic is trivially unit-testable. """ from __future__ import annotations import json import threading from dataclasses import dataclass from datetime import UTC, date, datetime from pathlib import Path from typing import Literal SafetyReason = Literal["rate_limited", "daily_cap"] @dataclass(frozen=True) class SafetyVerdict: """Outcome of a :meth:`RateLimiter.check_and_register` call.""" allowed: bool reason: SafetyReason | None detail: str daily_remaining: int daily_cap: int class RateLimiter: """Per-IP + daily gate for the live-solve path. Thread-safe via one lock.""" def __init__( self, *, window_seconds: int = 3, daily_cap: int = 5000, persist_path: Path | None = None, ) -> None: if window_seconds < 0: raise ValueError("window_seconds must be >= 0") if daily_cap < 0: raise ValueError("daily_cap must be >= 0") self._window = window_seconds self._cap = daily_cap self._persist_path = persist_path self._lock = threading.Lock() self._last_ip: dict[str, float] = {} self._day: date | None = None self._count: int = 0 self._load_persisted() def check_and_register(self, *, ip: str, now: datetime) -> SafetyVerdict: """Decide whether ``ip`` may run a live solve at ``now`` (commit-on-allow).""" with self._lock: self._roll_day_if_needed(now) self._evict_stale_ips(now.timestamp()) if self._count >= self._cap: return SafetyVerdict( allowed=False, reason="daily_cap", detail=f"Daily limit of {self._cap} live solves reached. Resets midnight UTC.", daily_remaining=0, daily_cap=self._cap, ) last = self._last_ip.get(ip) if last is not None and (now.timestamp() - last) < self._window: wait = self._window - int(now.timestamp() - last) return SafetyVerdict( allowed=False, reason="rate_limited", detail=f"Please wait {max(wait, 1)}s between live solves.", daily_remaining=max(0, self._cap - self._count), daily_cap=self._cap, ) self._last_ip[ip] = now.timestamp() self._count += 1 self._persist() return SafetyVerdict( allowed=True, reason=None, detail="ok", daily_remaining=max(0, self._cap - self._count), daily_cap=self._cap, ) def daily_remaining(self, now: datetime) -> int: with self._lock: self._roll_day_if_needed(now) return max(0, self._cap - self._count) def daily_cap(self) -> int: return self._cap def _roll_day_if_needed(self, now: datetime) -> None: today = now.astimezone(UTC).date() if self._day != today: self._day = today self._count = 0 def _evict_stale_ips(self, now_ts: float) -> None: if self._window <= 0: self._last_ip.clear() return cutoff = now_ts - self._window self._last_ip = {ip: ts for ip, ts in self._last_ip.items() if ts > cutoff} def _load_persisted(self) -> None: if self._persist_path is None: return try: raw = json.loads(self._persist_path.read_text(encoding="utf-8")) except (FileNotFoundError, json.JSONDecodeError): return if not isinstance(raw, dict): return try: self._day = date.fromisoformat(str(raw.get("date"))) self._count = int(raw.get("count", 0)) except (TypeError, ValueError): self._day = None self._count = 0 def _persist(self) -> None: if self._persist_path is None or self._day is None: return try: self._persist_path.parent.mkdir(parents=True, exist_ok=True) self._persist_path.write_text( json.dumps({"date": self._day.isoformat(), "count": self._count}), encoding="utf-8", ) except OSError: self._persist_path = None def default_persist_path() -> Path | None: """Return ``/data/qagent_quota.json`` when HF Persistent Storage is mounted.""" candidate = Path("/data") if candidate.is_dir(): try: test = candidate / ".qa_write_test" test.write_text("x") test.unlink() except OSError: return None return candidate / "qagent_quota.json" return None