python-ballchasing 0.5.2__py3-none-any.whl → 0.5.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ballchasing/__init__.py +1 -0
- ballchasing/api.py +176 -62
- ballchasing/rate_limit.py +227 -0
- {python_ballchasing-0.5.2.dist-info → python_ballchasing-0.5.3.dist-info}/METADATA +1 -1
- {python_ballchasing-0.5.2.dist-info → python_ballchasing-0.5.3.dist-info}/RECORD +9 -8
- tests/test_fixes.py +191 -7
- {python_ballchasing-0.5.2.dist-info → python_ballchasing-0.5.3.dist-info}/WHEEL +0 -0
- {python_ballchasing-0.5.2.dist-info → python_ballchasing-0.5.3.dist-info}/licenses/LICENSE +0 -0
- {python_ballchasing-0.5.2.dist-info → python_ballchasing-0.5.3.dist-info}/top_level.txt +0 -0
ballchasing/__init__.py
CHANGED
|
@@ -49,6 +49,7 @@ except Exception:
|
|
|
49
49
|
|
|
50
50
|
# Import the main classes and constants to make them easily accessible to users.
|
|
51
51
|
from .api import BallchasingApi
|
|
52
|
+
from .rate_limit import RateLimiter, RateLimitCategory, rate_limited
|
|
52
53
|
|
|
53
54
|
Api = BallchasingApi # For importing like `import ballchasing; api = ballchasing.Api`
|
|
54
55
|
|
ballchasing/api.py
CHANGED
|
@@ -12,6 +12,7 @@ from requests.exceptions import ConnectionError, Timeout
|
|
|
12
12
|
|
|
13
13
|
from ballchasing.constants import GroupSortBy, SortDir, AnySeason, AnyRank, AnyReplaySortBy, AnySortDir, \
|
|
14
14
|
AnyVisibility, AnyGroupSortBy, AnyPlayerIdentification, AnyTeamIdentification, AnyMatchResult, NoneOrMore
|
|
15
|
+
from ballchasing.rate_limit import RateLimiter, RateLimitCategory, resolve_category, rate_limited
|
|
15
16
|
from ballchasing.typed import DeepReplay, ShallowReplay, DeepGroup, ShallowGroup
|
|
16
17
|
from .typed.shared import BaseGroup, BasicGroup
|
|
17
18
|
from .util.dates import to_rfc3339
|
|
@@ -45,6 +46,7 @@ class BallchasingApi:
|
|
|
45
46
|
base_url: str | None = None,
|
|
46
47
|
do_initial_ping: bool = True,
|
|
47
48
|
typed: bool = False,
|
|
49
|
+
proactive_rate_limit: bool = False,
|
|
48
50
|
):
|
|
49
51
|
"""
|
|
50
52
|
|
|
@@ -52,6 +54,7 @@ class BallchasingApi:
|
|
|
52
54
|
:param sleep_time_on_rate_limit: seconds to wait after being rate limited.
|
|
53
55
|
Default value is calculated depending on patron type.
|
|
54
56
|
:param print_on_rate_limit: whether or not to print upon rate limits.
|
|
57
|
+
:param proactive_rate_limit: whether to pace requests proactively before hitting limits.
|
|
55
58
|
"""
|
|
56
59
|
self.auth_key = auth_key
|
|
57
60
|
self._session = sessions.Session()
|
|
@@ -63,6 +66,10 @@ class BallchasingApi:
|
|
|
63
66
|
self._sleep_time_on_rate_limit = sleep_time_on_rate_limit
|
|
64
67
|
self.print_on_rate_limit = print_on_rate_limit
|
|
65
68
|
self.typed = typed
|
|
69
|
+
self.rate_limiter = RateLimiter(
|
|
70
|
+
patron_type_getter=lambda: self._ping_result.get("type") if self._ping_result else None,
|
|
71
|
+
proactive=proactive_rate_limit,
|
|
72
|
+
)
|
|
66
73
|
|
|
67
74
|
@property
|
|
68
75
|
def sleep_time_on_rate_limit(self) -> float:
|
|
@@ -76,6 +83,21 @@ class BallchasingApi:
|
|
|
76
83
|
def sleep_time_on_rate_limit(self, value: float | None):
|
|
77
84
|
self._sleep_time_on_rate_limit = value
|
|
78
85
|
|
|
86
|
+
@property
|
|
87
|
+
def last_rate_limited_at(self) -> float | None:
|
|
88
|
+
"""Timestamp (time.time()) of the most recent 429 response, or None if never rate-limited."""
|
|
89
|
+
return self.rate_limiter.last_rate_limited_at
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def is_rate_limited(self) -> bool:
|
|
93
|
+
"""True if the instance is currently waiting out a 429 rate-limit cooldown."""
|
|
94
|
+
return self.rate_limiter.is_rate_limited
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def rate_limit_stats(self) -> dict:
|
|
98
|
+
"""Detailed rate-limiting statistics across all categories."""
|
|
99
|
+
return self.rate_limiter.get_stats()
|
|
100
|
+
|
|
79
101
|
@property
|
|
80
102
|
def steam_name(self):
|
|
81
103
|
if self._ping_result is None:
|
|
@@ -104,45 +126,58 @@ class BallchasingApi:
|
|
|
104
126
|
self,
|
|
105
127
|
url_or_endpoint: str,
|
|
106
128
|
method: str,
|
|
129
|
+
allow_status_codes: tuple[int, ...] = (),
|
|
130
|
+
category: RateLimitCategory | str | None = None,
|
|
107
131
|
**params
|
|
108
132
|
) -> Response:
|
|
109
133
|
"""
|
|
110
134
|
Helper method for all requests.
|
|
111
135
|
|
|
112
|
-
:param
|
|
136
|
+
:param url_or_endpoint: url or endpoint for request.
|
|
113
137
|
:param method: the method to use.
|
|
114
|
-
:param
|
|
138
|
+
:param allow_status_codes: additional HTTP status codes to treat as successful responses.
|
|
139
|
+
:param category: rate limit category for this request.
|
|
140
|
+
:param params: parameters for request (e.g. params, json, files, stream).
|
|
115
141
|
:return: the request result.
|
|
116
142
|
:raises ConnectionError: if the connection fails after max retries.
|
|
117
|
-
:raises HTTPError: if the request fails with a status code other than 2xx or
|
|
143
|
+
:raises HTTPError: if the request fails with a status code other than 2xx, 429, or allow_status_codes.
|
|
118
144
|
"""
|
|
145
|
+
cat = RateLimitCategory(category) if category else resolve_category(url_or_endpoint)
|
|
119
146
|
headers = {"Authorization": self.auth_key}
|
|
120
147
|
url = f"{self.base_url}{url_or_endpoint}" if url_or_endpoint.startswith("/") else url_or_endpoint
|
|
121
148
|
max_retries = 8
|
|
122
149
|
retries = 0
|
|
123
150
|
rate_limit_retries = 0
|
|
151
|
+
|
|
152
|
+
# Wait if global cooldown is active or if proactive pacing applies
|
|
153
|
+
self.rate_limiter.before_request(cat)
|
|
154
|
+
|
|
124
155
|
while True:
|
|
125
156
|
try:
|
|
126
157
|
r: Response = self._session.request(method=method, url=url, headers=headers, **params)
|
|
127
|
-
if 200 <= r.status_code < 300:
|
|
158
|
+
if 200 <= r.status_code < 300 or r.status_code in allow_status_codes:
|
|
128
159
|
return r
|
|
129
160
|
elif r.status_code == 429:
|
|
130
161
|
self.rate_limit_count += 1
|
|
131
|
-
if self.print_on_rate_limit:
|
|
132
|
-
logger.warning(f"Rate limited at {url} ({self.rate_limit_count} total rate limits)")
|
|
133
|
-
else:
|
|
134
|
-
logger.debug(f"Rate limited at {url} ({self.rate_limit_count} total rate limits)")
|
|
135
162
|
rate_limit_retries += 1
|
|
136
|
-
if rate_limit_retries > max_retries:
|
|
137
|
-
r.raise_for_status()
|
|
138
163
|
retry_after = r.headers.get("Retry-After", '0')
|
|
139
|
-
retry_after =
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
164
|
+
retry_after = float(retry_after) if retry_after.isdigit() else None
|
|
165
|
+
wait_time = self.rate_limiter.record_429(
|
|
166
|
+
cat,
|
|
167
|
+
retry_after=retry_after,
|
|
168
|
+
fallback_sleep_time=self._sleep_time_on_rate_limit,
|
|
169
|
+
retries=rate_limit_retries,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if self.print_on_rate_limit:
|
|
173
|
+
logger.warning(
|
|
174
|
+
f"Rate limited at {url} [{cat.value}] ({self.rate_limit_count} total rate limits), retrying in {wait_time:.1f}s..."
|
|
175
|
+
)
|
|
144
176
|
else:
|
|
145
|
-
|
|
177
|
+
logger.debug(
|
|
178
|
+
f"Rate limited at {url} [{cat.value}] ({self.rate_limit_count} total rate limits), retrying in {wait_time:.1f}s..."
|
|
179
|
+
)
|
|
180
|
+
time.sleep(wait_time)
|
|
146
181
|
elif r.status_code in (502, 503, 504):
|
|
147
182
|
raise ConnectionError(f"Server error {r.status_code}: {r.reason or 'Transient gateway error'}")
|
|
148
183
|
else:
|
|
@@ -155,6 +190,7 @@ class BallchasingApi:
|
|
|
155
190
|
time.sleep(s)
|
|
156
191
|
retries += 1
|
|
157
192
|
|
|
193
|
+
@rate_limited(RateLimitCategory.PING)
|
|
158
194
|
def ping(self) -> dict:
|
|
159
195
|
"""
|
|
160
196
|
Use this API to:
|
|
@@ -165,7 +201,7 @@ class BallchasingApi:
|
|
|
165
201
|
This method runs automatically at initialization and the steam name and id as well as patron type are stored.
|
|
166
202
|
:return: ping response.
|
|
167
203
|
"""
|
|
168
|
-
result = self._request("/", "GET").json()
|
|
204
|
+
result = self._request("/", "GET", category=RateLimitCategory.PING).json()
|
|
169
205
|
self._ping_result = result
|
|
170
206
|
return result
|
|
171
207
|
|
|
@@ -181,7 +217,7 @@ class BallchasingApi:
|
|
|
181
217
|
return
|
|
182
218
|
|
|
183
219
|
def fetch_page(p):
|
|
184
|
-
return self._request(url, "GET", params=p).json()
|
|
220
|
+
return self._request(url, "GET", params=p, category=RateLimitCategory.LIST).json()
|
|
185
221
|
|
|
186
222
|
with ThreadPoolExecutor(max_workers=1) as executor:
|
|
187
223
|
params["count"] = min(remaining, 200)
|
|
@@ -211,6 +247,7 @@ class BallchasingApi:
|
|
|
211
247
|
if not prefetch:
|
|
212
248
|
future = executor.submit(fetch_page, dict(params))
|
|
213
249
|
|
|
250
|
+
@rate_limited(RateLimitCategory.LIST)
|
|
214
251
|
def get_replays(
|
|
215
252
|
self,
|
|
216
253
|
*,
|
|
@@ -267,8 +304,8 @@ class BallchasingApi:
|
|
|
267
304
|
RFC3339 format, e.g. '2020-01-02T15:00:05+01:00'
|
|
268
305
|
:param count: returns at most count replays. Since the implementation uses an iterator it supports iterating
|
|
269
306
|
past the limit of 200 set by the API
|
|
270
|
-
:param sort_by: sort replays according the selected field
|
|
271
|
-
:param sort_dir: sort direction
|
|
307
|
+
:param sort_by: sort replays according the selected field ('replay-date', 'upload-date')
|
|
308
|
+
:param sort_dir: sort direction ('asc', 'desc')
|
|
272
309
|
:param deep: whether to get full stats for each replay (will be much slower).
|
|
273
310
|
:param typed: whether to return a typed object (default is self.typed).
|
|
274
311
|
:param deduplicate: whether to deduplicate replays that seem to be the same game.
|
|
@@ -303,6 +340,7 @@ class BallchasingApi:
|
|
|
303
340
|
iterator = (ShallowReplay(**r) for r in iterator)
|
|
304
341
|
yield from iterator
|
|
305
342
|
|
|
343
|
+
@rate_limited(RateLimitCategory.CORE)
|
|
306
344
|
def get_replay(self, replay_id: str, *, typed: bool | None = None) -> dict | DeepReplay:
|
|
307
345
|
"""
|
|
308
346
|
Retrieve a given replay’s details and stats.
|
|
@@ -311,43 +349,82 @@ class BallchasingApi:
|
|
|
311
349
|
:param typed: whether to return a typed object (default is self.typed).
|
|
312
350
|
:return: the result of the GET request.
|
|
313
351
|
"""
|
|
314
|
-
result = self._request(f"/replays/{replay_id}", "GET").json()
|
|
352
|
+
result = self._request(f"/replays/{replay_id}", "GET", category=RateLimitCategory.CORE).json()
|
|
315
353
|
if typed is None:
|
|
316
354
|
typed = self.typed
|
|
317
355
|
if typed:
|
|
318
356
|
result = DeepReplay(**result)
|
|
319
357
|
return result
|
|
320
358
|
|
|
321
|
-
|
|
359
|
+
@rate_limited(RateLimitCategory.CORE)
|
|
360
|
+
def patch_replay(
|
|
361
|
+
self,
|
|
362
|
+
replay_id: str,
|
|
363
|
+
*,
|
|
364
|
+
title: str | None = None,
|
|
365
|
+
visibility: AnyVisibility | None = None,
|
|
366
|
+
group: str | None = None,
|
|
367
|
+
**params
|
|
368
|
+
) -> None:
|
|
322
369
|
"""
|
|
323
|
-
This endpoint can patch one or more fields of the specified replay
|
|
370
|
+
This endpoint can patch one or more fields of the specified replay.
|
|
324
371
|
|
|
325
372
|
:param replay_id: the replay id.
|
|
326
|
-
:param
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
373
|
+
:param title: new title for the replay.
|
|
374
|
+
:param visibility: new visibility ('public', 'unlisted', 'private').
|
|
375
|
+
:param group: group id to assign, or "" to unassign replay from group.
|
|
376
|
+
:param params: additional parameters for the PATCH request.
|
|
377
|
+
"""
|
|
378
|
+
payload = dict(params)
|
|
379
|
+
if title is not None:
|
|
380
|
+
payload["title"] = title
|
|
381
|
+
if visibility is not None:
|
|
382
|
+
payload["visibility"] = visibility
|
|
383
|
+
if group is not None:
|
|
384
|
+
payload["group"] = group
|
|
385
|
+
self._request(f"/replays/{replay_id}", "PATCH", json=payload, category=RateLimitCategory.CORE)
|
|
386
|
+
|
|
387
|
+
@rate_limited(RateLimitCategory.CORE)
|
|
330
388
|
def upload_replay(
|
|
331
389
|
self,
|
|
332
390
|
replay_file: str | Path | BinaryIO,
|
|
333
391
|
*,
|
|
334
392
|
visibility: AnyVisibility | None = None,
|
|
335
|
-
group: str | None = None
|
|
393
|
+
group: str | None = None,
|
|
394
|
+
raise_on_duplicate: bool = False,
|
|
336
395
|
) -> dict:
|
|
337
396
|
"""
|
|
338
397
|
Use this API to upload a replay file to ballchasing.com.
|
|
339
398
|
|
|
340
399
|
:param replay_file: replay file to upload. Can be a file path (str or Path) or a file-like object.
|
|
341
|
-
:param visibility: to set the visibility of the uploaded replay.
|
|
400
|
+
:param visibility: to set the visibility of the uploaded replay ('public', 'unlisted', 'private').
|
|
342
401
|
:param group: to upload the replay to an existing group.
|
|
343
|
-
:
|
|
402
|
+
:param raise_on_duplicate: if True, raise an HTTPError when a 409 Conflict (duplicate replay)
|
|
403
|
+
is returned. If False (default), returns the API response dict with the
|
|
404
|
+
existing replay id.
|
|
405
|
+
:return: the result of the POST request (dict containing replay 'id').
|
|
344
406
|
"""
|
|
345
407
|
if isinstance(replay_file, (str, Path)):
|
|
346
408
|
with open(replay_file, "rb") as f:
|
|
347
|
-
return self.upload_replay(
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
409
|
+
return self.upload_replay(
|
|
410
|
+
f,
|
|
411
|
+
visibility=visibility,
|
|
412
|
+
group=group,
|
|
413
|
+
raise_on_duplicate=raise_on_duplicate,
|
|
414
|
+
)
|
|
415
|
+
r = self._request(
|
|
416
|
+
"/v2/upload",
|
|
417
|
+
"POST",
|
|
418
|
+
files={"file": replay_file},
|
|
419
|
+
params={"group": group, "visibility": visibility},
|
|
420
|
+
allow_status_codes=(409,),
|
|
421
|
+
category=RateLimitCategory.CORE,
|
|
422
|
+
)
|
|
423
|
+
if r.status_code == 409 and raise_on_duplicate:
|
|
424
|
+
r.raise_for_status()
|
|
425
|
+
return r.json()
|
|
426
|
+
|
|
427
|
+
@rate_limited(RateLimitCategory.CORE)
|
|
351
428
|
def delete_replay(self, replay_id: str) -> None:
|
|
352
429
|
"""
|
|
353
430
|
This endpoint deletes the specified replay.
|
|
@@ -355,8 +432,9 @@ class BallchasingApi:
|
|
|
355
432
|
|
|
356
433
|
:param replay_id: the replay id.
|
|
357
434
|
"""
|
|
358
|
-
self._request(f"/replays/{replay_id}", "DELETE")
|
|
435
|
+
self._request(f"/replays/{replay_id}", "DELETE", category=RateLimitCategory.CORE)
|
|
359
436
|
|
|
437
|
+
@rate_limited(RateLimitCategory.LIST)
|
|
360
438
|
def get_groups(
|
|
361
439
|
self,
|
|
362
440
|
*,
|
|
@@ -385,14 +463,14 @@ class BallchasingApi:
|
|
|
385
463
|
RFC3339 format, e.g. 2020-01-02T15:00:05+01:00
|
|
386
464
|
:param count: returns at most count groups. Since the implementation uses an iterator it supports iterating
|
|
387
465
|
past the limit of 200 set by the API
|
|
388
|
-
:param sort_by: Sort groups according the selected field.
|
|
389
|
-
:param sort_dir: Sort direction.
|
|
466
|
+
:param sort_by: Sort groups according the selected field ('created', 'name').
|
|
467
|
+
:param sort_dir: Sort direction ('asc', 'desc').
|
|
390
468
|
:param deep: whether to get full stats for each group (will be much slower).
|
|
391
469
|
:param typed: whether to return a typed object (default is self.typed).
|
|
392
470
|
:param disable_prefetch: whether to disable prefetching.
|
|
393
471
|
:return: an iterator over the groups returned by the API.
|
|
394
472
|
"""
|
|
395
|
-
url = f"{self.base_url}/groups
|
|
473
|
+
url = f"{self.base_url}/groups"
|
|
396
474
|
params = {"name": name, "creator": creator, "group": group, "created-before": to_rfc3339(created_before),
|
|
397
475
|
"created-after": to_rfc3339(created_after), "count": count, "sort-by": sort_by, "sort-dir": sort_dir}
|
|
398
476
|
|
|
@@ -408,6 +486,7 @@ class BallchasingApi:
|
|
|
408
486
|
iterator = (ShallowGroup(**g) for g in iterator)
|
|
409
487
|
yield from iterator
|
|
410
488
|
|
|
489
|
+
@rate_limited(RateLimitCategory.CORE)
|
|
411
490
|
def create_group(
|
|
412
491
|
self,
|
|
413
492
|
*,
|
|
@@ -420,7 +499,7 @@ class BallchasingApi:
|
|
|
420
499
|
Use this API to create a new replay group.
|
|
421
500
|
|
|
422
501
|
:param name: the new group name.
|
|
423
|
-
:param player_identification: how to identify the same player across multiple replays.
|
|
502
|
+
:param player_identification: how to identify the same player across multiple replays ('by-id', 'by-name').
|
|
424
503
|
Some tournaments (e.g. RLCS) make players use a pool of generic Steam accounts,
|
|
425
504
|
meaning the same player could end up using 2 different accounts in 2 series.
|
|
426
505
|
That's when the `by-name` comes in handy
|
|
@@ -428,13 +507,19 @@ class BallchasingApi:
|
|
|
428
507
|
Set to `by-distinct-players` if teams have a fixed roster of players for
|
|
429
508
|
every single game. In some tournaments/leagues, teams allow player rotations,
|
|
430
509
|
or a sub can replace another player, in which case use `by-player-clusters`.
|
|
431
|
-
:param parent: if set,the new group will be created as a child of the specified group
|
|
510
|
+
:param parent: if set, the new group will be created as a child of the specified group
|
|
432
511
|
:return: the result of the POST request.
|
|
433
512
|
"""
|
|
434
|
-
json = {
|
|
435
|
-
|
|
436
|
-
|
|
513
|
+
json = {
|
|
514
|
+
"name": name,
|
|
515
|
+
"player_identification": player_identification,
|
|
516
|
+
"team_identification": team_identification,
|
|
517
|
+
}
|
|
518
|
+
if parent is not None:
|
|
519
|
+
json["parent"] = parent
|
|
520
|
+
return self._request(f"/groups", "POST", json=json, category=RateLimitCategory.CORE).json()
|
|
437
521
|
|
|
522
|
+
@rate_limited(RateLimitCategory.CORE)
|
|
438
523
|
def get_group(
|
|
439
524
|
self,
|
|
440
525
|
group_id: str,
|
|
@@ -448,22 +533,46 @@ class BallchasingApi:
|
|
|
448
533
|
:param typed: whether to return a typed object (default is self.typed).
|
|
449
534
|
:return: the group info with stats.
|
|
450
535
|
"""
|
|
451
|
-
result = self._request(f"/groups/{group_id}", "GET").json()
|
|
536
|
+
result = self._request(f"/groups/{group_id}", "GET", category=RateLimitCategory.CORE).json()
|
|
452
537
|
if typed is None:
|
|
453
538
|
typed = self.typed
|
|
454
539
|
if typed:
|
|
455
540
|
result = DeepGroup(**result)
|
|
456
541
|
return result
|
|
457
542
|
|
|
458
|
-
|
|
543
|
+
@rate_limited(RateLimitCategory.CORE)
|
|
544
|
+
def patch_group(
|
|
545
|
+
self,
|
|
546
|
+
group_id: str,
|
|
547
|
+
*,
|
|
548
|
+
player_identification: AnyPlayerIdentification | None = None,
|
|
549
|
+
team_identification: AnyTeamIdentification | None = None,
|
|
550
|
+
parent: str | None = None,
|
|
551
|
+
shared: bool | None = None,
|
|
552
|
+
**params
|
|
553
|
+
) -> None:
|
|
459
554
|
"""
|
|
460
555
|
This endpoint can patch one or more fields of the specified group.
|
|
461
556
|
|
|
462
557
|
:param group_id: the group id
|
|
463
|
-
:param
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
558
|
+
:param player_identification: how to identify the same player across multiple replays ('by-id', 'by-name').
|
|
559
|
+
:param team_identification: how to identify the same team ('by-distinct-players', 'by-player-clusters').
|
|
560
|
+
:param parent: parent group id to assign, or "" to unparent.
|
|
561
|
+
:param shared: whether to mark the group as shared.
|
|
562
|
+
:param params: additional parameters for the PATCH request.
|
|
563
|
+
"""
|
|
564
|
+
payload = dict(params)
|
|
565
|
+
if player_identification is not None:
|
|
566
|
+
payload["player_identification"] = player_identification
|
|
567
|
+
if team_identification is not None:
|
|
568
|
+
payload["team_identification"] = team_identification
|
|
569
|
+
if parent is not None:
|
|
570
|
+
payload["parent"] = parent
|
|
571
|
+
if shared is not None:
|
|
572
|
+
payload["shared"] = shared
|
|
573
|
+
self._request(f"/groups/{group_id}", "PATCH", json=payload, category=RateLimitCategory.CORE)
|
|
574
|
+
|
|
575
|
+
@rate_limited(RateLimitCategory.CORE)
|
|
467
576
|
def delete_group(self, group_id: str) -> None:
|
|
468
577
|
"""
|
|
469
578
|
This endpoint deletes the specified group.
|
|
@@ -471,7 +580,7 @@ class BallchasingApi:
|
|
|
471
580
|
|
|
472
581
|
:param group_id: the group id.
|
|
473
582
|
"""
|
|
474
|
-
self._request(f"/groups/{group_id}", "DELETE")
|
|
583
|
+
self._request(f"/groups/{group_id}", "DELETE", category=RateLimitCategory.CORE)
|
|
475
584
|
|
|
476
585
|
def get_group_replays(
|
|
477
586
|
self,
|
|
@@ -519,22 +628,25 @@ class BallchasingApi:
|
|
|
519
628
|
for replay in self.get_replays(group_id=group_id, deep=deep, typed=typed):
|
|
520
629
|
yield [group_id], replay
|
|
521
630
|
|
|
522
|
-
|
|
631
|
+
@rate_limited(RateLimitCategory.DOWNLOAD)
|
|
632
|
+
def download_replay(self, replay_id: str, path: str | Path) -> Path:
|
|
523
633
|
"""
|
|
524
634
|
Download a replay file.
|
|
525
635
|
|
|
526
636
|
:param replay_id: the replay id.
|
|
527
637
|
:param path: the path to download the replay to. Can be a file path or a directory.
|
|
638
|
+
:return: the Path to the downloaded file.
|
|
528
639
|
"""
|
|
529
|
-
|
|
530
|
-
if
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
640
|
+
dest = Path(path)
|
|
641
|
+
if dest.is_dir():
|
|
642
|
+
dest = dest / f"{replay_id}.replay"
|
|
643
|
+
r = self._request(f"/replays/{replay_id}/file", "GET", stream=True, category=RateLimitCategory.DOWNLOAD)
|
|
644
|
+
with open(dest, "wb") as f:
|
|
645
|
+
for chunk in r.iter_content(chunk_size=8192):
|
|
646
|
+
f.write(chunk)
|
|
647
|
+
return dest
|
|
536
648
|
|
|
537
|
-
def download_group(self, group_id: str, folder: str, *, keep_tree_structure=True):
|
|
649
|
+
def download_group(self, group_id: str, folder: str | Path, *, keep_tree_structure=True):
|
|
538
650
|
"""
|
|
539
651
|
Download an entire group.
|
|
540
652
|
|
|
@@ -542,22 +654,24 @@ class BallchasingApi:
|
|
|
542
654
|
:param folder: the folder in which to create the group folder.
|
|
543
655
|
:param keep_tree_structure: whether to create new folders for child groups.
|
|
544
656
|
"""
|
|
545
|
-
folder =
|
|
657
|
+
folder = Path(folder) / group_id
|
|
546
658
|
if keep_tree_structure:
|
|
547
|
-
|
|
659
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
548
660
|
for child_group in self.get_groups(group=group_id):
|
|
549
661
|
self.download_group(child_group["id"], folder, keep_tree_structure=True)
|
|
550
662
|
for replay in self.get_replays(group_id=group_id):
|
|
551
663
|
self.download_replay(replay["id"], folder)
|
|
552
664
|
else:
|
|
665
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
553
666
|
for replay in self.get_group_replays(group_id):
|
|
554
667
|
self.download_replay(replay["id"], folder)
|
|
555
668
|
|
|
669
|
+
@rate_limited(RateLimitCategory.CORE)
|
|
556
670
|
def get_maps(self):
|
|
557
671
|
"""
|
|
558
672
|
Use this API to get the list of map codes to map names (map as in stadium).
|
|
559
673
|
"""
|
|
560
|
-
res = self._request("/maps", "GET").json()
|
|
674
|
+
res = self._request("/maps", "GET", category=RateLimitCategory.CORE).json()
|
|
561
675
|
return res
|
|
562
676
|
|
|
563
677
|
def get_stats(self, replay: dict | str):
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Callable, Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RateLimitCategory(str, Enum):
|
|
11
|
+
"""Rate limit categories defined in ballchasing.com API documentation."""
|
|
12
|
+
LIST = "list" # GET /replays, GET /groups
|
|
13
|
+
CORE = "core" # GET/PATCH/DELETE /replays/{id}, /groups/{id}, POST /groups, upload, maps
|
|
14
|
+
DOWNLOAD = "download" # GET /replays/{id}/file
|
|
15
|
+
PING = "ping" # GET /
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Hourly rate limit backoff times per category (seconds to wait between requests)
|
|
19
|
+
# - regular: list 500/hr (7.2s), core 1000/hr (3.6s), download 200/hr (18.0s)
|
|
20
|
+
# - gold: list 1000/hr (3.6s), core 2000/hr (1.8s), download 400/hr (9.0s)
|
|
21
|
+
# - diamond: list 2000/hr (1.8s), core 5000/hr (0.72s), download 1000/hr (3.6s)
|
|
22
|
+
# - champion: list 8/s, core 8/s, download 2000/hr (1.8s)
|
|
23
|
+
# - gc: list 16/s, core 16/s, download 2/s (0.5s)
|
|
24
|
+
CATEGORY_HOURLY_SLEEP_TIMES: dict[str, dict[RateLimitCategory, float]] = {
|
|
25
|
+
"regular": {
|
|
26
|
+
RateLimitCategory.LIST: 3600 / 500, # 7.2s
|
|
27
|
+
RateLimitCategory.CORE: 3600 / 1000, # 3.6s
|
|
28
|
+
RateLimitCategory.DOWNLOAD: 3600 / 200, # 18.0s
|
|
29
|
+
RateLimitCategory.PING: 0.0,
|
|
30
|
+
},
|
|
31
|
+
"gold": {
|
|
32
|
+
RateLimitCategory.LIST: 3600 / 1000, # 3.6s
|
|
33
|
+
RateLimitCategory.CORE: 3600 / 2000, # 1.8s
|
|
34
|
+
RateLimitCategory.DOWNLOAD: 3600 / 400, # 9.0s
|
|
35
|
+
RateLimitCategory.PING: 0.0,
|
|
36
|
+
},
|
|
37
|
+
"diamond": {
|
|
38
|
+
RateLimitCategory.LIST: 3600 / 2000, # 1.8s
|
|
39
|
+
RateLimitCategory.CORE: 3600 / 5000, # 0.72s
|
|
40
|
+
RateLimitCategory.DOWNLOAD: 3600 / 1000, # 3.6s
|
|
41
|
+
RateLimitCategory.PING: 0.0,
|
|
42
|
+
},
|
|
43
|
+
"champion": {
|
|
44
|
+
RateLimitCategory.LIST: 1.0 / 8, # 0.125s
|
|
45
|
+
RateLimitCategory.CORE: 1.0 / 8, # 0.125s
|
|
46
|
+
RateLimitCategory.DOWNLOAD: 3600 / 2000, # 1.8s
|
|
47
|
+
RateLimitCategory.PING: 0.0,
|
|
48
|
+
},
|
|
49
|
+
"gc": {
|
|
50
|
+
RateLimitCategory.LIST: 1.0 / 16, # 0.0625s
|
|
51
|
+
RateLimitCategory.CORE: 1.0 / 16, # 0.0625s
|
|
52
|
+
RateLimitCategory.DOWNLOAD: 1.0 / 2, # 0.5s
|
|
53
|
+
RateLimitCategory.PING: 0.0,
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Maximum calls per second (burst rates)
|
|
58
|
+
CATEGORY_BURST_RATES: dict[str, dict[RateLimitCategory, float]] = {
|
|
59
|
+
"regular": {RateLimitCategory.LIST: 2.0, RateLimitCategory.CORE: 2.0, RateLimitCategory.DOWNLOAD: 1.0, RateLimitCategory.PING: 10.0},
|
|
60
|
+
"gold": {RateLimitCategory.LIST: 2.0, RateLimitCategory.CORE: 2.0, RateLimitCategory.DOWNLOAD: 2.0, RateLimitCategory.PING: 10.0},
|
|
61
|
+
"diamond": {RateLimitCategory.LIST: 4.0, RateLimitCategory.CORE: 4.0, RateLimitCategory.DOWNLOAD: 2.0, RateLimitCategory.PING: 10.0},
|
|
62
|
+
"champion": {RateLimitCategory.LIST: 8.0, RateLimitCategory.CORE: 8.0, RateLimitCategory.DOWNLOAD: 2.0, RateLimitCategory.PING: 10.0},
|
|
63
|
+
"gc": {RateLimitCategory.LIST: 16.0, RateLimitCategory.CORE: 16.0, RateLimitCategory.DOWNLOAD: 2.0, RateLimitCategory.PING: 10.0},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def resolve_category(url_or_endpoint: str) -> RateLimitCategory:
|
|
68
|
+
"""Categorize an endpoint URL or path into a RateLimitCategory."""
|
|
69
|
+
path = url_or_endpoint.split("?")[0].rstrip("/")
|
|
70
|
+
if path.endswith("/file"):
|
|
71
|
+
return RateLimitCategory.DOWNLOAD
|
|
72
|
+
if path.endswith("/replays") or path.endswith("/groups"):
|
|
73
|
+
return RateLimitCategory.LIST
|
|
74
|
+
if path == "" or path.endswith("/api") or path.endswith("/api/"):
|
|
75
|
+
return RateLimitCategory.PING
|
|
76
|
+
return RateLimitCategory.CORE
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class RateLimiter:
|
|
80
|
+
"""
|
|
81
|
+
Thread-safe rate limiter and global 429 tracker for BallchasingApi.
|
|
82
|
+
|
|
83
|
+
Coordinates rate limits across concurrent requests/threads sharing the same
|
|
84
|
+
BallchasingApi instance to prevent cascading 429 errors.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
patron_type_getter: Callable[[], str | None] | None = None,
|
|
90
|
+
*,
|
|
91
|
+
proactive: bool = False,
|
|
92
|
+
):
|
|
93
|
+
self._lock = threading.Lock()
|
|
94
|
+
self._patron_type_getter = patron_type_getter
|
|
95
|
+
self.proactive = proactive
|
|
96
|
+
|
|
97
|
+
# Global reactive cooldown (shared across all threads/endpoints for this API instance)
|
|
98
|
+
self._rate_limited_until: float = 0.0
|
|
99
|
+
self._last_rate_limited_at: float | None = None
|
|
100
|
+
|
|
101
|
+
# Per-category tracking
|
|
102
|
+
self._last_request_time: dict[RateLimitCategory, float] = {}
|
|
103
|
+
self._category_rate_limit_count: dict[RateLimitCategory, int] = {}
|
|
104
|
+
self._last_rate_limited_per_category: dict[RateLimitCategory, float] = {}
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def last_rate_limited_at(self) -> float | None:
|
|
108
|
+
"""Timestamp (time.time()) of the most recent 429 response, or None if never rate-limited."""
|
|
109
|
+
with self._lock:
|
|
110
|
+
return self._last_rate_limited_at
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def is_rate_limited(self) -> bool:
|
|
114
|
+
"""True if the instance is currently waiting out a 429 rate-limit cooldown."""
|
|
115
|
+
with self._lock:
|
|
116
|
+
return time.monotonic() < self._rate_limited_until
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def rate_limited_until(self) -> float:
|
|
120
|
+
"""Monotonic timestamp until which the global cooldown is active."""
|
|
121
|
+
with self._lock:
|
|
122
|
+
return self._rate_limited_until
|
|
123
|
+
|
|
124
|
+
def get_patron_type(self) -> str:
|
|
125
|
+
if self._patron_type_getter:
|
|
126
|
+
try:
|
|
127
|
+
pt = self._patron_type_getter()
|
|
128
|
+
if pt:
|
|
129
|
+
return pt
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
return "regular"
|
|
133
|
+
|
|
134
|
+
def get_category_sleep_time(self, category: RateLimitCategory) -> float:
|
|
135
|
+
"""Returns the hourly backoff sleep time for the specified category and current patron tier."""
|
|
136
|
+
pt = self.get_patron_type()
|
|
137
|
+
return CATEGORY_HOURLY_SLEEP_TIMES.get(pt, CATEGORY_HOURLY_SLEEP_TIMES["regular"]).get(
|
|
138
|
+
category, 3.6
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def before_request(self, category: RateLimitCategory) -> None:
|
|
142
|
+
"""
|
|
143
|
+
Called before sending an HTTP request.
|
|
144
|
+
If a global 429 cooldown is active, waits for it to expire.
|
|
145
|
+
If proactive rate limiting is enabled, paces requests according to the category's burst rate.
|
|
146
|
+
"""
|
|
147
|
+
sleep_duration = 0.0
|
|
148
|
+
with self._lock:
|
|
149
|
+
now_mono = time.monotonic()
|
|
150
|
+
# 1. Check global reactive 429 cooldown
|
|
151
|
+
if now_mono < self._rate_limited_until:
|
|
152
|
+
sleep_duration = self._rate_limited_until - now_mono
|
|
153
|
+
|
|
154
|
+
# 2. If proactive mode, ensure spacing between requests
|
|
155
|
+
if self.proactive:
|
|
156
|
+
pt = self.get_patron_type()
|
|
157
|
+
rate = CATEGORY_BURST_RATES.get(pt, CATEGORY_BURST_RATES["regular"]).get(category, 2.0)
|
|
158
|
+
min_interval = 1.0 / rate if rate > 0 else 0.0
|
|
159
|
+
last_time = self._last_request_time.get(category, 0.0)
|
|
160
|
+
elapsed = now_mono - last_time
|
|
161
|
+
if elapsed < min_interval:
|
|
162
|
+
proactive_wait = min_interval - elapsed
|
|
163
|
+
sleep_duration = max(sleep_duration, proactive_wait)
|
|
164
|
+
|
|
165
|
+
self._last_request_time[category] = now_mono + sleep_duration
|
|
166
|
+
|
|
167
|
+
if sleep_duration > 0:
|
|
168
|
+
time.sleep(sleep_duration)
|
|
169
|
+
|
|
170
|
+
def record_429(
|
|
171
|
+
self,
|
|
172
|
+
category: RateLimitCategory,
|
|
173
|
+
*,
|
|
174
|
+
retry_after: float | None = None,
|
|
175
|
+
fallback_sleep_time: float | None = None,
|
|
176
|
+
retries: int = 1,
|
|
177
|
+
) -> float:
|
|
178
|
+
"""
|
|
179
|
+
Record a 429 response. Updates the global and per-category tracker,
|
|
180
|
+
and returns the duration (in seconds) that the caller should sleep.
|
|
181
|
+
"""
|
|
182
|
+
with self._lock:
|
|
183
|
+
now_wall = time.time()
|
|
184
|
+
now_mono = time.monotonic()
|
|
185
|
+
|
|
186
|
+
self._last_rate_limited_at = now_wall
|
|
187
|
+
self._last_rate_limited_per_category[category] = now_wall
|
|
188
|
+
self._category_rate_limit_count[category] = self._category_rate_limit_count.get(category, 0) + 1
|
|
189
|
+
|
|
190
|
+
if retry_after is not None and retry_after > 0:
|
|
191
|
+
wait_time = float(retry_after)
|
|
192
|
+
elif fallback_sleep_time is not None and fallback_sleep_time > 0:
|
|
193
|
+
wait_time = float(fallback_sleep_time)
|
|
194
|
+
else:
|
|
195
|
+
cat_sleep = self.get_category_sleep_time(category)
|
|
196
|
+
backoff = float(2 ** (retries - 1))
|
|
197
|
+
wait_time = max(cat_sleep, min(backoff, 60.0))
|
|
198
|
+
|
|
199
|
+
# Extend global cooldown until this wait expires
|
|
200
|
+
self._rate_limited_until = max(self._rate_limited_until, now_mono + wait_time)
|
|
201
|
+
return wait_time
|
|
202
|
+
|
|
203
|
+
def get_stats(self) -> dict[str, Any]:
|
|
204
|
+
"""Returns statistics on rate limits encountered per category."""
|
|
205
|
+
with self._lock:
|
|
206
|
+
return {
|
|
207
|
+
"last_rate_limited_at": self._last_rate_limited_at,
|
|
208
|
+
"is_rate_limited": time.monotonic() < self._rate_limited_until,
|
|
209
|
+
"category_counts": {k.value: v for k, v in self._category_rate_limit_count.items()},
|
|
210
|
+
"last_per_category": {k.value: v for k, v in self._last_rate_limited_per_category.items()},
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def rate_limited(category: RateLimitCategory | str | None = None):
|
|
215
|
+
"""
|
|
216
|
+
Decorator for BallchasingApi methods to declare their RateLimitCategory
|
|
217
|
+
and ensure proper categorization.
|
|
218
|
+
"""
|
|
219
|
+
cat = RateLimitCategory(category) if category else RateLimitCategory.CORE
|
|
220
|
+
|
|
221
|
+
def decorator(fn):
|
|
222
|
+
@functools.wraps(fn)
|
|
223
|
+
def wrapper(self, *args, **kwargs):
|
|
224
|
+
return fn(self, *args, **kwargs)
|
|
225
|
+
wrapper.rate_limit_category = cat
|
|
226
|
+
return wrapper
|
|
227
|
+
return decorator
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
ballchasing/__init__.py,sha256=
|
|
2
|
-
ballchasing/api.py,sha256=
|
|
1
|
+
ballchasing/__init__.py,sha256=xEWxUkk8wQ5V2zjq8I7JccccrgPxu60jGgAL779JP5k,2770
|
|
2
|
+
ballchasing/api.py,sha256=JPnkwt1XPEzh_ueNJa0l9HmE9Wf6ptJ-1YTwp7xfyHU,30850
|
|
3
3
|
ballchasing/constants.py,sha256=BEVGNljc5K1AxkaXc1nyXwhGZ8ceHDLmA8hReOBikVQ,12705
|
|
4
|
+
ballchasing/rate_limit.py,sha256=zr31_oo741xordFwG4oO42aPbec8Vu22zTcmeaMjgcQ,9489
|
|
4
5
|
ballchasing/stats_info.tsv,sha256=IBvVe0CF5HsTvNJFQlVCT4zeq-RfiKvgtro5a8KMTis,12258
|
|
5
6
|
ballchasing/typed/__init__.py,sha256=oHkYhNK4m1tNwq_E72CYi-mUpoqo9w2kgEF17SdcRP4,279
|
|
6
7
|
ballchasing/typed/deep_group.py,sha256=WCl3oldYLn0ogXn9sQXIZcck9Sr5bl6rM_EJiJ3x3uY,12916
|
|
@@ -13,13 +14,13 @@ ballchasing/util/dates.py,sha256=gUSldK6-jMNbGLaBiYwmiKUR0h0ulzPiiDCZdFbBq2s,883
|
|
|
13
14
|
ballchasing/util/iterators.py,sha256=1An0MiQ6QZHLpa-38eDYek-OrOGLI5pI9m0h1YDKUdc,4196
|
|
14
15
|
ballchasing/util/replays.py,sha256=4OmDqrNrjHKz6G9iVVz01UWq3KGJGEYbWJ-nNaDqK0E,8380
|
|
15
16
|
ballchasing/util/stats.py,sha256=8Bi3TvbUmV97eVXY1moc8hjTUetKXZ8XAhSJFW_DC8s,2461
|
|
16
|
-
python_ballchasing-0.5.
|
|
17
|
+
python_ballchasing-0.5.3.dist-info/licenses/LICENSE,sha256=ua0R_J89EfdcyYXy_6TMz6BoSxABvyBYCfgfOKu12FE,1075
|
|
17
18
|
scripts/get_maps.py,sha256=y3X28uqoi4kRo8OMZH0c2JS0AGlNCl4ZLO4d7_ZVteA,6065
|
|
18
19
|
scripts/make_types.py,sha256=6EBF5V0Lh7bt7LTKKBWJI8jywVObpDC20-GYOOdFqFU,6588
|
|
19
20
|
scripts/test.py,sha256=j_qRBqFeymHtzh75z9JQxpwJSnAwsmljSsS8b2yyu9I,2748
|
|
20
21
|
scripts/test_typed.py,sha256=3XYwQWVLqH2L_gr6FOfZ_elXYvSipzAhnmVUXYH1te4,3116
|
|
21
|
-
tests/test_fixes.py,sha256=
|
|
22
|
-
python_ballchasing-0.5.
|
|
23
|
-
python_ballchasing-0.5.
|
|
24
|
-
python_ballchasing-0.5.
|
|
25
|
-
python_ballchasing-0.5.
|
|
22
|
+
tests/test_fixes.py,sha256=swVEcvuXM6Kba3ce90ZduCMBOl36NBT2TRKKYBY-Bqw,20593
|
|
23
|
+
python_ballchasing-0.5.3.dist-info/METADATA,sha256=WZ1Y9vdpVrxUG8VdfoMISeatQUwh9cX57kPddVKe8oI,3687
|
|
24
|
+
python_ballchasing-0.5.3.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
25
|
+
python_ballchasing-0.5.3.dist-info/top_level.txt,sha256=XsIp-HGAPOjN3ZpjMRRGvJcbra_qptToUFUjAOk5VZA,26
|
|
26
|
+
python_ballchasing-0.5.3.dist-info/RECORD,,
|
tests/test_fixes.py
CHANGED
|
@@ -1,25 +1,33 @@
|
|
|
1
|
+
import io
|
|
1
2
|
import logging
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
2
5
|
from unittest.mock import patch, MagicMock
|
|
3
6
|
import pytest
|
|
4
7
|
from requests import Response
|
|
5
|
-
from requests.exceptions import ConnectionError as RequestsConnectionError, Timeout
|
|
8
|
+
from requests.exceptions import ConnectionError as RequestsConnectionError, HTTPError, Timeout
|
|
6
9
|
|
|
7
10
|
from ballchasing.api import BallchasingApi, PATRON_RATE_LIMIT_SLEEP_TIMES
|
|
8
11
|
from ballchasing.constants import Map
|
|
12
|
+
from ballchasing.rate_limit import RateLimiter, RateLimitCategory, resolve_category, rate_limited
|
|
9
13
|
from ballchasing.typed.shared import PlayerID, BasePlayer, BaseReplay
|
|
10
14
|
from ballchasing.typed.deep_replay import DeepReplay, TeamCoreStatsDR, PlayerCoreStatsDR
|
|
11
15
|
from ballchasing.util.replays import is_standard_replay
|
|
12
16
|
|
|
13
17
|
|
|
14
|
-
def make_mock_response(status_code=200, json_data=None, headers=None):
|
|
18
|
+
def make_mock_response(status_code=200, json_data=None, headers=None, content=None):
|
|
15
19
|
resp = Response()
|
|
16
20
|
resp.status_code = status_code
|
|
17
|
-
if
|
|
18
|
-
resp._content =
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
if content is not None:
|
|
22
|
+
resp._content = content
|
|
23
|
+
resp.raw = io.BytesIO(content)
|
|
24
|
+
elif json_data is not None:
|
|
25
|
+
raw_bytes = bytes(__import__("json").dumps(json_data), "utf-8")
|
|
26
|
+
resp._content = raw_bytes
|
|
27
|
+
resp.raw = io.BytesIO(raw_bytes)
|
|
21
28
|
else:
|
|
22
29
|
resp._content = b"{}"
|
|
30
|
+
resp.raw = io.BytesIO(b"{}")
|
|
23
31
|
if headers:
|
|
24
32
|
resp.headers.update(headers)
|
|
25
33
|
return resp
|
|
@@ -94,7 +102,7 @@ class TestHttpRetryAndLogging:
|
|
|
94
102
|
patch("time.sleep") as mock_sleep:
|
|
95
103
|
res = api._request("/test", "GET")
|
|
96
104
|
assert res.status_code == 200
|
|
97
|
-
mock_sleep.assert_called_once_with(3)
|
|
105
|
+
mock_sleep.assert_called_once_with(3.0)
|
|
98
106
|
|
|
99
107
|
def test_rate_limit_backoff_default_when_unset(self):
|
|
100
108
|
api = BallchasingApi("dummy_key", sleep_time_on_rate_limit=0, do_initial_ping=False)
|
|
@@ -108,6 +116,18 @@ class TestHttpRetryAndLogging:
|
|
|
108
116
|
assert mock_sleep.call_count == 1
|
|
109
117
|
assert mock_sleep.call_args[0][0] >= 1.0
|
|
110
118
|
|
|
119
|
+
def test_rate_limit_retries_indefinitely(self):
|
|
120
|
+
api = BallchasingApi("dummy_key", sleep_time_on_rate_limit=0.125, do_initial_ping=False)
|
|
121
|
+
# Simulate 10 consecutive 429s followed by a 200 OK
|
|
122
|
+
rl_resps = [make_mock_response(status_code=429) for _ in range(10)]
|
|
123
|
+
ok_resp = make_mock_response(status_code=200, json_data={"ok": True})
|
|
124
|
+
|
|
125
|
+
with patch.object(api._session, "request", side_effect=rl_resps + [ok_resp]), \
|
|
126
|
+
patch("time.sleep") as mock_sleep:
|
|
127
|
+
res = api._request("/test", "GET")
|
|
128
|
+
assert res.status_code == 200
|
|
129
|
+
assert mock_sleep.call_count == 10
|
|
130
|
+
|
|
111
131
|
def test_logging_used_on_retry(self, caplog):
|
|
112
132
|
api = BallchasingApi("dummy_key", do_initial_ping=False, print_on_rate_limit=True)
|
|
113
133
|
rl_resp = make_mock_response(status_code=429)
|
|
@@ -244,3 +264,167 @@ class TestNonePlayerIdFixes:
|
|
|
244
264
|
valid_nobots, reason_nobots = is_standard_replay(replay, allow_bots=False)
|
|
245
265
|
assert valid_nobots is False
|
|
246
266
|
assert "Bot1 has no ID" in reason_nobots
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class TestApiDocReflections:
|
|
270
|
+
def test_upload_replay_returns_id_on_duplicate_409_when_not_raising(self):
|
|
271
|
+
api = BallchasingApi("dummy_key", do_initial_ping=False)
|
|
272
|
+
resp_409 = make_mock_response(409, json_data={"id": "existing-replay-id", "error": "duplicate replay"})
|
|
273
|
+
|
|
274
|
+
with patch.object(api._session, "request", return_value=resp_409):
|
|
275
|
+
result = api.upload_replay(io.BytesIO(b"dummy_replay_data"), raise_on_duplicate=False)
|
|
276
|
+
assert result["id"] == "existing-replay-id"
|
|
277
|
+
|
|
278
|
+
def test_upload_replay_raises_on_duplicate_409_when_configured(self):
|
|
279
|
+
api = BallchasingApi("dummy_key", do_initial_ping=False)
|
|
280
|
+
resp_409 = make_mock_response(409, json_data={"id": "existing-replay-id", "error": "duplicate replay"})
|
|
281
|
+
|
|
282
|
+
with patch.object(api._session, "request", return_value=resp_409):
|
|
283
|
+
with pytest.raises(HTTPError):
|
|
284
|
+
api.upload_replay(io.BytesIO(b"dummy_replay_data"), raise_on_duplicate=True)
|
|
285
|
+
|
|
286
|
+
def test_create_group_omits_parent_when_none(self):
|
|
287
|
+
api = BallchasingApi("dummy_key", do_initial_ping=False)
|
|
288
|
+
ok_resp = make_mock_response(201, json_data={"id": "grp1", "name": "Top Level"})
|
|
289
|
+
|
|
290
|
+
with patch.object(api._session, "request", return_value=ok_resp) as mock_req:
|
|
291
|
+
res = api.create_group(name="Top Level", player_identification="by-id", team_identification="by-distinct-players")
|
|
292
|
+
assert res["id"] == "grp1"
|
|
293
|
+
assert mock_req.call_count == 1
|
|
294
|
+
payload = mock_req.call_args[1]["json"]
|
|
295
|
+
assert "parent" not in payload
|
|
296
|
+
assert payload == {
|
|
297
|
+
"name": "Top Level",
|
|
298
|
+
"player_identification": "by-id",
|
|
299
|
+
"team_identification": "by-distinct-players"
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
def test_create_group_includes_parent_when_provided(self):
|
|
303
|
+
api = BallchasingApi("dummy_key", do_initial_ping=False)
|
|
304
|
+
ok_resp = make_mock_response(201, json_data={"id": "grp2", "name": "Child Group"})
|
|
305
|
+
|
|
306
|
+
with patch.object(api._session, "request", return_value=ok_resp) as mock_req:
|
|
307
|
+
api.create_group(name="Child Group", player_identification="by-id", team_identification="by-distinct-players", parent="parent-grp")
|
|
308
|
+
payload = mock_req.call_args[1]["json"]
|
|
309
|
+
assert payload["parent"] == "parent-grp"
|
|
310
|
+
|
|
311
|
+
def test_patch_replay_explicit_kwargs(self):
|
|
312
|
+
api = BallchasingApi("dummy_key", do_initial_ping=False)
|
|
313
|
+
ok_resp = make_mock_response(204)
|
|
314
|
+
|
|
315
|
+
with patch.object(api._session, "request", return_value=ok_resp) as mock_req:
|
|
316
|
+
api.patch_replay("rep-123", title="New Title", visibility="private", group="")
|
|
317
|
+
payload = mock_req.call_args[1]["json"]
|
|
318
|
+
assert payload == {"title": "New Title", "visibility": "private", "group": ""}
|
|
319
|
+
|
|
320
|
+
def test_patch_group_explicit_kwargs(self):
|
|
321
|
+
api = BallchasingApi("dummy_key", do_initial_ping=False)
|
|
322
|
+
ok_resp = make_mock_response(204)
|
|
323
|
+
|
|
324
|
+
with patch.object(api._session, "request", return_value=ok_resp) as mock_req:
|
|
325
|
+
api.patch_group("grp-123", player_identification="by-name", team_identification="by-player-clusters", parent="new-parent", shared=True)
|
|
326
|
+
payload = mock_req.call_args[1]["json"]
|
|
327
|
+
assert payload == {
|
|
328
|
+
"player_identification": "by-name",
|
|
329
|
+
"team_identification": "by-player-clusters",
|
|
330
|
+
"parent": "new-parent",
|
|
331
|
+
"shared": True
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
def test_get_groups_url_no_trailing_slash(self):
|
|
335
|
+
api = BallchasingApi("dummy_key", do_initial_ping=False)
|
|
336
|
+
ok_resp = make_mock_response(200, json_data={"list": []})
|
|
337
|
+
|
|
338
|
+
with patch.object(api._session, "request", return_value=ok_resp) as mock_req:
|
|
339
|
+
list(api.get_groups(count=10, disable_prefetch=True))
|
|
340
|
+
called_url = mock_req.call_args[1]["url"]
|
|
341
|
+
assert called_url == f"{api.base_url}/groups"
|
|
342
|
+
|
|
343
|
+
def test_download_replay_supports_pathlib(self, tmp_path):
|
|
344
|
+
api = BallchasingApi("dummy_key", do_initial_ping=False)
|
|
345
|
+
ok_resp = make_mock_response(200, content=b"PK\x03\x04fake_replay_binary")
|
|
346
|
+
|
|
347
|
+
with patch.object(api._session, "request", return_value=ok_resp):
|
|
348
|
+
dest = api.download_replay("rep-123", tmp_path)
|
|
349
|
+
assert dest == tmp_path / "rep-123.replay"
|
|
350
|
+
assert dest.read_bytes() == b"PK\x03\x04fake_replay_binary"
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
class TestRateLimitingAndTracker:
|
|
354
|
+
def test_initial_state_not_rate_limited(self):
|
|
355
|
+
api = BallchasingApi("dummy_key", do_initial_ping=False)
|
|
356
|
+
assert api.is_rate_limited is False
|
|
357
|
+
assert api.last_rate_limited_at is None
|
|
358
|
+
stats = api.rate_limit_stats
|
|
359
|
+
assert stats["is_rate_limited"] is False
|
|
360
|
+
assert stats["last_rate_limited_at"] is None
|
|
361
|
+
assert stats["category_counts"] == {}
|
|
362
|
+
|
|
363
|
+
def test_category_resolution(self):
|
|
364
|
+
assert resolve_category("/replays") == RateLimitCategory.LIST
|
|
365
|
+
assert resolve_category("https://ballchasing.com/api/groups") == RateLimitCategory.LIST
|
|
366
|
+
assert resolve_category("/replays/123/file") == RateLimitCategory.DOWNLOAD
|
|
367
|
+
assert resolve_category("/replays/abc-123") == RateLimitCategory.CORE
|
|
368
|
+
assert resolve_category("/groups/grp-456") == RateLimitCategory.CORE
|
|
369
|
+
assert resolve_category("/v2/upload") == RateLimitCategory.CORE
|
|
370
|
+
assert resolve_category("/maps") == RateLimitCategory.CORE
|
|
371
|
+
assert resolve_category("/") == RateLimitCategory.PING
|
|
372
|
+
|
|
373
|
+
def test_record_429_updates_tracker_and_stats(self):
|
|
374
|
+
api = BallchasingApi("dummy_key", do_initial_ping=False)
|
|
375
|
+
rl_resp = make_mock_response(status_code=429, headers={"Retry-After": "5"})
|
|
376
|
+
ok_resp = make_mock_response(status_code=200, json_data={"list": []})
|
|
377
|
+
|
|
378
|
+
with patch.object(api._session, "request", side_effect=[rl_resp, ok_resp]), \
|
|
379
|
+
patch("time.sleep"):
|
|
380
|
+
api._request("/replays", "GET")
|
|
381
|
+
|
|
382
|
+
assert api.last_rate_limited_at is not None
|
|
383
|
+
assert time.time() - api.last_rate_limited_at < 5.0
|
|
384
|
+
stats = api.rate_limit_stats
|
|
385
|
+
assert stats["category_counts"].get("list") == 1
|
|
386
|
+
assert "list" in stats["last_per_category"]
|
|
387
|
+
|
|
388
|
+
def test_category_sleep_times(self):
|
|
389
|
+
limiter = RateLimiter(patron_type_getter=lambda: "regular")
|
|
390
|
+
assert limiter.get_category_sleep_time(RateLimitCategory.LIST) == 3600 / 500 # 7.2s
|
|
391
|
+
assert limiter.get_category_sleep_time(RateLimitCategory.CORE) == 3600 / 1000 # 3.6s
|
|
392
|
+
assert limiter.get_category_sleep_time(RateLimitCategory.DOWNLOAD) == 3600 / 200 # 18.0s
|
|
393
|
+
|
|
394
|
+
gold_limiter = RateLimiter(patron_type_getter=lambda: "gold")
|
|
395
|
+
assert gold_limiter.get_category_sleep_time(RateLimitCategory.LIST) == 3600 / 1000 # 3.6s
|
|
396
|
+
assert gold_limiter.get_category_sleep_time(RateLimitCategory.CORE) == 3600 / 2000 # 1.8s
|
|
397
|
+
assert gold_limiter.get_category_sleep_time(RateLimitCategory.DOWNLOAD) == 3600 / 400 # 9.0s
|
|
398
|
+
|
|
399
|
+
def test_global_tracker_blocks_concurrent_requests(self):
|
|
400
|
+
# Simulate instance being put into cooldown
|
|
401
|
+
api = BallchasingApi("dummy_key", do_initial_ping=False)
|
|
402
|
+
api.rate_limiter.record_429(RateLimitCategory.CORE, retry_after=5.0)
|
|
403
|
+
|
|
404
|
+
assert api.is_rate_limited is True
|
|
405
|
+
# A subsequent call to before_request should sleep for the remaining cooldown
|
|
406
|
+
with patch("time.sleep") as mock_sleep:
|
|
407
|
+
api.rate_limiter.before_request(RateLimitCategory.LIST)
|
|
408
|
+
assert mock_sleep.call_count == 1
|
|
409
|
+
slept = mock_sleep.call_args[0][0]
|
|
410
|
+
assert 0.0 < slept <= 5.0
|
|
411
|
+
|
|
412
|
+
def test_proactive_mode_enforces_burst_rate(self):
|
|
413
|
+
api = BallchasingApi("dummy_key", do_initial_ping=False, proactive_rate_limit=True)
|
|
414
|
+
# On proactive mode, 2 calls in immediate succession will sleep for min_interval
|
|
415
|
+
with patch("time.sleep") as mock_sleep:
|
|
416
|
+
api.rate_limiter.before_request(RateLimitCategory.LIST)
|
|
417
|
+
# First call has no elapsed constraint
|
|
418
|
+
assert mock_sleep.call_count == 0
|
|
419
|
+
# Immediate second call should sleep
|
|
420
|
+
api.rate_limiter.before_request(RateLimitCategory.LIST)
|
|
421
|
+
assert mock_sleep.call_count == 1
|
|
422
|
+
slept = mock_sleep.call_args[0][0]
|
|
423
|
+
assert 0.4 <= slept <= 0.6 # 1 / 2.0s = 0.5s interval
|
|
424
|
+
|
|
425
|
+
def test_rate_limited_decorator_attaches_to_api_methods(self):
|
|
426
|
+
api = BallchasingApi("dummy_key", do_initial_ping=False)
|
|
427
|
+
assert getattr(api.get_replay, "rate_limit_category", None) == RateLimitCategory.CORE
|
|
428
|
+
assert getattr(api.get_replays, "rate_limit_category", None) == RateLimitCategory.LIST
|
|
429
|
+
assert getattr(api.download_replay, "rate_limit_category", None) == RateLimitCategory.DOWNLOAD
|
|
430
|
+
assert getattr(api.ping, "rate_limit_category", None) == RateLimitCategory.PING
|
|
File without changes
|
|
File without changes
|
|
File without changes
|