serialize shared-token lichess.org requests to fix accuracy dropping again
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 15s

games/user and user/activity share one LICHESS_APP_TOKEN process-wide, and
Lichess allows only 1 concurrent request per token there (confirmed live:
429 "Please only run 1 request(s) at a time" under concurrent traffic).
The existing rate limiter only paced request starts 0.2s apart without
holding the lock through the request itself, so concurrent games/period
calls (periodic checks vs. on-demand /today etc.) could still collide and
get 429'd, silently dropping the accuracy shown in /today, /yesterday,
/week (that fetch is best-effort and swallows errors). Hold a dedicated
lock for the full request/response cycle on these two calls; puzzle
requests use per-user tokens and don't need it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
vrubelroman 2026-07-25 23:26:25 +00:00
parent 7ef5875f58
commit 31e6fdd23d
2 changed files with 32 additions and 7 deletions

View file

@ -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
from rate_limiter import get_rate_limiter, get_shared_token_lock
# Настройка логирования для модуля
logger = logging.getLogger(__name__)
@ -45,6 +45,10 @@ class LichessClient:
self.base_url = "https://lichess.org/api" # Базовый URL Lichess API
self.client = httpx.AsyncClient(timeout=30.0) # HTTP клиент с таймаутом
self.rate_limiter = get_rate_limiter()
# Сериализует games/user и user/activity между собой (см. rate_limiter.py) —
# обе шарят один LICHESS_APP_TOKEN, и Lichess не пускает по нему 2+
# запроса одновременно.
self.shared_token_lock = get_shared_token_lock()
# Lichess начал возвращать 404 (вместо честного 401/429) на анонимные
# запросы к games/user и user/activity — тот же валидный токен, отправленный
# для другого юзера, сразу превращает 404 в 429, то есть эндпоинт жив, просто
@ -73,15 +77,18 @@ class LichessClient:
try:
# Rate limiting: ждем если нужно
await self.rate_limiter.wait_if_needed()
# Формируем URL для получения активности пользователя
url = f"{self.base_url}/user/{username}/activity"
logger.info(f"🔍 Making request to Lichess API: {url}")
headers = {'Authorization': f'Bearer {self.app_token}'} if self.app_token else {}
# Выполняем HTTP GET запрос
response = await self.client.get(url, headers=headers)
# Держим shared_token_lock на весь запрос-ответ (не только на паузу
# выше) — LICHESS_APP_TOKEN общий с get_games_of_period, и Lichess
# не пускает по нему 2+ запроса одновременно.
async with self.shared_token_lock:
response = await self.client.get(url, headers=headers)
logger.info(f"🔍 Lichess API response status: {response.status_code} for {username}")
response.raise_for_status() # Проверяем статус ответа
@ -151,9 +158,13 @@ class LichessClient:
headers['Authorization'] = f'Bearer {self.app_token}'
logger.info(f"Запрос игр для {username} с {since_ms} по {until_ms}")
# Выполняем HTTP GET запрос
response = await self.client.get(url, params=params, headers=headers)
# Держим 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:
response = await self.client.get(url, params=params, headers=headers)
response.raise_for_status() # Проверяем статус ответа
# Парсим NDJSON (Newline Delimited JSON)

View file

@ -51,3 +51,17 @@ 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()
def get_shared_token_lock() -> asyncio.Lock:
"""Lock serializing lichess.org calls made with the shared LICHESS_APP_TOKEN."""
return _shared_token_lock