Rotate OpenDota pro-match refresh to stay under rate limits.
Daily now refreshes the 15 oldest watchlist pros, keeps prior cache for the rest, fails fast on consecutive 429s, and still writes a partial result. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
"""Unit tests for pro_matches incremental refresh + 429 circuit breaker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.error
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "web"))
|
||||
|
||||
from web.fetch_pro_matches import ( # noqa: E402
|
||||
OpenDotaClient,
|
||||
RateLimitTripped,
|
||||
load_existing,
|
||||
retain_cell,
|
||||
select_refresh_batch,
|
||||
write_out,
|
||||
)
|
||||
|
||||
|
||||
class SelectRefreshBatchTests(unittest.TestCase):
|
||||
def test_oldest_and_missing_first(self) -> None:
|
||||
picked = [
|
||||
(1, {"name": "A"}),
|
||||
(2, {"name": "B"}),
|
||||
(3, {"name": "C"}),
|
||||
(4, {"name": "D"}),
|
||||
]
|
||||
existing = {
|
||||
"1": {"fetched_at": "2026-07-28T00:00:00+00:00", "matches": []},
|
||||
"2": {"fetched_at": "2026-07-20T00:00:00+00:00", "matches": []},
|
||||
# 3 missing => oldest
|
||||
"4": {"fetched_at": "2026-07-29T00:00:00+00:00", "matches": []},
|
||||
}
|
||||
refresh, retain = select_refresh_batch(picked, existing, 2)
|
||||
self.assertEqual([aid for aid, _ in refresh], [3, 2])
|
||||
self.assertEqual([aid for aid, _ in retain], [1, 4])
|
||||
|
||||
def test_refresh_limit_zero_means_all(self) -> None:
|
||||
picked = [(1, {}), (2, {})]
|
||||
refresh, retain = select_refresh_batch(picked, {}, 0)
|
||||
self.assertEqual(len(refresh), 2)
|
||||
self.assertEqual(retain, [])
|
||||
|
||||
def test_retain_keeps_prior_matches(self) -> None:
|
||||
existing = {
|
||||
"account_id": 9,
|
||||
"name": "Old",
|
||||
"matches": [{"match_id": 1}],
|
||||
"fetched_at": "2026-07-01T00:00:00+00:00",
|
||||
}
|
||||
cell = retain_cell(9, {"name": "New"}, existing)
|
||||
self.assertEqual(cell["matches"], [{"match_id": 1}])
|
||||
self.assertEqual(cell["fetched_at"], "2026-07-01T00:00:00+00:00")
|
||||
self.assertEqual(cell["match_count"], 1)
|
||||
|
||||
|
||||
class RateLimitClientTests(unittest.TestCase):
|
||||
def test_trips_after_consecutive_429(self) -> None:
|
||||
client = OpenDotaClient(consecutive_limit=2)
|
||||
|
||||
def boom(_url: str, **_kwargs):
|
||||
raise urllib.error.HTTPError(
|
||||
"https://api.opendota.com/api/x", 429, "Too Many", hdrs=None, fp=None
|
||||
)
|
||||
|
||||
with patch("web.fetch_pro_matches.http_json", side_effect=boom):
|
||||
with self.assertRaises(urllib.error.HTTPError):
|
||||
client.json("/players/1/matches")
|
||||
with self.assertRaises(RateLimitTripped):
|
||||
client.json("/players/1/matches")
|
||||
self.assertTrue(client.tripped)
|
||||
|
||||
def test_success_resets_streak(self) -> None:
|
||||
client = OpenDotaClient(consecutive_limit=3)
|
||||
calls = {"n": 0}
|
||||
|
||||
def flaky(url: str, **_kwargs):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
raise urllib.error.HTTPError(url, 429, "Too Many", hdrs=None, fp=None)
|
||||
return []
|
||||
|
||||
with patch("web.fetch_pro_matches.http_json", side_effect=flaky):
|
||||
with self.assertRaises(urllib.error.HTTPError):
|
||||
client.json("/players/1/matches")
|
||||
self.assertEqual(client.consecutive_429, 1)
|
||||
self.assertEqual(client.json("/players/2/matches"), [])
|
||||
self.assertEqual(client.consecutive_429, 0)
|
||||
|
||||
|
||||
class WriteOutMergeTests(unittest.TestCase):
|
||||
def test_write_out_records_refresh_meta(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "pro_matches.json"
|
||||
by_pro = {
|
||||
"1": {
|
||||
"account_id": 1,
|
||||
"name": "A",
|
||||
"matches": [],
|
||||
"match_count": 0,
|
||||
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
"2": {
|
||||
"account_id": 2,
|
||||
"name": "B",
|
||||
"matches": [{"match_id": 9, "hero_key": "axe"}],
|
||||
"match_count": 1,
|
||||
"fetched_at": "2026-07-01T00:00:00+00:00",
|
||||
},
|
||||
}
|
||||
write_out(
|
||||
path,
|
||||
by_pro=by_pro,
|
||||
by_hero={"axe": {"matches": [{"match_id": 9}]}},
|
||||
pros_meta={"1": {"account_id": 1}, "2": {"account_id": 2}},
|
||||
item_catalog={},
|
||||
limit=8,
|
||||
limit_pros=2,
|
||||
lobby_types=(1, 2, 7),
|
||||
player_source="watchlist:x.json",
|
||||
refreshed_count=1,
|
||||
retained_count=1,
|
||||
refresh_limit=15,
|
||||
rate_limited=True,
|
||||
)
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
meta = payload["meta"]
|
||||
self.assertEqual(meta["refreshed_count"], 1)
|
||||
self.assertEqual(meta["retained_count"], 1)
|
||||
self.assertEqual(meta["refresh_limit"], 15)
|
||||
self.assertTrue(meta["rate_limited"])
|
||||
self.assertEqual(load_existing(path)["by_pro"]["2"]["match_count"], 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user