feat!: api 拆包、心跳迁入传输层、限流重试与后台 handler

This commit is contained in:
snowy-yang committed 2026-09-20 21:26:55 +08:00
1 parent 750ed81d96
commit 3237558128
30 files changed
+1145 -714

No files matched your search

+144 -3
View File
@@ -1,7 +1,8 @@
import pytest
from aiohttp import ClientSession, ClientTimeout, web
from aiohttp.test_utils import TestServer
from qqbotsdk.api import BotApi, button, keyboard
from qqbotsdk.api import ApiError, BotApi, button, keyboard
from qqbotsdk.config import Config
from qqbotsdk.token import AccessToken
@@ -155,8 +156,9 @@ async def test_post_message_markdown_dict_and_event_id():
async def test_media_takes_precedence_over_markdown():
api, cap = make_api()
await api.post_group_message("G1", content="看图", msg_id="m1",
media={"file_info": "f"}, markdown="# t")
await api.post_group_message(
"G1", content="看图", msg_id="m1", media={"file_info": "f"}, markdown="# t"
)
body = cap["call"][2]["json"]
assert body["msg_type"] == 7
assert "markdown" not in body
@@ -228,3 +230,142 @@ async def test_request_tolerates_non_json_content_type():
await http.close()
finally:
await server.close()
# ---------- 请求内核:429 限流重试 / 401 刷新 token 重试 ----------
class ScriptedResponse:
def __init__(self, status: int, payload, headers: dict | None = None) -> None:
self.status = status
self.headers = headers or {}
self._payload = payload
async def json(self, **kwargs):
return self._payload
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
class ScriptedSession:
"""按序弹出响应,记录 request 调用。"""
def __init__(self, *responses: ScriptedResponse) -> None:
self._responses = list(responses)
self.calls: list[tuple[str, str]] = []
def request(self, method: str, url: str, **kwargs):
self.calls.append((method, url))
return self._responses.pop(0)
class StubToken:
def __init__(self) -> None:
self.fetches = 0
self.invalidated = 0
async def get_access_token(self) -> str:
self.fetches += 1
return "t1"
def invalidate(self) -> None:
self.invalidated += 1
def make_core_api(http: ScriptedSession, token: StubToken | None = None) -> BotApi:
config = Config(
app_id="id",
app_secret="s",
base_url="https://x",
timeout=ClientTimeout(total=5),
connecter="websocket",
intents_file="intents.toml",
webhook_host="0.0.0.0",
webhook_port=8080,
webhook_path="/qqbot/webhook",
)
return BotApi(config, http, token or StubToken()) # type: ignore[arg-type]
async def test_request_retries_429_honoring_retry_after(monkeypatch):
delays: list[float] = []
async def fake_sleep(delay):
delays.append(delay)
monkeypatch.setattr("qqbotsdk.api.core.asyncio.sleep", fake_sleep)
http = ScriptedSession(
ScriptedResponse(
429, {"code": 11244, "message": "limit"}, {"Retry-After": "2"}
),
ScriptedResponse(200, {"ok": 1}),
)
api = make_core_api(http)
assert await api.request("POST", "/v1/x") == {"ok": 1}
assert len(http.calls) == 2
assert delays == [2.0]
async def test_request_429_dirty_header_falls_back_to_backoff(monkeypatch):
delays: list[float] = []
async def fake_sleep(delay):
delays.append(delay)
monkeypatch.setattr("qqbotsdk.api.core.asyncio.sleep", fake_sleep)
http = ScriptedSession(
# 线上见过的脏值 "30,120":取首段 30
ScriptedResponse(429, {}, {"Retry-After": "30,120"}),
# 无 Retry-After:指数退避 1 * 2**1 = 2
ScriptedResponse(429, {}),
ScriptedResponse(200, {}),
)
api = make_core_api(http)
assert await api.get("/v1/x") == {}
assert delays == [30.0, 2.0]
async def test_request_429_retry_after_capped(monkeypatch):
delays: list[float] = []
async def fake_sleep(delay):
delays.append(delay)
monkeypatch.setattr("qqbotsdk.api.core.asyncio.sleep", fake_sleep)
http = ScriptedSession(
ScriptedResponse(429, {}, {"Retry-After": "3600"}),
ScriptedResponse(200, {}),
)
api = make_core_api(http)
await api.get("/v1/x")
assert delays == [30.0] # 封顶,不真睡一小时
async def test_request_429_exhausted_raises_api_error(monkeypatch):
async def fake_sleep(delay):
pass
monkeypatch.setattr("qqbotsdk.api.core.asyncio.sleep", fake_sleep)
http = ScriptedSession(*(ScriptedResponse(429, {}) for _ in range(4)))
api = make_core_api(http)
with pytest.raises(ApiError) as exc_info:
await api.get("/v1/x")
assert exc_info.value.status == 429
assert len(http.calls) == 4 # 首次 + 3 次重试
async def test_request_401_invalidates_token_and_retries_once():
http = ScriptedSession(
ScriptedResponse(401, {"code": -1, "message": "unauthorized"}),
ScriptedResponse(200, {"ok": 1}),
)
token = StubToken()
api = make_core_api(http, token)
assert await api.get("/v1/x") == {"ok": 1}
assert token.invalidated == 1
assert token.fetches == 2
assert len(http.calls) == 2