qagent / safety.py
Laborator's picture
deploy: sync from qagent 2b322e9
27fb8ce verified
Raw
History Blame Contribute Delete
5.63 kB
"""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