ASE-GLM / server /ratelimit.py
Bit-Trading-Company's picture
CI deploy 1ed12bd5
936a0b1 verified
Raw History Blame Contribute Delete
4.15 kB
"""How often one viewer may ask.
This does **not** protect a wallet. Inference is billed to whoever signed in,
so a busy visitor spends their own credits and not the owner's — see
``credentials.py``. What it protects is the Space itself: one free CPU serving
a public URL, where a single script can make it unresponsive for everyone else
without spending a penny of anyone's money.
So the ceiling is deliberately generous. A rate limiter tight enough to be felt
by a real person is worse than none at all on a Space nobody is paying for.
Configuration, all optional:
=========================== ======= ====================================
``ASE_RATE_LIMIT`` ``30`` requests per window; **0 disables it**
``ASE_RATE_WINDOW`` ``60`` seconds in a window
=========================== ======= ====================================
In memory, per process. A restart forgives everyone and a second replica keeps
its own count — both acceptable for one small Space, and both reasons not to
reach for this as a security control.
"""
from __future__ import annotations
import os
import time
from collections import deque
from dataclasses import dataclass
DEFAULT_LIMIT = 30
DEFAULT_WINDOW_S = 60
# One entry per caller seen this window. Bounded so a flood of distinct callers
# cannot grow the table without limit.
MAX_TRACKED = 4096
def limit() -> int:
try:
return max(0, int(os.environ.get("ASE_RATE_LIMIT", DEFAULT_LIMIT)))
except ValueError:
return DEFAULT_LIMIT
def window_s() -> float:
try:
return max(1.0, float(os.environ.get("ASE_RATE_WINDOW", DEFAULT_WINDOW_S)))
except ValueError:
return float(DEFAULT_WINDOW_S)
def enabled() -> bool:
return limit() > 0
@dataclass(frozen=True)
class Verdict:
ok: bool
remaining: int
retry_after_s: float = 0.0
@property
def detail(self) -> str:
if self.ok:
return ""
return (
f"That is more than {limit()} requests in {int(window_s())} seconds. "
f"Try again in {max(1, int(self.retry_after_s))}s — this Space runs on one "
f"free CPU, so the limit is there to keep it responsive for everyone."
)
class RateLimiter:
"""A sliding window per caller.
A sliding window rather than a fixed one: a fixed window lets twice the
limit through across a boundary, which on a small box is exactly the burst
it exists to stop.
"""
def __init__(self) -> None:
self._seen: dict[str, deque[float]] = {}
def check(self, key: str, now: float | None = None) -> Verdict:
cap = limit()
if cap <= 0:
return Verdict(True, -1)
now = time.monotonic() if now is None else now
span = window_s()
hits = self._seen.get(key)
if hits is None:
self._prune(now, span)
hits = self._seen.setdefault(key, deque())
while hits and now - hits[0] >= span:
hits.popleft()
if len(hits) >= cap:
return Verdict(False, 0, span - (now - hits[0]))
hits.append(now)
return Verdict(True, cap - len(hits))
def _prune(self, now: float, span: float) -> None:
"""Drop callers who have gone quiet, before adding another."""
if len(self._seen) < MAX_TRACKED:
return
stale = [k for k, v in self._seen.items() if not v or now - v[-1] >= span]
for k in stale:
del self._seen[k]
# Still full of active callers: this is a flood, and forgetting the
# oldest is better than growing without bound.
if len(self._seen) >= MAX_TRACKED:
for k in list(self._seen)[: MAX_TRACKED // 4]:
del self._seen[k]
def caller_key(user: str | None, client_host: str | None) -> str:
"""Who is being counted.
A signed-in viewer is counted as themselves, so sharing an office does not
mean sharing a quota. Everyone else is counted by address, which is the
only handle an anonymous caller has.
"""
if user:
return f"user:{user}"
return f"ip:{client_host or 'unknown'}"