Skip to content

Commit 5749ba8

Browse files
authored
Use Timers instead of bools to filter opponents to allow temporary stops to challenges (#1141)
* Make matchmaking challenge filters time-limited This will allow for filters to expire in case challenge declinations are for temporary reasons. Also, in the case of an opponent reaching the 100-game daily limit, this will be useful for trying again in the time given in the interval. * Factor out variable * Distinguish between bot limit and opponent limit In an upcoming lichess update, challenging an opponent that has played 100 bot games will result in a similar error response to when the user's bot has played 100 bot games. This change distinguishes between them by checking if the error message contains the opponent's name. This could also be distinguished by checking if the status code is 429 (user's bot) or 400 (opponent bot). But that information isn't returned from the Lichess.challenge() call. * Be more specific about challenge pause delay * Factor out function to get timeout from error * Use status code to tell who is rate limited A status code of 400 indicates that the opponent is rate limited, while 429 indicates that the bot is limited. Also, put who is rate limited (if anyone) in the challenge response so the Matchmaking class can use it. * Simplify challenge control flow * Only set rate limit when bot gets 429 status code Don't set rate limit when opponent is rate limited (400 status code). * Create a method to handle responses to challenges
1 parent a6a051c commit 5749ba8

3 files changed

Lines changed: 75 additions & 33 deletions

File tree

lib/lichess.py

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,36 @@ def is_new_rate_limit(response: requests.models.Response) -> bool:
7171
"""Check if the status code is 429, which means that we are rate limited."""
7272
return response.status_code == 429
7373

74+
def is_daily_game_rate_limit(response: requests.models.Response, rate_limit_status_code: int) -> bool:
75+
"""Check if response to challenge is a rate limit, either of the bot or the opponent."""
76+
if response.status_code != rate_limit_status_code:
77+
return False
78+
79+
try:
80+
body = response.json()
81+
return "error" in body and body.get("ratelimit", {}).get("key", "") == "bot.vsBot.day"
82+
except requests.exceptions.JSONDecodeError:
83+
return False
84+
85+
86+
def is_opponent_rate_limit(response: requests.models.Response) -> bool:
87+
"""Check if response to a challenge is 400, which means opponent is rate limited."""
88+
return is_daily_game_rate_limit(response, 400)
89+
90+
91+
def is_bot_rate_limit(response: requests.models.Response) -> bool:
92+
"""Check if response to a challenge is 429, which means the bot is rate limited."""
93+
return is_daily_game_rate_limit(response, 429)
94+
95+
96+
def get_challenge_timeout(challenge_response: ChallengeType) -> Optional[datetime.timedelta]:
97+
"""Return the timeout in a challenge response if the bot or the opponent cannot play another game."""
98+
rate_limit = challenge_response.get("ratelimit", {})
99+
key = rate_limit.get("key", "")
100+
if key == "bot.vsBot.day":
101+
return seconds(float(rate_limit["seconds"]))
102+
return None
103+
74104

75105
def is_final(exception: Exception) -> bool:
76106
"""If `is_final` returns True then we won't retry."""
@@ -240,18 +270,11 @@ def api_post(self,
240270
url = urljoin(self.baseUrl, path_template.format(*template_args))
241271
response = self.session.post(url, data=data, headers=headers, params=params, json=payload, timeout=2)
242272

273+
if endpoint_name == "challenge":
274+
return self.handle_challenge(response)
275+
243276
if is_new_rate_limit(response):
244-
delay = seconds(60)
245-
try:
246-
if endpoint_name == "challenge":
247-
body = response.json()
248-
rate_limit = body.get("ratelimit", {})
249-
key = rate_limit.get("key", "")
250-
if key == "bot.vsBot.day":
251-
delay = seconds(rate_limit["seconds"])
252-
except requests.exceptions.JSONDecodeError:
253-
pass
254-
self.set_rate_limit_delay(path_template, delay)
277+
self.set_rate_limit_delay(path_template, seconds(60))
255278

256279
if raise_for_status:
257280
response.raise_for_status()
@@ -268,11 +291,26 @@ def get_path_template(self, endpoint_name: str) -> str:
268291
"""
269292
path_template = ENDPOINTS[endpoint_name]
270293
if self.is_rate_limited(path_template):
271-
raise RateLimitedError(f"{path_template} is rate-limited. "
272-
f"Will retry in {sec_str(self.rate_limit_time_left(path_template))} seconds.",
273-
self.rate_limit_time_left(path_template))
294+
time_left = self.rate_limit_time_left(path_template)
295+
raise RateLimitedError(
296+
f"{path_template} is rate-limited. Will retry in {sec_str(time_left)} seconds.", time_left)
274297
return path_template
275298

299+
def handle_challenge(self, response: requests.models.Response) -> ChallengeType:
300+
"""Handle the response to a challenge and, if necessary, the daily game timeout."""
301+
bot_is_rate_limited = is_bot_rate_limit(response)
302+
opponent_is_rate_limited = is_opponent_rate_limit(response)
303+
challenge_response: ChallengeType = response.json()
304+
if bot_is_rate_limited or opponent_is_rate_limited:
305+
delay = cast(datetime.timedelta, get_challenge_timeout(challenge_response))
306+
if bot_is_rate_limited:
307+
self.set_rate_limit_delay(ENDPOINTS["challenge"], delay)
308+
challenge_response["bot_is_rate_limited"] = bot_is_rate_limited
309+
challenge_response["opponent_is_rate_limited"] = opponent_is_rate_limited
310+
challenge_response["rate_limit_timeout"] = delay
311+
312+
return challenge_response
313+
276314
def set_rate_limit_delay(self, path_template: str, delay_time: datetime.timedelta) -> None:
277315
"""
278316
Set a delay to a path template if it was rate limited.

lib/lichess_types.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from chess import Move, Board
55
from queue import Queue
66
import logging
7+
import datetime
78
from enum import Enum
89
from types import TracebackType
910

@@ -188,7 +189,11 @@ class ChallengeType(TypedDict, total=False):
188189
declineReason: str
189190
declineReasonKey: str
190191
initialFen: str
191-
error: dict[str, Union[str, dict[str, str]]]
192+
error: str
193+
ratelimit: dict[str, Union[str, int]]
194+
bot_is_rate_limited: bool
195+
opponent_is_rate_limited: bool
196+
rate_limit_timeout: datetime.timedelta
192197

193198

194199
class EventType(TypedDict, total=False):

lib/matchmaking.py

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import datetime
55
import contextlib
66
from lib import model
7-
from lib.timer import Timer, seconds, minutes, years
7+
from lib.timer import Timer, days, seconds, minutes, years
88
from collections import defaultdict
99
from collections.abc import Sequence
1010
from lib.lichess import Lichess, RateLimitedError
@@ -41,7 +41,7 @@ def __init__(self, li: Lichess, config: Configuration, user_profile: UserProfile
4141
# - variant (standard, horde, etc.)
4242
# - casual/rated
4343
# - empty string (if no other reason is given or self.filter_type is COARSE)
44-
self.challenge_type_acceptable: defaultdict[tuple[str, str], bool] = defaultdict(lambda: True)
44+
self.challenge_type_acceptable: defaultdict[tuple[str, str], Timer] = defaultdict(Timer)
4545
self.challenge_filter = self.matchmaking_cfg.challenge_filter
4646

4747
for name in self.matchmaking_cfg.block_list:
@@ -95,16 +95,15 @@ def create_challenge(self, username: str, base_time: int, increment: int, days:
9595
def handle_challenge_error_response(self, response: ChallengeType, username: str) -> None:
9696
"""If a challenge fails, print the error and adjust the challenge requirements in response."""
9797
logger.error(response)
98-
if "error" in response:
99-
rate_limit = cast(dict[str, str], response.get("ratelimit", {}))
100-
key = rate_limit.get("key", "")
101-
if key == "bot.vsBot.day":
102-
self.rate_limit_timer = Timer(seconds(float(rate_limit["seconds"])))
98+
if response.get("bot_is_rate_limited"):
99+
timeout = cast(datetime.timedelta, response.get("rate_limit_timeout"))
100+
self.rate_limit_timer = Timer(timeout)
101+
elif response.get("opponent_is_rate_limited"):
102+
self.add_challenge_filter(username, "", response.get("rate_limit_timeout"))
103103
else:
104-
self.add_to_block_list(username)
104+
self.add_challenge_filter(username, "")
105105
self.show_earliest_challenge_time()
106106

107-
108107
def perf(self) -> dict[str, PerfType]:
109108
"""Get the bot's rating in every variant. Bullet, blitz, rapid etc. are considered different variants."""
110109
user_perf: dict[str, PerfType] = self.user_profile["perfs"]
@@ -263,22 +262,22 @@ def show_earliest_challenge_time(self) -> None:
263262

264263
def add_to_block_list(self, username: str) -> None:
265264
"""Add a bot to the blocklist."""
266-
self.add_challenge_filter(username, "")
265+
self.add_challenge_filter(username, "", years(10))
267266

268267
def in_block_list(self, username: str) -> bool:
269268
"""Check if an opponent is in the block list to prevent future challenges."""
270269
return not self.should_accept_challenge(username, "")
271270

272-
def add_challenge_filter(self, username: str, game_aspect: str) -> None:
271+
def add_challenge_filter(self, username: str, game_aspect: str, timeout: Union[datetime.timedelta, None] = None) -> None:
273272
"""
274-
Prevent creating another challenge when an opponent has decline a challenge.
273+
Prevent creating another challenge for a timeout when an opponent has declined a challenge.
275274
276275
:param username: The name of the opponent.
277-
:param game_aspect: The aspect of a game (time control, chess variant, etc.)
278-
that caused the opponent to decline a challenge. If the parameter is empty,
279-
that is equivalent to adding the opponent to the block list.
276+
:param game_aspect: The aspect of a game (time control, chess variant, etc.) that caused the opponent to decline a
277+
challenge. If the parameter is empty, that is equivalent to adding the opponent to the block list.
278+
:param timeout: The amount of time to not challenge an opponent. If None, the default is a day.
280279
"""
281-
self.challenge_type_acceptable[(username, game_aspect)] = False
280+
self.challenge_type_acceptable[(username, game_aspect)] = Timer(timeout or days(1))
282281

283282
def should_accept_challenge(self, username: str, game_aspect: str) -> bool:
284283
"""
@@ -288,7 +287,7 @@ def should_accept_challenge(self, username: str, game_aspect: str) -> bool:
288287
:param game_aspect: A category of the challenge type (time control, chess variant, etc.) to test for acceptance.
289288
If game_aspect is empty, this is equivalent to checking if the opponent is in the block list.
290289
"""
291-
return self.challenge_type_acceptable[(username, game_aspect)]
290+
return self.challenge_type_acceptable[(username, game_aspect)].is_expired()
292291

293292
def accepted_challenge(self, event: EventType) -> None:
294293
"""
@@ -329,7 +328,7 @@ def declined_challenge(self, event: EventType) -> None:
329328
logger.warning(f"Unknown decline reason received: {reason_key}")
330329
game_problem = decline_details.get(reason_key, "") if self.challenge_filter == FilterType.FINE else ""
331330
self.add_challenge_filter(opponent.name, game_problem)
332-
logger.info(f"Will not challenge {opponent} to another {game_problem}".strip() + " game.")
331+
logger.info(f"Will not challenge {opponent} to another {game_problem}".strip() + " game today.")
333332

334333
self.show_earliest_challenge_time()
335334

0 commit comments

Comments
 (0)