Skip to content

Commit 5e77a47

Browse files
authored
Handle new error message when challenging bots (lichess-bot-devs#1137)
* Handle new error message when challenging bots Reference issue: lichess-bot-devs#1135 Detect when the rate limiting status code (429) is due to issuing more than 100 challenges in a day. The change to matchmaking prevents adding other bots to the block list in case the error is due to this rate limit. * Remove tracking of daily challenges The purpose of tracking daily challenges was to slow down the issuing of challenges when the rate limit of 400/day was being approached. However, now that the number of games is limited to 100/day (whether the bot issues or receives the challenge), this tracking is redundant. The rate at which challenges are issued is now entirely controlled by the matchmaking: challenge_timeout configuration, which specifies how long after a game ends to issue a new challenge. * Remove unneeded Timer attributes Now that challenge times aren't recorded, there's no need for backdated Timers or the recording of when a Timer was created. * Delete unused type * Put daily game limit info in user messages Put the rate-limit timeout into the RateLimitedError exception so other parts of the program can use it. Add a rate-limit Timer to the Matchmaking class so that the user's logs aren't fill with rate-limited messages. Also, change the format of the "Next challenge will be created after" message to include the date since the delay may last into the next day. * Put back Matchmaking.min_wait_time This serves as a general rate limit on challenges and is still useful. * Undo unneeded parenthesis move * Handle daily game limit on first failed challenge Create a function to handle the daily game limit. Update types to accomodate the response error message. * Update wiki regarding daily bot game limits
1 parent 028deb0 commit 5e77a47

6 files changed

Lines changed: 53 additions & 78 deletions

File tree

lib/lichess.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ def __init__(self) -> None:
6161
class RateLimitedError(RuntimeError):
6262
"""Exception raised when we are rate limited (status code 429)."""
6363

64+
def __init__(self, message: str, timeout: datetime.timedelta) -> None:
65+
"""Create a rate-limited error with the time left until the rate limit expires."""
66+
super().__init__(message)
67+
self.timeout = timeout
68+
6469

6570
def is_new_rate_limit(response: requests.models.Response) -> bool:
6671
"""Check if the status code is 429, which means that we are rate limited."""
@@ -236,7 +241,17 @@ def api_post(self,
236241
response = self.session.post(url, data=data, headers=headers, params=params, json=payload, timeout=2)
237242

238243
if is_new_rate_limit(response):
239-
self.set_rate_limit_delay(path_template, seconds(60))
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)
240255

241256
if raise_for_status:
242257
response.raise_for_status()
@@ -254,7 +269,8 @@ def get_path_template(self, endpoint_name: str) -> str:
254269
path_template = ENDPOINTS[endpoint_name]
255270
if self.is_rate_limited(path_template):
256271
raise RateLimitedError(f"{path_template} is rate-limited. "
257-
f"Will retry in {sec_str(self.rate_limit_time_left(path_template))} seconds.")
272+
f"Will retry in {sec_str(self.rate_limit_time_left(path_template))} seconds.",
273+
self.rate_limit_time_left(path_template))
258274
return path_template
259275

260276
def set_rate_limit_delay(self, path_template: str, delay_time: datetime.timedelta) -> None:

lib/lichess_types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ class ChallengeType(TypedDict, total=False):
188188
declineReason: str
189189
declineReasonKey: str
190190
initialFen: str
191+
error: dict[str, Union[str, dict[str, str]]]
191192

192193

193194
class EventType(TypedDict, total=False):

lib/matchmaking.py

Lines changed: 28 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -4,40 +4,17 @@
44
import datetime
55
import contextlib
66
from lib import model
7-
from lib.timer import Timer, seconds, minutes, days, years
7+
from lib.timer import Timer, seconds, minutes, years
88
from collections import defaultdict
99
from collections.abc import Sequence
10-
from lib.lichess import Lichess
10+
from lib.lichess import Lichess, RateLimitedError
1111
from lib.config import Configuration
12-
from typing import Optional, Union
13-
from lib.lichess_types import UserProfileType, PerfType, EventType, FilterType
12+
from typing import Optional, Union, cast
13+
from lib.lichess_types import UserProfileType, PerfType, EventType, FilterType, ChallengeType
1414
MULTIPROCESSING_LIST_TYPE = Sequence[model.Challenge]
15-
DAILY_TIMERS_TYPE = list[Timer]
1615

1716
logger = logging.getLogger(__name__)
1817

