Skip to content

Commit 38d3446

Browse files
authored
fix: respect challenge rate limits (#1211)
Treat generic 429 responses from the challenge endpoint as bot rate limits, honoring Retry-After when present and otherwise using bounded exponential backoff. Also prevent expired outgoing challenges from bypassing the local challenge rate-limit timer. Validation: - python3 -m py_compile lib/lichess.py lib/matchmaking.py test_bot/test_lichess.py test_bot/test_matchmaking.py - python -m ruff check lib/lichess.py lib/matchmaking.py test_bot/test_lichess.py test_bot/test_matchmaking.py - python -m pytest test_bot/test_lichess.py::test_challenge_429_without_ratelimit_body_sets_bot_rate_limit test_bot/test_lichess.py::test_challenge_429_without_retry_after_uses_exponential_backoff test_bot/test_matchmaking.py::test_should_create_challenge_respects_rate_limit_when_previous_challenge_expired
1 parent cba56b1 commit 38d3446

4 files changed

Lines changed: 99 additions & 2 deletions

File tree

lib/lichess.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ def __init__(self, token: str, url: str, version: str, logging_level: int, max_r
145145
self.logging_level = logging_level
146146
self.max_retries = max_retries
147147
self.rate_limit_timers: defaultdict[str, Timer] = defaultdict(Timer)
148+
self.challenge_rate_limit_backoff = seconds(60)
148149

149150
# Confirm that the OAuth token has the proper permission to play on lichess
150151
token_response = cast(TOKEN_TESTS_TYPE, self.api_post("token_test", data=token))
@@ -308,6 +309,28 @@ def handle_challenge(self, response: requests.models.Response) -> ChallengeType:
308309
challenge_response["bot_is_rate_limited"] = bot_is_rate_limited
309310
challenge_response["opponent_is_rate_limited"] = opponent_is_rate_limited
310311
challenge_response["rate_limit_timeout"] = delay
312+
elif is_new_rate_limit(response):
313+
# Generic 429 without a ratelimit body (no bot.vsBot.day key). Honor
314+
# Retry-After if lichess sent it, otherwise back off exponentially
315+
# (60 → 120 → 240 → 480, capped at 600s) so repeated 429s escalate
316+
# the cooldown instead of retrying at the same short interval.
317+
delay = None
318+
retry_after = response.headers.get("Retry-After")
319+
if retry_after:
320+
try:
321+
delay = seconds(float(retry_after))
322+
except ValueError:
323+
delay = None
324+
if delay is None:
325+
delay = self.challenge_rate_limit_backoff
326+
self.challenge_rate_limit_backoff = min(seconds(600), self.challenge_rate_limit_backoff * 2)
327+
self.set_rate_limit_delay(ENDPOINTS["challenge"], delay)
328+
challenge_response["bot_is_rate_limited"] = True
329+
challenge_response["opponent_is_rate_limited"] = False
330+
challenge_response["rate_limit_timeout"] = delay
331+
else:
332+
# Any non-429 response resets the backoff to its floor.
333+
self.challenge_rate_limit_backoff = seconds(60)
311334

312335
return challenge_response
313336

lib/matchmaking.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,15 +53,16 @@ def __init__(self, li: Lichess, config: Configuration, user_profile: UserProfile
5353
def should_create_challenge(self) -> bool:
5454
"""Whether we should create a challenge."""
5555
matchmaking_enabled = self.matchmaking_cfg.allow_matchmaking
56-
time_has_passed = self.last_game_ended_delay.is_expired() and self.rate_limit_timer.is_expired()
56+
rate_limit_ok = self.rate_limit_timer.is_expired()
57+
time_has_passed = self.last_game_ended_delay.is_expired()
5758
challenge_expired = self.last_challenge_created_delay.is_expired() and self.challenge_id
5859
min_wait_time_passed = self.last_challenge_created_delay.time_since_reset() > self.min_wait_time
5960
if challenge_expired:
6061
self.li.cancel(self.challenge_id)
6162
logger.info(f"Challenge id {self.challenge_id} cancelled.")
6263
self.discard_challenge(self.challenge_id)
6364
self.show_earliest_challenge_time()
64-
return bool(matchmaking_enabled and (time_has_passed or challenge_expired) and min_wait_time_passed)
65+
return bool(matchmaking_enabled and rate_limit_ok and (time_has_passed or challenge_expired) and min_wait_time_passed)
6566

6667
def create_challenge(self, username: str, base_time: int, increment: int, days: int, variant: str,
6768
mode: str) -> str:

test_bot/test_lichess.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,60 @@
11
"""Tests for the lichess communication."""
22

33
from lib import lichess
4+
from lib.timer import Timer, seconds
5+
from collections import defaultdict
6+
from requests.models import Response
47
import logging
58
import os
69
import pytest
10+
from typing import cast
11+
12+
13+
def mock_response(status_code: int, body: dict[str, object], headers: dict[str, str] | None = None) -> Response:
14+
"""Create a mock HTTP response."""
15+
class MockResponse:
16+
def __init__(self) -> None:
17+
self.status_code = status_code
18+
self.headers = headers or {}
19+
20+
def json(self) -> dict[str, object]:
21+
return dict(body)
22+
23+
return cast(Response, MockResponse())
24+
25+
26+
def lichess_without_init() -> lichess.Lichess:
27+
"""Create a minimal Lichess instance without checking a real token."""
28+
li = object.__new__(lichess.Lichess)
29+
li.rate_limit_timers = defaultdict(Timer)
30+
li.challenge_rate_limit_backoff = seconds(60)
31+
return li
32+
33+
34+
def test_challenge_429_without_ratelimit_body_sets_bot_rate_limit() -> None:
35+
"""Generic challenge 429s should still block new challenge attempts."""
36+
li = lichess_without_init()
37+
response = mock_response(429, {"error": "Too many requests. Try again later."}, {"Retry-After": "120"})
38+
39+
challenge_response = li.handle_challenge(response)
40+
41+
assert challenge_response["bot_is_rate_limited"] is True
42+
assert challenge_response["opponent_is_rate_limited"] is False
43+
assert challenge_response["rate_limit_timeout"] == seconds(120)
44+
assert li.is_rate_limited(lichess.ENDPOINTS["challenge"])
45+
46+
47+
def test_challenge_429_without_retry_after_uses_exponential_backoff() -> None:
48+
"""Repeated generic challenge 429s should increase the local cooldown."""
49+
li = lichess_without_init()
50+
response = mock_response(429, {"error": "Too many requests. Try again later."})
51+
52+
first_response = li.handle_challenge(response)
53+
second_response = li.handle_challenge(response)
54+
55+
assert first_response["rate_limit_timeout"] == seconds(60)
56+
assert second_response["rate_limit_timeout"] == seconds(120)
57+
assert li.challenge_rate_limit_backoff == seconds(240)
758

859

960
def test_lichess() -> None:

test_bot/test_matchmaking.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from unittest.mock import Mock
33
from lib.matchmaking import game_category, Matchmaking
44
from lib.config import Configuration
5+
from lib.timer import Timer, seconds
56
from lib.lichess_types import UserProfileType
67

78

@@ -198,6 +199,27 @@ def test_get_random_config_value__returns_specific_value() -> None:
198199
assert result == "atomic", f"Expected 'atomic' but got '{result}'"
199200

200201

202+
def test_should_create_challenge_respects_rate_limit_when_previous_challenge_expired() -> None:
203+
"""An expired previous challenge should not bypass the challenge rate-limit timer."""
204+
mock_li = Mock()
205+
mock_config = Configuration({
206+
"challenge": {"variants": ["standard"]},
207+
"matchmaking": {
208+
"allow_matchmaking": True,
209+
"block_list": [],
210+
"online_block_list": [],
211+
"challenge_timeout": 0
212+
}
213+
})
214+
mock_user_profile: UserProfileType = {"username": "testbot", "perfs": {}}
215+
matchmaking = Matchmaking(mock_li, mock_config, mock_user_profile)
216+
matchmaking.challenge_id = "challenge-id"
217+
matchmaking.last_challenge_created_delay.starting_time -= 100
218+
matchmaking.rate_limit_timer = Timer(seconds(60))
219+
220+
assert matchmaking.should_create_challenge() is False
221+
222+
201223
def test_get_random_config_value__returns_from_choices_when_random() -> None:
202224
"""Test that get_random_config_value returns a value from choices when config value is 'random'."""
203225
# Create mock objects

0 commit comments

Comments
 (0)