feat: bzzoiro 多 API Key 轮换,遇限流自动切换
新增 KeyRing 轮换环(src/data/key_ring.py): - 支持逗号/分号/换行分隔多个 key,单 key 场景零开销 - 遇到 429 自动标记当前 key 冷却(默认 60s)并立即切换到下一个 key - 全部 key 冷却时等待最早恢复的 key,避免无谓重试 - 热更新 key 列表(增删 key 无需重启) - 进程级单例,按 base URL 隔离 后端集成: - bzzoiro._fetch_json_async 接入 KeyRing,429 立即轮换(不等待) - 新增 /admin/keyring/status 端点展示 key 环状态 - 新增 /admin/keyring/cooldown/reset 紧急重置冷却 - BZZOIRO_KEY 配置描述提示多 key 支持 - mask_value 多 key 显示数量(如 "3 个 key(末段 …XXXX)") - 清理已移除 injuries 的 API_FOOTBALL_KEY 配置项 前端: - 数据源页新增 Key Ring 状态面板(每个 key 可用/冷却状态 + 重置按钮) - 配置项脱敏展示支持多 key 计数 测试: 新增 24 个 KeyRing 单元测试 + 2 个 bzzoiro 轮换集成测试,全部通过。 Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
This commit is contained in:
co-authored by
new-provider/LongCat-2.0 <
parent
f05dc1ae15
commit
f52ec8b963
+36
-10
@@ -22,6 +22,7 @@ import httpx
|
||||
from src.core.runtime_config import get_runtime_value
|
||||
from src.core.http_client import get_client
|
||||
from src.data.config import BZZOIRO_LEAGUE_IDS, LEAGUE_COUNTRIES, LEAGUE_NAMES, REQUEST_INTERVAL
|
||||
from src.data.key_ring import get_key_ring
|
||||
from src.data.normalize import normalize_bzzoiro
|
||||
from src.data.team_names_zh import zh_name
|
||||
from src.data.sources import register
|
||||
@@ -61,20 +62,25 @@ def _match_key(home_team_id: int, away_team_id: int, match_date) -> tuple[int, i
|
||||
|
||||
|
||||
async def _fetch_json_async(path: str, params: dict | None = None, max_retries: int = 3) -> dict | list:
|
||||
"""异步 HTTP(bzzoiro 使用 httpx,不再阻塞事件循环线程池)。"""
|
||||
"""异步 HTTP(bzzoiro 使用 httpx,不再阻塞事件循环线程池)。
|
||||
|
||||
多 key 轮换:遇到 429 自动切换到下一个 key;全部 key 冷却时等待最早恢复。
|
||||
"""
|
||||
base = (await get_runtime_value("BZZOIRO_BASE")).rstrip("/")
|
||||
raw_keys = await get_runtime_value("BZZOIRO_KEY")
|
||||
ring = get_key_ring(base, raw_keys)
|
||||
|
||||
url = f"{base}/{path.lstrip('/')}"
|
||||
key = await get_runtime_value("BZZOIRO_KEY")
|
||||
key = ring.get()
|
||||
if not key:
|
||||
raise RuntimeError("BZZOIRO_KEY 未设置")
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Token {key}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(max_retries):
|
||||
headers = {
|
||||
"Authorization": f"Token {key}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
try:
|
||||
client = get_client()
|
||||
# 整请求兜底: httpx 无 total 超时,用 wait_for 防「滴水式」限速挂死
|
||||
@@ -91,9 +97,22 @@ async def _fetch_json_async(path: str, params: dict | None = None, max_retries:
|
||||
last_exc = e
|
||||
status = getattr(getattr(e, "response", None), "status_code", None)
|
||||
if status == 429:
|
||||
delay = min(2 ** attempt, 16) + random.uniform(0, 1)
|
||||
logger.warning("bzzoiro 429, retry %d in %.1fs", attempt + 1, delay)
|
||||
await asyncio.sleep(delay)
|
||||
# 限流:标记当前 key 冷却,切换到下一个
|
||||
new_key = ring.report_rate_limited(key)
|
||||
if new_key and new_key != key:
|
||||
logger.info("bzzoiro 429 → 切换 key: %s → %s,立即重试", _km(key), _km(new_key))
|
||||
key = new_key
|
||||
continue # 立即重试,不等待
|
||||
# 单 key 或全部冷却:等待最早恢复的 key
|
||||
wait = ring.wait_if_all_blocked()
|
||||
if wait > 0:
|
||||
logger.warning("bzzoiro 全部 key 冷却,等待 %.1fs 后重试", wait)
|
||||
await asyncio.sleep(min(wait, 30.0))
|
||||
else:
|
||||
delay = min(2 ** attempt, 16) + random.uniform(0, 1)
|
||||
logger.warning("bzzoiro 429, retry %d in %.1fs", attempt + 1, delay)
|
||||
await asyncio.sleep(delay)
|
||||
key = ring.get() or key
|
||||
continue
|
||||
if 500 <= (status or 0) < 600:
|
||||
delay = min(2 ** attempt, 16) + random.uniform(0, 1)
|
||||
@@ -110,6 +129,13 @@ async def _fetch_json_async(path: str, params: dict | None = None, max_retries:
|
||||
raise RuntimeError(f"bzzoiro request failed after {max_retries} attempts: {last_exc}")
|
||||
|
||||
|
||||
def _km(key: str) -> str:
|
||||
"""key 脱敏缩写(用于日志)。"""
|
||||
if len(key) <= 8:
|
||||
return key[:2] + "***"
|
||||
return key[:4] + "..." + key[-4:]
|
||||
|
||||
|
||||
async def fetch_bzzoiro_events(
|
||||
league_code: str,
|
||||
*,
|
||||
|
||||
Reference in New Issue
Block a user