Files
qqbotsdk/tests/test_reconnect.py
snowy-yang 13a1d971be feat: 连接寿命到点前主动重连(服务端约 1 小时会无预警断开)
实测 QQ 服务端在一个连接建立约 1 小时后会自己断开。此前只能等它断:
要么被看门狗判成静默超时、要么等读写失败,都算"异常断开",观感与恢复
路径都不理想。现改为临到期前主动收尾重连——是正常断开 + RESUME 续传,
期间消息由网关补发。

实现:receive_helper 记录寿命截止点(_SERVER_LIFETIME - _RECONNECT_MARGIN,
默认 60min - 10min = 50min),收帧超时取"静默阈值"与"寿命余量"两个截止里
的先到者——静默过久仍抛 TimeoutError 交给看门狗,寿命到点则 return 正常
结束本轮连接,run() 见存活超过 _STABLE_SECONDS 便立刻重连(沿用 b37a53f
的"健康断开零等待"路径),RESUME 带上 session_id 续传。

_connect 的存活秒数天然 >= 50min > _STABLE_SECONDS,主动重连必被判定为
健康断开,无需额外标志位。

测试:新增 test_proactive_reconnect_before_server_lifetime(真实 ws 端到端,
把寿命阈值调到 0.5s 让这一刻立即发生);已验证把寿命阈值关掉后该测试会
超时失败,不是空转到通过。另给 tests/test_reconnect.py 补 CHANGELOG 条目
(b37a53f 漏记),并同步 connecter 模块 docstring / ARCHITECTURE / GUIDE。

90 测试全绿,ruff check/format + pyright 干净。
2026-09-21 18:28:53 +08:00

92 lines
3.2 KiB
Python

"""重连循环:健康断开立刻重连(不记连接/断开),失败才退避并记断开。"""
import asyncio
from typing import Any
import pytest
from loguru import logger
from qqbotsdk.connecter import RateLimitError, WebsocketConnecter
pytestmark = pytest.mark.asyncio
def _make(monkeypatch: pytest.MonkeyPatch, seq: list[Any]) -> WebsocketConnecter:
"""构造 connecter,并把 _connect 换成按 seq 依次返回(float=存活秒数,
Exception=连接失败)。seq 用尽后抛 IndexError 中止 run() 的无限循环。"""
conn = WebsocketConnecter.__new__(WebsocketConnecter)
remaining = list(seq)
async def fake_connect() -> float:
item = remaining.pop(0) # 用尽则 IndexError 逃出 run()
if isinstance(item, Exception):
raise item
return float(item)
monkeypatch.setattr(conn, "_connect", fake_connect)
return conn
async def _run_capture(
monkeypatch: pytest.MonkeyPatch, seq: list[Any]
) -> tuple[list[float], list[str]]:
"""跑 run() 直到 seq 用尽,返回 (sleep 延迟序列, 日志消息列表)。"""
sleeps: list[float] = []
logs: list[str] = []
real_sleep = asyncio.sleep
async def fake_sleep(delay: float) -> None:
sleeps.append(delay)
await real_sleep(0) # 让出控制权,避免紧循环
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
sink_id = logger.add(lambda m: logs.append(m.record["message"]), level="INFO")
try:
conn = _make(monkeypatch, seq)
with pytest.raises(IndexError):
await conn.run()
finally:
logger.remove(sink_id)
return sleeps, logs
async def test_healthy_disconnect_reconnects_without_delay_or_log(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""健康连接(存活 >= 稳定阈值)断开后立刻重连:不 sleep、不记连接/断开。"""
sleeps, logs = await _run_capture(monkeypatch, [3600.0, 3600.0])
assert sleeps == [] # 两次都立刻重连
assert not any("已连接" in m or "已断开" in m for m in logs)
async def test_failed_reconnect_backs_off_and_logs_disconnect(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""连不上时记断开并按退避等待,退避逐次翻倍。"""
sleeps, logs = await _run_capture(monkeypatch, [3600.0, TimeoutError(), 0.0, 0.0])
# 健康断开 → 无 sleep;失败后依次 1s、2s、4s
assert sleeps == [1, 2, 4]
assert sum("已断开" in m for m in logs) == 3
async def test_rate_limit_backs_off_hard(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""gateway 限流:退避 60s,且不重复记断开。"""
sleeps, logs = await _run_capture(monkeypatch, [RateLimitError(), 3600.0])
assert sleeps == [60]
assert any("频率限制" in m for m in logs)
assert not any("已断开" in m for m in logs)
async def test_flapping_connection_does_not_reset_backoff(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""连接建立后立刻掉线(存活 < 阈值)视为抖动:退避持续拉长,不归零。"""
sleeps, _ = await _run_capture(monkeypatch, [0.5, 0.5, 0.5])
assert sleeps == [1, 2, 4] # 每次都等得更久(若归零会是 1,1,1)