import abc import time import uuid from collections import deque from dataclasses import dataclass, field from redis import Redis _RPS_WINDOW_S = 60 # 1 min — responsive "right now" rate; last_rps persists beyond this @dataclass(kw_only=True) class RequestStats: requests: int = field(default=0) retries: int = field(default=0) rate_limited: int = field(default=0) rps: float | None = field(default=None) # rate over the last 60 s; None when idle last_rps: float | None = field(default=None) class StatsBackend(abc.ABC): @abc.abstractmethod def incr_requests(self) -> None: ... @abc.abstractmethod def incr_retries(self) -> None: ... @abc.abstractmethod def incr_rate_limited(self) -> None: ... @abc.abstractmethod def snapshot(self) -> RequestStats: ... class InMemoryStatsBackend(StatsBackend): def __init__(self) -> None: self._requests = 0 self._retries = 0 self._rate_limited = 0 self._window: deque[float] = deque() self._last_rps: float | None = None def incr_requests(self) -> None: self._requests += 1 self._window.append(time.monotonic()) def incr_retries(self) -> None: self._retries += 1 def incr_rate_limited(self) -> None: self._rate_limited += 1 def snapshot(self) -> RequestStats: now = time.monotonic() cutoff = now - _RPS_WINDOW_S while self._window and self._window[0] < cutoff: self._window.popleft() window = list(self._window) rps: float | None = None if len(window) >= 2: span = window[-1] - window[0] if span > 0: rps = round(len(window) / span, 1) if rps is not None: self._last_rps = rps return RequestStats( requests=self._requests, retries=self._retries, rate_limited=self._rate_limited, rps=rps, last_rps=self._last_rps, ) class RedisStatsBackend(StatsBackend): def __init__(self, client: Redis[bytes], key_prefix: str) -> None: self._client = client self._keys = { "requests": f"{key_prefix}:requests", "retries": f"{key_prefix}:retries", "rate_limited": f"{key_prefix}:rate_limited", "rps_window": f"{key_prefix}:rps_window", "last_rps": f"{key_prefix}:last_rps", } def incr_requests(self) -> None: now = time.time() pipe = self._client.pipeline() pipe.incr(self._keys["requests"]) pipe.zadd(self._keys["rps_window"], {uuid.uuid4().hex: now}) pipe.zremrangebyscore(self._keys["rps_window"], 0, now - _RPS_WINDOW_S) pipe.execute() def incr_retries(self) -> None: self._client.incr(self._keys["retries"]) def incr_rate_limited(self) -> None: self._client.incr(self._keys["rate_limited"]) def _get_int(self, key: str) -> int: value = self._client.get(key) return int(value) if value is not None else 0 def _get_float(self, key: str) -> float: value = self._client.get(key) return float(value) if value is not None else 0.0 def snapshot(self) -> RequestStats: now = time.time() pipe = self._client.pipeline() pipe.get(self._keys["requests"]) pipe.get(self._keys["retries"]) pipe.get(self._keys["rate_limited"]) pipe.zrangebyscore( self._keys["rps_window"], now - _RPS_WINDOW_S, now, withscores=True ) pipe.get(self._keys["last_rps"]) results = pipe.execute() timestamps = [score for _, score in (results[3] or [])] rps: float | None = None if len(timestamps) >= 2: span = max(timestamps) - min(timestamps) if span > 0: rps = round(len(timestamps) / span, 1) # Persist best current value so it survives after the window expires. # Also use it directly — the pipeline ran before the SET, so results[4] # would lag one call behind if we relied on it when rps is non-None. stored = results[4] if rps is not None: self._client.set(self._keys["last_rps"], rps) last_rps = rps else: last_rps = float(stored) if stored is not None else None return RequestStats( requests=self._get_int(self._keys["requests"]), retries=self._get_int(self._keys["retries"]), rate_limited=self._get_int(self._keys["rate_limited"]), rps=rps, last_rps=last_rps, )