prioritize interactive requests over background checks in shared Lichess-token queue
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 15s

/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>
This commit is contained in:
vrubelroman 2026-07-26 10:25:34 +00:00
parent 0dbd0c400b
commit d4eb61f994
6 changed files with 76 additions and 25 deletions

View file

@ -5,6 +5,7 @@ Ensures minimum delay between requests
import asyncio
import time
import logging
from collections import deque
from typing import Optional
logger = logging.getLogger(__name__)
@ -65,26 +66,49 @@ class SharedTokenGate:
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._lock = asyncio.Lock()
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
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):
def release(self):
self._last_release_time = time.time()
self._lock.release()
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
@ -95,6 +119,6 @@ class SharedTokenGate:
_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."""
"""Gate serializing+pacing lichess.org calls made with the shared LICHESS_APP_TOKEN. Use acquire()/release()."""
return _shared_token_gate