""" Rate limiter for Lichess API requests Ensures minimum delay between requests """ import asyncio import time import logging from typing import Optional logger = logging.getLogger(__name__) class RateLimiter: """ Rate limiter that ensures minimum delay between requests. Thread-safe and async-safe. """ def __init__(self, min_delay: float = 0.2): """ Initialize rate limiter. Args: min_delay: Minimum delay in seconds between requests (default: 0.2) """ self.min_delay = min_delay self.last_request_time: Optional[float] = None self.lock = asyncio.Lock() async def wait_if_needed(self): """ Wait if necessary to maintain minimum delay between requests. Should be called before each API request. """ async with self.lock: now = time.time() if self.last_request_time is not None: elapsed = now - self.last_request_time if elapsed < self.min_delay: wait_time = self.min_delay - elapsed logger.debug(f"Rate limiter: waiting {wait_time:.3f} seconds") await asyncio.sleep(wait_time) now = time.time() self.last_request_time = now # Global rate limiter instance _rate_limiter = RateLimiter(min_delay=0.2) def get_rate_limiter() -> RateLimiter: """Get the global rate limiter instance""" return _rate_limiter class SharedTokenGate: """ Serializes AND paces games/user + user/activity calls sharing LICHESS_APP_TOKEN. wait_if_needed() alone isn't enough here: it only paces request STARTS and releases its lock before the request runs, so two requests can still overlap in flight. A bare mutex isn't enough either: Lichess keeps rejecting this token with "Please only run 1 request(s) at a time" even when calls are strictly sequential with only ~100-300ms between one finishing and the next starting — confirmed live on prod, where nearly every request was getting 429'd despite never truly overlapping. Whatever Lichess is enforcing here needs a real cooldown between calls, not just non-overlap, so this gate tracks the last release time and forces a minimum gap before the next request is allowed to start. """ def __init__(self, min_gap: float = 6.0): self._lock = asyncio.Lock() self._min_gap = min_gap self._last_release_time: Optional[float] = None async def __aenter__(self): await self._lock.acquire() if self._last_release_time is not None: elapsed = time.time() - self._last_release_time if elapsed < self._min_gap: wait_time = self._min_gap - elapsed logger.debug(f"Shared token gate: waiting {wait_time:.3f}s before next games/user or user/activity call") await asyncio.sleep(wait_time) return self async def __aexit__(self, exc_type, exc, tb): self._last_release_time = time.time() self._lock.release() # min_gap=6.0: no documented number from Lichess for this endpoint's real # limit: the games/user docs only mention a streaming throttle (games/sec # within one response), not an inter-request cooldown. 6s was chosen after # 429s persisted at looser spacing under real prod load; revisit if 429s # either persist (raise further) or checkpoint lag grows unacceptably # (Lichess documents its own real limit somewhere and this can be lowered). _shared_token_gate = SharedTokenGate(min_gap=6.0) def get_shared_token_gate() -> SharedTokenGate: """Async context manager serializing+pacing lichess.org calls made with the shared LICHESS_APP_TOKEN.""" return _shared_token_gate