""" Rate limiter for Lichess API requests Ensures minimum delay between requests """ import asyncio import time import logging from collections import deque 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. Two-tier priority: background periodic checks queue behind interactive on-demand requests (/today etc.), so a user command doesn't have to wait out an entire backlog of checks (observed ~2min on prod) — just whatever single request is already in flight. acquire()/release() (not a plain `async with`) because the priority has to be picked at call time. """ def __init__(self, min_gap: float = 6.0): self._min_gap = min_gap self._last_release_time: Optional[float] = None self._busy = False self._interactive_waiters: "deque[asyncio.Future]" = deque() self._background_waiters: "deque[asyncio.Future]" = deque() async def acquire(self, priority: str = "background"): """priority: "interactive" (/today etc.) jumps ahead of "background" (periodic checks).""" if not self._busy and not self._interactive_waiters and not self._background_waiters: self._busy = True else: fut = asyncio.get_event_loop().create_future() queue = self._interactive_waiters if priority == "interactive" else self._background_waiters queue.append(fut) await fut 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) def release(self): self._last_release_time = time.time() if self._interactive_waiters: nxt = self._interactive_waiters.popleft() elif self._background_waiters: nxt = self._background_waiters.popleft() else: self._busy = False return if not nxt.done(): nxt.set_result(None) # 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: """Gate serializing+pacing lichess.org calls made with the shared LICHESS_APP_TOKEN. Use acquire()/release().""" return _shared_token_gate