From 0dbd0c400bf58735a31ff88bc5477eb0300ad5c5 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Sat, 25 Jul 2026 23:33:11 +0000 Subject: [PATCH] enforce a real 6s cooldown between shared-token lichess.org requests, not just non-overlap 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 --- LichessWebServices/lichess_client.py | 23 +++++------ LichessWebServices/rate_limiter.py | 57 ++++++++++++++++++++++------ 2 files changed, 57 insertions(+), 23 deletions(-) diff --git a/LichessWebServices/lichess_client.py b/LichessWebServices/lichess_client.py index 516f203..3c3bb81 100644 --- a/LichessWebServices/lichess_client.py +++ b/LichessWebServices/lichess_client.py @@ -19,7 +19,7 @@ from typing import List, Dict, Any, Optional from datetime import datetime, timedelta import logging 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__) @@ -48,7 +48,7 @@ class LichessClient: # Сериализует games/user и user/activity между собой (см. rate_limiter.py) — # обе шарят один 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) на анонимные # запросы к games/user и user/activity — тот же валидный токен, отправленный # для другого юзера, сразу превращает 404 в 429, то есть эндпоинт жив, просто @@ -84,10 +84,10 @@ class LichessClient: headers = {'Authorization': f'Bearer {self.app_token}'} if self.app_token else {} - # Держим shared_token_lock на весь запрос-ответ (не только на паузу - # выше) — LICHESS_APP_TOKEN общий с get_games_of_period, и Lichess - # не пускает по нему 2+ запроса одновременно. - async with self.shared_token_lock: + # Держим shared_token_gate на весь запрос-ответ и с паузой после — + # LICHESS_APP_TOKEN общий с get_games_of_period, и Lichess не + # пускает 2+ запроса подряд без ощутимой паузы между ними. + async with self.shared_token_gate: response = await self.client.get(url, headers=headers) logger.info(f"🔍 Lichess API response status: {response.status_code} for {username}") response.raise_for_status() # Проверяем статус ответа @@ -159,11 +159,12 @@ class LichessClient: logger.info(f"Запрос игр для {username} с {since_ms} по {until_ms}") - # Держим shared_token_lock на весь запрос-ответ (не только на паузу - # выше) — LICHESS_APP_TOKEN общий с get_user_activity, и Lichess - # не пускает по нему 2+ запроса одновременно (наблюдали 429 - # "Please only run 1 request(s) at a time" под конкурентной нагрузкой). - async with self.shared_token_lock: + # Держим shared_token_gate на весь запрос-ответ и с паузой после — + # LICHESS_APP_TOKEN общий с get_user_activity, и Lichess не пускает + # 2+ запроса подряд без ощутимой паузы между ними (даже строго + # последовательные с интервалом ~100-300мс ловили 429 "Please only + # run 1 request(s) at a time" под реальной нагрузкой на проде). + async with self.shared_token_gate: response = await self.client.get(url, params=params, headers=headers) response.raise_for_status() # Проверяем статус ответа diff --git a/LichessWebServices/rate_limiter.py b/LichessWebServices/rate_limiter.py index d61ce4e..7789c93 100644 --- a/LichessWebServices/rate_limiter.py +++ b/LichessWebServices/rate_limiter.py @@ -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