Files
nanobot/tests/channels/test_qq_reconnect_backoff.py
T

271 lines
8.0 KiB
Python
Raw Normal View History

2026-07-20 23:14:50 +08:00
"""Regression tests for QQ WebSocket reconnect backoff."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import aiohttp
import pytest
pytest.importorskip("botpy")
def _make_channel():
from nanobot.bus.queue import MessageBus
from nanobot.channels.qq.runtime import QQChannel, QQConfig
bus = MessageBus()
config = QQConfig(app_id="test_app", secret="test_secret")
return QQChannel(config, bus)
def _make_bot(channel):
from nanobot.channels.qq.runtime import _make_bot_class
bot_cls = _make_bot_class(channel)
bot = bot_cls.__new__(bot_cls)
bot._connection = MagicMock()
bot._connection.add = MagicMock()
2026-07-20 23:14:50 +08:00
bot._ws_backoff = {}
bot._ws_retry_at = {}
return bot
@pytest.mark.asyncio
2026-07-20 23:14:50 +08:00
async def test_bot_connect_dns_error_accounts_for_sdk_pacing():
import asyncio
from botpy.connection import ConnectionSession
channel = _make_channel()
bot = _make_bot(channel)
dns_error = aiohttp.ClientConnectorError(
connection_key=MagicMock(),
os_error=OSError("No address associated with hostname"),
)
2026-07-20 23:14:50 +08:00
clock = 0.0
attempt_times = []
async def fail_connect():
attempt_times.append(clock)
raise dns_error
async def advance_clock(delay):
nonlocal clock
clock += delay
connection = ConnectionSession(
max_async=1,
connect=bot.bot_connect,
dispatch=MagicMock(),
loop=asyncio.get_running_loop(),
)
bot._connection = connection
session = {"session_id": "", "url": "wss://example.com/ws"}
connection.add(session)
with (
2026-07-20 23:14:50 +08:00
patch("nanobot.channels.qq.runtime.BotWebSocket") as mock_ws_cls,
patch("nanobot.channels.qq.runtime.time.monotonic", side_effect=lambda: clock),
patch("asyncio.sleep", side_effect=advance_clock),
):
mock_client = MagicMock()
2026-07-20 23:14:50 +08:00
mock_client.ws_connect = AsyncMock(side_effect=fail_connect)
mock_ws_cls.return_value = mock_client
2026-07-20 23:14:50 +08:00
for _ in range(3):
await connection.multi_run(session_interval=5)
2026-07-20 23:14:50 +08:00
assert attempt_times == [0, 5, 15]
assert bot._ws_backoff[id(session)] == 40
assert connection._session_list == [session]
@pytest.mark.asyncio
async def test_bot_connect_dns_error_no_traceback(capsys):
channel = _make_channel()
bot = _make_bot(channel)
dns_error = aiohttp.ClientConnectorError(
connection_key=MagicMock(),
os_error=OSError("No address associated with hostname"),
)
with (
patch(
"nanobot.channels.qq.runtime.BotWebSocket"
) as mock_ws_cls,
patch("asyncio.sleep", new=AsyncMock()),
):
mock_client = MagicMock()
mock_client.ws_connect = AsyncMock(side_effect=dns_error)
mock_ws_cls.return_value = mock_client
session = {"session_id": "", "url": "wss://example.com/ws"}
await bot.bot_connect(session)
captured = capsys.readouterr()
assert "Traceback" not in captured.out
assert "Traceback" not in captured.err
@pytest.mark.asyncio
async def test_bot_connect_connector_error_applies_backoff():
channel = _make_channel()
bot = _make_bot(channel)
connector_error = aiohttp.ClientConnectorError(
connection_key=MagicMock(),
os_error=ConnectionRefusedError("Connection refused"),
)
with (
patch(
"nanobot.channels.qq.runtime.BotWebSocket"
) as mock_ws_cls,
patch("asyncio.sleep", new=AsyncMock()) as mock_sleep,
):
mock_client = MagicMock()
mock_client.ws_connect = AsyncMock(side_effect=connector_error)
mock_ws_cls.return_value = mock_client
session = {"session_id": "", "url": "wss://example.com/ws"}
await bot.bot_connect(session)
2026-07-20 23:14:50 +08:00
mock_sleep.assert_not_awaited()
assert bot._ws_backoff[id(session)] == 10
@pytest.mark.asyncio
async def test_bot_connect_backoff_doubles_and_caps():
from nanobot.channels.qq.runtime import _RECONNECT_BACKOFF_MAX
channel = _make_channel()
bot = _make_bot(channel)
dns_error = aiohttp.ClientConnectorError(
connection_key=MagicMock(),
os_error=OSError("DNS failure"),
)
with (
patch(
"nanobot.channels.qq.runtime.BotWebSocket"
) as mock_ws_cls,
patch("asyncio.sleep", new=AsyncMock()),
):
mock_client = MagicMock()
mock_client.ws_connect = AsyncMock(side_effect=dns_error)
mock_ws_cls.return_value = mock_client
session = {"session_id": "", "url": "wss://example.com/ws"}
for expected_backoff in [5, 10, 20, 40, 80, 160, 300, 300]:
bot._ws_backoff[id(session)] = expected_backoff
2026-07-20 23:14:50 +08:00
bot._ws_retry_at.pop(id(session), None)
await bot.bot_connect(session)
expected_next = min(expected_backoff * 2, _RECONNECT_BACKOFF_MAX)
assert bot._ws_backoff[id(session)] == expected_next
@pytest.mark.asyncio
async def test_bot_connect_success_resets_backoff():
channel = _make_channel()
bot = _make_bot(channel)
session = {"session_id": "", "url": "wss://example.com/ws"}
2026-07-20 23:14:50 +08:00
bot._ws_backoff[id(session)] = 80
bot._ws_retry_at[id(session)] = 0.0
with patch("nanobot.channels.qq.runtime.BotWebSocket") as mock_ws_cls:
mock_client = MagicMock()
2026-07-20 23:14:50 +08:00
mock_client.ws_connect = AsyncMock()
mock_ws_cls.return_value = mock_client
await bot.bot_connect(session)
2026-07-20 23:14:50 +08:00
assert id(session) not in bot._ws_backoff
assert id(session) not in bot._ws_retry_at
bot._connection.add.assert_not_called()
@pytest.mark.asyncio
async def test_bot_connect_non_network_error_still_requeues():
channel = _make_channel()
2026-07-20 23:14:50 +08:00
channel.logger = MagicMock()
bot = _make_bot(channel)
runtime_error = RuntimeError("Unexpected error")
with (
patch(
"nanobot.channels.qq.runtime.BotWebSocket"
) as mock_ws_cls,
patch("asyncio.sleep", new=AsyncMock()) as mock_sleep,
):
mock_client = MagicMock()
mock_client.ws_connect = AsyncMock(side_effect=runtime_error)
mock_ws_cls.return_value = mock_client
session = {"session_id": "", "url": "wss://example.com/ws"}
await bot.bot_connect(session)
mock_sleep.assert_not_awaited()
bot._connection.add.assert_called_once_with(session)
2026-07-20 23:14:50 +08:00
channel.logger.exception.assert_called_once_with(
"QQ bot WebSocket error: {}", runtime_error
)
@pytest.mark.asyncio
async def test_bot_connect_per_session_backoff_isolated():
channel = _make_channel()
bot = _make_bot(channel)
connector_error = aiohttp.ClientConnectorError(
connection_key=MagicMock(),
os_error=ConnectionRefusedError("Connection refused"),
)
with (
patch(
"nanobot.channels.qq.runtime.BotWebSocket"
) as mock_ws_cls,
patch("asyncio.sleep", new=AsyncMock()),
):
mock_client = MagicMock()
mock_client.ws_connect = AsyncMock(side_effect=connector_error)
mock_ws_cls.return_value = mock_client
session_a = {"session_id": "a", "url": "wss://example.com/ws"}
session_b = {"session_id": "b", "url": "wss://example.com/ws"}
await bot.bot_connect(session_a)
assert bot._ws_backoff[id(session_a)] == 10
await bot.bot_connect(session_b)
2026-07-20 23:14:50 +08:00
assert bot._ws_backoff[id(session_b)] == 10
await bot.bot_connect(session_a)
assert bot._ws_backoff[id(session_a)] == 20
assert bot._ws_backoff[id(session_b)] == 10
def test_is_network_error_classification():
from nanobot.channels.qq.runtime import _is_network_error
assert _is_network_error(
aiohttp.ClientConnectorError(
connection_key=MagicMock(),
os_error=ConnectionRefusedError(),
)
)
assert _is_network_error(OSError("generic"))
assert _is_network_error(ConnectionRefusedError())
assert not _is_network_error(RuntimeError("not network"))
assert not _is_network_error(ValueError("not network"))
assert not _is_network_error(Exception("generic"))