stop infinite retry loop on rejected Lichess tokens
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 12s
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 12s
A gamer's periodic check would get permanently stuck if their stored token was revoked/expired: our stats API collapsed both "Lichess rejected the token" (401/403, permanent) and genuine transient errors into the same 502 response, so the bot treated an invalid token exactly like a network blip — retrying the same window forever at a capped 300s backoff, never advancing the checkpoint (observed in prod: Dor1zz stuck for 100+ consecutive errors over 8+ hours, admin alerts firing every 25 failures). Preserve the distinction that already existed one layer down (lichess_client.py already tells 401/403 apart from other failures) instead of collapsing it in stats_service.py: add PuzzleOfPeriodResponse.auth_failed, have main.py return 401 specifically for that case, and have the bot raise a distinct InvalidTokenError instead of returning None. On InvalidTokenError, the bot now clears the token for that pair, notifies the user to reconnect via /addtoken, and continues tracking games normally instead of stalling forever.
This commit is contained in:
parent
8080921141
commit
619c00aa06
7 changed files with 70 additions and 16 deletions
|
|
@ -23,7 +23,7 @@ from config import (
|
|||
)
|
||||
from version import BOT_VERSION
|
||||
from database import Database
|
||||
from lichess_api import LichessAPI
|
||||
from lichess_api import LichessAPI, InvalidTokenError
|
||||
from formatters import StatsFormatter
|
||||
from i18n import t
|
||||
from admin_bot import get_admin_bot, init_admin_bot
|
||||
|
|
@ -1713,19 +1713,38 @@ class LichessBot:
|
|||
continue
|
||||
|
||||
if gamer.get('token'):
|
||||
# Ошибки получения пазлов обрабатываются так же, как ошибки игр:
|
||||
# Транзитные ошибки получения пазлов обрабатываются так же, как ошибки игр:
|
||||
# исключение всплывает во внешний обработчик, чекпоинт не продвигается,
|
||||
# то же окно ретраится на следующей итерации (не считаем "пазлов не было").
|
||||
# InvalidTokenError — отдельный случай: Lichess отклонил токен насовсем
|
||||
# (401), ретраить бессмысленно — это раньше приводило к вечному циклу
|
||||
# ретраев без продвижения чекпоинта (см. историю с Dor1zz). Вместо этого
|
||||
# отключаем пазлы для этой пары и продолжаем отслеживать партии как обычно.
|
||||
logger.info(f"📥 Adding puzzles request to queue for {gamer['username']}")
|
||||
puzzles_data = await self.request_queue.add_request(
|
||||
self.lichess_api.get_puzzles_period,
|
||||
gamer['token'], since_timestamp, until_timestamp_approx, 150
|
||||
)
|
||||
if puzzles_data is None:
|
||||
raise RuntimeError("Puzzles period API returned no data")
|
||||
# Обновляем фактическое время после получения ответа по пазлам
|
||||
request_end_time = datetime.now()
|
||||
logger.info(f"✅ Puzzles API response received for {gamer['username']} at {request_end_time}")
|
||||
try:
|
||||
puzzles_data = await self.request_queue.add_request(
|
||||
self.lichess_api.get_puzzles_period,
|
||||
gamer['token'], since_timestamp, until_timestamp_approx, 150
|
||||
)
|
||||
except InvalidTokenError:
|
||||
logger.warning(f"🔑 Token rejected by Lichess for {gamer['username']} (user {user_id}); disabling puzzle tracking for this pair")
|
||||
self.db.clear_user_gamer_token(user_id, gamer['id'])
|
||||
gamer['token'] = None
|
||||
puzzles_data = None
|
||||
try:
|
||||
user_lang = self.db.get_user_language(user_id)
|
||||
await self.application.bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=t('token_rejected_notice', user_lang, username=gamer['username'])
|
||||
)
|
||||
except Exception as notify_err:
|
||||
logger.error(f"Failed to notify user {user_id} about rejected token: {notify_err}")
|
||||
else:
|
||||
if puzzles_data is None:
|
||||
raise RuntimeError("Puzzles period API returned no data")
|
||||
# Обновляем фактическое время после получения ответа по пазлам
|
||||
request_end_time = datetime.now()
|
||||
logger.info(f"✅ Puzzles API response received for {gamer['username']} at {request_end_time}")
|
||||
|
||||
# Сбрасываем счетчик ошибок при успешном запросе
|
||||
consecutive_errors = 0
|
||||
|
|
|
|||
|
|
@ -335,6 +335,16 @@ class Database:
|
|||
)
|
||||
conn.commit()
|
||||
|
||||
def clear_user_gamer_token(self, user_id: int, gamer_id: int):
|
||||
"""Clear a rejected/expired token so periodic checks stop sending it (games-only monitoring continues)."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"UPDATE user_gamers SET token = NULL WHERE user_id = ? AND gamer_id = ?",
|
||||
(user_id, gamer_id)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_period_checkpoint(self, user_id: int, gamer_id: int) -> Optional[int]:
|
||||
"""Get persisted period checkpoint timestamp (seconds since epoch)."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ TRANSLATIONS = {
|
|||
'user_not_found': "❌ Player {username} not found on Lichess. Check the spelling of the name.",
|
||||
'lichess_temporarily_unavailable': "⚠️ Lichess is temporarily unavailable. Please try again in a minute.",
|
||||
'gamer_already_added': "ℹ️ Player {username} is already being tracked.\n\nTo add another player, use /addgamer",
|
||||
'token_rejected_notice': "⚠️ Lichess rejected the saved token for {username} — puzzle stats have been turned off for this player, game tracking continues as normal. Use /addtoken to reconnect a fresh token if you want puzzle stats back.",
|
||||
|
||||
# Get gamers
|
||||
'no_gamers': "📭 No players in database. Use /addgamer to add.",
|
||||
|
|
@ -215,6 +216,7 @@ TRANSLATIONS = {
|
|||
'user_not_found': "❌ Игрок {username} не найден на Lichess. Проверьте правильность написания имени.",
|
||||
'lichess_temporarily_unavailable': "⚠️ Lichess временно недоступен. Попробуйте, пожалуйста, через минуту.",
|
||||
'gamer_already_added': "ℹ️ Игрок {username} уже отслеживается.\n\nДля добавления следующего игрока воспользуйтесь /addgamer",
|
||||
'token_rejected_notice': "⚠️ Lichess отклонил сохранённый токен для {username} — статистика по пазлам для этого игрока отключена, отслеживание партий продолжается как обычно. Используйте /addtoken, чтобы подключить новый токен и вернуть статистику по пазлам.",
|
||||
|
||||
# Get gamers
|
||||
'no_gamers': "📭 Нет игроков в базе данных. Используйте /addgamer для добавления.",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@ from rate_limiter import get_rate_limiter
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InvalidTokenError(Exception):
|
||||
"""Raised when Lichess rejects a stored token (401) — permanent, not worth retrying."""
|
||||
pass
|
||||
|
||||
|
||||
class LichessAPI:
|
||||
def __init__(self):
|
||||
self.lichess_base_url = LICHESS_API_BASE_URL
|
||||
|
|
@ -125,7 +131,14 @@ class LichessAPI:
|
|||
return None
|
||||
|
||||
async def get_puzzles_period(self, token: str, since: int, until: int, max_puzzles: int = 150) -> Optional[Dict[str, Any]]:
|
||||
"""Get puzzles for a specific period"""
|
||||
"""
|
||||
Get puzzles for a specific period.
|
||||
|
||||
Raises InvalidTokenError on a 401 (Lichess rejected the token — permanent,
|
||||
the caller should stop retrying with this token) instead of returning None,
|
||||
so callers can tell that apart from a transient failure (network error,
|
||||
Lichess hiccup) where retrying the same window later makes sense.
|
||||
"""
|
||||
await self.rate_limiter.wait_if_needed()
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
|
@ -138,9 +151,14 @@ class LichessAPI:
|
|||
) as response:
|
||||
if response.status == 200:
|
||||
return await response.json()
|
||||
elif response.status == 401:
|
||||
logger.warning(f"Puzzles period: token rejected by Lichess (401)")
|
||||
raise InvalidTokenError("Lichess rejected the token")
|
||||
else:
|
||||
logger.error(f"Failed to get puzzles period: {response.status}")
|
||||
return None
|
||||
except InvalidTokenError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting puzzles period: {e}")
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -704,7 +704,10 @@ async def get_puzzle_of_period(
|
|||
try:
|
||||
result = await stats_service.get_puzzle_of_period(token, since, until, max)
|
||||
if not result.success:
|
||||
# Реальная ошибка при обращении к Lichess — не маскируем её под "0 пазлов"
|
||||
if result.auth_failed:
|
||||
# Токен отклонён Lichess (401/403) — permanent, ретраить бессмысленно
|
||||
raise HTTPException(status_code=401, detail=result.message)
|
||||
# Прочая (транзитная) ошибка при обращении к Lichess — не маскируем её под "0 пазлов"
|
||||
raise HTTPException(status_code=502, detail=result.message)
|
||||
return result
|
||||
except HTTPException:
|
||||
|
|
|
|||
|
|
@ -253,6 +253,7 @@ class PuzzleOfPeriodResponse(BaseModel):
|
|||
"""
|
||||
message: str = Field(..., description="Сообщение о результате запроса", example="Статистика решения задач за период")
|
||||
success: bool = Field(True, description="False, если запрос к Lichess завершился ошибкой (а не легитимным нулевым результатом)", example=True)
|
||||
auth_failed: bool = Field(False, description="True, если Lichess отклонил токен (401/403) — permanent, не стоит ретраить; отличается от транзитных ошибок")
|
||||
period_start: int = Field(..., description="Начало периода (Unix timestamp в миллисекундах)", example=1640995200000)
|
||||
period_end: int = Field(..., description="Конец периода (Unix timestamp в миллисекундах)", example=1641081600000)
|
||||
max_puzzles: int = Field(..., description="Максимальное количество задач для получения", example=50)
|
||||
|
|
|
|||
|
|
@ -839,6 +839,7 @@ class StatsService:
|
|||
return PuzzleOfPeriodResponse(
|
||||
message="Неверный токен авторизации или доступ запрещен",
|
||||
success=False,
|
||||
auth_failed=True,
|
||||
period_start=since_ms,
|
||||
period_end=until_ms,
|
||||
max_puzzles=max_puzzles,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue