venthrive-api / utils /rate_limiter.py
GitHub Actions
Sync from GitHub · 2026-09-24 23:37 UTC
889e86b
Raw History Blame Contribute Delete
4.07 kB
"""
Per-UID rate limiter — design settled 2026-09-09 (see
docs/blueprint/adr/0010-per-uid-in-memory-rate-limiting.md), built 2026-09-25.
Keys by authenticated Firebase UID, not IP — every limited route already
requires a verified token, and a UID can't be spoofed the way an IP/header
can. Counters are in-memory, per-process: safe from client/devtools
tampering by construction, sufficient for the current single-container
Hugging Face Space deployment, and reset on redeploy. If this ever needs to
survive across multiple replicas, move the counters into Firestore/Redis —
the enforce_rate_limit() call site wouldn't need to change.
"""
import time
import threading
from collections import defaultdict
from fastapi import Header, HTTPException
from firebase_admin import firestore
from auth import verify_firebase_token
# (label, limit, period_seconds) per tier. Tiers are named by cost, not by
# route, so multiple routes can share one (see routes/*.py's with_rate_limit
# calls for which tier each route uses).
TIERS: dict[str, list[tuple[str, int, int]]] = {
"chat": [("minute", 10, 60), ("day", 200, 86400)],
"generation": [("hour", 8, 3600), ("day", 30, 86400)],
"transcription": [("day", 20, 86400)],
}
class _Window:
__slots__ = ("count", "reset_at")
def __init__(self) -> None:
self.count = 0
self.reset_at = 0.0
_lock = threading.Lock()
_counters: dict[tuple[str, str, str], _Window] = defaultdict(_Window)
# Consecutive-violation count per UID, purely for the "log repeat offenders"
# requirement — never used to make an enforcement decision itself.
_violation_counts: dict[str, int] = defaultdict(int)
def _log_violation(uid: str, tier: str, label: str, count: int, limit: int) -> None:
_violation_counts[uid] += 1
print(
f"[rate-limit] uid={uid} tier={tier} window={label} "
f"count={count}/{limit} (consecutive violations: {_violation_counts[uid]})"
)
# Best-effort — a Firestore write failing must never break enforcement itself.
try:
firestore.client().collection("rate_limit_violations").add({
"uid": uid,
"tier": tier,
"window": label,
"count": count,
"limit": limit,
"consecutiveViolations": _violation_counts[uid],
"at": firestore.SERVER_TIMESTAMP,
})
except Exception:
pass
def enforce_rate_limit(uid: str, tier: str) -> None:
"""Raises HTTPException(429) if uid has exceeded any of tier's limits."""
if tier not in TIERS:
raise ValueError(f"Unknown rate limit tier: {tier!r}")
now = time.time()
with _lock:
for label, limit, period in TIERS[tier]:
window = _counters[(uid, tier, label)]
if now >= window.reset_at:
window.count = 0
window.reset_at = now + period
window.count += 1
if window.count > limit:
retry_after = max(1, int(window.reset_at - now))
_log_violation(uid, tier, label, window.count, limit)
raise HTTPException(
status_code=429,
detail=(
f"You've hit the {label}ly limit for this action "
f"({limit} per {label}). Try again in {retry_after} seconds."
),
headers={"Retry-After": str(retry_after)},
)
def with_rate_limit(tier: str):
"""
FastAPI dependency: verifies the caller's token AND enforces `tier`'s rate
limit in one pass. Use this in place of `Depends(verify_firebase_token)`
on any route that should be limited — it calls verify_firebase_token
itself rather than composing as a second dependency, so the token is only
verified once per request.
"""
async def dependency(authorization: str = Header(...)) -> dict:
user = await verify_firebase_token(authorization)
enforce_rate_limit(user["uid"], tier)
return user
return dependency