ai / _utils /_rate_limit.py
celik-muhammed's picture
Upload 34 files
89e4531 verified
Raw
History Blame Contribute Delete
5.79 kB
"""
Rate-limit control plane for the HF proxy.
The default backend is deliberately process-local and is an abuse gate only.
Operators that need one quota decision shared by multiple proxy replicas may
select the optional ``redis`` backend. Redis mode uses one atomic server-side
Lua operation per request and HMACs the client identity before it leaves the
process, so raw IP-like identifiers are never stored as Redis keys.
The Redis guarantee is scoped to one Redis consistency domain. This module does
not claim billing/accounting correctness across independent Redis deployments,
Active-Active conflict domains, or a gateway that bypasses this service.
"""
from __future__ import annotations
import asyncio
import hashlib
import hmac
from typing import Any
from ._redis_security import RedisSecurityError, redis_connection_kwargs
class RateLimitBackendError(RuntimeError):
"""Stable, non-sensitive rate-limit backend error."""
def __init__(self, code: str) -> None:
super().__init__(code)
self.code = code
_REDIS_FIXED_WINDOW_LUA = r"""
local key = KEYS[1]
local window_seconds = tonumber(ARGV[1])
local current = redis.call('INCR', key)
if current == 1 then
redis.call('EXPIRE', key, window_seconds)
end
local ttl = redis.call('TTL', key)
return { current, ttl }
""".strip()
def _safe_component(value: str, fallback: str = "generic") -> str:
out = "".join(ch for ch in str(value or "").lower() if ch.isalnum() or ch in "_-:")
return out[:64] or fallback
class RedisRateLimiter:
"""Shared fixed-window limiter backed by Redis atomic scripting."""
backend = "redis"
shared = True
authoritative = True
consistency_scope = "single_redis_consistency_domain"
def __init__(
self,
url: str,
*,
identity_secret: str,
key_prefix: str = "sphinx-ai-assistant",
socket_timeout_seconds: float = 2.0,
client: Any | None = None,
require_tls: bool = False,
) -> None:
if not str(url or "").strip():
raise RateLimitBackendError("REDIS_URL_REQUIRED")
if len(str(identity_secret or "").encode("utf-8")) < (
32 # ruff: ignore[magic-value-comparison]
):
raise RateLimitBackendError("IDENTITY_SECRET_TOO_SHORT")
self.url = str(url).strip()
self.require_tls = bool(require_tls)
try:
self._transport, self._connection_kwargs = redis_connection_kwargs(
self.url,
require_tls=self.require_tls,
socket_timeout_seconds=socket_timeout_seconds,
)
except RedisSecurityError as exc:
raise RateLimitBackendError(exc.code) from exc
self._secret = str(identity_secret).encode("utf-8")
self.key_prefix = _safe_component(key_prefix, "sphinx-ai-assistant")
self.socket_timeout_seconds = max(
0.25, min(float(socket_timeout_seconds), 10.0)
)
self._client = client
self._owns_client = client is None
self._init_lock = asyncio.Lock()
def manifest(self) -> dict[str, Any]:
return {
"backend": self.backend,
"shared": self.shared,
"authoritative": self.authoritative,
"consistency_scope": self.consistency_scope,
"identity_externalized": "hmac_sha256",
**self._transport.manifest(),
}
async def initialize(self) -> None:
async with self._init_lock:
if self._client is None:
try:
import redis.asyncio as redis_async # type: ignore[import-not-found] # ruff: ignore[import-outside-top-level]
except Exception as exc: # pragma: no cover - deployment dependency
raise RateLimitBackendError("REDIS_DEPENDENCY_UNAVAILABLE") from exc
self._client = redis_async.from_url(self.url, **self._connection_kwargs)
try:
await self._client.ping()
except Exception as exc:
raise RateLimitBackendError("REDIS_UNAVAILABLE") from exc
async def close(self) -> None:
if self._client is None or not self._owns_client:
return
closer = getattr(self._client, "aclose", None)
if closer is None:
closer = getattr(self._client, "close", None)
if closer is not None:
result = closer()
if hasattr(result, "__await__"):
await result
self._client = None
def _identity_key(self, identity: str, scope: str) -> str:
digest = hmac.new(
self._secret, str(identity or "unknown").encode("utf-8"), hashlib.sha256
).hexdigest()
return f"{self.key_prefix}:rl:{_safe_component(scope)}:{digest}"
async def consume(
self,
identity: str,
*,
scope: str,
limit: int,
window_seconds: int = 3600,
) -> tuple[bool, int, int]:
if self._client is None:
raise RateLimitBackendError("REDIS_NOT_INITIALIZED")
bounded_limit = max(1, min(int(limit), 1_000_000))
bounded_window = max(1, min(int(window_seconds), 86_400))
key = self._identity_key(identity, scope)
try:
result = await self._client.eval(
_REDIS_FIXED_WINDOW_LUA, 1, key, bounded_window
)
count = int(result[0])
ttl = int(result[1])
except Exception as exc:
raise RateLimitBackendError("REDIS_CONSUME_FAILED") from exc
retry_after = max(1, ttl if ttl > 0 else bounded_window)
return count <= bounded_limit, count, retry_after
__all__ = ["_REDIS_FIXED_WINDOW_LUA", "RateLimitBackendError", "RedisRateLimiter"]