feat: v1.20.0 服务器失败自动 ping 切换(无需手动 easy-tdx ping)

服务器连不上或返回空数据时,自动测速切到延迟最低的可用服务器再试,
Python API / CLI / Web API 三入口全部自动生效。

核心改动:
- _reconnect.py 新增 select_best_host_sync/async(连接失败 failover)
  和 find_working_host_sync/async(空数据逐台实测)
- 8 个 client 的 _execute 注入跨主机故障转移(复用 auto_reconnect 开关)
- get_market_stat 空数据时按延迟顺序逐台实测找返回数据的服务器
- 新增 _reconnect/_areconnect helper 收敛重建连接副本
- MacClient failover 用 save_best_mac_host(延续 v1.19.4 不污染 best_host)
- 顺手修复 test_commands_offline 未使用 import(main CI failure 根因)

测试:925 passed(新增 18 个 failover 测试),ruff/mypy 零新增错误。
This commit is contained in:
GitHub
2026-07-08 18:07:37 +08:00
parent 14debdb446
commit d0d65d64b8
9 changed files with 990 additions and 66 deletions
+219 -1
View File
@@ -9,13 +9,20 @@
逐字节重复(仅心跳命令和 logger 名不同)。这里抽出
``AsyncHeartbeatMixin`` 收敛这些副本——子类只需实现 ``_heartbeat_cmd()``
返回一个 awaitable 即可,未来改心跳策略只需改一处。
跨主机故障转移(failover):8 个 client 的 ``_execute`` 在同主机重试耗尽
``_RETRY_DELAYS`` 走完仍 ``TdxConnectionError``)后,调用本模块的
``select_best_host_sync`` / ``select_best_host_async`` 重新测速、切到延迟
最低的**另一台**服务器再试一轮。这样服务器连不上时无需用户手动 ``ping``,
Python API / CLI / Web API 三入口自动生效(三者最终都汇聚到 ``_execute``)。
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Awaitable
import time
from collections.abc import Awaitable, Callable
from .exceptions import TdxConnectionError, TdxDecodeError
@@ -33,6 +40,217 @@ _HEARTBEAT_RETRYABLE: tuple[type[BaseException], ...] = (
TdxDecodeError,
)
# --------------------------------------------------------------------------- #
# 跨主机故障转移(failover)共享实现
# --------------------------------------------------------------------------- #
#
# 设计要点:
# 1. 纯函数,不依赖任何 client 状态——8 个 client 各自传入自己的
# (候选主机列表, 测速函数, 持久化函数, 端口)。便于单测、避免循环依赖。
# 2. 只返回与 current_host *不同* 的最优主机;若所有候选都不可达或唯一可达
# 的就是 current_host,返回 None(调用方保持原 host 不变)。
# 3. 进程级节流:_FAILOVER_PING_THROTTLE_SEC 秒内不重复全量测速——一次失败
# 可能触发多个并发请求同时进入 failover,节流避免对几十台服务器发起
# "惊群"式测速。节流窗口内直接返回 None(放弃本次跨主机切换,让外层
# 同主机重试兜底)。
# 同一进程内两次全量测速的最小间隔(秒)。
_FAILOVER_PING_THROTTLE_SEC: float = 30.0
# 上次全量测速完成的时间戳(monotonic);初始 0 表示"从未测过"。
_last_failover_ts: float = 0.0
def _throttled() -> bool:
"""距上次全量测速是否仍在节流窗口内(True=应跳过本次测速)。"""
global _last_failover_ts
return (time.monotonic() - _last_failover_ts) < _FAILOVER_PING_THROTTLE_SEC
def _mark_failover_done() -> None:
"""记录"本次全量测速已完成",开启新一轮节流窗口。"""
global _last_failover_ts
_last_failover_ts = time.monotonic()
# 测速函数的统一签名:(hosts, port, timeout) -> [(host, latency_seconds), ...]
PingFn = Callable[..., list[tuple[str, float]]]
# 持久化函数的统一签名:(host) -> None
SaveFn = Callable[[str], None]
def select_best_host_sync(
hosts: list[str],
ping_fn: PingFn,
save_fn: SaveFn,
port: int,
ping_timeout: float,
current_host: str,
) -> str | None:
"""重新测速并选出优于当前主机的最佳主机(同步)。
Args:
hosts: 候选主机列表(如 ``get_known_hosts()``)。
ping_fn: 测速函数(``ping_all`` / ``ping_mac_all`` / ``ping_ex_all``),
签名 ``(hosts, port, timeout) -> [(host, latency), ...]``,已按
延迟升序返回,不可达主机不在结果中。
save_fn: 持久化函数(``save_best_host`` / ``save_best_ex_host`` /
``save_best_mac_ex_host``),将选中的主机写回 config.json。
port: 目标端口。
ping_timeout: 单台测速超时(秒)。
current_host: 当前正在使用(且已判定不可用)的主机,结果会跳过它。
Returns:
选中的新主机(已 ``save_fn`` 持久化);若无更优选择或处于节流窗口
内,返回 ``None``(调用方保持原 host)。
"""
if _throttled():
logging.getLogger(__name__).debug(
"跨主机故障转移:处于 %ss 节流窗口内,跳过本次测速",
_FAILOVER_PING_THROTTLE_SEC,
)
return None
try:
ranked = ping_fn(hosts, port, ping_timeout)
finally:
# 无论测速是否拿到结果,都视为"完成一次测速",开启节流窗口,
# 避免失败时被高频重试反复触发。
_mark_failover_done()
# 跳过当前(已判定不可用)主机,取延迟最低的另一台
for host, _latency in ranked:
if host != current_host:
save_fn(host)
logging.getLogger(__name__).info("跨主机故障转移:从 %s 切换到 %s", current_host, host)
return host
return None
async def select_best_host_async(
hosts: list[str],
ping_fn: PingFn,
save_fn: SaveFn,
port: int,
ping_timeout: float,
current_host: str,
) -> str | None:
"""重新测速并选出优于当前主机的最佳主机(异步)。
与 :func:`select_best_host_sync` 语义一致;测速在线程池中执行
``ping_fn`` 是阻塞实现,用 ``asyncio.to_thread`` 避免阻塞事件循环),
节流与持久化语义不变。
"""
if _throttled():
logging.getLogger(__name__).debug(
"跨主机故障转移:处于 %ss 节流窗口内,跳过本次测速",
_FAILOVER_PING_THROTTLE_SEC,
)
return None
try:
ranked = await asyncio.to_thread(ping_fn, hosts, port, ping_timeout)
finally:
_mark_failover_done()
for host, _latency in ranked:
if host != current_host:
save_fn(host)
logging.getLogger(__name__).info("跨主机故障转移:从 %s 切换到 %s", current_host, host)
return host
return None
# 空数据故障转移时最多尝试多少台候选主机(按延迟升序)。统计指数等数据
# 并非所有服务器都提供,延迟最低的不一定返回数据,故需轮询前几台。
_WORKING_HOST_MAX_ATTEMPTS = 5
# 验证函数签名:(host) -> True 表示该主机可用(如返回非空数据)。
TryFn = Callable[[str], bool]
AsyncTryFn = Callable[[str], Awaitable[bool]]
def find_working_host_sync(
ranked_hosts: list[tuple[str, float]],
try_fn: TryFn,
save_fn: SaveFn,
current_host: str,
max_attempts: int = _WORKING_HOST_MAX_ATTEMPTS,
) -> str | None:
"""按延迟顺序逐台测试候选主机,返回第一台"可用"的(同步)。
与 :func:`select_best_host_sync` 的区别:后者只按延迟选一台(用于连接
失败的故障转移);本函数用于"连接成功但数据空"的场景(如 ``get_market_stat``
的统计指数 880005/880001/880006 并非所有服务器都提供),需逐台实际查询
才能确定哪台返回有效数据。
Args:
ranked_hosts: 已按延迟升序排序的 ``[(host, latency), ...]``(来自
``ping_fn`` 的返回值)。
try_fn: 对单台主机的验证函数,返回 ``True`` 表示该主机可用(如返回
非空数据)。调用方在其中负责连接、查询、清理。
save_fn: 持久化函数,选中可用主机后调用。
current_host: 当前主机(跳过,它已被判定不可用)。
max_attempts: 最多尝试多少台候选(默认 5),避免极端情况下逐台试探
全部候选拖垮响应。
Returns:
第一台可用的主机(已 ``save_fn`` 持久化);全部不可用则返回 ``None``。
"""
log = logging.getLogger(__name__)
tried = 0
for host, _latency in ranked_hosts:
if host == current_host:
continue
if tried >= max_attempts:
break
tried += 1
try:
if try_fn(host):
save_fn(host)
log.info(
"空数据故障转移:从 %s 切换到 %s(第 %d 台候选可用)",
current_host,
host,
tried,
)
return host
except Exception:
# 验证单台主机时的任何异常(连接失败、解析错误等)都只跳过该台,
# 继续尝试下一台,不让单台拖垮整个轮询。
log.debug("空数据故障转移:%s 验证失败,尝试下一台", host, exc_info=True)
return None
async def find_working_host_async(
ranked_hosts: list[tuple[str, float]],
try_fn: AsyncTryFn,
save_fn: SaveFn,
current_host: str,
max_attempts: int = _WORKING_HOST_MAX_ATTEMPTS,
) -> str | None:
"""按延迟顺序逐台测试候选主机,返回第一台"可用"的(异步)。
与 :func:`find_working_host_sync` 语义一致;``try_fn`` 为 async 函数。
"""
log = logging.getLogger(__name__)
tried = 0
for host, _latency in ranked_hosts:
if host == current_host:
continue
if tried >= max_attempts:
break
tried += 1
try:
if await try_fn(host):
save_fn(host)
log.info(
"空数据故障转移:从 %s 切换到 %s(第 %d 台候选可用)",
current_host,
host,
tried,
)
return host
except Exception:
log.debug("空数据故障转移:%s 验证失败,尝试下一台", host, exc_info=True)
return None
class AsyncHeartbeatMixin:
"""async client 心跳三件套的共享实现(审计复审 L1)。
+153 -26
View File
@@ -22,7 +22,14 @@ from ._df import (
_merge_txn_datetime,
_to_df,
)
from ._reconnect import _RETRY_DELAYS, AsyncHeartbeatMixin
from ._reconnect import (
_RETRY_DELAYS,
AsyncHeartbeatMixin,
find_working_host_async,
find_working_host_sync,
select_best_host_async,
select_best_host_sync,
)
from .codec.block import parse_block_dat
from .codec.financial import parse_financial_dat, parse_financial_file_list
from .codec.industry import parse_tdxhy_cfg
@@ -55,6 +62,7 @@ from .models.finance import (
FinancialFileInfo,
FinancialRecord,
)
from .models.quote import SecurityQuote
from .models.security import SecurityInfo
from .models.stats import FundFlow, HistoricalFundFlow, MarketStat
from .models.timeseries import TransactionRecord
@@ -293,12 +301,24 @@ class TdxClient:
try:
self._execute(GetSecurityCountCmd(Market.SH))
except TdxConnectionError:
self._conn.stop_heartbeat()
self._conn.close()
self._conn = TdxConnection(self._host, self._port, self._timeout)
self._conn.connect()
if self._heartbeat_interval > 0:
self._conn.start_heartbeat(self._heartbeat_interval)
self._reconnect()
def _reconnect(self, host: str | None = None) -> None:
"""关闭当前连接并重建(默认连 self._host;传入 host 则切换主机)。
统一收敛所有"重建 TdxConnection + 起心跳"的副本:``_execute`` 同主机
重试、``_execute`` 跨主机故障转移、``ensure_connected``、
``get_market_stat`` 空数据重试都走这里,保证 4 处重建逻辑一致。
"""
target = host if host is not None else self._host
if host is not None:
self._host = host
self._conn.stop_heartbeat()
self._conn.close()
self._conn = TdxConnection(target, self._port, self._timeout)
self._conn.connect()
if self._heartbeat_interval > 0:
self._conn.start_heartbeat(self._heartbeat_interval)
def __enter__(self) -> "TdxClient":
self.connect()
@@ -313,11 +333,18 @@ class TdxClient:
self.close()
# ------------------------------------------------------------------ #
# 内部执行:含自动重连
# 内部执行:含自动重连 + 跨主机故障转移
# ------------------------------------------------------------------ #
def _execute(self, cmd: "BaseCommand[_T]") -> _T:
"""执行命令;断线时指数退避重试"""
"""执行命令;断线时指数退避重试,同主机耗尽则跨主机故障转移。
两阶段韧性:
1. 同主机重试(``_RETRY_DELAYS``,4 次指数退避)——应对瞬时抖动。
2. 跨主机故障转移——同主机重试仍失败时,重新测速选延迟最低的另
一台服务器再试一轮。服务器连不上时用户无需手动 ``ping``。
``auto_reconnect=False`` 时两阶段都不触发,直接抛出原异常。
"""
try:
return self._conn.execute(cmd)
except TdxConnectionError:
@@ -326,11 +353,22 @@ class TdxClient:
last_exc: TdxConnectionError | None = None
for delay in _RETRY_DELAYS:
time.sleep(delay)
self._conn.close()
self._conn = TdxConnection(self._host, self._port, self._timeout)
self._conn.connect()
if self._heartbeat_interval > 0:
self._conn.start_heartbeat(self._heartbeat_interval)
self._reconnect()
try:
return self._conn.execute(cmd)
except TdxConnectionError as e:
last_exc = e
# 第二阶段:跨主机故障转移——重新测速切到另一台服务器再试一次
new_host = select_best_host_sync(
get_known_hosts(),
ping_all,
save_best_host,
self._port,
5.0,
self._host,
)
if new_host is not None:
self._reconnect(new_host)
try:
return self._conn.execute(cmd)
except TdxConnectionError as e:
@@ -681,13 +719,18 @@ class TdxClient:
通达信这三个"统计指数"的计数类字段(涨/跌/平/总数/涨停/跌停家数)
返回的是真实家数的 1/10,需统一 ×10 还原。成交额/量/市值字段不受影响。
`suspended_count` 由 `total - up - down - neutral` 推得,用于保证计数守恒。
空数据容错:880005/880001/880006 并非所有服务器都提供,会返回空 quotes。
此时不只切换到延迟最低的一台(它可能也不提供),而是按延迟顺序逐台实测,
找到第一台返回有效数据的服务器,避免用户手动 ``easy-tdx ping``。
"""
# 通达信中 880005 是全市场行情统计,880001 是总市值指数,880006 是涨跌停统计
quotes = self._execute(
GetSecurityQuotesCmd(
[(Market.SH, "880005"), (Market.SH, "880001"), (Market.SH, "880006")]
)
_cmd = GetSecurityQuotesCmd(
[(Market.SH, "880005"), (Market.SH, "880001"), (Market.SH, "880006")]
)
quotes = self._execute(_cmd)
if not quotes and self._auto_reconnect:
quotes = self._find_host_returning_quotes(_cmd)
if not quotes:
raise RuntimeError("无法获取市场统计数据")
q = quotes[0]
@@ -714,6 +757,32 @@ class TdxClient:
)
)
def _find_host_returning_quotes(
self, cmd: "BaseCommand[list[SecurityQuote]]"
) -> list[SecurityQuote]:
"""空数据故障转移:测速后按延迟顺序逐台实测,返回首台有效数据的 quotes。
专供 ``get_market_stat`` 使用——统计指数并非所有服务器都提供,延迟最低
的不一定返回数据,故需逐台实际查询。最多尝试 ``_WORKING_HOST_MAX_ATTEMPTS``
台(见 ``_reconnect``)。找到后 client 停在该 host;全失败返回空。
"""
bad_host = self._host
ranked = ping_all(get_known_hosts(), self._port, 5.0)
def _try(host: str) -> bool:
# 切换到候选 host 并实测;非空即视为该 host 可用
self._reconnect(host)
return bool(self._execute(cmd))
new_host = find_working_host_sync(ranked, _try, save_best_host, bad_host)
if new_host is None:
# 全部候选都不可用,回退到原 host(保持状态可预测)
if self._host != bad_host:
self._reconnect(bad_host)
return []
# _try 已把 client 切到 new_host 并执行过 cmd,重新取一次拿结果
return self._execute(cmd)
def _collect_transaction_records(
self,
fetch_page: Callable[[int, int], list[TransactionRecord]],
@@ -912,8 +981,29 @@ class AsyncTdxClient(AsyncHeartbeatMixin):
"""心跳使用的轻量请求(get_security_count,复用 _execute 重连)。"""
return self.get_security_count(Market.SH)
async def _areconnect(self, host: str | None = None) -> None:
"""关闭当前连接并重建(默认连 self._host;传入 host 则切换主机)。
async 版的统一重建入口,与 sync ``_reconnect`` 对称,供 ``_execute``
同主机重试、跨主机故障转移、``get_market_stat`` 空数据重试复用。
"""
target = host if host is not None else self._host
if host is not None:
self._host = host
await self._stop_heartbeat()
await self._conn.close()
self._conn = AsyncTdxConnection(target, self._port, self._timeout)
await self._conn.connect()
self._start_heartbeat()
async def _execute(self, cmd: "BaseCommand[_T]") -> _T:
"""执行命令;断线时指数退避重试"""
"""执行命令;断线时指数退避重试,同主机耗尽则跨主机故障转移。
两阶段韧性与 sync 版对称:先同主机重试(``_RETRY_DELAYS``),再跨主机
故障转移(重新测速切到另一台服务器)。整个流程在 ``_execute_lock`` 内
串行,避免并发请求触发多次故障转移抖动。``auto_reconnect=False`` 时
两阶段都不触发。
"""
async with self._execute_lock:
try:
return await self._conn.execute(cmd)
@@ -923,9 +1013,22 @@ class AsyncTdxClient(AsyncHeartbeatMixin):
last_exc: TdxConnectionError | None = None
for delay in _RETRY_DELAYS:
await asyncio.sleep(delay)
await self._conn.close()
self._conn = AsyncTdxConnection(self._host, self._port, self._timeout)
await self._conn.connect()
await self._areconnect()
try:
return await self._conn.execute(cmd)
except TdxConnectionError as e:
last_exc = e
# 第二阶段:跨主机故障转移
new_host = await select_best_host_async(
get_known_hosts(),
ping_all,
save_best_host,
self._port,
5.0,
self._host,
)
if new_host is not None:
await self._areconnect(new_host)
try:
return await self._conn.execute(cmd)
except TdxConnectionError as e:
@@ -1214,13 +1317,17 @@ class AsyncTdxClient(AsyncHeartbeatMixin):
通达信这三个"统计指数"的计数类字段(涨/跌/平/总数/涨停/跌停家数)
返回的是真实家数的 1/10,需统一 ×10 还原。成交额/量/市值字段不受影响。
`suspended_count` 由 `total - up - down - neutral` 推得,用于保证计数守恒。
空数据容错:与 sync 版对称——空 quotes 时按延迟顺序逐台实测,找到首台
返回有效数据的服务器。
"""
# 通达信中 880005 是全市场行情统计,880001 是总市值指数,880006 是涨跌停统计
quotes = await self._execute(
GetSecurityQuotesCmd(
[(Market.SH, "880005"), (Market.SH, "880001"), (Market.SH, "880006")]
)
_cmd = GetSecurityQuotesCmd(
[(Market.SH, "880005"), (Market.SH, "880001"), (Market.SH, "880006")]
)
quotes = await self._execute(_cmd)
if not quotes and self._auto_reconnect:
quotes = await self._find_host_returning_quotes(_cmd)
if not quotes:
raise RuntimeError("无法获取市场统计数据")
q = quotes[0]
@@ -1247,6 +1354,26 @@ class AsyncTdxClient(AsyncHeartbeatMixin):
)
)
async def _find_host_returning_quotes(
self, cmd: "BaseCommand[list[SecurityQuote]]"
) -> list[SecurityQuote]:
"""空数据故障转移(async):与 sync ``_find_host_returning_quotes`` 对称。"""
bad_host = self._host
ranked = await asyncio.to_thread(ping_all, get_known_hosts(), self._port, 5.0)
async def _try(host: str) -> bool:
await self._areconnect(host)
# mypy 对 async 闭包内泛型参数的推断会宽化为 BaseCommand[object]
# (sync 同模式可正确推断),此处为已知 mypy 限制,非真实类型错误。
return bool(await self._execute(cmd)) # type: ignore[arg-type]
new_host = await find_working_host_async(ranked, _try, save_best_host, bad_host)
if new_host is None:
if self._host != bad_host:
await self._areconnect(bad_host)
return []
return await self._execute(cmd)
async def _collect_transaction_records(
self,
fetch_page: Callable[[int, int], Awaitable[list[TransactionRecord]]],
+71 -9
View File
@@ -9,7 +9,12 @@ from types import TracebackType
from typing import TypeVar
from .._df import _apply_bar_time_align_bars, _category_to_minutes
from .._reconnect import _RETRY_DELAYS, AsyncHeartbeatMixin
from .._reconnect import (
_RETRY_DELAYS,
AsyncHeartbeatMixin,
select_best_host_async,
select_best_host_sync,
)
from ..commands.base import BaseCommand
from ..config import get_best_ex_host, get_ex_hosts, save_best_ex_host
from ..exceptions import TdxConnectionError
@@ -120,8 +125,25 @@ class ExTdxClient:
) -> None:
self.close()
def _reconnect(self, host: str | None = None) -> None:
"""关闭当前连接并重建(默认连 self._host;传入 host 则切换主机)。
扩展行情无心跳,重建仅 close → new ExTdxConnection → connect。
供 ``_execute`` 同主机重试与跨主机故障转移复用。
"""
target = host if host is not None else self._host
if host is not None:
self._host = host
self._conn.close()
self._conn = ExTdxConnection(target, self._port, self._timeout)
self._conn.connect()
def _execute(self, cmd: "BaseCommand[_T]") -> _T:
"""执行命令;断线时指数退避重试(4 次,与 A 股/MAC 统一,审计 #2)。"""
"""执行命令;断线时指数退避重试,同主机耗尽则跨主机故障转移。
两阶段韧性与 A 股/MAC 统一(审计 #2):先同主机重试 4 次,再跨主机
故障转移(测速切到另一台扩展行情服务器)。
"""
try:
return self._conn.execute(cmd)
except TdxConnectionError:
@@ -130,9 +152,22 @@ class ExTdxClient:
last_exc: TdxConnectionError | None = None
for delay in _RETRY_DELAYS:
time.sleep(delay)
self._conn.close()
self._conn = ExTdxConnection(self._host, self._port, self._timeout)
self._conn.connect()
self._reconnect()
try:
return self._conn.execute(cmd)
except TdxConnectionError as e:
last_exc = e
# 第二阶段:跨主机故障转移
new_host = select_best_host_sync(
get_ex_hosts(),
ping_ex_all,
save_best_ex_host,
self._port,
5.0,
self._host,
)
if new_host is not None:
self._reconnect(new_host)
try:
return self._conn.execute(cmd)
except TdxConnectionError as e:
@@ -350,8 +385,22 @@ class AsyncExTdxClient(AsyncHeartbeatMixin):
"""心跳使用的轻量请求(get_instrument_count,复用 _execute 重连)。"""
return self.get_instrument_count()
async def _areconnect(self, host: str | None = None) -> None:
"""关闭当前连接并重建(默认连 self._host;传入 host 则切换主机)。
供 ``_execute`` 同主机重试与跨主机故障转移复用。
"""
target = host if host is not None else self._host
if host is not None:
self._host = host
await self._stop_heartbeat()
await self._conn.close()
self._conn = AsyncExTdxConnection(target, self._port, self._timeout)
await self._conn.connect()
self._start_heartbeat()
async def _execute(self, cmd: "BaseCommand[_T]") -> _T:
"""执行命令;断线时指数退避重试(4 次,与 A 股/MAC 统一,审计 #2)"""
"""执行命令;断线时指数退避重试,同主机耗尽则跨主机故障转移"""
async with self._execute_lock:
try:
return await self._conn.execute(cmd)
@@ -361,9 +410,22 @@ class AsyncExTdxClient(AsyncHeartbeatMixin):
last_exc: TdxConnectionError | None = None
for delay in _RETRY_DELAYS:
await asyncio.sleep(delay)
await self._conn.close()
self._conn = AsyncExTdxConnection(self._host, self._port, self._timeout)
await self._conn.connect()
await self._areconnect()
try:
return await self._conn.execute(cmd)
except TdxConnectionError as e:
last_exc = e
# 第二阶段:跨主机故障转移
new_host = await select_best_host_async(
get_ex_hosts(),
ping_ex_all,
save_best_ex_host,
self._port,
5.0,
self._host,
)
if new_host is not None:
await self._areconnect(new_host)
try:
return await self._conn.execute(cmd)
except TdxConnectionError as e:
+52 -4
View File
@@ -15,7 +15,12 @@ from typing import Any, TypeVar
import pandas as pd
from .._df import _to_df
from .._reconnect import _RETRY_DELAYS, AsyncHeartbeatMixin
from .._reconnect import (
_RETRY_DELAYS,
AsyncHeartbeatMixin,
select_best_host_async,
select_best_host_sync,
)
from ..commands.base import BaseCommand
from ..config import get_best_mac_ex_host, get_mac_ex_hosts, save_best_mac_ex_host
from ..exceptions import TdxConnectionError
@@ -148,11 +153,12 @@ class MacExClient:
self._conn.execute(MacExLoginCmd())
def _execute(self, cmd: "BaseCommand[_T]") -> _T:
"""执行命令;断线时指数退避重试(4 次,与 A 股/MAC 统一,审计 #2)
"""执行命令;断线时指数退避重试,同主机耗尽则跨主机故障转移
每次重连后必须重新 ``_login()``(MAC 协议扩展行情特有)。登录握手期的
``TdxConnectionError`` 与业务请求一样计入退避重试;``TdxCommandError``
(登录被拒等确定性失败)不重试,直接抛出。
(登录被拒等确定性失败)不重试,直接抛出。跨主机故障转移阶段同样遵循
``connect + login`` 纳入重试的语义。
"""
try:
return self._conn.execute(cmd)
@@ -173,6 +179,27 @@ class MacExClient:
return self._conn.execute(cmd)
except TdxConnectionError as e:
last_exc = e
# 第二阶段:跨主机故障转移——测速切到另一台 MAC 扩展行情服务器
new_host = select_best_host_sync(
get_mac_ex_hosts(),
ping_ex_all,
save_best_mac_ex_host,
self._port,
5.0,
self._host,
)
if new_host is not None:
self._host = new_host
self._conn.close()
self._conn = ExTdxConnection(
self._host, self._port, self._timeout, mac_ex_mode=True
)
try:
self._conn.connect()
self._login()
return self._conn.execute(cmd)
except TdxConnectionError as e:
last_exc = e
raise last_exc # type: ignore[misc]
# ------------------------------------------------------------------ #
@@ -585,7 +612,7 @@ class AsyncMacExClient(AsyncHeartbeatMixin):
await self._conn.execute(MacExLoginCmd())
async def _execute(self, cmd: "BaseCommand[_T]") -> _T:
"""执行命令;断线时指数退避重试(4 次,与 A 股/MAC 统一,审计 #2)
"""执行命令;断线时指数退避重试,同主机耗尽则跨主机故障转移
每次重连后必须重新 ``_login()``(MAC 协议扩展行情特有)。登录握手期的
``TdxConnectionError`` 与业务请求一样计入退避重试;``TdxCommandError``
@@ -611,6 +638,27 @@ class AsyncMacExClient(AsyncHeartbeatMixin):
return await self._conn.execute(cmd)
except TdxConnectionError as e:
last_exc = e
# 第二阶段:跨主机故障转移
new_host = await select_best_host_async(
get_mac_ex_hosts(),
ping_ex_all,
save_best_mac_ex_host,
self._port,
5.0,
self._host,
)
if new_host is not None:
self._host = new_host
await self._conn.close()
self._conn = AsyncExTdxConnection(
self._host, self._port, self._timeout, mac_ex_mode=True
)
try:
await self._conn.connect()
await self._login()
return await self._conn.execute(cmd)
except TdxConnectionError as e:
last_exc = e
raise last_exc # type: ignore[misc]
# ------------------------------------------------------------------ #
+82 -23
View File
@@ -13,7 +13,12 @@ from typing import Any, TypeVar
import pandas as pd
from .._df import _apply_bar_time_align_df, _period_to_minutes, _to_df
from .._reconnect import _RETRY_DELAYS, AsyncHeartbeatMixin
from .._reconnect import (
_RETRY_DELAYS,
AsyncHeartbeatMixin,
select_best_host_async,
select_best_host_sync,
)
from ..codec.bitmap import Fields, PresetField
from ..commands.base import BaseCommand
from ..config import (
@@ -224,12 +229,23 @@ class MacClient:
try:
self._execute(KlineOffsetCmd(0, 1))
except TdxConnectionError:
self._conn.stop_heartbeat()
self._conn.close()
self._conn = TdxConnection(self._host, self._port, self._timeout)
self._conn.connect()
if self._heartbeat_interval > 0:
self._conn.start_heartbeat(self._heartbeat_interval)
self._reconnect()
def _reconnect(self, host: str | None = None) -> None:
"""关闭当前连接并重建(默认连 self._host;传入 host 则切换主机)。
与 TdxClient._reconnect 对称:统一收敛 ``_execute`` 同主机重试、
跨主机故障转移、``ensure_connected`` 的重建副本。
"""
target = host if host is not None else self._host
if host is not None:
self._host = host
self._conn.stop_heartbeat()
self._conn.close()
self._conn = TdxConnection(target, self._port, self._timeout)
self._conn.connect()
if self._heartbeat_interval > 0:
self._conn.start_heartbeat(self._heartbeat_interval)
def __enter__(self) -> MacClient:
self.connect()
@@ -244,11 +260,15 @@ class MacClient:
self.close()
# ------------------------------------------------------------------ #
# 内部执行:含自动重连
# 内部执行:含自动重连 + 跨主机故障转移
# ------------------------------------------------------------------ #
def _execute(self, cmd: BaseCommand[_T]) -> _T:
"""执行命令;断线时指数退避重试"""
"""执行命令;断线时指数退避重试,同主机耗尽则跨主机故障转移。
两阶段韧性与 TdxClient 对称:先同主机重试(``_RETRY_DELAYS``),
再跨主机故障转移(重新测速切到另一台 MAC 服务器)。
"""
try:
return self._conn.execute(cmd)
except TdxConnectionError:
@@ -257,11 +277,24 @@ class MacClient:
last_exc: TdxConnectionError | None = None
for delay in _RETRY_DELAYS:
time.sleep(delay)
self._conn.close()
self._conn = TdxConnection(self._host, self._port, self._timeout)
self._conn.connect()
if self._heartbeat_interval > 0:
self._conn.start_heartbeat(self._heartbeat_interval)
self._reconnect()
try:
return self._conn.execute(cmd)
except TdxConnectionError as e:
last_exc = e
# 第二阶段:跨主机故障转移——测速切到另一台 MAC 服务器再试一次。
# save_best_mac_host(而非 save_best_host):MAC 服务器写入独立的
# best_mac_host 配置项,不污染标准 best_host(v1.19.4 修复的回归)。
new_host = select_best_host_sync(
get_mac_hosts(),
ping_mac_all,
save_best_mac_host,
self._port,
5.0,
self._host,
)
if new_host is not None:
self._reconnect(new_host)
try:
return self._conn.execute(cmd)
except TdxConnectionError as e:
@@ -1234,11 +1267,22 @@ class AsyncMacClient(AsyncHeartbeatMixin):
try:
await self._execute(KlineOffsetCmd(0, 1))
except TdxConnectionError:
await self._stop_heartbeat()
await self._conn.close()
self._conn = AsyncTdxConnection(self._host, self._port, self._timeout)
await self._conn.connect()
self._start_heartbeat()
await self._areconnect()
async def _areconnect(self, host: str | None = None) -> None:
"""关闭当前连接并重建(默认连 self._host;传入 host 则切换主机)。
与 AsyncTdxClient._areconnect 对称。统一收敛 ``_execute`` 同主机重试、
跨主机故障转移、``ensure_connected`` 的重建副本。
"""
target = host if host is not None else self._host
if host is not None:
self._host = host
await self._stop_heartbeat()
await self._conn.close()
self._conn = AsyncTdxConnection(target, self._port, self._timeout)
await self._conn.connect()
self._start_heartbeat()
async def __aenter__(self) -> AsyncMacClient:
await self.connect()
@@ -1265,7 +1309,7 @@ class AsyncMacClient(AsyncHeartbeatMixin):
# ------------------------------------------------------------------ #
async def _execute(self, cmd: BaseCommand[_T]) -> _T:
"""执行命令;断线时指数退避重试。"""
"""执行命令;断线时指数退避重试,同主机耗尽则跨主机故障转移"""
async with self._execute_lock:
try:
return await self._conn.execute(cmd)
@@ -1275,9 +1319,24 @@ class AsyncMacClient(AsyncHeartbeatMixin):
last_exc: TdxConnectionError | None = None
for delay in _RETRY_DELAYS:
await asyncio.sleep(delay)
await self._conn.close()
self._conn = AsyncTdxConnection(self._host, self._port, self._timeout)
await self._conn.connect()
await self._areconnect()
try:
return await self._conn.execute(cmd)
except TdxConnectionError as e:
last_exc = e
# 第二阶段:跨主机故障转移
# save_best_mac_host:写入独立配置项,不污染标准 best_host
# v1.19.4 修复的回归:MAC failover 不可用 save_best_host
new_host = await select_best_host_async(
get_mac_hosts(),
ping_mac_all,
save_best_mac_host,
self._port,
5.0,
self._host,
)
if new_host is not None:
await self._areconnect(new_host)
try:
return await self._conn.execute(cmd)
except TdxConnectionError as e: