Files
easy-tdx/tests/unit/test_client_reconnect.py
GitHub 155328df8b release: v1.16.2 — 三轮审计质量加固(B6.9→A7.9)
经三轮代码审计后的综合质量加固版本,覆盖协议核心层、数据正确性、
错误处理、测试真实度与可维护性。761 单测全绿(+58),ruff/mypy 全过。

主要修复:
- 离线 .day 写入原子化(fsync + _repair_tail + 读取校验,CQS 守住)
- 回测止损前视偏差(延迟下一根开盘 + 跳空保护)
- VWAP 权重索引 / bar_time fail-fast / 绩效除零保护
- 闭包绑定 / 路径穿越 / naive datetime 跨时区 / ruff UP038

重构:
- 抽 AsyncHeartbeatMixin 收敛 4 处心跳副本(12→1)
- 统一 _RETRY_DELAYS 退避序列 / scanner 失败可观测性

新增 5 个测试文件 + 公共 API 类型契约,CI 加 Windows 矩阵 +
trusted publishing 签名 + 锁文件。

详见 CHANGELOG.md
2026-07-02 03:37:37 +08:00

159 lines
6.8 KiB
Python

"""TdxClient._execute 的指数退避重连测试(sync + async)。
之前 _execute 的 4 次 _RETRY_DELAYS 退避重连路径零测试(审计报告 #9),
仅 async 有 transport 层的真实重连测试,未覆盖 _execute 自身的退避循环。
本文件 mock _conn.execute 让前 N 次抛 TdxConnectionError、第 N+1 次成功,
并 patch time.sleep / asyncio.sleep 验证退避序列。
"""
from __future__ import annotations
import asyncio
from unittest.mock import MagicMock, patch
import pytest
from easy_tdx.client import _RETRY_DELAYS, AsyncTdxClient, TdxClient
from easy_tdx.commands.security_count import GetSecurityCountCmd
from easy_tdx.exceptions import TdxConnectionError
from easy_tdx.models.enums import Market
# --------------------------------------------------------------------------- #
# 同步 _execute 重连
# --------------------------------------------------------------------------- #
class TestSyncExecuteReconnect:
def test_reconnect_succeeds_on_second_attempt(self) -> None:
"""首次抛 TdxConnectionError,重连后第 1 次重试成功。"""
with patch("easy_tdx.client.TdxConnection") as mock_conn_cls:
mock_conn = MagicMock()
# execute 首次抛错,重连后(第1次重试)成功
mock_conn.execute.side_effect = [
TdxConnectionError("disconnected"),
1000, # 重连后成功
]
mock_conn_cls.return_value = mock_conn
client = TdxClient("1.1.1.1", 7709, 1.0, auto_reconnect=True, heartbeat_interval=0)
with patch("easy_tdx.client.time.sleep"): # 跳过真实 sleep
result = client._execute(GetSecurityCountCmd(Market.SH))
assert result == 1000
# 应重连了 1 次(首次失败 + 1 次重试成功)
assert mock_conn.close.call_count == 1
def test_all_retries_exhausted_raises_last(self) -> None:
"""4 次重试全部失败,应抛出最后一个异常。"""
with patch("easy_tdx.client.TdxConnection") as mock_conn_cls:
mock_conn = MagicMock()
# 首次 + 4 次重试全部失败
mock_conn.execute.side_effect = TdxConnectionError("always down")
mock_conn_cls.return_value = mock_conn
client = TdxClient("1.1.1.1", 7709, 1.0, auto_reconnect=True, heartbeat_interval=0)
with patch("easy_tdx.client.time.sleep") as mock_sleep:
with pytest.raises(TdxConnectionError):
client._execute(GetSecurityCountCmd(Market.SH))
# 应 sleep 了 4 次(_RETRY_DELAYS 长度)
assert mock_sleep.call_count == len(_RETRY_DELAYS)
def test_no_reconnect_when_disabled(self) -> None:
"""auto_reconnect=False 时首次失败立即抛出,不重试。"""
with patch("easy_tdx.client.TdxConnection") as mock_conn_cls:
mock_conn = MagicMock()
mock_conn.execute.side_effect = TdxConnectionError("down")
mock_conn_cls.return_value = mock_conn
client = TdxClient("1.1.1.1", 7709, 1.0, auto_reconnect=False, heartbeat_interval=0)
with patch("easy_tdx.client.time.sleep") as mock_sleep:
with pytest.raises(TdxConnectionError):
client._execute(GetSecurityCountCmd(Market.SH))
# 禁用重连时不应 sleep
mock_sleep.assert_not_called()
def test_retry_uses_exponential_backoff_delays(self) -> None:
"""验证 sleep 调用的延迟序列与 _RETRY_DELAYS 一致。"""
with patch("easy_tdx.client.TdxConnection") as mock_conn_cls:
mock_conn = MagicMock()
mock_conn.execute.side_effect = TdxConnectionError("down")
mock_conn_cls.return_value = mock_conn
client = TdxClient("1.1.1.1", 7709, 1.0, auto_reconnect=True, heartbeat_interval=0)
with patch("easy_tdx.client.time.sleep") as mock_sleep:
with pytest.raises(TdxConnectionError):
client._execute(GetSecurityCountCmd(Market.SH))
actual_delays = [call.args[0] for call in mock_sleep.call_args_list]
assert tuple(actual_delays) == _RETRY_DELAYS
# --------------------------------------------------------------------------- #
# 异步 _execute 重连
# --------------------------------------------------------------------------- #
class TestAsyncExecuteReconnect:
def test_async_reconnect_succeeds_on_second_attempt(self) -> None:
async def main() -> int:
with patch("easy_tdx.client.AsyncTdxConnection") as mock_conn_cls:
mock_conn = MagicMock()
call_count = [0]
async def _execute(cmd: object) -> int:
call_count[0] += 1
if call_count[0] == 1:
raise TdxConnectionError("down")
return 2000
async def _noop() -> None:
return None
mock_conn.execute = _execute
mock_conn.close = _noop
mock_conn.connect = _noop
mock_conn_cls.return_value = mock_conn
client = AsyncTdxClient(
"1.1.1.1", 7709, 1.0, auto_reconnect=True, heartbeat_interval=0
)
with patch("easy_tdx.client.asyncio.sleep", new=AsyncMockSleep()):
result = await client._execute(GetSecurityCountCmd(Market.SH))
return result
assert asyncio.run(main()) == 2000
def test_async_all_retries_exhausted(self) -> None:
async def main() -> None:
with patch("easy_tdx.client.AsyncTdxConnection") as mock_conn_cls:
mock_conn = MagicMock()
async def _execute(cmd: object) -> int:
raise TdxConnectionError("always down")
async def _noop() -> None:
return None
mock_conn.execute = _execute
mock_conn.close = _noop
mock_conn.connect = _noop
mock_conn_cls.return_value = mock_conn
client = AsyncTdxClient(
"1.1.1.1", 7709, 1.0, auto_reconnect=True, heartbeat_interval=0
)
with patch("easy_tdx.client.asyncio.sleep", new=AsyncMockSleep()) as mock_sleep:
with pytest.raises(TdxConnectionError):
await client._execute(GetSecurityCountCmd(Market.SH))
assert mock_sleep.call_count == len(_RETRY_DELAYS)
asyncio.run(main())
class AsyncMockSleep:
"""轻量 async sleep 替身,记录调用次数但不真实等待。"""
def __init__(self) -> None:
self.call_count = 0
async def __call__(self, delay: float) -> None:
self.call_count += 1