python-ballchasing 0.5.2__tar.gz → 0.5.3__tar.gz

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.
Files changed (31) hide show
  1. {python_ballchasing-0.5.2 → python_ballchasing-0.5.3}/PKG-INFO +1 -1
  2. {python_ballchasing-0.5.2 → python_ballchasing-0.5.3}/ballchasing/__init__.py +1 -0
  3. {python_ballchasing-0.5.2 → python_ballchasing-0.5.3}/ballchasing/api.py +176 -62
  4. python_ballchasing-0.5.3/ballchasing/rate_limit.py +227 -0
  5. {python_ballchasing-0.5.2 → python_ballchasing-0.5.3}/pyproject.toml +1 -1
  6. {python_ballchasing-0.5.2 → python_ballchasing-0.5.3}/python_ballchasing.egg-info/PKG-INFO +1 -1
  7. {python_ballchasing-0.5.2 → python_ballchasing-0.5.3}/python_ballchasing.egg-info/SOURCES.txt +1 -0
  8. {python_ballchasing-0.5.2 → python_ballchasing-0.5.3}/tests/test_fixes.py +191 -7
  9. {python_ballchasing-0.5.2 → python_ballchasing-0.5.3}/LICENSE +0 -0
  10. {python_ballchasing-0.5.2 → python_ballchasing-0.5.3}/README.md +0 -0
  11. {python_ballchasing-0.5.2 → python_ballchasing-0.5.3}/ballchasing/constants.py +0 -0
  12. {python_ballchasing-0.5.2 → python_ballchasing-0.5.3}/ballchasing/stats_info.tsv +0 -0
  13. {python_ballchasing-0.5.2 → python_ballchasing-0.5.3}/ballchasing/typed/__init__.py +0 -0
  14. {python_ballchasing-0.5.2 → python_ballchasing-0.5.3}/ballchasing/typed/deep_group.py +0 -0
  15. {python_ballchasing-0.5.2 → python_ballchasing-0.5.3}/ballchasing/typed/deep_replay.py +0 -0
  16. {python_ballchasing-0.5.2 → python_ballchasing-0.5.3}/ballchasing/typed/shallow_group.py +0 -0
  17. {python_ballchasing-0.5.2 → python_ballchasing-0.5.3}/ballchasing/typed/shallow_replay.py +0 -0
  18. {python_ballchasing-0.5.2 → python_ballchasing-0.5.3}/ballchasing/typed/shared.py +0 -0
  19. {python_ballchasing-0.5.2 → python_ballchasing-0.5.3}/ballchasing/util/__init__.py +0 -0
  20. {python_ballchasing-0.5.2 → python_ballchasing-0.5.3}/ballchasing/util/dates.py +0 -0
  21. {python_ballchasing-0.5.2 → python_ballchasing-0.5.3}/ballchasing/util/iterators.py +0 -0
  22. {python_ballchasing-0.5.2 → python_ballchasing-0.5.3}/ballchasing/util/replays.py +0 -0
  23. {python_ballchasing-0.5.2 → python_ballchasing-0.5.3}/ballchasing/util/stats.py +0 -0
  24. {python_ballchasing-0.5.2 → python_ballchasing-0.5.3}/python_ballchasing.egg-info/dependency_links.txt +0 -0
  25. {python_ballchasing-0.5.2 → python_ballchasing-0.5.3}/python_ballchasing.egg-info/requires.txt +0 -0
  26. {python_ballchasing-0.5.2 → python_ballchasing-0.5.3}/python_ballchasing.egg-info/top_level.txt +0 -0
  27. {python_ballchasing-0.5.2 → python_ballchasing-0.5.3}/scripts/get_maps.py +0 -0
  28. {python_ballchasing-0.5.2 → python_ballchasing-0.5.3}/scripts/make_types.py +0 -0
  29. {python_ballchasing-0.5.2 → python_ballchasing-0.5.3}/scripts/test.py +0 -0
  30. {python_ballchasing-0.5.2 → python_ballchasing-0.5.3}/scripts/test_typed.py +0 -0
  31. {python_ballchasing-0.5.2 → python_ballchasing-0.5.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-ballchasing
3
- Version: 0.5.2
3
+ Version: 0.5.3
4
4
  Summary: A Python wrapper around the Ballchasing API
5
5
  Author-email: Rolv-Arild Braaten <rolv_arild@hotmail.com>
6
6
  License: MIT License
@@ -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
 
@@ -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 url: url or endpoint for request.
136
+ :param url_or_endpoint: url or endpoint for request.
113
137
  :param method: the method to use.
114
- :param params: parameters for GET request.
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 429.
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 = int(retry_after) if retry_after.isdigit() else None
140
- if retry_after: # integer > 0
141
- time.sleep(retry_after)
142
- elif self.sleep_time_on_rate_limit:
143
- time.sleep(self.sleep_time_on_rate_limit)
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
- time.sleep(max(1.0, float(2 ** (rate_limit_retries - 1))))
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
- def patch_replay(self, replay_id: str, **params) -> None:
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 params: parameters for the PATCH request.
327
- """
328
- self._request(f"/replays/{replay_id}", "PATCH", json=params)
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
- :return: the result of the POST request.
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(f, visibility=visibility, group=group)
348
- return self._request(f"/v2/upload", "POST", files={"file": replay_file},
349
- params={"group": group, "visibility": visibility}).json()
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 = {"name": name, "player_identification": player_identification,
435
- "team_identification": team_identification, "parent": parent}
436
- return self._request(f"/groups", "POST", json=json).json()
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
- def patch_group(self, group_id: str, **params) -> None:
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 params: parameters for the PATCH request.
464
- """
465
- self._request(f"/groups/{group_id}", "PATCH", json=params)
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
- def download_replay(self, replay_id: str, path: str):
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
- r = self._request(f"/replays/{replay_id}/file", "GET")
530
- if os.path.isdir(path):
531
- # If path is a directory, use the replay id as the filename
532
- filename = f"{replay_id}.replay"
533
- path = os.path.join(path, filename)
534
- with open(path, "wb") as f:
535
- f.write(r.content)
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 = os.path.join(folder, group_id)
657
+ folder = Path(folder) / group_id
546
658
  if keep_tree_structure:
547
- os.makedirs(folder, exist_ok=True)
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-ballchasing"
7
- version = "0.5.2"
7
+ version = "0.5.3"
8
8
  authors = [
9
9
  { name = "Rolv-Arild Braaten", email = "rolv_arild@hotmail.com" },
10
10
  ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-ballchasing
3
- Version: 0.5.2
3
+ Version: 0.5.3
4
4
  Summary: A Python wrapper around the Ballchasing API
5
5
  Author-email: Rolv-Arild Braaten <rolv_arild@hotmail.com>
6
6
  License: MIT License
@@ -4,6 +4,7 @@ pyproject.toml
4
4
  ballchasing/__init__.py
5
5
  ballchasing/api.py
6
6
  ballchasing/constants.py
7
+ ballchasing/rate_limit.py
7
8
  ballchasing/stats_info.tsv
8
9
  ballchasing/typed/__init__.py
9
10
  ballchasing/typed/deep_group.py
@@ -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 json_data is not None:
18
- resp._content = (
19
- bytes(__import__("json").dumps(json_data), "utf-8")
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