fix(wechat): extend QR-code status request timeout (#6783)

* fix(wechat): extend QR status polling timeout

* fix(wechat): bound QR polling request timeout

* fix(wechat): enforce QR status deadline
This commit is contained in:
OUAO-FRANK 2026-10-14 06:18:58 +08:00 committed by GitHub
parent b5bcf64741
commit c645f65b3a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 121 additions and 5 deletions

View File

@ -31,6 +31,7 @@ from app.channels.message_bus import InboundMessage, InboundMessageType, Message
from deerflow.utils.file_io import await_drained
logger = logging.getLogger(__name__)
_monotonic = time.monotonic
class MessageItemType(IntEnum):
@ -227,6 +228,9 @@ class WechatChannel(Channel):
DEFAULT_QRCODE_POLL_INTERVAL = 2.0
DEFAULT_QRCODE_POLL_TIMEOUT = 180.0
DEFAULT_QRCODE_BOT_TYPE = 3
DEFAULT_QRCODE_STATUS_TIMEOUT = 30.0
# Browser QR login allows a small cushion over the channel polling timeout.
DEFAULT_QRCODE_ADMIN_STATUS_TIMEOUT = DEFAULT_QRCODE_STATUS_TIMEOUT + 5.0
DEFAULT_API_TIMEOUT = 15.0
DEFAULT_CONFIG_TIMEOUT = 10.0
DEFAULT_CDN_TIMEOUT = 30.0
@ -836,7 +840,11 @@ class WechatChannel(Channel):
params = {"qrcode": qrcode}
if verify_code:
params["verify_code"] = verify_code
return await self._request_public_get_json("/ilink/bot/get_qrcode_status", params=params, timeout=timeout)
return await self._request_public_get_json(
"/ilink/bot/get_qrcode_status",
params=params,
timeout=self.DEFAULT_QRCODE_STATUS_TIMEOUT if timeout is None else timeout,
)
async def _save_auth_state_drained(
self,
@ -881,9 +889,19 @@ class WechatChannel(Channel):
qrcode_img_content=qrcode_img_content or None,
)
deadline = time.monotonic() + max(self._qrcode_poll_timeout, 1.0)
while time.monotonic() < deadline:
status_data = await self.request_login_status(qrcode)
deadline = _monotonic() + max(self._qrcode_poll_timeout, 1.0)
while True:
remaining = deadline - _monotonic()
if remaining <= 0:
break
try:
async with asyncio.timeout(remaining):
status_data = await self.request_login_status(
qrcode,
timeout=min(remaining, self.DEFAULT_QRCODE_STATUS_TIMEOUT),
)
except TimeoutError:
break
status = str(status_data.get("status") or "").strip().lower()
if status == "confirmed":
token = str(status_data.get("bot_token") or "").strip()

View File

@ -73,7 +73,11 @@ class WechatQRLogin:
try:
if qrcode is None:
return await channel.request_login_qrcode()
return await channel.request_login_status(qrcode, timeout=35, verify_code=verify_code)
return await channel.request_login_status(
qrcode,
timeout=WechatChannel.DEFAULT_QRCODE_ADMIN_STATUS_TIMEOUT,
verify_code=verify_code,
)
finally:
await channel.stop()

View File

@ -117,6 +117,80 @@ def test_timing_config_requires_positive_finite_values():
assert channel._retry_delay == 0.25
def test_qrcode_status_uses_dedicated_request_timeout():
from app.channels.wechat import WechatChannel
async def go():
channel = WechatChannel(bus=MessageBus(), config={"bot_token": "test-token"})
request = AsyncMock(return_value={"status": "wait"})
channel._request_public_get_json = request # type: ignore[method-assign]
await channel.request_login_status("qr-token")
request.assert_awaited_once_with(
"/ilink/bot/get_qrcode_status",
params={"qrcode": "qr-token"},
timeout=WechatChannel.DEFAULT_QRCODE_STATUS_TIMEOUT,
)
_run(go())
def test_qrcode_poll_request_timeout_is_bounded_by_remaining_deadline(monkeypatch):
from app.channels import wechat as wechat_module
from app.channels.wechat import WechatChannel
async def go():
channel = WechatChannel(
bus=MessageBus(),
config={"bot_token": "test-token", "qrcode_poll_timeout": 10},
)
channel.request_login_qrcode = AsyncMock(return_value={"qrcode": "qr-token"}) # type: ignore[method-assign]
channel.request_login_status = AsyncMock( # type: ignore[method-assign]
return_value={"status": "confirmed", "bot_token": "bound-token"}
)
channel._save_auth_state_drained = AsyncMock(return_value={"status": "confirmed"}) # type: ignore[method-assign]
monotonic_values = iter([0.0, 9.0])
monkeypatch.setattr(wechat_module, "_monotonic", lambda: next(monotonic_values))
result = await channel._bind_via_qrcode()
assert result == {"status": "confirmed"}
channel.request_login_status.assert_awaited_once_with("qr-token", timeout=1.0)
_run(go())
def test_qrcode_poll_request_is_cancelled_at_total_deadline(monkeypatch):
from app.channels import wechat as wechat_module
from app.channels.wechat import WechatChannel
async def go():
channel = WechatChannel(
bus=MessageBus(),
config={"bot_token": "test-token", "qrcode_poll_timeout": 1},
)
channel.request_login_qrcode = AsyncMock(return_value={"qrcode": "qr-token"}) # type: ignore[method-assign]
async def request_status(_qrcode, **_kwargs):
await asyncio.sleep(10)
channel.request_login_status = request_status # type: ignore[method-assign]
save_auth_state = AsyncMock(return_value={"status": "timeout"})
channel._save_auth_state_drained = save_auth_state # type: ignore[method-assign]
monotonic_values = iter([0.0, 0.0])
monkeypatch.setattr(wechat_module, "_monotonic", lambda: next(monotonic_values))
with pytest.raises(TimeoutError):
await asyncio.wait_for(channel._bind_via_qrcode(), timeout=2)
assert save_auth_state.await_args_list[-1].kwargs["status"] == "timeout"
_run(go())
def test_handle_update_publishes_private_chat_message():
from app.channels.wechat import WechatChannel

View File

@ -137,6 +137,26 @@ async def test_browser_transport_closes_http_client_without_starting_channel(mon
start.assert_not_awaited()
@pytest.mark.asyncio
async def test_browser_status_request_uses_channel_qrcode_timeout(monkeypatch):
from app.channels.wechat import WechatChannel
assert WechatChannel.DEFAULT_QRCODE_ADMIN_STATUS_TIMEOUT == 35.0
request = AsyncMock(return_value={"status": "wait"})
stop = AsyncMock()
monkeypatch.setattr(WechatChannel, "request_login_status", request)
monkeypatch.setattr(WechatChannel, "stop", stop)
await WechatQRLogin().request({}, "qr-token")
request.assert_awaited_once_with(
"qr-token",
timeout=WechatChannel.DEFAULT_QRCODE_ADMIN_STATUS_TIMEOUT,
verify_code=None,
)
stop.assert_awaited_once()
@pytest.mark.asyncio
async def test_concurrent_polls_only_apply_once(login):
session = await login.start("alice", {})