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

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:
vrubelroman 2026-07-25 23:33:11 +00:00
parent 31e6fdd23d
commit 0dbd0c400b
2 changed files with 57 additions and 23 deletions

View file

@ -19,7 +19,7 @@ from typing import List, Dict, Any, Optional
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
import json import json
from rate_limiter import get_rate_limiter, get_shared_token_lock from rate_limiter import get_rate_limiter, get_shared_token_gate
# Настройка логирования для модуля # Настройка логирования для модуля
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -48,7 +48,7 @@ class LichessClient:
# Сериализует games/user и user/activity между собой (см. rate_limiter.py) — # Сериализует games/user и user/activity между собой (см. rate_limiter.py) —
# обе шарят один LICHESS_APP_TOKEN, и Lichess не пускает по нему 2+ # обе шарят один LICHESS_APP_TOKEN, и Lichess не пускает по нему 2+
# запроса одновременно. # запроса одновременно.
self.shared_token_lock = get_shared_token_lock() self.shared_token_gate = get_shared_token_gate()
# Lichess начал возвращать 404 (вместо честного 401/429) на анонимные # Lichess начал возвращать 404 (вместо честного 401/429) на анонимные
# запросы к games/user и user/activity — тот же валидный токен, отправленный # запросы к games/user и user/activity — тот же валидный токен, отправленный
# для другого юзера, сразу превращает 404 в 429, то есть эндпоинт жив, просто # для другого юзера, сразу превращает 404 в 429, то есть эндпоинт жив, просто
@ -84,10 +84,10 @@ class LichessClient:
headers = {'Authorization': f'Bearer {self.app_token}'} if self.app_token else {} headers = {'Authorization': f'Bearer {self.app_token}'} if self.app_token else {}
# Держим shared_token_lock на весь запрос-ответ (не только на паузу # Держим shared_token_gate на весь запрос-ответ и с паузой после —
# выше) — LICHESS_APP_TOKEN общий с get_games_of_period, и Lichess # LICHESS_APP_TOKEN общий с get_games_of_period, и Lichess не
# не пускает по нему 2+ запроса одновременно. # пускает 2+ запроса подряд без ощутимой паузы между ними.
async with self.shared_token_lock: async with self.shared_token_gate:
response = await self.client.get(url, headers=headers) response = await self.client.get(url, headers=headers)
logger.info(f"🔍 Lichess API response status: {response.status_code} for {username}") logger.info(f"🔍 Lichess API response status: {response.status_code} for {username}")
response.raise_for_status() # Проверяем статус ответа response.raise_for_status() # Проверяем статус ответа
@ -159,11 +159,12 @@ class LichessClient:
logger.info(f"Запрос игр для {username} с {since_ms} по {until_ms}") logger.info(f"Запрос игр для {username} с {since_ms} по {until_ms}")
# Держим shared_token_lock на весь запрос-ответ (не только на паузу # Держим shared_token_gate на весь запрос-ответ и с паузой после —
# выше) — LICHESS_APP_TOKEN общий с get_user_activity, и Lichess # LICHESS_APP_TOKEN общий с get_user_activity, и Lichess не пускает
# не пускает по нему 2+ запроса одновременно (наблюдали 429 # 2+ запроса подряд без ощутимой паузы между ними (даже строго
# "Please only run 1 request(s) at a time" под конкурентной нагрузкой). # последовательные с интервалом ~100-300мс ловили 429 "Please only
async with self.shared_token_lock: # run 1 request(s) at a time" под реальной нагрузкой на проде).
async with self.shared_token_gate:
response = await self.client.get(url, params=params, headers=headers) response = await self.client.get(url, params=params, headers=headers)
response.raise_for_status() # Проверяем статус ответа response.raise_for_status() # Проверяем статус ответа

View file

@ -51,17 +51,50 @@ def get_rate_limiter() -> RateLimiter:
"""Get the global rate limiter instance""" """Get the global rate limiter instance"""
return _rate_limiter return _rate_limiter
# wait_if_needed() only paces request STARTS 0.2s apart; it releases the lock class SharedTokenGate:
# 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 Serializes AND paces games/user + user/activity calls sharing LICHESS_APP_TOKEN.
# (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()
def get_shared_token_lock() -> asyncio.Lock: wait_if_needed() alone isn't enough here: it only paces request STARTS and
"""Lock serializing lichess.org calls made with the shared LICHESS_APP_TOKEN.""" releases its lock before the request runs, so two requests can still
return _shared_token_lock 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