diff --git a/LichessWebServices/lichess_client.py b/LichessWebServices/lichess_client.py index e8ddae8..516f203 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 +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) diff --git a/LichessWebServices/rate_limiter.py b/LichessWebServices/rate_limiter.py index f135f8b..d61ce4e 100644 --- a/LichessWebServices/rate_limiter.py +++ b/LichessWebServices/rate_limiter.py @@ -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 +