LichessStatTgWeb/LichessWebServices/rate_limiter.py

125 lines
4.9 KiB
Python
Raw Permalink 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 collections import deque
2025-11-18 15:10:19 +03:00
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