19-
daily_challenges_file_name = "daily_challenge_times.txt"
20-
timestamp_format = "%Y-%m-%d %H:%M:%S\n"
21-
22-
23-
def read_daily_challenges() -> DAILY_TIMERS_TYPE:
24-
"""Read the challenges we have created in the past 24 hours from a text file."""
25-
timers: DAILY_TIMERS_TYPE = []
26-
try:
27-
with open(daily_challenges_file_name) as file:
28-
for line in file:
29-
timers.append(Timer(days(1), datetime.datetime.strptime(line, timestamp_format)))
30-
except FileNotFoundError:
31-
pass
32-
33-
return [timer for timer in timers if not timer.is_expired()]
34-
35-
36-
def write_daily_challenges(daily_challenges: DAILY_TIMERS_TYPE) -> None:
37-
"""Write the challenges we have created in the past 24 hours to a text file."""
38-
with open(daily_challenges_file_name, "w") as file:
39-
file.writelines(timer.starting_timestamp(timestamp_format) for timer in daily_challenges)
40-
4118

4219
class Matchmaking:
4320
"""Challenge other bots."""
@@ -52,11 +29,11 @@ def __init__(self, li: Lichess, config: Configuration, user_profile: UserProfile
5229
self.last_game_ended_delay = Timer(minutes(self.matchmaking_cfg.challenge_timeout))
5330
self.last_user_profile_update_time = Timer(minutes(5))
5431
self.min_wait_time = seconds(60) # Wait before new challenge to avoid api rate limits.
32+
self.rate_limit_timer = Timer()
5533

5634
# Maximum time between challenges, even if there are active games
5735
self.max_wait_time = minutes(10) if self.matchmaking_cfg.allow_during_games else years(10)
5836
self.challenge_id = ""
59-
self.daily_challenges = read_daily_challenges()
6037

6138
# (opponent name, game aspect) --> other bot is likely to accept challenge
6239
# game aspect is the one the challenged bot objects to and is one of:
@@ -73,7 +50,7 @@ def __init__(self, li: Lichess, config: Configuration, user_profile: UserProfile
7350
def should_create_challenge(self) -> bool:
7451
"""Whether we should create a challenge."""
7552
matchmaking_enabled = self.matchmaking_cfg.allow_matchmaking
76-
time_has_passed = self.last_game_ended_delay.is_expired()
53+
time_has_passed = self.last_game_ended_delay.is_expired() and self.rate_limit_timer.is_expired()
7754
challenge_expired = self.last_challenge_created_delay.is_expired() and self.challenge_id
7855
min_wait_time_passed = self.last_challenge_created_delay.time_since_reset() > self.min_wait_time
7956
if challenge_expired:
@@ -99,35 +76,34 @@ def create_challenge(self, username: str, base_time: int, increment: int, days:
9976
return ""
10077

10178
try:
102-
self.update_daily_challenge_record()
10379
self.last_challenge_created_delay.reset()
10480
response = self.li.challenge(username, params)
10581
challenge_id = response.get("id", "")
10682
if not challenge_id:
107-
logger.error(response)
108-
self.add_to_block_list(username)
109-
self.show_earliest_challenge_time()
83+
self.handle_challenge_error_response(response, username)
11084
return challenge_id
85+
except RateLimitedError as e:
86+
logger.warning(e)
87+
self.rate_limit_timer = Timer(e.timeout)
11188
except Exception as e:
112-
logger.warning("Could not create challenge")
11389
logger.debug(e, exc_info=e)
114-
self.show_earliest_challenge_time()
115-
return ""
11690

117-
def update_daily_challenge_record(self) -> None:
118-
"""
119-
Record timestamp of latest challenge and update minimum wait time.
91+
logger.warning("Could not create challenge")
92+
self.show_earliest_challenge_time()
93+
return ""
94+
95+
def handle_challenge_error_response(self, response: ChallengeType, username: str) -> None:
96+
"""If a challenge fails, print the error and adjust the challenge requirements in response."""
97+
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"])))
103+
else:
104+
self.add_to_block_list(username)
105+
self.show_earliest_challenge_time()
120106

121-
As the number of challenges in a day increase, the minimum wait time between challenges increases.
122-
0 - 49 challenges --> 1 minute
123-
50 - 99 challenges --> 2 minutes
124-
100 - 149 challenges --> 3 minutes
125-
etc.
126-
"""
127-
self.daily_challenges = [timer for timer in self.daily_challenges if not timer.is_expired()]
128-
self.daily_challenges.append(Timer(days(1)))
129-
self.min_wait_time = seconds(60) * ((len(self.daily_challenges) // 50) + 1)
130-
write_daily_challenges(self.daily_challenges)
131107

132108
def perf(self) -> dict[str, PerfType]:
133109
"""Get the bot's rating in every variant. Bullet, blitz, rapid etc. are considered different variants."""
@@ -280,11 +256,10 @@ def show_earliest_challenge_time(self) -> None:
280256
if self.matchmaking_cfg.allow_matchmaking:
281257
postgame_timeout = self.last_game_ended_delay.time_until_expiration()
282258
time_to_next_challenge = self.min_wait_time - self.last_challenge_created_delay.time_since_reset()
283-
time_left = max(postgame_timeout, time_to_next_challenge)
259+
rate_limit_delay = self.rate_limit_timer.time_until_expiration()
260+
time_left = max(postgame_timeout, time_to_next_challenge, rate_limit_delay)
284261
earliest_challenge_time = datetime.datetime.now() + time_left
285-
challenges = "challenge" + ("" if len(self.daily_challenges) == 1 else "s")
286-
logger.info(f"Next challenge will be created after {earliest_challenge_time.strftime('%X')} "
287-
f"({len(self.daily_challenges)} {challenges} in last 24 hours)")
262+
logger.info(f"Next challenge will be created after {earliest_challenge_time.strftime('%c')}")
288263

289264
def add_to_block_list(self, username: str) -> None:
290265
"""Add a bot to the blocklist."""

lib/timer.py

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
"""A timer for use in lichess-bot."""
22

3-
from datetime import datetime, timedelta
3+
from datetime import timedelta
44
from time import perf_counter
5-
from typing import Optional
65

76

87
def msec(time_in_msec: float) -> timedelta:
@@ -72,8 +71,7 @@ class Timer:
7271
the timer was created or since it was last reset.
7372
"""
7473

75-
def __init__(self, duration: timedelta = zero_seconds,
76-
backdated_timestamp: Optional[datetime] = None) -> None:
74+
def __init__(self, duration: timedelta = zero_seconds) -> None:
7775
"""
7876
Start the timer.
7977
@@ -83,9 +81,6 @@ def __init__(self, duration: timedelta = zero_seconds,
8381
self.duration = duration
8482
self.starting_time = perf_counter()
8583

86-
if backdated_timestamp:
87-
self.starting_time -= to_seconds(datetime.now() - backdated_timestamp)
88-
8984
def is_expired(self) -> bool:
9085
"""Check if a timer is expired."""
9186
return self.time_since_reset() >= self.duration
@@ -101,7 +96,3 @@ def time_since_reset(self) -> timedelta:
10196
def time_until_expiration(self) -> timedelta:
10297
"""How much time is left until it expires."""
10398
return max(seconds(0), self.duration - self.time_since_reset())
104-
105-
def starting_timestamp(self, timestamp_format: str) -> str:
106-
"""When the timer started."""
107-
return (datetime.now() - self.time_since_reset()).strftime(timestamp_format)

test_bot/test_timer.py

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Test functions dedicated to time measurement and conversion."""
22

3-
from datetime import datetime, timedelta
3+
from datetime import timedelta
44

55
from lib import timer
66

@@ -40,10 +40,6 @@ def test_init() -> None:
4040
assert t.duration == duration
4141
assert t.starting_time is not None
4242

43-
backdated_timestamp = datetime.now() - timedelta(seconds=10)
44-
t = timer.Timer(backdated_timestamp=backdated_timestamp)
45-
assert t.starting_time is not None
46-
assert t.time_since_reset() >= timedelta(seconds=10)
4743

4844
def test_is_expired() -> None:
4945
"""Test timer expiration."""
@@ -86,10 +82,3 @@ def test_time() -> None:
8682
t = timer.Timer(timedelta(seconds=10))
8783
t.starting_time -= 5
8884
assert timer.sec_str(t.time_until_expiration()) == timer.sec_str(timedelta(seconds=5))
89-
90-
def test_starting_timestamp() -> None:
91-
"""Test timestamp conversion and integration."""
92-
t = timer.Timer(timedelta(seconds=10))
93-
timestamp_format = "%Y-%m-%d %H:%M:%S"
94-
expected_timestamp = (datetime.now() - t.time_since_reset()).strftime(timestamp_format)
95-
assert t.starting_timestamp(timestamp_format) == expected_timestamp

wiki/Configure-lichess-bot.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,9 @@ will precede the `go` command to start thinking with `sd 5`. The other `go_comma
242242
- `allow_during_games`: Whether to issue new challenges while the bot is already playing games. If true, no more than 10 minutes will pass between matchmaking challenges.
243243
- `challenge_variant`: The variant for the challenges. If set to `random` a variant from the ones enabled in `challenge.variants` will be chosen at random.
244244
- `challenge_timeout`: The time (in minutes) the bot has to be idle before it creates a challenge.
245+
246+
Bots are limited to playing 100 games against other bots per day, so setting `challenge_timeout` to a small value will result in this limit being reached quickly. There is no limit to the number of games a bot can play against humans.
247+
245248
- `challenge_initial_time`: A list of initial times (in seconds and to be chosen at random) for the challenges.
246249
- `challenge_increment`: A list of increments (in seconds and to be chosen at random) for the challenges.
247250
- `challenge_days`: A list of number of days for a correspondence challenge (to be chosen at random).

0 commit comments

Comments
 (0)