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:
voson
2026-07-30 22:59:34 +08:00
co-authored by Cursor
parent 3b7d00b32b
commit 0f7e7b68dd
5 changed files with 420 additions and 22 deletions
+2 -2
View File
@@ -88,7 +88,7 @@ climperor/
| `web/fetch_hero_items.py` | 拉取 OpenDota 热门装备 → `web/data/hero_items.json` + `web/assets/item_icons/` |
| `web/fetch_hero_stats.py` | 拉取 OpenDota 各段位场次/胜场 → `web/data/hero_stats.json`(**仅上分帝 Web**;勿写入 relations/heroes,勿进 recommend |
| `web/fetch_hero_matches.py` | 拉取同英雄近期比赛 + 终局出装/加点 → `web/data/hero_matches.json``--source league\|public\|both`;合并后保留最近 N 场胜局,默认 10;天梯需传奇及以上;公开列表过滤 bot/Turbo、仅 ranked lobby`--public-region china` 优先国服;`--workers` 详情并发 + 凑满即停;可选 `OPENDOTA_API_KEY`;增量补缺失/不足 N 场;`--enrich-item-times` 补购买时间;**仅上分帝 Web**;勿进 recommend |
| `web/fetch_pro_matches.py` | 按明星名单拉近期联赛/锦标赛对局 → `web/data/pro_matches.json`(默认读 `web/data/pro_player_watchlist.json``--include-pubs` 另拉天梯 lobby 7;每种 lobby 各保留 `--limit` 场;`--players` 覆盖整文件;`--all-pros``/proPlayers` 盲抽;含终局出装/加点;进 `refresh_web` daily 且带 `--include-pubs`;**仅上分帝 Web「比赛」**;勿进 recommend |
| `web/fetch_pro_matches.py` | 按明星名单拉近期联赛/锦标赛对局 → `web/data/pro_matches.json`(默认读 `web/data/pro_player_watchlist.json``--include-pubs` 另拉天梯 lobby 7;每种 lobby 各保留 `--limit` 场;`--refresh-limit N``fetched_at` 只刷新最陈旧 N 人并保留其余;连续 429 熔断后仍写盘;可选 `OPENDOTA_API_KEY``--players` 覆盖整文件且不做轮换`--all-pros``/proPlayers` 盲抽;含终局出装/加点;进 `refresh_web` daily 且带 `--include-pubs --refresh-limit 15`;**仅上分帝 Web「比赛」**;勿进 recommend |
| `web/fetch_leaderboards.py` | 拉取 Valve Immortal 四区榜 Top100 → `web/data/leaderboards.json`(**仅上分帝 Web「排行」**;无 MMR/account_id;勿进 recommend |
| `web/fetch_streamers.py` | 从抖音 / 斗鱼主页补全 `web/data/streamers.json` 的昵称/签名/关注粉丝获赞(播放)/头像(手工名单 + 直播间/主页 URL;抖音支持 `v.douyin.com` 短链;斗鱼优先 `v.douyu.com/author/<hash>`(兼容 `author-video`)的 `$DATA`,仅房间号时从直播间 HTML 解析 `up_id` 再拉作者页,失败才回退 `betard`;失败保留旧值;**不**探测开播(由 `fetch_streamer_live.py` 负责);**仅上分帝 Web「主播」**;勿进 recommend;进 `refresh_web` daily |
| `web/fetch_streamer_live.py` | 探测主播真实在播状态回写 `web/data/streamers.json``is_live`/`live_probed_at`:抖音解析直播间 SSR 页 `roomStore.roomInfo.room.status`(2 在播 / 4 下播;预热 cookie + ~1s 间隔;web_rid 校验),B 站走 `Room/get_info``live_status==1` 在播,轮播算下播),斗鱼走 `betard/{room_id}``show_status==1` 在播,`videoLoop==1` 轮播算下播);探测失败清为 `is_live:false` 并去掉 `live_probed_at`(与 `/api/live-status` 一致,不沿用旧直播中)、始终 exit 0;`--ids a,b` 限范围、`--dry-run` 只打印;仅 Web;进 `refresh_web` daily(角标以访问触发的 live API 为准,daily 仅作 data.json 兜底) |
@@ -187,7 +187,7 @@ python web/fetch_hero_items.py # OpenDota 热门装备缓存(上分帝
python web/fetch_hero_stats.py # OpenDota 各段位胜率/场次(上分帝 Web 走势兜底)
python web/fetch_stratz_meta.py # STRATZ 周胜率/分路/对位 Top(走势/对位/Meta;需 token
python web/fetch_hero_matches.py # 同英雄近期比赛出装/加点(上分帝 Web;可 --heroes antimage--workers 6--enrich-item-times
python web/fetch_pro_matches.py # 明星选手近期联赛对局(默认 watchlist--include-pubs 含天梯;可 --players Ame,898754153
python web/fetch_pro_matches.py # 明星选手近期联赛对局(默认 watchlist--include-pubs 含天梯;--refresh-limit 15 轮换;可 --players Ame,898754153
python web/fetch_leaderboards.py # Valve Immortal 四区 Top100(排行页)
python web/fetch_streamers.py # 抖音主播主页补全(主播页;手工名单;亦由 daily 定时软失败刷新)
python web/fetch_streamer_live.py # 探测真实在播状态回写 is_live(主播页角标;daily--dry-run 只打印)
+10
View File
@@ -8,10 +8,20 @@
- PC 本人锁定英雄后,按阵容推荐装备:核心装(`hero_items` 相对热度)+ 敌方应对装(定性规则),overlay 以图标条展示;结果写入 `recommendations.items`
### Changed
- 上分帝 Web 每日刷新明星比赛改为按 `fetched_at` 轮换(默认每次 15 人),遇 OpenDota 连续 429 熔断并保留旧缓存,避免整档 daily 被限流拖死。
### Removed
- PC 选将 overlay 不再在顶栏头像下显示定位图标;仍保留网格「克/搭/补」与阵容分析条。
## [0.6.9] - 2026-07-30
### Fixed
- 恢复 OSS 英雄横版头像(曾被 96×96 匹配模板误覆盖);静态导出/上传不再回退到 `pc/templates/cdn`,缺图或非宽图会直接失败;头像 URL 缓存戳改为 `v=wide2`
## [0.6.8] - 2026-07-30
### Added
+257 -19
View File
@@ -7,10 +7,16 @@ Per player: /players/{id}/matches per lobby_type (practice/tournament, and
ranked with --include-pubs), up to --limit newest each; then /matches/{id}
for final items + skill builds. League rows do not crowd out ranked pubs.
Daily refresh uses --refresh-limit to rotate the oldest / missing pros so the
OpenDota anonymous quota is not burned on a full 90-player crawl every night.
Optional OPENDOTA_API_KEY raises rate limits. Consecutive 429s trip a circuit
breaker: remaining pros keep prior cache and the script still writes.
Output: web/data/pro_matches.json (Climperor web only; not used by recommend).
Usage:
python web/fetch_pro_matches.py
python web/fetch_pro_matches.py --include-pubs --refresh-limit 15
python web/fetch_pro_matches.py --include-pubs --limit 8
python web/fetch_pro_matches.py --players 898754153,Ame
python web/fetch_pro_matches.py --all-pros --limit-pros 20 --with-team
@@ -36,8 +42,8 @@ from shared.paths import DATA
from fetch_hero_matches import (
collect_item_ids,
extract_player_row,
fetch_match,
load_ability_id_map,
opendota_url,
)
from fetch_hero_items import load_item_catalog
from fetch_pro_builds import fetch_pro_index
@@ -47,10 +53,48 @@ OUT = DATA / "pro_matches.json"
WATCHLIST = DATA / "pro_player_watchlist.json"
DEFAULT_LIMIT = 8
DEFAULT_LIMIT_PROS = 40
DEFAULT_REFRESH_LIMIT = 0 # 0 = refresh all selected pros
DEFAULT_429_STREAK = 3
# OpenDota lobby_type: 1=practice, 2=tournament (pro/league biased).
LOBBY_LEAGUE = (1, 2)
class RateLimitTripped(Exception):
"""OpenDota returned enough consecutive 429s to abort the remaining batch."""
class OpenDotaClient:
"""Fail-fast OpenDota JSON client with consecutive-429 circuit breaker."""
def __init__(self, *, consecutive_limit: int = DEFAULT_429_STREAK) -> None:
self.consecutive_limit = max(1, int(consecutive_limit))
self.consecutive_429 = 0
self.tripped = False
def json(self, path: str) -> dict | list:
if self.tripped:
raise RateLimitTripped("opendota rate-limit circuit open")
url = opendota_url(path)
try:
# No 5/10/20/40s retry chain — daily must finish within budget.
data = http_json(url, retries=0)
except urllib.error.HTTPError as e:
if e.code == 429:
self.consecutive_429 += 1
_log(
f"HTTP 429 {url} — streak "
f"{self.consecutive_429}/{self.consecutive_limit}"
)
if self.consecutive_429 >= self.consecutive_limit:
self.tripped = True
raise RateLimitTripped(
f"opendota 429 x{self.consecutive_429}"
) from e
raise
self.consecutive_429 = 0
return data
def _log(msg: str) -> None:
try:
print(msg, flush=True)
@@ -113,6 +157,58 @@ def load_watchlist(path: Path) -> list[dict]:
return out
def load_existing(path: Path) -> dict:
"""Load prior pro_matches.json; empty dict when missing/unreadable."""
if not path.is_file():
return {}
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return raw if isinstance(raw, dict) else {}
def parse_ts(value: object) -> datetime | None:
if not isinstance(value, str) or not value.strip():
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
def cell_fetched_at(cell: dict | None) -> datetime | None:
if not isinstance(cell, dict):
return None
return parse_ts(cell.get("fetched_at"))
def select_refresh_batch(
picked: list[tuple[int, dict]],
existing_by_pro: dict[str, dict],
refresh_limit: int,
) -> tuple[list[tuple[int, dict]], list[tuple[int, dict]]]:
"""Split watchlist into (to_refresh, to_retain) by staleness.
Missing / unscored cells sort oldest. ``refresh_limit <= 0`` refreshes all.
"""
if refresh_limit <= 0 or refresh_limit >= len(picked):
return list(picked), []
ranked: list[tuple[float, int, tuple[int, dict]]] = []
for idx, item in enumerate(picked):
aid, _prof = item
ts = cell_fetched_at(existing_by_pro.get(str(aid)))
# Missing timestamp => oldest (refresh first).
score = ts.timestamp() if ts is not None else float("-inf")
ranked.append((score, idx, item))
ranked.sort(key=lambda row: (row[0], row[1]))
to_refresh = [item for _score, _idx, item in ranked[:refresh_limit]]
refresh_ids = {aid for aid, _ in to_refresh}
to_retain = [item for item in picked if item[0] not in refresh_ids]
return to_refresh, to_retain
def parse_pro_filter(raw: str, pro_index: dict[int, dict]) -> list[int]:
"""Comma-separated account ids or registered pro names."""
if not raw.strip():
@@ -219,6 +315,7 @@ def player_match_metas(
account_id: int,
limit: int,
*,
client: OpenDotaClient,
lobby_types: tuple[int, ...] = LOBBY_LEAGUE,
) -> list[dict]:
"""Recent match list rows for a pro (deduped, newest first).
@@ -229,9 +326,11 @@ def player_match_metas(
"""
by_id: dict[int, dict] = {}
for lt in lobby_types:
url = f"{OPENDOTA}/players/{account_id}/matches?limit={limit}&lobby_type={lt}"
path = f"/players/{account_id}/matches?limit={limit}&lobby_type={lt}"
try:
raw = http_json(url)
raw = client.json(path)
except RateLimitTripped:
raise
except (
urllib.error.HTTPError,
urllib.error.URLError,
@@ -269,9 +368,26 @@ def player_match_metas(
)
def fetch_match_detail(client: OpenDotaClient, match_id: int) -> dict | None:
try:
raw = client.json(f"/matches/{match_id}")
except RateLimitTripped:
raise
except (
urllib.error.HTTPError,
urllib.error.URLError,
TimeoutError,
json.JSONDecodeError,
OSError,
):
return None
return raw if isinstance(raw, dict) else None
def fetch_player_matches(
account_id: int,
*,
client: OpenDotaClient,
limit: int,
id_map: dict[int, str],
catalog: dict[int, dict],
@@ -279,7 +395,9 @@ def fetch_player_matches(
delay: float,
lobby_types: tuple[int, ...],
) -> list[dict]:
metas = player_match_metas(account_id, limit, lobby_types=lobby_types)
metas = player_match_metas(
account_id, limit, client=client, lobby_types=lobby_types
)
rows: list[dict] = []
for meta in metas:
try:
@@ -295,7 +413,7 @@ def fetch_player_matches(
except (TypeError, ValueError):
lt_i = None
row_origin = "public" if lt_i == 7 else "pro"
detail = fetch_match(mid)
detail = fetch_match_detail(client, mid)
if delay > 0:
time.sleep(delay)
if not detail:
@@ -363,6 +481,32 @@ def build_indexes(
return out
def retain_cell(
aid: int,
prof: dict,
existing: dict | None,
) -> dict:
"""Keep prior matches for a pro not refreshed this round."""
if isinstance(existing, dict) and isinstance(existing.get("matches"), list):
cell = dict(existing)
cell["account_id"] = aid
for key in ("name", "team_tag", "team_name", "country_code"):
if prof.get(key) and not cell.get(key):
cell[key] = prof.get(key)
cell["match_count"] = len(cell.get("matches") or [])
return cell
return {
"account_id": aid,
"name": prof.get("name"),
"team_tag": prof.get("team_tag"),
"team_name": prof.get("team_name"),
"country_code": prof.get("country_code"),
"match_count": 0,
"matches": [],
"fetched_at": None,
}
def write_out(
path: Path,
*,
@@ -374,6 +518,10 @@ def write_out(
limit_pros: int,
lobby_types: tuple[int, ...],
player_source: str,
refreshed_count: int,
retained_count: int,
refresh_limit: int,
rate_limited: bool,
) -> None:
used = collect_item_ids(by_hero)
items_out = {
@@ -402,10 +550,15 @@ def write_out(
"pro_count": len(by_pro),
"match_count": match_count,
"hero_count": len(by_hero),
"refresh_limit": refresh_limit,
"refreshed_count": refreshed_count,
"retained_count": retained_count,
"rate_limited": bool(rate_limited),
"note_zh": (
"OpenDota 明星选手近期联赛/锦标赛对局(可选含天梯 lobby_type=7);"
"默认名单见 web/data/pro_player_watchlist.json"
"每种 lobby 各保留最近 limit 场,避免联赛挤掉天梯;"
"daily 按 fetched_at 轮换最陈旧选手(--refresh-limit);"
"lobby_type 1=训练/practice、2=tournament、7=ranked"
"含终局出装、加点与联赛名(若有)。"
),
@@ -431,7 +584,7 @@ def main() -> None:
"--limit",
type=int,
default=DEFAULT_LIMIT,
help=f"Matches per pro (default: {DEFAULT_LIMIT})",
help=f"Matches per lobby per pro (default: {DEFAULT_LIMIT})",
)
ap.add_argument(
"--limit-pros",
@@ -439,6 +592,16 @@ def main() -> None:
default=DEFAULT_LIMIT_PROS,
help=f"Max pros when --all-pros (default: {DEFAULT_LIMIT_PROS})",
)
ap.add_argument(
"--refresh-limit",
type=int,
default=DEFAULT_REFRESH_LIMIT,
help=(
"Max pros to refresh this run by oldest fetched_at "
f"(0=all; default: {DEFAULT_REFRESH_LIMIT}). "
"Ignored when --players is set."
),
)
ap.add_argument(
"--players",
default="",
@@ -466,15 +629,25 @@ def main() -> None:
action="store_true",
help="Also include ranked pub lobby_type=7 (high-MMR scrims)",
)
ap.add_argument(
"--429-streak",
dest="streak_429",
type=int,
default=DEFAULT_429_STREAK,
help=f"Consecutive 429s before aborting remaining pros (default: {DEFAULT_429_STREAK})",
)
args = ap.parse_args()
limit = max(1, int(args.limit))
limit_pros = max(1, int(args.limit_pros))
refresh_limit = max(0, int(args.refresh_limit))
active_days = int(args.active_days) if args.active_days > 0 else None
lobby_types: tuple[int, ...] = LOBBY_LEAGUE
if args.include_pubs:
lobby_types = LOBBY_LEAGUE + (7,)
client = OpenDotaClient(consecutive_limit=int(args.streak_429))
_log("loading pro players ...")
pro_index = fetch_pro_index()
_log(f" {len(pro_index)} registered pros")
@@ -508,6 +681,25 @@ def main() -> None:
if not picked:
raise SystemExit("No pros matched filters")
existing = load_existing(args.out)
existing_by_pro = existing.get("by_pro") if isinstance(existing.get("by_pro"), dict) else {}
assert isinstance(existing_by_pro, dict)
# Explicit --players means refresh those fully (no rotation).
if player_ids:
to_refresh, to_retain = list(picked), []
effective_refresh_limit = 0
else:
to_refresh, to_retain = select_refresh_batch(
picked, existing_by_pro, refresh_limit
)
effective_refresh_limit = refresh_limit
_log(
f" refresh batch={len(to_refresh)} retain={len(to_retain)} "
f"refresh_limit={effective_refresh_limit or 'all'}"
)
heroes = hero_table()
id_to_key = {int(h["id"]): h["key"] for h in heroes}
catalog, _ = load_item_catalog()
@@ -516,20 +708,57 @@ def main() -> None:
by_pro: dict[str, dict] = {}
pros_meta: dict[str, dict] = {}
total_matches = 0
refreshed_count = 0
rate_limited = False
now_iso = datetime.now(timezone.utc).isoformat()
for i, (aid, prof) in enumerate(picked, 1):
label = prof.get("name") or prof.get("team_tag") or str(aid)
_log(f"[{i}/{len(picked)}] {label} ({aid}) ...")
matches = fetch_player_matches(
aid,
limit=limit,
id_map=id_map,
catalog=catalog,
id_to_key=id_to_key,
delay=args.delay,
lobby_types=lobby_types,
)
for aid, prof in to_retain:
sid = str(aid)
cell = retain_cell(aid, prof, existing_by_pro.get(sid))
by_pro[sid] = cell
pros_meta[sid] = {
"account_id": aid,
"name": cell.get("name") or prof.get("name"),
"team_tag": cell.get("team_tag") or prof.get("team_tag"),
"team_name": cell.get("team_name") or prof.get("team_name"),
"country_code": cell.get("country_code") or prof.get("country_code"),
}
total_matches += len(cell.get("matches") or [])
for i, (aid, prof) in enumerate(to_refresh, 1):
label = prof.get("name") or prof.get("team_tag") or str(aid)
sid = str(aid)
_log(f"[{i}/{len(to_refresh)}] {label} ({aid}) ...")
try:
matches = fetch_player_matches(
aid,
client=client,
limit=limit,
id_map=id_map,
catalog=catalog,
id_to_key=id_to_key,
delay=args.delay,
lobby_types=lobby_types,
)
except RateLimitTripped as exc:
rate_limited = True
_log(f" rate-limited; keeping prior cache for remaining ({exc})")
# Keep old (or empty) for this pro and every leftover refresh target.
remaining = to_refresh[i - 1 :]
for raid, rprof in remaining:
rsid = str(raid)
cell = retain_cell(raid, rprof, existing_by_pro.get(rsid))
by_pro[rsid] = cell
pros_meta[rsid] = {
"account_id": raid,
"name": cell.get("name") or rprof.get("name"),
"team_tag": cell.get("team_tag") or rprof.get("team_tag"),
"team_name": cell.get("team_name") or rprof.get("team_name"),
"country_code": cell.get("country_code") or rprof.get("country_code"),
}
total_matches += len(cell.get("matches") or [])
break
by_pro[sid] = {
"account_id": aid,
"name": prof.get("name"),
@@ -538,6 +767,7 @@ def main() -> None:
"country_code": prof.get("country_code"),
"match_count": len(matches),
"matches": matches,
"fetched_at": now_iso,
}
pros_meta[sid] = {
"account_id": aid,
@@ -546,9 +776,11 @@ def main() -> None:
"team_name": prof.get("team_name"),
"country_code": prof.get("country_code"),
}
refreshed_count += 1
total_matches += len(matches)
_log(f" {len(matches)} matches")
retained_count = len(by_pro) - refreshed_count
by_hero = build_indexes(by_pro, id_to_key)
write_out(
args.out,
@@ -560,9 +792,15 @@ def main() -> None:
limit_pros=len(picked),
lobby_types=lobby_types,
player_source=player_source,
refreshed_count=refreshed_count,
retained_count=max(0, retained_count),
refresh_limit=effective_refresh_limit,
rate_limited=rate_limited,
)
_log(
f"done pros={len(by_pro)} matches={total_matches} heroes={len(by_hero)}{args.out}"
f"done pros={len(by_pro)} refreshed={refreshed_count} "
f"retained={max(0, retained_count)} matches={total_matches} "
f"heroes={len(by_hero)} rate_limited={rate_limited}{args.out}"
)
+7 -1
View File
@@ -275,7 +275,13 @@ def run_daily(*, dry_run: bool = False) -> bool:
run_script("fetch_hero_stats.py", dry_run=dry_run)
run_script("fetch_leaderboards.py", dry_run=dry_run)
run_script("fetch_hero_matches.py", "--source", "league", dry_run=dry_run)
run_script("fetch_pro_matches.py", "--include-pubs", dry_run=dry_run)
run_script(
"fetch_pro_matches.py",
"--include-pubs",
"--refresh-limit",
"15",
dry_run=dry_run,
)
# Soft-fail Douyin enrichment (script itself exits 0; keep previous values on miss).
run_script("fetch_streamers.py", dry_run=dry_run, soft_fail=True)
# Soft-fail live probe (exits 0; probe failures clear is_live / live_probed_at).
+144
View File
@@ -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()