LichessStatTgWeb/LichessWebServices/rate_limiter.py
vrubelroman d4eb61f994
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 15s
prioritize interactive requests over background checks in shared Lichess-token queue
/today, /yesterday, /week were queuing behind the entire periodic-check
backlog on the same rate-limited LICHESS_APP_TOKEN (observed ~2min wait on
prod for a single command). Add a two-tier priority queue to SharedTokenGate:
interactive on-demand requests jump ahead of background periodic checks,
which still drain normally when nothing interactive is waiting. Verified
locally end-to-end (isolated queue unit tests, live request with priority
correctly reaching Lichess, and a real successful accuracy fetch rendered
through StatsFormatter).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 10:25:34 +00:00

124 lines
4.9 KiB
Python

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