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

+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