2025-11-18 15:10:19 +03:00
|
|
|
"""
|
|
|
|
|
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
|
|
|
|
|
|
2026-07-25 23:33:11 +00:00
|
|
|
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)
|
2026-07-25 23:26:25 +00:00
|
|
|
|
2026-07-25 23:33:11 +00:00
|
|
|
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
|
2026-07-25 23:26:25 +00:00
|
|
|
|