自动重登修两处:并发去重读错缓存、只读订单查询掉登录被静默吞掉
- try_relogin 的并发去重原本读 status().logged_in,但重登成功后的 reload() 会把它重置成 None,排队在 site 锁上的调用方一律判「还没人登上」,N 个并发 调用会串行触发 N 次真实登录(各自最长 relogin_timeout)。改用 _relogin_epochs 计数:等锁期间 epoch 变过就真探测一次,已登录即跳过;仍未登录说明这轮站点侧 就是登不上(验证码/密码错/风控),直接失败,不在同一波并发里重复触发。 原并发测试的桩自相矛盾(login_one 返回成功、探针页始终回未登录),断言只能 松到 count >= 1;桩改为登录成功时翻转探针页,断言收紧到 count == 1。 - check_order_status / list_recent_orders 执行中掉登录此前会被静默吞掉:订单页 被踢到 SSO 后既不报错也没订单号,_parse_order_status 返回 found=False 被 _monitor_order 当成「订单还没反映出来」继续轮询(默认 3 小时一轮), _parse_order_list 则退化成空列表让 verify_on_site 转 unknown 卡住等人工。 新增 SiteInteractor._read_with_relogin_retry 外壳:只读操作中途判定掉登录时 重登一次并整个重跑,第二次仍失败抛 NotLoggedInError。只给读操作用——写操作 中途掉登录不能重跑(上次动作可能已在站点侧生效),这条边界在两边文档里写明。 - 判据是新增的 auth_site.looks_logged_out:与探针页上权威的 is_logged_in 分开, 它是业务页上的单边启发式(返回 False 不代表登录着),只用于「判错最多多花一次 重登」的重试决策。刻意排除 session/upgrade——那是已登录时的站点风控复核密码, 不是 cookie 过期,误判会把风控当掉登录去重登。 判据里「掉登录会跳到 SSO 域」这一步没有真实探测证据(要复现得先让一份真实登录态 过期),是按站点通行行为的推断,已在常量注释标注;新增测试用替身页面,不是真实 站点 HTML。399 测试全绿(仓库未配 ruff/flake8/mypy,只跑了 pytest)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -330,10 +330,16 @@ async def test_try_relogin_is_disabled_when_flag_off(tmp_path):
|
||||
|
||||
|
||||
async def test_try_relogin_serializes_concurrent_calls_same_site(tmp_path, monkeypatch):
|
||||
"""同 site 并发触发只跑一次 login_one:靠 site 级锁串行化"""
|
||||
"""同 site 并发触发**只跑一次** login_one:锁串行化 + 排队者复核登录态后跳过
|
||||
|
||||
不能只断言「没并发执行」:重登成功后的 reload() 会把 logged_in 重置成 None,
|
||||
排队者如果读缓存去重就会一律判「还没人登上」,于是 N 个并发调用串行触发 N 次
|
||||
真实登录(每次最长 relogin_timeout)。这里断言 count == 1 就是钉住这一点。
|
||||
"""
|
||||
_patch_accounts_file(monkeypatch, tmp_path)
|
||||
|
||||
invocations = {"count": 0, "in_flight_max": 0, "current": 0}
|
||||
state = {"logged_in": False}
|
||||
from app.trading.services import login_runner
|
||||
|
||||
async def counting_login_one(account, s, *, timeout_seconds=300, progress=None):
|
||||
@@ -348,12 +354,19 @@ async def test_try_relogin_serializes_concurrent_calls_same_site(tmp_path, monke
|
||||
"rakuten",
|
||||
[{"name": "X", "value": "1", "domain": ".rakuten.co.jp", "path": "/"}],
|
||||
)
|
||||
# 登录成功后探针页也必须随之翻转,否则桩自相矛盾(login_one 说成功、站点说
|
||||
# 没登上),测不出真实行为
|
||||
state["logged_in"] = True
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(login_runner, "login_one", counting_login_one)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
text = CART_LOGGED_IN if state["logged_in"] else CART_LOGGED_OUT
|
||||
return httpx.Response(200, text=text)
|
||||
|
||||
settings = make_settings(tmp_path, relogin_enabled=True, relogin_timeout_seconds=10)
|
||||
session = await build_session(settings, lambda r: httpx.Response(200, text=CART_LOGGED_OUT))
|
||||
session = await build_session(settings, handler)
|
||||
|
||||
try:
|
||||
# 第一次状态为 logged_in=None,三个并发都进入 try_relogin
|
||||
@@ -362,12 +375,43 @@ async def test_try_relogin_serializes_concurrent_calls_same_site(tmp_path, monke
|
||||
session.try_relogin("rakuten"),
|
||||
session.try_relogin("rakuten"),
|
||||
)
|
||||
# login_one 至少被调一次(串行下后续可能因 status 已 logged_in 跳过)
|
||||
assert invocations["count"] >= 1
|
||||
# 关键:login_one 永远没并发执行
|
||||
assert invocations["count"] == 1
|
||||
# login_one 永远没并发执行(同账号同 user_data_dir,撞锁会失败)
|
||||
assert invocations["in_flight_max"] == 1
|
||||
# 结果都成功(要么真重登,要么拿到锁后发现已 logged_in)
|
||||
assert all(results)
|
||||
# 三个调用方都拿到「已登录」结论:一个真登录,两个复核后跳过
|
||||
assert results == [True, True, True]
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def test_try_relogin_does_not_retry_after_queued_failure(tmp_path, monkeypatch):
|
||||
"""一次重登失败后,排在锁上的并发调用不再重复触发同一个注定失败的登录
|
||||
|
||||
站点侧登不上(验证码/密码错/风控)时,把每个调用方各卡一个 relogin_timeout
|
||||
不会改变结果,只会让整批任务慢几倍。
|
||||
"""
|
||||
_patch_accounts_file(monkeypatch, tmp_path)
|
||||
|
||||
calls = {"count": 0}
|
||||
from app.trading.services import login_runner
|
||||
|
||||
async def failing_login_one(account, s, *, timeout_seconds=300, progress=None):
|
||||
calls["count"] += 1
|
||||
await asyncio.sleep(0.05) # 让其余并发调用叠到锁上
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(login_runner, "login_one", failing_login_one)
|
||||
|
||||
settings = make_settings(tmp_path, relogin_enabled=True, relogin_timeout_seconds=10)
|
||||
session = await build_session(settings, lambda r: httpx.Response(200, text=CART_LOGGED_OUT))
|
||||
try:
|
||||
results = await asyncio.gather(
|
||||
session.try_relogin("rakuten"),
|
||||
session.try_relogin("rakuten"),
|
||||
session.try_relogin("rakuten"),
|
||||
)
|
||||
assert results == [False, False, False]
|
||||
assert calls["count"] == 1
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user