from __future__ import annotations import math import os import time from dataclasses import dataclass from typing import Any, Dict, Optional, Protocol from .logging import log_event @dataclass class RateLimitDecision: allowed: bool retry_after_s: int remaining_tokens: float class RateLimiter(Protocol): def consume(self, key: str, rps: float, burst: int, cost: float = 1.0) -> RateLimitDecision: ... class InMemoryRateLimiter: def __init__(self, max_keys: int = 10000) -> None: self._max_keys = max(1000, int(max_keys)) self._buckets: Dict[str, Dict[str, float]] = {} def consume(self, key: str, rps: float, burst: int, cost: float = 1.0) -> RateLimitDecision: rate = max(0.0, float(rps)) cap = max(1.0, float(burst)) req = max(0.1, float(cost)) now = time.monotonic() hit = self._buckets.get(key) if not hit: tokens = cap updated_at = now else: tokens = float(hit.get("tokens") or 0.0) updated_at = float(hit.get("updated_at") or now) elapsed = max(0.0, now - updated_at) if rate > 0: tokens = min(cap, tokens + elapsed * rate) allowed = tokens >= req retry_after = 0 if allowed: tokens -= req else: needed = max(0.0, req - tokens) retry_after = int(math.ceil(needed / rate)) if rate > 0 else 60 self._buckets[key] = {"tokens": max(0.0, tokens), "updated_at": now} self._gc() return RateLimitDecision(allowed=allowed, retry_after_s=retry_after, remaining_tokens=max(0.0, tokens)) def _gc(self) -> None: if len(self._buckets) <= self._max_keys: return items = sorted(self._buckets.items(), key=lambda kv: float((kv[1] or {}).get("updated_at") or 0.0)) to_drop = max(1, len(items) // 2) for k, _ in items[:to_drop]: self._buckets.pop(k, None) def reset(self) -> None: self._buckets.clear() class RedisRateLimiter: def __init__(self, redis_client: Any, prefix: str = "sofiia:rl:", key_ttl_s: int = 86400) -> None: self._redis = redis_client self._prefix = str(prefix or "sofiia:rl:") self._key_ttl_s = max(60, int(key_ttl_s)) self._lua = """ local key = KEYS[1] local now = tonumber(ARGV[1]) local rps = tonumber(ARGV[2]) local burst = tonumber(ARGV[3]) local cost = tonumber(ARGV[4]) local ttl = tonumber(ARGV[5]) local data = redis.call('HMGET', key, 'tokens', 'ts') local tokens = tonumber(data[1]) local ts = tonumber(data[2]) if tokens == nil then tokens = burst end if ts == nil then ts = now end if now > ts then tokens = math.min(burst, tokens + ((now - ts) * rps)) end local allowed = 0 local retry = 0 if tokens >= cost then tokens = tokens - cost allowed = 1 else local needed = cost - tokens if rps > 0 then retry = math.ceil(needed / rps) else retry = 60 end end redis.call('HMSET', key, 'tokens', tokens, 'ts', now) redis.call('EXPIRE', key, ttl) return {allowed, retry, tokens} """ def _k(self, key: str) -> str: return f"{self._prefix}{key}" def consume(self, key: str, rps: float, burst: int, cost: float = 1.0) -> RateLimitDecision: now = time.time() rate = max(0.0, float(rps)) cap = max(1.0, float(burst)) req = max(0.1, float(cost)) out = self._redis.eval( self._lua, 1, self._k(key), now, rate, cap, req, self._key_ttl_s, ) allowed = bool((out or [0])[0]) retry_after = int((out or [0, 0])[1] or 0) rem = float((out or [0, 0, 0.0])[2] or 0.0) return RateLimitDecision(allowed=allowed, retry_after_s=retry_after, remaining_tokens=max(0.0, rem)) def reset(self) -> None: keys = self._redis.keys(f"{self._prefix}*") if keys: self._redis.delete(*keys) _RATE_LIMITER: Optional[RateLimiter] = None def _make_redis_client(redis_url: str) -> Any: import redis # type: ignore return redis.Redis.from_url(redis_url, decode_responses=False) def get_rate_limiter() -> RateLimiter: global _RATE_LIMITER if _RATE_LIMITER is None: backend = os.getenv("SOFIIA_RATE_LIMIT_BACKEND", "inmemory").strip().lower() or "inmemory" if backend == "redis": redis_url = os.getenv("SOFIIA_REDIS_URL", "redis://localhost:6379/0").strip() prefix = os.getenv("SOFIIA_RATE_LIMIT_PREFIX", "sofiia:rl:").strip() or "sofiia:rl:" ttl = int(os.getenv("SOFIIA_RATE_LIMIT_KEY_TTL_S", "86400")) try: client = _make_redis_client(redis_url) _RATE_LIMITER = RedisRateLimiter(client, prefix=prefix, key_ttl_s=ttl) except Exception as exc: _RATE_LIMITER = InMemoryRateLimiter() log_event( "rate_limit.backend.fallback", backend="redis", status="degraded", error_code="redis_unavailable", error=str(exc)[:180], ) else: max_keys = int(os.getenv("SOFIIA_RATE_LIMIT_MAX_KEYS", "10000")) _RATE_LIMITER = InMemoryRateLimiter(max_keys=max_keys) return _RATE_LIMITER