fix(qq): add exponential backoff to WebSocket reconnect loop

The QQ channel's _run_bot() used a fixed 5-second reconnect interval with
no backoff. When the network is unavailable (e.g., DNS failure), this
produces excessive botpy SDK error tracebacks every 5 seconds, flooding
logs.

botpy's Client.bot_connect() catches ws_connect() exceptions internally
and calls BotWebSocket.on_error(), which logs a full traceback and
immediately re-queues the session. The outer _run_bot() except never
fires for the reported DNS failure path.

Override bot_connect() on the _Bot subclass to:
- Apply exponential backoff (5s -> 300s cap) before re-queuing the session
- Log network errors (ClientConnectorDNSError, ClientConnectorError,
  OSError) compactly without traceback
- Reset backoff on successful connection
- Still call traceback.print_exc() for non-network errors

The outer _run_bot() loop retains exponential backoff as a fallback for
exceptions that escape start() entirely. The botpy library logging
redirect is elevated to ERROR to suppress redundant connection tracebacks.

Consistent with patterns already used in matrix.py and napcat.py.

Add 7 regression tests covering:
- DNS error applies backoff and re-queues session
- No traceback printed for network errors
- ClientConnectorError also triggers backoff
- Backoff doubles and caps at 300s
- Successful connection resets backoff
- Non-network errors still re-queue without backoff
- _is_network_error() classification

Fixes #4767
This commit is contained in:
gola
2026-07-20 23:24:03 +08:00
committed by chengyongru
parent 9d830fb6b6
commit ab0d28103b
2 changed files with 368 additions and 5 deletions
+279
View File
@@ -0,0 +1,279 @@
"""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"))