LichessStatTgWeb/LichessWebServices/rate_limiter.py

101 lines
3.7 KiB
Python
Raw Normal View History

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
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