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:
@@ -48,12 +48,14 @@ except Exception: # pragma: no cover
|
||||
|
||||
try:
|
||||
import botpy
|
||||
from botpy.gateway import BotWebSocket
|
||||
from botpy.http import Route
|
||||
|
||||
QQ_AVAILABLE = True
|
||||
except ImportError: # pragma: no cover
|
||||
QQ_AVAILABLE = False
|
||||
botpy = None
|
||||
BotWebSocket = None
|
||||
Route = None
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -104,14 +106,39 @@ def _guess_send_file_type(filename: str) -> int:
|
||||
return QQ_FILE_TYPE_FILE
|
||||
|
||||
|
||||
# Exponential backoff for WebSocket reconnect inside bot_connect.
|
||||
_RECONNECT_BACKOFF_START = 5
|
||||
_RECONNECT_BACKOFF_MAX = 300 # 5 minutes cap
|
||||
|
||||
|
||||
def _is_network_error(exc: BaseException) -> bool:
|
||||
"""Check whether an exception is a transient network/DNS error."""
|
||||
return isinstance(
|
||||
exc,
|
||||
(aiohttp.ClientConnectorError, OSError),
|
||||
)
|
||||
|
||||
|
||||
def _make_bot_class(channel: QQChannel) -> type[botpy.Client]:
|
||||
"""Create a botpy Client subclass bound to the given channel."""
|
||||
"""Create a botpy Client subclass bound to the given channel.
|
||||
|
||||
The base ``botpy.Client.bot_connect()`` catches ``ws_connect()`` exceptions
|
||||
internally and calls ``BotWebSocket.on_error()``, which logs a full
|
||||
traceback via ``_log.error()`` + ``traceback.print_exc()`` and immediately
|
||||
re-queues the session — causing log flooding on persistent DNS/network
|
||||
failures with no backoff.
|
||||
|
||||
We override ``bot_connect()`` to:
|
||||
- Apply exponential backoff before the session is re-queued.
|
||||
- Log network errors compactly instead of dumping the full traceback.
|
||||
"""
|
||||
intents = botpy.Intents(public_messages=True, direct_message=True)
|
||||
|
||||
class _Bot(botpy.Client):
|
||||
def __init__(self):
|
||||
# Disable botpy's file log — nanobot uses loguru; default "botpy.log" fails on read-only fs
|
||||
super().__init__(intents=intents, ext_handlers=False)
|
||||
self._ws_backoff = {} # per-session backoff: {id(session): delay}
|
||||
|
||||
async def on_ready(self):
|
||||
logger.info("QQ bot ready: {}", self.robot.name)
|
||||
@@ -125,6 +152,48 @@ def _make_bot_class(channel: QQChannel) -> type[botpy.Client]:
|
||||
async def on_direct_message_create(self, message):
|
||||
await channel._on_message(message, is_group=False)
|
||||
|
||||
async def bot_connect(self, session):
|
||||
"""Override to add exponential backoff and compact error logging.
|
||||
|
||||
The original ``bot_connect`` catches the ``ws_connect`` exception
|
||||
and calls ``BotWebSocket.on_error`` which does ``_log.error`` +
|
||||
``traceback.print_exc`` then re-queues the session immediately.
|
||||
We intercept here to apply backoff *before* re-queuing and suppress
|
||||
the noisy traceback for transient network errors.
|
||||
"""
|
||||
_log = botpy.logging.get_logger() if hasattr(botpy, "logging") else None
|
||||
|
||||
client = BotWebSocket(session, self._connection)
|
||||
session_id = id(session)
|
||||
backoff = self._ws_backoff.get(session_id, _RECONNECT_BACKOFF_START)
|
||||
try:
|
||||
await client.ws_connect()
|
||||
# Connection succeeded — reset per-session backoff
|
||||
self._ws_backoff.pop(session_id, None)
|
||||
except (Exception, KeyboardInterrupt, SystemExit) as e:
|
||||
if _is_network_error(e):
|
||||
# Compact log for transient network/DNS errors — no traceback
|
||||
channel.logger.warning(
|
||||
"QQ bot network error (retry in {}s): {}",
|
||||
backoff,
|
||||
e,
|
||||
)
|
||||
# Apply backoff before the session is re-queued by on_error
|
||||
await asyncio.sleep(backoff)
|
||||
self._ws_backoff[session_id] = min(backoff * 2, _RECONNECT_BACKOFF_MAX)
|
||||
else:
|
||||
# Non-network error — log normally and let on_error handle it
|
||||
if _log:
|
||||
_log.error(
|
||||
"[botpy] websocket连接: %s, 异常信息 : %s", client._conn, e
|
||||
)
|
||||
import traceback as _tb
|
||||
|
||||
_tb.print_exc()
|
||||
|
||||
# Re-queue the session (same as original on_error / on_closed)
|
||||
self._connection.add(session)
|
||||
|
||||
return _Bot
|
||||
|
||||
|
||||
@@ -210,15 +279,30 @@ class QQChannel(BaseChannel):
|
||||
await self._run_bot()
|
||||
|
||||
async def _run_bot(self) -> None:
|
||||
"""Run the bot connection with auto-reconnect."""
|
||||
"""Run the bot connection with auto-reconnect.
|
||||
|
||||
Note: most WebSocket reconnect logic now lives in the overridden
|
||||
``bot_connect()`` method on the bot class, which handles per-session
|
||||
backoff inside botpy's internal ``_pool_init`` loop. This outer loop
|
||||
is a fallback for exceptions that escape ``start()`` entirely.
|
||||
"""
|
||||
backoff = 5
|
||||
max_backoff = 300
|
||||
while self._running:
|
||||
try:
|
||||
await self._client.start(appid=self.config.app_id, secret=self.config.secret)
|
||||
backoff = 5 # reset on clean exit
|
||||
except Exception as e:
|
||||
self.logger.warning("bot error: {}", e)
|
||||
if _is_network_error(e):
|
||||
self.logger.warning(
|
||||
"QQ bot network error (retry in {}s): {}", backoff, e
|
||||
)
|
||||
else:
|
||||
self.logger.warning("bot error: {}", e)
|
||||
if self._running:
|
||||
self.logger.info("Reconnecting bot in 5 seconds...")
|
||||
await asyncio.sleep(5)
|
||||
self.logger.info("Reconnecting bot in {} seconds...", backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
backoff = min(backoff * 2, max_backoff)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop bot and cleanup resources."""
|
||||
|
||||
Reference in New Issue
Block a user