280 lines
9.2 KiB
Python
280 lines
9.2 KiB
Python
"""Regression tests for QQ channel WebSocket reconnect backoff.
|
|||
|
|
|
||
|
|
Tests that the overridden ``bot_connect()`` applies exponential backoff and
|
||
|
|
compact error logging when ``BotWebSocket.ws_connect`` raises a DNS/network
|
||
|
|
error, instead of the original behavior of immediately re-queuing the session
|
||
|
|
and dumping a full traceback.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
|
|
||
|
|
import aiohttp
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
# Skip all tests if botpy is not installed
|
||
|
|
pytest.importorskip("botpy")
|
||
|
|
|
||
|
|
|
||
|
|
def _make_channel():
|
||
|
|
"""Create a minimal QQChannel instance for testing."""
|
||
|
|
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):
|
||
|
|
"""Create the _Bot class and instantiate it with mocked internals."""
|
||
|
|
from nanobot.channels.qq.runtime import _make_bot_class
|
||
|
|
|
||
|
|
bot_cls = _make_bot_class(channel)
|
||
|
|
bot = bot_cls.__new__(bot_cls)
|
||
|
|
|
||
|
|
# Mock the attributes that bot_connect() needs
|
||
|
|
bot._connection = MagicMock()
|
||
|
|
bot._connection.add = MagicMock()
|
||
|
|
bot._ws_backoff = {} # per-session dict
|
||
|
|
return bot
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_bot_connect_dns_error_applies_backoff():
|
||
|
|
"""bot_connect() should sleep with exponential backoff on DNS errors."""
|
||
|
|
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()) as mock_sleep,
|
||
|
|
):
|
||
|
|
mock_client = MagicMock()
|
||
|
|
mock_client.ws_connect = AsyncMock(side_effect=dns_error)
|
||
|
|
mock_client._conn = None
|
||
|
|
mock_ws_cls.return_value = mock_client
|
||
|
|
|
||
|
|
session = {"session_id": "", "url": "wss://example.com/ws"}
|
||
|
|
await bot.bot_connect(session)
|
||
|
|
|
||
|
|
# Should have slept (backoff applied)
|
||
|
|
mock_sleep.assert_awaited_once_with(5)
|
||
|
|
|
||
|
|
# Backoff should have doubled for this session
|
||
|
|
assert bot._ws_backoff[id(session)] == 10
|
||
|
|
|
||
|
|
# Session should have been re-queued
|
||
|
|
bot._connection.add.assert_called_once_with(session)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_bot_connect_dns_error_no_traceback(capsys):
|
||
|
|
"""bot_connect() should NOT print traceback for DNS/network errors."""
|
||
|
|
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_client._conn = None
|
||
|
|
mock_ws_cls.return_value = mock_client
|
||
|
|
|
||
|
|
session = {"session_id": "", "url": "wss://example.com/ws"}
|
||
|
|
await bot.bot_connect(session)
|
||
|
|
|
||
|
|
# Capture stdout/stderr — should NOT contain traceback
|
||
|
|
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():
|
||
|
|
"""bot_connect() should apply backoff for ClientConnectorError too."""
|
||
|
|
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_client._conn = None
|
||
|
|
mock_ws_cls.return_value = mock_client
|
||
|
|
|
||
|
|
session = {"session_id": "", "url": "wss://example.com/ws"}
|
||
|
|
await bot.bot_connect(session)
|
||
|
|
|
||
|
|
mock_sleep.assert_awaited_once_with(5)
|
||
|
|
assert bot._ws_backoff[id(session)] == 10
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_bot_connect_backoff_doubles_and_caps():
|
||
|
|
"""Backoff should double on each failure and cap at max_backoff."""
|
||
|
|
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_client._conn = None
|
||
|
|
mock_ws_cls.return_value = mock_client
|
||
|
|
|
||
|
|
session = {"session_id": "", "url": "wss://example.com/ws"}
|
||
|
|
|
||
|
|
# Simulate multiple failures with the same session
|
||
|
|
for expected_backoff in [5, 10, 20, 40, 80, 160, 300, 300]:
|
||
|
|
bot._ws_backoff[id(session)] = expected_backoff
|
||
|
|
await bot.bot_connect(session)
|
||
|
|
# After this call, backoff should have doubled (capped at max)
|
||
|
|
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():
|
||
|
|
"""bot_connect() should reset backoff on successful connection."""
|
||
|
|
channel = _make_channel()
|
||
|
|
bot = _make_bot(channel)
|
||
|
|
session = {"session_id": "", "url": "wss://example.com/ws"}
|
||
|
|
bot._ws_backoff[id(session)] = 80 # was backing off
|
||
|
|
|
||
|
|
with patch("nanobot.channels.qq.runtime.BotWebSocket") as mock_ws_cls:
|
||
|
|
mock_client = MagicMock()
|
||
|
|
mock_client.ws_connect = AsyncMock() # succeeds
|
||
|
|
mock_client._conn = MagicMock()
|
||
|
|
mock_ws_cls.return_value = mock_client
|
||
|
|
|
||
|
|
await bot.bot_connect(session)
|
||
|
|
|
||
|
|
assert id(session) not in bot._ws_backoff # per-session backoff cleared
|
||
|
|
# Session should NOT be re-queued on success
|
||
|
|
bot._connection.add.assert_not_called()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_bot_connect_non_network_error_still_requeues():
|
||
|
|
"""bot_connect() should still re-queue session for non-network errors."""
|
||
|
|
channel = _make_channel()
|
||
|
|
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_client._conn = None
|
||
|
|
mock_ws_cls.return_value = mock_client
|
||
|
|
|
||
|
|
session = {"session_id": "", "url": "wss://example.com/ws"}
|
||
|
|
await bot.bot_connect(session)
|
||
|
|
|
||
|
|
# No backoff sleep for non-network errors
|
||
|
|
mock_sleep.assert_not_awaited()
|
||
|
|
|
||
|
|
# Session still re-queued
|
||
|
|
bot._connection.add.assert_called_once_with(session)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_bot_connect_per_session_backoff_isolated():
|
||
|
|
"""Backoff should be tracked per-session, not shared across sessions."""
|
||
|
|
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_client._conn = None
|
||
|
|
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"}
|
||
|
|
|
||
|
|
# Fail session A once -> backoff 5 -> 10
|
||
|
|
await bot.bot_connect(session_a)
|
||
|
|
assert bot._ws_backoff[id(session_a)] == 10
|
||
|
|
|
||
|
|
# Fail session B once -> backoff should start at 5, not 10
|
||
|
|
await bot.bot_connect(session_b)
|
||
|
|
assert bot._ws_backoff[id(session_b)] == 10 # 5 * 2
|
||
|
|
|
||
|
|
# Fail session A again -> should be 20 (10 * 2), not affected by B
|
||
|
|
await bot.bot_connect(session_a)
|
||
|
|
assert bot._ws_backoff[id(session_a)] == 20
|
||
|
|
|
||
|
|
# Session B still at 10, independent
|
||
|
|
assert bot._ws_backoff[id(session_b)] == 10
|
||
|
|
|
||
|
|
def test_is_network_error_classification():
|
||
|
|
"""_is_network_error correctly classifies error types."""
|
||
|
|
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"))
|