From 95bdf8a27dda62ba059813181f16d6e9dca42ef0 Mon Sep 17 00:00:00 2001 From: claude_dev Date: Thu, 20 Aug 2026 08:49:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(data):=20bs=5Feod=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E9=87=8D=E8=AF=95=E2=80=94=E2=80=94=E6=B2=BB?= =?UTF-8?q?08-18=E5=9E=8B=E7=9E=AC=E6=96=AD=E6=95=B4=E5=A4=A9=E7=BC=BA?= =?UTF-8?q?=E5=8F=A3=E2=80=94=E2=80=94main()=E7=99=BB=E5=BD=95=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E4=B8=8D=E5=86=8D=E5=BD=93=E5=9C=BAexit2,=E6=94=B9log?= =?UTF-8?q?in=5Fwith=5Fretry=E4=B8=B2=E8=A1=8C=E9=87=8D=E8=AF=95(1+2?= =?UTF-8?q?=E8=BD=AE,=E9=97=B4=E9=9A=94120s/300s,=E6=97=A0=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E4=B8=8D=E8=A7=A6=E5=B0=81=E7=A6=81=E7=BA=A2=E7=BA=BF?= =?UTF-8?q?;=E7=94=A8=E6=88=B7=E6=8B=8D=E6=9D=BF2026-08-20:=E4=BC=98?= =?UTF-8?q?=E5=85=88=E8=87=AA=E8=BA=AB=E5=81=A5=E5=A3=AE=E6=80=A7,?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B=E6=97=A0=E9=A2=91=E7=B9=81=E9=87=8D=E8=BF=9E?= =?UTF-8?q?/=E8=B6=8548k=E9=85=8D=E9=A2=9D);=E7=9E=AC=E6=97=B6=E9=97=AA?= =?UTF-8?q?=E6=96=AD(10054/10002007)=E7=A7=92~=E5=88=86=E9=92=9F=E7=BA=A7?= =?UTF-8?q?=E6=81=A2=E5=A4=8D,2/5=E5=88=86=E9=92=9F=E4=B8=A4=E8=BD=AE?= =?UTF-8?q?=E8=A6=86=E7=9B=96;=E4=BB=8D=E5=A4=B1=E8=B4=A5=E6=89=8Dexit2,?= =?UTF-8?q?=E6=AC=A1=E6=97=A518:05=E4=BE=8B=E8=A1=8CLOOKBACK=3D7=E8=87=AA?= =?UTF-8?q?=E6=84=88(=E8=87=AA=E6=84=88=E6=9C=BA=E5=88=B6=E6=9C=AC?= =?UTF-8?q?=E5=B0=B1=E5=AE=8C=E5=A5=BD,=E9=87=8D=E8=AF=95=E5=8F=AA?= =?UTF-8?q?=E6=98=AF=E6=8A=8A'=E5=BD=93=E6=99=9A=E7=9E=AC=E6=96=AD'?= =?UTF-8?q?=E6=B6=88=E6=8E=89);+4=E6=B5=8B=E8=AF=95(=E9=A6=96=E8=AF=95?= =?UTF-8?q?=E6=88=90=E5=8A=9F=E4=B8=8D=E7=AD=89=E5=BE=85/=E7=AC=AC2?= =?UTF-8?q?=E6=AC=A1=E6=81=A2=E5=A4=8D/3=E6=AC=A1=E5=85=A8=E8=B4=A5False/?= =?UTF-8?q?=E9=9B=86=E6=88=90exit2=E5=A5=91=E7=BA=A6=E4=BF=9D=E6=8C=81);da?= =?UTF-8?q?ta=5Fplatform=20116=E7=BB=BF=20[vps]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/data_platform/bs_eod.py | 25 +++++++- tests/data_platform/test_bs_eod_resilience.py | 57 +++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/scripts/data_platform/bs_eod.py b/scripts/data_platform/bs_eod.py index ae8bcf6..0defa7c 100644 --- a/scripts/data_platform/bs_eod.py +++ b/scripts/data_platform/bs_eod.py @@ -82,6 +82,27 @@ def relogin(): return login_once() +# 启动登录重试间隔(秒): 1 + 2 轮重试, 共 3 次尝试。 +# 2026-08-20 用户拍板: 优先自身健壮性(实测无频繁重连/超 48k 配额问题, 封禁风险低)。 +# 08-18 实录: 18:05:06 登录被 10054 掐断即 graceful exit, 整天缺口裸奔到次日; +# 瞬时闪断秒~分钟级恢复, 2/5 分钟两轮串行重试(无并发)覆盖; 仍失败(真服务故障) +# 才放弃 exit 2, 次日 LOOKBACK=7 自愈。 +LOGIN_RETRY_DELAYS = (120, 300) + + +def login_with_retry(): + """启动登录: 失败按 LOGIN_RETRY_DELAYS 串行重试(治瞬时闪断), 全败返 False。""" + if login_once(): + return True + total = len(LOGIN_RETRY_DELAYS) + 1 + for i, delay in enumerate(LOGIN_RETRY_DELAYS, start=2): + log.warning("登录失败, 第 %d/%d 次重试(等待 %ds)", i, total, delay) + time.sleep(delay) + if login_once(): + return True + return False + + def fetch_all_stocks(): """query_stock_basic() 无参 -> 全 A 含退市 (type=1)。返回 [(code, 'sh'/'sz')]。""" global QUERY_COUNT @@ -281,8 +302,8 @@ def main(): start = (today - dt.timedelta(days=LOOKBACK)).strftime("%Y-%m-%d") log.info("bs_eod start window=%s~%s LOOKBACK=%d limit=%s", start, end, LOOKBACK, args.limit or "无") - if not login_once(): - log.error("[SKIP] baostock 黑名单/冷却, graceful exit 2") + if not login_with_retry(): + log.error("[SKIP] 重试后仍登录失败(baostock 冷却/服务故障), graceful exit 2") sys.exit(2) try: diff --git a/tests/data_platform/test_bs_eod_resilience.py b/tests/data_platform/test_bs_eod_resilience.py index 77e2337..6c71c26 100644 --- a/tests/data_platform/test_bs_eod_resilience.py +++ b/tests/data_platform/test_bs_eod_resilience.py @@ -451,3 +451,60 @@ def test_periodic_relogin_disabled_when_relogin_every_huge(isolated_main_env, mo with pytest.raises(SystemExit): bs_eod.main() assert m_rel.call_count == 0 # 5 < 10000, 没到周期 + + +# ---------- ④ 启动登录重试 (2026-08-20: 治 08-18 18:05 登录秒败整天缺口) ---------- + +def _login_result(error_code="0"): + """构造 baostock login 返回对象风格 (error_code/error_msg).""" + m = MagicMock() + m.error_code = error_code + m.error_msg = "" if error_code == "0" else "网络接收错误" + return m + + +def test_login_with_retry_first_try_ok(monkeypatch): + """首次登录成功 → 不 sleep 不重试.""" + mock_bs = MagicMock() + mock_bs.login.return_value = _login_result("0") + sleeps: list = [] + monkeypatch.setattr(bs_eod, "bs", mock_bs) + monkeypatch.setattr(bs_eod.time, "sleep", lambda s: sleeps.append(s)) + assert bs_eod.login_with_retry() is True + assert mock_bs.login.call_count == 1 + assert sleeps == [] + + +def test_login_with_retry_recovers_on_second_attempt(monkeypatch): + """瞬时闪断(08-18 实录 10054): 第 1 次失败, 第 2 次成功 → 重试覆盖, 不放弃.""" + mock_bs = MagicMock() + mock_bs.login.side_effect = [_login_result("10002007"), _login_result("0")] + sleeps: list = [] + monkeypatch.setattr(bs_eod, "bs", mock_bs) + monkeypatch.setattr(bs_eod.time, "sleep", lambda s: sleeps.append(s)) + assert bs_eod.login_with_retry() is True + assert mock_bs.login.call_count == 2 + assert sleeps == [120] # 只等了第一轮间隔 + + +def test_login_with_retry_gives_up_after_all_attempts(monkeypatch): + """持续失败(真冷却/服务故障): 3 次全败 → 返 False (main 转 exit 2, 次日自愈).""" + mock_bs = MagicMock() + mock_bs.login.return_value = _login_result("10002007") + sleeps: list = [] + monkeypatch.setattr(bs_eod, "bs", mock_bs) + monkeypatch.setattr(bs_eod.time, "sleep", lambda s: sleeps.append(s)) + assert bs_eod.login_with_retry() is False + assert mock_bs.login.call_count == 3 # 1 + 2 重试 + assert sleeps == [120, 300] + + +def test_main_exits_2_when_all_login_retries_fail(isolated_main_env, monkeypatch): + """集成: 全部重试失败 → 维持原 graceful exit 2 契约 (schtask 结果码可见).""" + mock_bs = MagicMock() + mock_bs.login.return_value = _login_result("10002007") + monkeypatch.setattr(bs_eod, "bs", mock_bs) + monkeypatch.setattr(bs_eod.time, "sleep", lambda s: None) + with pytest.raises(SystemExit) as ei: + bs_eod.main() + assert ei.value.code == 2