fix(qq): account for SDK reconnect pacing

Use per-session retry deadlines so botpy's post-connect delay counts toward backoff, and keep unexpected failures on the channel logger.
This commit is contained in:
chengyongru
2026-07-20 23:24:03 +08:00
committed by chengyongru
parent ab0d28103b
commit b67f4b1371
2 changed files with 66 additions and 105 deletions
+16 -46
View File
@@ -106,9 +106,8 @@ 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
_RECONNECT_BACKOFF_MAX = 300
def _is_network_error(exc: BaseException) -> bool:
@@ -120,25 +119,15 @@ def _is_network_error(exc: BaseException) -> bool:
def _make_bot_class(channel: QQChannel) -> type[botpy.Client]:
"""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.
"""
"""Create a botpy client with per-session reconnect backoff."""
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}
self._ws_backoff: dict[int, int] = {}
self._ws_retry_at: dict[int, float] = {}
async def on_ready(self):
logger.info("QQ bot ready: {}", self.robot.name)
@@ -153,45 +142,32 @@ def _make_bot_class(channel: QQChannel) -> type[botpy.Client]:
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
"""Connect a botpy session with exponential retry backoff."""
session_id = id(session)
retry_at = self._ws_retry_at.pop(session_id, None)
if retry_at is not None:
remaining = retry_at - time.monotonic()
if remaining > 0:
await asyncio.sleep(remaining)
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)
# Count botpy's post-connect pacing toward the retry delay.
self._ws_retry_at[session_id] = time.monotonic() + 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
channel.logger.exception("QQ bot WebSocket error: {}", e)
_tb.print_exc()
# Re-queue the session (same as original on_error / on_closed)
self._connection.add(session)
return _Bot
@@ -279,19 +255,13 @@ class QQChannel(BaseChannel):
await self._run_bot()
async def _run_bot(self) -> None:
"""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.
"""
"""Run botpy with fallback backoff for errors escaping start()."""
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
backoff = 5
except Exception as e:
if _is_network_error(e):
self.logger.warning(