import hashlib import threading import time from collections import defaultdict, deque from redis import Redis from redis.exceptions import RedisError from app.config import get_settings _lock = threading.Lock() _hits: dict[str, deque[float]] = defaultdict(deque) def allow(key: str, limit: int, window_seconds: float) -> bool: """Distributed fixed-window limiter with a local fail-safe fallback.""" digest = hashlib.sha256(key.encode()).hexdigest() window = int(time.time() // window_seconds) try: client = Redis.from_url( get_settings().redis_url, socket_connect_timeout=0.2, socket_timeout=0.2, ) redis_key = f"rate:{digest}:{window}" with client.pipeline() as pipeline: pipeline.incr(redis_key) pipeline.expire(redis_key, int(window_seconds) + 1) count, _ = pipeline.execute() return int(count) <= limit except RedisError: pass now = time.monotonic() with _lock: bucket = _hits[digest] while bucket and bucket[0] <= now - window_seconds: bucket.popleft() if len(bucket) >= limit: return False bucket.append(now) return True