Spaces:
Running
Running
Delete limiter.py
#10
by TITIONI - opened
- limiter.py +0 -276
limiter.py
DELETED
|
@@ -1,276 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Per-day talk-time budget for the speech-to-speech demo.
|
| 3 |
-
|
| 4 |
-
Our server isn't in the audio path (the browser dials the compute WebSocket
|
| 5 |
-
directly), so it can't cut a live stream. What it *can* do is meter time with a
|
| 6 |
-
server-clock, chunked reservation:
|
| 7 |
-
|
| 8 |
-
- At grant we reserve the first chunk (CHUNK_SEC) and debit it from the day's
|
| 9 |
-
budget. A parallel grant therefore sees the budget already spent.
|
| 10 |
-
- The client heartbeats; each heartbeat extends the reservation one chunk at a
|
| 11 |
-
time until the daily budget runs out, then we report `expired` so the client
|
| 12 |
-
tears down.
|
| 13 |
-
- On a clean end (sendBeacon) we reconcile to the real elapsed time and refund
|
| 14 |
-
the unused chunk. A crash (no end, no heartbeats) is reaped by a sweep and
|
| 15 |
-
forfeits at most one chunk.
|
| 16 |
-
|
| 17 |
-
All time is the server's clock. Budgets are per UTC day; a new day is simply a
|
| 18 |
-
new row (no explicit reset). Logged-in users are keyed by a hashed HF `sub`;
|
| 19 |
-
anonymous users by BOTH a hashed IP and a hashed signed-cookie id, OR-matched
|
| 20 |
-
(spent = max of the two) so clearing one identifier doesn't reset the budget.
|
| 21 |
-
|
| 22 |
-
Storage is SQLite at $USAGE_DB_PATH, else /data (persistent Spaces storage),
|
| 23 |
-
else a /tmp fallback. On /tmp the budget is only per-uptime — flagged in logs.
|
| 24 |
-
"""
|
| 25 |
-
|
| 26 |
-
import hashlib
|
| 27 |
-
import hmac
|
| 28 |
-
import logging
|
| 29 |
-
import math
|
| 30 |
-
import os
|
| 31 |
-
import sqlite3
|
| 32 |
-
import tempfile
|
| 33 |
-
import threading
|
| 34 |
-
import time
|
| 35 |
-
from datetime import datetime, timezone
|
| 36 |
-
from pathlib import Path
|
| 37 |
-
|
| 38 |
-
logger = logging.getLogger("s2s.limiter")
|
| 39 |
-
|
| 40 |
-
# ── Tunables (env-overridable) ───────────────────────────────────────────────
|
| 41 |
-
ANON_SEC = int(os.environ.get("LIMIT_ANON_SEC", "300")) # 5 min/day, not signed in
|
| 42 |
-
FREE_SEC = int(os.environ.get("LIMIT_FREE_SEC", "600")) # 10 min/day, signed in, no PRO
|
| 43 |
-
CHUNK_SEC = int(os.environ.get("RESERVE_CHUNK_SEC", "10")) # reservation granularity
|
| 44 |
-
HEARTBEAT_SEC = int(os.environ.get("HEARTBEAT_SEC", "5")) # advertised client cadence
|
| 45 |
-
REAP_AFTER_SEC = int(os.environ.get("SESSION_REAP_SEC", "15")) # silence before sweep
|
| 46 |
-
|
| 47 |
-
# Stable across restarts or the hashed keys (and signed cookies) rotate and the
|
| 48 |
-
# budget effectively resets. Set it as a Space secret. Falls back to a per-boot
|
| 49 |
-
# random value (keys then only hold within one uptime).
|
| 50 |
-
_HASH_SECRET = (os.environ.get("USAGE_HASH_SECRET", "").strip() or os.urandom(32).hex()).encode()
|
| 51 |
-
|
| 52 |
-
_lock = threading.Lock()
|
| 53 |
-
_db_path: "Path | None" = None
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
def budget_for(tier: str) -> "int | None":
|
| 57 |
-
"""Daily second-budget for a tier, or None for unlimited.
|
| 58 |
-
|
| 59 |
-
Unlimited tiers: 'pro' (paying PRO members) and 'org' (members of an
|
| 60 |
-
allow-listed organisation, see UNLIMITED_ORGS in auth.py)."""
|
| 61 |
-
if tier in ("pro", "org"):
|
| 62 |
-
return None
|
| 63 |
-
if tier == "free":
|
| 64 |
-
return FREE_SEC
|
| 65 |
-
return ANON_SEC
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
def hash_key(raw: str) -> str:
|
| 69 |
-
"""HMAC a raw identifier (sub / ip / cookie id) into an opaque storage key."""
|
| 70 |
-
digest = hmac.new(_HASH_SECRET, raw.encode("utf-8"), hashlib.sha256).hexdigest()
|
| 71 |
-
return f"k_{digest}"
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
def sign_cookie(value: str) -> str:
|
| 75 |
-
"""`<id>.<sig>` so a forged anon-cookie id is rejected on read."""
|
| 76 |
-
sig = hmac.new(_HASH_SECRET, value.encode("utf-8"), hashlib.sha256).hexdigest()[:32]
|
| 77 |
-
return f"{value}.{sig}"
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
def verify_cookie(signed: str) -> "str | None":
|
| 81 |
-
"""Return the id if the signature checks out, else None."""
|
| 82 |
-
if not signed or "." not in signed:
|
| 83 |
-
return None
|
| 84 |
-
value, _, sig = signed.rpartition(".")
|
| 85 |
-
want = hmac.new(_HASH_SECRET, value.encode("utf-8"), hashlib.sha256).hexdigest()[:32]
|
| 86 |
-
return value if hmac.compare_digest(sig, want) else None
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
def _today() -> str:
|
| 90 |
-
return datetime.now(timezone.utc).date().isoformat()
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
def _resolve_db_path() -> Path:
|
| 94 |
-
explicit = os.environ.get("USAGE_DB_PATH", "").strip()
|
| 95 |
-
if explicit:
|
| 96 |
-
return Path(explicit)
|
| 97 |
-
data = Path("/data")
|
| 98 |
-
if data.is_dir() and os.access(data, os.W_OK):
|
| 99 |
-
return data / "s2s-usage.sqlite3"
|
| 100 |
-
logger.warning("No persistent /data — usage budget falls back to /tmp (per-uptime only).")
|
| 101 |
-
return Path(tempfile.gettempdir()) / "s2s-usage.sqlite3"
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
def _connect() -> sqlite3.Connection:
|
| 105 |
-
con = sqlite3.connect(_db_path, timeout=5.0)
|
| 106 |
-
con.execute("PRAGMA journal_mode=WAL")
|
| 107 |
-
con.execute("PRAGMA busy_timeout=5000")
|
| 108 |
-
return con
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
def init() -> None:
|
| 112 |
-
"""Create the schema. Call once at startup."""
|
| 113 |
-
global _db_path
|
| 114 |
-
_db_path = _resolve_db_path()
|
| 115 |
-
with _lock, _connect() as con:
|
| 116 |
-
con.execute(
|
| 117 |
-
"""CREATE TABLE IF NOT EXISTS usage_daily (
|
| 118 |
-
user_key TEXT NOT NULL,
|
| 119 |
-
day TEXT NOT NULL,
|
| 120 |
-
spent_sec INTEGER NOT NULL DEFAULT 0,
|
| 121 |
-
updated_at INTEGER NOT NULL,
|
| 122 |
-
PRIMARY KEY (user_key, day)
|
| 123 |
-
)"""
|
| 124 |
-
)
|
| 125 |
-
con.execute(
|
| 126 |
-
"""CREATE TABLE IF NOT EXISTS sessions (
|
| 127 |
-
session_id TEXT PRIMARY KEY,
|
| 128 |
-
keys TEXT NOT NULL, -- comma-joined usage_daily keys to debit
|
| 129 |
-
day TEXT NOT NULL,
|
| 130 |
-
tier TEXT NOT NULL,
|
| 131 |
-
grant_ts REAL NOT NULL,
|
| 132 |
-
last_seen_ts REAL NOT NULL,
|
| 133 |
-
reserved_sec INTEGER NOT NULL,
|
| 134 |
-
ended INTEGER NOT NULL DEFAULT 0
|
| 135 |
-
)"""
|
| 136 |
-
)
|
| 137 |
-
logger.info("Usage limiter ready at %s (anon=%ss free=%ss chunk=%ss)", _db_path, ANON_SEC, FREE_SEC, CHUNK_SEC)
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
# ── Internal helpers (call under _lock) ───────────────────────────────────────
|
| 141 |
-
|
| 142 |
-
def _spent(con, key: str, day: str) -> int:
|
| 143 |
-
row = con.execute(
|
| 144 |
-
"SELECT spent_sec FROM usage_daily WHERE user_key=? AND day=?", (key, day)
|
| 145 |
-
).fetchone()
|
| 146 |
-
return int(row[0]) if row else 0
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
def _spent_max(con, keys, day: str) -> int:
|
| 150 |
-
"""OR-match: the most-spent identifier governs."""
|
| 151 |
-
return max((_spent(con, k, day) for k in keys), default=0)
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
def _add(con, keys, day: str, delta: int) -> None:
|
| 155 |
-
now = int(time.time())
|
| 156 |
-
for k in keys:
|
| 157 |
-
cur = _spent(con, k, day)
|
| 158 |
-
nxt = max(0, cur + delta)
|
| 159 |
-
con.execute(
|
| 160 |
-
"""INSERT INTO usage_daily (user_key, day, spent_sec, updated_at)
|
| 161 |
-
VALUES (?, ?, ?, ?)
|
| 162 |
-
ON CONFLICT(user_key, day) DO UPDATE SET
|
| 163 |
-
spent_sec = excluded.spent_sec, updated_at = excluded.updated_at""",
|
| 164 |
-
(k, day, nxt, now),
|
| 165 |
-
)
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
# ── Public API ────────────────────────────────────────────────────────────────
|
| 169 |
-
|
| 170 |
-
def remaining(keys, tier: str) -> "int | None":
|
| 171 |
-
"""Seconds left today for these keys (None = unlimited). No mutation."""
|
| 172 |
-
budget = budget_for(tier)
|
| 173 |
-
if budget is None:
|
| 174 |
-
return None
|
| 175 |
-
with _lock, _connect() as con:
|
| 176 |
-
return max(0, budget - _spent_max(con, keys, _today()))
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
def begin(session_id: str, keys, tier: str) -> int:
|
| 180 |
-
"""Reserve the first chunk for a new session and record it. Returns the
|
| 181 |
-
chunk reserved (0 if the budget is already exhausted — the first heartbeat
|
| 182 |
-
will then expire it). PRO (unlimited) is never tracked; don't call it here."""
|
| 183 |
-
day = _today()
|
| 184 |
-
budget = budget_for(tier)
|
| 185 |
-
now = time.time()
|
| 186 |
-
with _lock, _connect() as con:
|
| 187 |
-
avail = budget - _spent_max(con, keys, day) if budget is not None else CHUNK_SEC
|
| 188 |
-
chunk = max(0, min(CHUNK_SEC, avail))
|
| 189 |
-
if chunk:
|
| 190 |
-
_add(con, keys, day, chunk)
|
| 191 |
-
con.execute(
|
| 192 |
-
"""INSERT OR REPLACE INTO sessions
|
| 193 |
-
(session_id, keys, day, tier, grant_ts, last_seen_ts, reserved_sec, ended)
|
| 194 |
-
VALUES (?, ?, ?, ?, ?, ?, ?, 0)""",
|
| 195 |
-
(session_id, ",".join(keys), day, tier, now, now, chunk),
|
| 196 |
-
)
|
| 197 |
-
return chunk
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
def heartbeat(session_id: str) -> bool:
|
| 201 |
-
"""Keep a session alive: extend the reservation toward `elapsed + 1 chunk`,
|
| 202 |
-
debiting the budget chunk by chunk. Returns True while alive, False once the
|
| 203 |
-
budget is spent (caller should tear down) or the session is unknown/ended."""
|
| 204 |
-
now = time.time()
|
| 205 |
-
with _lock, _connect() as con:
|
| 206 |
-
row = con.execute(
|
| 207 |
-
"SELECT keys, day, tier, grant_ts, reserved_sec, ended FROM sessions WHERE session_id=?",
|
| 208 |
-
(session_id,),
|
| 209 |
-
).fetchone()
|
| 210 |
-
if not row or row[5]:
|
| 211 |
-
return False
|
| 212 |
-
keys = row[0].split(",")
|
| 213 |
-
day, tier, grant_ts, reserved = row[1], row[2], row[3], int(row[4])
|
| 214 |
-
budget = budget_for(tier)
|
| 215 |
-
elapsed = now - grant_ts
|
| 216 |
-
|
| 217 |
-
# Grow the reservation one chunk at a time until it covers elapsed + a
|
| 218 |
-
# one-chunk lookahead, or the budget runs dry.
|
| 219 |
-
while reserved < elapsed + CHUNK_SEC:
|
| 220 |
-
if budget is not None and _spent_max(con, keys, day) >= budget:
|
| 221 |
-
break
|
| 222 |
-
_add(con, keys, day, CHUNK_SEC)
|
| 223 |
-
reserved += CHUNK_SEC
|
| 224 |
-
|
| 225 |
-
alive = reserved > elapsed # could we cover the time already elapsed?
|
| 226 |
-
con.execute(
|
| 227 |
-
"UPDATE sessions SET last_seen_ts=?, reserved_sec=?, ended=? WHERE session_id=?",
|
| 228 |
-
(now, reserved, 0 if alive else 1, session_id),
|
| 229 |
-
)
|
| 230 |
-
if not alive:
|
| 231 |
-
_reconcile(con, keys, day, grant_ts, reserved, end_ts=now)
|
| 232 |
-
return alive
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
def end(session_id: str) -> None:
|
| 236 |
-
"""Clean teardown: reconcile to actual elapsed time and refund the unused
|
| 237 |
-
reservation. Idempotent."""
|
| 238 |
-
now = time.time()
|
| 239 |
-
with _lock, _connect() as con:
|
| 240 |
-
row = con.execute(
|
| 241 |
-
"SELECT keys, day, grant_ts, reserved_sec, ended FROM sessions WHERE session_id=?",
|
| 242 |
-
(session_id,),
|
| 243 |
-
).fetchone()
|
| 244 |
-
if not row or row[4]:
|
| 245 |
-
return
|
| 246 |
-
keys, day, grant_ts, reserved = row[0].split(","), row[1], row[2], int(row[3])
|
| 247 |
-
_reconcile(con, keys, day, grant_ts, reserved, end_ts=now)
|
| 248 |
-
con.execute("UPDATE sessions SET ended=1, last_seen_ts=? WHERE session_id=?", (now, session_id))
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
def sweep() -> None:
|
| 252 |
-
"""Reap sessions that went silent (crash / closed without a beacon): bill
|
| 253 |
-
their elapsed time, refund the rest, mark ended. Forfeits ≤ one chunk."""
|
| 254 |
-
now = time.time()
|
| 255 |
-
cutoff = now - REAP_AFTER_SEC
|
| 256 |
-
with _lock, _connect() as con:
|
| 257 |
-
stale = con.execute(
|
| 258 |
-
"SELECT session_id, keys, day, grant_ts, last_seen_ts, reserved_sec FROM sessions "
|
| 259 |
-
"WHERE ended=0 AND last_seen_ts < ?",
|
| 260 |
-
(cutoff,),
|
| 261 |
-
).fetchall()
|
| 262 |
-
for session_id, keys_s, day, grant_ts, last_seen, reserved in stale:
|
| 263 |
-
_reconcile(con, keys_s.split(","), day, grant_ts, int(reserved), end_ts=last_seen)
|
| 264 |
-
con.execute("UPDATE sessions SET ended=1 WHERE session_id=?", (session_id,))
|
| 265 |
-
if stale:
|
| 266 |
-
logger.debug("swept %d stale session(s)", len(stale))
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
def _reconcile(con, keys, day: str, grant_ts: float, reserved: int, end_ts: float) -> None:
|
| 270 |
-
"""Refund reserved-but-unused time. Bill elapsed rounded up to a chunk,
|
| 271 |
-
capped at what was reserved."""
|
| 272 |
-
elapsed = max(0.0, end_ts - grant_ts)
|
| 273 |
-
billed = min(reserved, int(math.ceil(elapsed / CHUNK_SEC) * CHUNK_SEC))
|
| 274 |
-
refund = reserved - billed
|
| 275 |
-
if refund > 0:
|
| 276 |
-
_add(con, keys, day, -refund)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|