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
+5 -2
View File
@@ -1,6 +1,5 @@
from qqbotsdk.config import Config
from qqbotsdk.connecter import WebhookConnecter
from qqbotsdk.emitter import EventEmitter
from qqbotsdk.queue import EventQueue
@@ -16,7 +15,11 @@ def make_connecter() -> WebhookConnecter:
webhook_port=8080,
webhook_path="/qqbot/webhook",
)
return WebhookConnecter(config, EventEmitter(EventQueue()), EventQueue())
async def noop_handle(payload):
return None
return WebhookConnecter(config, EventQueue(), noop_handle)
def test_first_push_is_not_duplicate():
+96 -11
View File
@@ -1,28 +1,27 @@
import asyncio
import pytest
from qqbotsdk.config import Config
from qqbotsdk.emitter import EventEmitter
from qqbotsdk.model import Opcode, payload_of
from qqbotsdk.payloads import GroupAtMessage
from qqbotsdk.queue import EventQueue
from qqbotsdk.session import Session
from qqbotsdk.token import AccessToken
from qqbotsdk.ws_protocol import WebsocketProtocol
def test_sequence_only_moves_forward():
s = Session()
assert s.sequence_id is None
assert s.seq == 0
s.update_sequence(5)
s.update_sequence(3) # 回退被忽略
assert s.sequence_id == 5
assert s.seq == 5
s.update_sequence(7)
assert s.sequence_id == 7
assert s.seq == 7
def test_seq_defaults_to_zero():
s = Session()
assert s.seq == 0
@pytest.mark.asyncio
async def test_handler_exception_is_isolated():
queue = EventQueue()
@@ -96,7 +95,93 @@ async def test_unregistered_annotation_falls_back_to_raw_dict():
async def handler(dep: Session): # Session 未登记进 services
received.append(dep)
await emitter.emit(
{"op": Opcode.DISPATCH, "t": "READY", "d": {"session_id": "s1"}}
)
await emitter.emit({"op": Opcode.DISPATCH, "t": "READY", "d": {"session_id": "s1"}})
assert received == [{"session_id": "s1"}]
@pytest.mark.asyncio
async def test_scalar_d_falls_back_to_raw_value():
"""回归:op=9 INVALID_SESSION 的 d 是布尔值,DI 回退必须传原始 d。
此前实现把 d dict 化成 {}(恒 falsy),resumable=true 也被当成不可续传。
走真实协议层 handler 验证路由 → 注入 → 会话状态的全链路。"""
config = Config(
app_id="a",
app_secret="s",
base_url="https://x",
timeout=None, # type: ignore[arg-type]
connecter="websocket",
intents_file="no-such.toml",
webhook_host="0.0.0.0",
webhook_port=8080,
webhook_path="/x",
)
emitter = EventEmitter()
session = Session()
session.session_id = "S1"
emitter.register_protocol(
WebsocketProtocol(config, session, AccessToken(config, None)) # type: ignore[arg-type]
)
seen: list[bool] = []
@emitter.on(Opcode.INVALID_SESSION)
async def spy(resumable: bool):
seen.append(resumable)
await emitter.emit({"op": Opcode.INVALID_SESSION, "d": True})
assert seen == [True]
assert session.session_id == "S1" # 可续传,会话保留
await emitter.emit({"op": Opcode.INVALID_SESSION, "d": False})
assert seen == [True, False]
assert session.session_id is None # 不可续传,会话清空
@pytest.mark.asyncio
async def test_background_handler_does_not_block_dispatch():
"""background=True:慢 handler 进后台任务,不阻塞同一事件的后续 handler。"""
emitter = EventEmitter()
slow_done = asyncio.Event()
quick_done = asyncio.Event()
@emitter.on("GROUP_AT_MESSAGE_CREATE", background=True)
async def slow(msg: GroupAtMessage):
await asyncio.sleep(0.05)
slow_done.set()
@emitter.on("GROUP_AT_MESSAGE_CREATE")
async def quick(msg: GroupAtMessage):
quick_done.set()
await emitter.emit(
{
"op": Opcode.DISPATCH,
"t": "GROUP_AT_MESSAGE_CREATE",
"d": {"content": "hi"},
}
)
# emit 返回时快 handler 已跑完,慢 handler 仍在后台
assert quick_done.is_set()
assert not slow_done.is_set()
await asyncio.wait_for(slow_done.wait(), timeout=2)
await asyncio.sleep(0) # 让已完成任务的 done_callback 清理 _bg_tasks
await emitter.close()
assert not emitter._bg_tasks # 收尾后在飞任务清空
@pytest.mark.asyncio
async def test_close_cancels_inflight_background_tasks():
emitter = EventEmitter()
cancelled = asyncio.Event()
@emitter.on("READY", background=True)
async def forever():
try:
await asyncio.sleep(60)
except asyncio.CancelledError:
cancelled.set()
raise
await emitter.emit({"op": Opcode.DISPATCH, "t": "READY", "d": {}})
await asyncio.sleep(0) # 让后台任务实际启动(未启动的任务被 cancel 不会执行函数体)
await emitter.close()
assert cancelled.is_set()
+1 -2
View File
@@ -2,12 +2,11 @@ import pytest
from qqbotsdk.emitter import EventEmitter
from qqbotsdk.model import Opcode
from qqbotsdk.queue import EventQueue
@pytest.fixture
def emitter() -> EventEmitter:
return EventEmitter(EventQueue())
return EventEmitter() # 无参构造,覆盖 queue 默认自建路径
def test_route_dispatch_uses_t(emitter: EventEmitter):
+61 -25
View File
@@ -1,4 +1,6 @@
"""AccessToken:成功换取与失败时抛 TokenError(带 QQ 的 code/message)。"""
"""AccessToken:缓存复用、失败抛 TokenError、并发只换取一次、invalidate 强制刷新。"""
import asyncio
import pytest
from aiohttp import ClientTimeout
@@ -7,27 +9,8 @@ from qqbotsdk.config import Config
from qqbotsdk.token import AccessToken, TokenError
def make_token(payload) -> AccessToken:
class FakeResponse:
status = 200
def __init__(self) -> None:
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 FakeSession:
def post(self, *args, **kwargs):
return FakeResponse()
config = Config(
def make_config() -> Config:
return Config(
app_id="id",
app_secret="s",
base_url="https://x",
@@ -38,14 +21,46 @@ def make_token(payload) -> AccessToken:
webhook_port=8080,
webhook_path="/qqbot/webhook",
)
return AccessToken(config, FakeSession()) # type: ignore[arg-type]
class FakeResponse:
def __init__(self, payload, status: int = 200) -> None:
self.status = status
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 FakeSession:
"""按序弹出响应体,记录 post 次数。"""
def __init__(self, *payloads) -> None:
self._payloads = list(payloads)
self.posts = 0
def post(self, *args, **kwargs):
self.posts += 1
return FakeResponse(self._payloads.pop(0))
def make_token(*payloads) -> AccessToken:
return AccessToken(make_config(), FakeSession(*payloads)) # type: ignore[arg-type]
async def test_success_caches_token():
token = make_token({"access_token": "t1", "expires_in": 7200})
session = FakeSession({"access_token": "t1", "expires_in": 7200})
token = AccessToken(make_config(), session) # type: ignore[arg-type]
assert await token.get_access_token() == "t1"
# 缓存期内直接复用,不再发请求(FakeSession 也没有第二次响应的意义)
# 缓存期内直接复用,不再发请求
assert await token.get_access_token() == "t1"
assert session.posts == 1
async def test_error_payload_raises_token_error():
@@ -62,3 +77,24 @@ async def test_non_dict_payload_raises_token_error():
with pytest.raises(TokenError) as exc_info:
await token.get_access_token()
assert exc_info.value.message == "gateway busy"
async def test_concurrent_first_fetch_only_requests_once():
session = FakeSession({"access_token": "t1", "expires_in": 7200})
token = AccessToken(make_config(), session) # type: ignore[arg-type]
results = await asyncio.gather(*(token.get_access_token() for _ in range(5)))
assert results == ["t1"] * 5
assert session.posts == 1 # 双重检查锁:并发首调只换取一次
async def test_invalidate_forces_refetch():
session = FakeSession(
{"access_token": "t1", "expires_in": 7200},
{"access_token": "t2", "expires_in": 7200},
)
token = AccessToken(make_config(), session) # type: ignore[arg-type]
assert await token.get_access_token() == "t1"
token.invalidate() # 模拟服务端提前作废
assert await token.get_access_token() == "t2"
assert session.posts == 2
+3 -6
View File
@@ -14,8 +14,6 @@ from qqbotsdk.connecter import WebhookConnecter
from qqbotsdk.crypto import build_seed
from qqbotsdk.emitter import EventEmitter
from qqbotsdk.payloads import GroupAtMessage
from qqbotsdk.queue import EventQueue
from qqbotsdk.session import Session
from qqbotsdk.webhook_protocol import WebhookProtocol
SECRET = "webhook-test-secret"
@@ -52,10 +50,9 @@ def event_body(event_id: str, content: str) -> dict[str, Any]:
@pytest.mark.asyncio
async def test_webhook_end_to_end():
config = make_config(0)
queue = EventQueue()
emitter = EventEmitter(queue)
emitter.register_protocol(WebhookProtocol(config, queue, Session()))
connecter = WebhookConnecter(config, emitter, queue)
emitter = EventEmitter()
emitter.register_protocol(WebhookProtocol(config))
connecter = WebhookConnecter(config, emitter.queue, emitter.handle)
received: list[GroupAtMessage] = []
+2 -2
View File
@@ -114,9 +114,9 @@ async def test_websocket_end_to_end():
queue = EventQueue()
session = Session()
emitter = EventEmitter(queue)
protocol = WebsocketProtocol(config, queue, session, token)
protocol = WebsocketProtocol(config, session, token)
emitter.register_protocol(protocol)
connecter = WebsocketConnecter(config, http, token, queue)
connecter = WebsocketConnecter(config, http, token, session, queue)
received: list[GroupAtMessage] = []