enforce a real 6s cooldown between shared-token lichess.org requests, not just non-overlap
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 13s
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 13s
A bare mutex around games/user and user/activity wasn't enough: Lichess kept rejecting the shared LICHESS_APP_TOKEN with 429 "Please only run 1 request(s) at a time" even when calls were strictly sequential with only ~100-300ms between one finishing and the next starting (confirmed live on prod: near every request was getting 429'd right after the previous mutex fix went out). Track the last release time and force a minimum 6s gap before the next call is allowed to start. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
31e6fdd23d
commit
0dbd0c400b
2 changed files with 57 additions and 23 deletions
|
|
@ -51,17 +51,50 @@ def get_rate_limiter() -> RateLimiter:
|
|||
"""Get the global rate limiter instance"""
|
||||
return _rate_limiter
|
||||
|
||||
# wait_if_needed() only paces request STARTS 0.2s apart; it releases the lock
|
||||
# before the request itself runs, so two requests can still be in flight at
|
||||
# once if either takes longer than 0.2s. That's fine for per-user-token calls
|
||||
# (each pair has its own identity), but games/user and user/activity share one
|
||||
# LICHESS_APP_TOKEN process-wide, and Lichess enforces a hard "1 request at a
|
||||
# time" limit per token there (observed as 429 "Please only run 1 request(s)
|
||||
# at a time" under concurrent traffic) — those two calls must hold this lock
|
||||
# for their entire request/response cycle, not just the pre-request wait.
|
||||
_shared_token_lock = asyncio.Lock()
|
||||
class SharedTokenGate:
|
||||
"""
|
||||
Serializes AND paces games/user + user/activity calls sharing LICHESS_APP_TOKEN.
|
||||
|
||||
def get_shared_token_lock() -> asyncio.Lock:
|
||||
"""Lock serializing lichess.org calls made with the shared LICHESS_APP_TOKEN."""
|
||||
return _shared_token_lock
|
||||
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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue