Files
Profeto/tests/test_key_ring.py
T
shangfangjianandnew-provider/LongCat-2.0 < f52ec8b963 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>>
2026-09-20 19:40:20 +08:00

184 lines
5.7 KiB
Python

"""KeyRing 多 key 轮换单元测试。"""
from __future__ import annotations
import asyncio
import time
import pytest
from src.data.key_ring import KeyRing, parse_keys, get_key_ring
class TestParseKeys:
def test_single_key(self):
assert parse_keys("abc123") == ["abc123"]
def test_comma_separated(self):
assert parse_keys("k1, k2,k3") == ["k1", "k2", "k3"]
def test_semicolon_separated(self):
assert parse_keys("k1;k2;k3") == ["k1", "k2", "k3"]
def test_newline_separated(self):
assert parse_keys("k1\nk2\nk3") == ["k1", "k2", "k3"]
def test_mixed_separators(self):
assert parse_keys("k1, k2; k3\nk4") == ["k1", "k2", "k3", "k4"]
def test_empty(self):
assert parse_keys("") == []
assert parse_keys(None) == []
assert parse_keys(" , ; ") == []
def test_strips_whitespace(self):
assert parse_keys(" a , b ") == ["a", "b"]
class TestKeyRingSingleKey:
"""单 key 场景:行为与之前一致。"""
def test_get_returns_key(self):
ring = KeyRing(["only-key"])
assert ring.get() == "only-key"
assert ring.active_key == "only-key"
def test_no_rotation(self):
ring = KeyRing(["key"])
ring.report_rate_limited()
# 单 key 切换后仍是自己
assert ring.get() == "key"
def test_empty_keys(self):
ring = KeyRing([])
assert ring.get() is None
assert ring.active_key is None
def test_has_multiple_false(self):
ring = KeyRing(["key"])
assert ring.has_multiple is False
class TestKeyRingMultiKey:
"""多 key 场景:429 自动轮换。"""
def test_get_rounds_robin(self):
ring = KeyRing(["a", "b", "c"])
# 前三次 get 依次返回 a, b, c
assert ring.get() == "a"
assert ring.get() == "a" # 不报告限流时保持当前 key
# 手动推进:通过 report 后 get
ring.report_rate_limited("a")
# a 被冷却,下一个可用的是 b
assert ring.get() == "b"
def test_rate_limit_skips_key(self):
ring = KeyRing(["a", "b", "c"], cooldown_seconds=60.0)
key = ring.get()
assert key == "a"
new_key = ring.report_rate_limited("a")
assert new_key == "b"
# 再次 get 应继续是 b(可用)
assert ring.get() == "b"
def test_cycle_back_to_first(self):
ring = KeyRing(["a", "b"], cooldown_seconds=0.1)
ring.report_rate_limited("a")
# b 可用
assert ring.get() == "b"
ring.report_rate_limited("b")
# a 仍在冷却,b 也在冷却 → 选最早过期的(可能是 a)
key = ring.get()
assert key in ("a", "b")
def test_cooldown_expires(self):
ring = KeyRing(["a", "b"], cooldown_seconds=0.05)
ring.report_rate_limited("a")
assert ring.get() == "b"
# 等 a 的冷却过期
time.sleep(0.08)
# 现在 get 应该能找到可用的 key(b 或 a 都行,取决于指针)
key = ring.get()
assert key in ("a", "b")
def test_wait_if_all_blocked(self):
ring = KeyRing(["a", "b"], cooldown_seconds=1.0)
ring.report_rate_limited("a")
ring.report_rate_limited("b")
wait = ring.wait_if_all_blocked()
assert wait > 0 # 应返回正数等待时间
def test_wait_if_not_all_blocked(self):
ring = KeyRing(["a", "b"], cooldown_seconds=1.0)
ring.report_rate_limited("a")
# b 仍可用
assert ring.wait_if_all_blocked() == 0.0
def test_stats(self):
ring = KeyRing(["a" * 12, "b" * 12], cooldown_seconds=1.0)
ring.report_rate_limited("a" * 12)
st = ring.stats()
assert st["total"] == 2
assert st["keys"][0]["blocked_remaining"] > 0
assert st["keys"][1]["blocked_remaining"] == 0
# 脱敏
assert "***" in st["keys"][0]["masked"] or "..." in st["keys"][0]["masked"]
def test_all_keys_property(self):
ring = KeyRing(["x", "y"])
assert ring.all_keys == ["x", "y"]
class TestKeyRingHotUpdate:
"""热更新 key 列表。"""
def test_setter_clears_state(self):
ring = KeyRing(["a", "b"])
ring.report_rate_limited("a")
assert ring.get() == "b"
# 更新 key 列表
ring._keys = ["c", "d"]
ring._blocked_until.clear()
ring._index = 0
assert ring.get() == "c"
class TestGetKeyRing:
def test_singleton_per_base(self):
r1 = get_key_ring("https://api.test.com", "k1, k2")
r2 = get_key_ring("https://api.test.com", "k1, k2")
assert r1 is r2
def test_different_base_isolated(self):
r1 = get_key_ring("https://a.com", "k1")
r2 = get_key_ring("https://b.com", "k2")
assert r1 is not r2
assert r1.get() == "k1"
assert r2.get() == "k2"
def test_hot_update_keys(self):
ring = get_key_ring("https://hot.com", "k1, k2")
assert set(ring.all_keys) == {"k1", "k2"}
# 更新(同 base 会命中缓存,触发热更新)
ring2 = get_key_ring("https://hot.com", "k3, k4")
assert ring2 is ring
assert set(ring.all_keys) == {"k3", "k4"}
class TestKeyRingAsyncSafety:
"""async 并发场景下单 event loop 不需要锁,但验证交替 429 不会死锁。"""
@pytest.mark.asyncio
async def test_concurrent_get(self):
ring = KeyRing(["a", "b", "c"])
async def worker():
for _ in range(20):
key = ring.get()
assert key in ("a", "b", "c")
# 模拟偶发 429
if hash(key) % 3 == 0:
ring.report_rate_limited(key)
await asyncio.sleep(0)
await asyncio.gather(*(worker() for _ in range(5)))