Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions gateway/platforms/qqbot/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,13 @@ async def _listen_loop(self) -> None:
RATE_LIMIT_DELAY,
)
if backoff_idx >= MAX_RECONNECT_ATTEMPTS:
logger.error("[%s] Max reconnect attempts reached (4008 rate-limit)", self._log_tag)
self._set_fatal_error(
"qq_reconnect_exhausted",
"Max reconnect attempts reached (4008 rate-limit)",
retryable=False,
)
await self._notify_fatal_error()
return
await asyncio.sleep(RATE_LIMIT_DELAY)
if await self._reconnect(backoff_idx):
Expand Down Expand Up @@ -537,6 +544,12 @@ async def _listen_loop(self) -> None:
backoff_idx += 1
if backoff_idx >= MAX_RECONNECT_ATTEMPTS:
logger.error("[%s] Max reconnect attempts reached (QQCloseError)", self._log_tag)
self._set_fatal_error(
"qq_reconnect_exhausted",
"Max reconnect attempts reached (QQCloseError)",
retryable=False,
)
await self._notify_fatal_error()
return

except Exception as exc:
Expand All @@ -548,6 +561,12 @@ async def _listen_loop(self) -> None:

if backoff_idx >= MAX_RECONNECT_ATTEMPTS:
logger.error("[%s] Max reconnect attempts reached", self._log_tag)
self._set_fatal_error(
"qq_reconnect_exhausted",
"Max reconnect attempts reached",
retryable=False,
)
await self._notify_fatal_error()
return

if await self._reconnect(backoff_idx):
Expand Down
99 changes: 99 additions & 0 deletions tests/gateway/test_qqbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -582,3 +582,102 @@ async def test_send_media_waits_for_reconnect(self):
assert not result.success
assert result.retryable is True
assert "Not connected" in result.error


# ---------------------------------------------------------------------------
# _listen_loop — reconnect exhaustion notifies gateway via _set_fatal_error
# ---------------------------------------------------------------------------

class TestListenLoopReconnectExhaustion:
"""Verify _listen_loop calls _set_fatal_error + _notify_fatal_error on all exhaustion paths."""

def _make_adapter(self, **extra):
from gateway.platforms.qqbot import QQAdapter
adapter = QQAdapter(_make_config(app_id="a", client_secret="b", **extra))
adapter._notify_fatal_error = mock.AsyncMock()
return adapter

def test_exception_path_sets_fatal_error(self):
"""Exception branch: exhausted backoff marks adapter as fatal and notifies gateway."""
adapter = self._make_adapter()
adapter._running = True
adapter._mark_disconnected = mock.MagicMock()
adapter._fail_pending = mock.MagicMock()

call_count = 0

async def _raise_once():
nonlocal call_count
call_count += 1
if call_count == 1:
raise RuntimeError("persistent network failure")
adapter._running = False

adapter._read_events = _raise_once
adapter._reconnect = mock.AsyncMock(return_value=False)

with mock.patch("gateway.platforms.qqbot.adapter.MAX_RECONNECT_ATTEMPTS", 0):
asyncio.run(adapter._listen_loop())

assert adapter.has_fatal_error
assert adapter._fatal_error_code == "qq_reconnect_exhausted"
assert adapter._fatal_error_retryable is False
adapter._notify_fatal_error.assert_awaited_once()

def test_qqcloseerror_path_sets_fatal_error(self):
"""QQCloseError branch: exhausted backoff marks adapter as fatal and notifies gateway."""
from gateway.platforms.qqbot.adapter import QQCloseError

adapter = self._make_adapter()
adapter._running = True
adapter._mark_disconnected = mock.MagicMock()
adapter._fail_pending = mock.MagicMock()

call_count = 0

async def _raise_close():
nonlocal call_count
call_count += 1
if call_count == 1:
raise QQCloseError(1006, "abnormal closure")
adapter._running = False

adapter._read_events = _raise_close
adapter._reconnect = mock.AsyncMock(return_value=False)

with mock.patch("gateway.platforms.qqbot.adapter.MAX_RECONNECT_ATTEMPTS", 1):
asyncio.run(adapter._listen_loop())

assert adapter.has_fatal_error
assert adapter._fatal_error_code == "qq_reconnect_exhausted"
assert adapter._fatal_error_retryable is False
adapter._notify_fatal_error.assert_awaited_once()

def test_rate_limit_4008_path_sets_fatal_error(self):
"""4008 rate-limit branch: exhausted backoff marks adapter as fatal and notifies gateway."""
from gateway.platforms.qqbot.adapter import QQCloseError

adapter = self._make_adapter()
adapter._running = True
adapter._mark_disconnected = mock.MagicMock()
adapter._fail_pending = mock.MagicMock()

call_count = 0

async def _raise_rate_limit():
nonlocal call_count
call_count += 1
if call_count == 1:
raise QQCloseError(4008, "rate limited")
adapter._running = False

adapter._read_events = _raise_rate_limit
adapter._reconnect = mock.AsyncMock(return_value=False)

with mock.patch("gateway.platforms.qqbot.adapter.MAX_RECONNECT_ATTEMPTS", 0):
asyncio.run(adapter._listen_loop())

assert adapter.has_fatal_error
assert adapter._fatal_error_code == "qq_reconnect_exhausted"
assert adapter._fatal_error_retryable is False
adapter._notify_fatal_error.assert_awaited_once()