fix(channels): prevent retry amplification and silent message loss across channels
Audited all channel implementations for overly broad exception handling that causes retry amplification or silent message loss during network errors. This is the same class of bug as #3050 (Telegram _send_text). Fixes by channel: Telegram (send_delta): - _stream_end path used except Exception for HTML edit fallback - Network errors (TimedOut, NetworkError) triggered redundant plain text edit, doubling connection demand during pool exhaustion - Changed to except BadRequest, matching the _send_text fix Discord: - send() caught all exceptions without re-raising - ChannelManager._send_with_retry() saw successful return, never retried - Messages silently dropped on any send failure - Added raise after error logging DingTalk: - _send_batch_message() returned False on all exceptions including network errors — no retry, fallback text sent unnecessarily - _read_media_bytes() and _upload_media() swallowed transport errors, causing _send_media_ref() to cascade through doomed fallback attempts - Added except httpx.TransportError handlers that re-raise immediately WeChat: - Media send failure triggered text fallback even for network errors - During network issues: 3×(media + text) = 6 API calls per message - Added specific catches: TimeoutException/TransportError re-raise, 5xx HTTPStatusError re-raises, 4xx falls back to text QQ: - _send_media() returned False on all exceptions - Network errors triggered fallback text instead of retry - Added except (aiohttp.ClientError, OSError) that re-raises Tests: 331 passed (283 existing + 48 new across 5 channel test files) Fixes: #3054 Related: #3050, #3053
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -14,6 +15,8 @@ except ImportError:
|
||||
if not QQ_AVAILABLE:
|
||||
pytest.skip("QQ dependencies not installed (qq-botpy)", allow_module_level=True)
|
||||
|
||||
import aiohttp
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.qq import QQChannel, QQConfig
|
||||
@@ -170,3 +173,221 @@ async def test_read_media_bytes_missing_file() -> None:
|
||||
data, filename = await channel._read_media_bytes("/nonexistent/path/image.png")
|
||||
assert data is None
|
||||
assert filename is None
|
||||
|
||||
|
||||
# -------------------------------------------------------
|
||||
# Tests for _send_media exception handling
|
||||
# -------------------------------------------------------
|
||||
|
||||
def _make_channel_with_local_file(suffix: str = ".png", content: bytes = b"\x89PNG\r\n"):
|
||||
"""Create a QQChannel with a fake client and a temp file for media."""
|
||||
channel = QQChannel(
|
||||
QQConfig(app_id="app", secret="secret", allow_from=["*"]),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._client = _FakeClient()
|
||||
channel._chat_type_cache["user1"] = "c2c"
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
|
||||
tmp.write(content)
|
||||
tmp.close()
|
||||
return channel, tmp.name
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_media_network_error_propagates() -> None:
|
||||
"""aiohttp.ClientError (network/transport) should re-raise, not return False."""
|
||||
channel, tmp_path = _make_channel_with_local_file()
|
||||
|
||||
# Make the base64 upload raise a network error
|
||||
channel._client.api._http = SimpleNamespace()
|
||||
channel._client.api._http.request = AsyncMock(
|
||||
side_effect=aiohttp.ServerDisconnectedError("connection lost"),
|
||||
)
|
||||
|
||||
with pytest.raises(aiohttp.ServerDisconnectedError):
|
||||
await channel._send_media(
|
||||
chat_id="user1",
|
||||
media_ref=tmp_path,
|
||||
msg_id="msg1",
|
||||
is_group=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_media_client_connector_error_propagates() -> None:
|
||||
"""aiohttp.ClientConnectorError (DNS/connection refused) should re-raise."""
|
||||
channel, tmp_path = _make_channel_with_local_file()
|
||||
|
||||
from aiohttp.client_reqrep import ConnectionKey
|
||||
conn_key = ConnectionKey("api.qq.com", 443, True, None, None, None, None)
|
||||
connector_error = aiohttp.ClientConnectorError(
|
||||
connection_key=conn_key,
|
||||
os_error=OSError("Connection refused"),
|
||||
)
|
||||
|
||||
channel._client.api._http = SimpleNamespace()
|
||||
channel._client.api._http.request = AsyncMock(
|
||||
side_effect=connector_error,
|
||||
)
|
||||
|
||||
with pytest.raises(aiohttp.ClientConnectorError):
|
||||
await channel._send_media(
|
||||
chat_id="user1",
|
||||
media_ref=tmp_path,
|
||||
msg_id="msg1",
|
||||
is_group=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_media_oserror_propagates() -> None:
|
||||
"""OSError (low-level I/O) should re-raise for retry."""
|
||||
channel, tmp_path = _make_channel_with_local_file()
|
||||
|
||||
channel._client.api._http = SimpleNamespace()
|
||||
channel._client.api._http.request = AsyncMock(
|
||||
side_effect=OSError("Network is unreachable"),
|
||||
)
|
||||
|
||||
with pytest.raises(OSError):
|
||||
await channel._send_media(
|
||||
chat_id="user1",
|
||||
media_ref=tmp_path,
|
||||
msg_id="msg1",
|
||||
is_group=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_media_api_error_returns_false() -> None:
|
||||
"""API-level errors (botpy RuntimeError subclasses) should return False, not raise."""
|
||||
channel, tmp_path = _make_channel_with_local_file()
|
||||
|
||||
# Simulate a botpy API error (e.g. ServerError is a RuntimeError subclass)
|
||||
from botpy.errors import ServerError
|
||||
|
||||
channel._client.api._http = SimpleNamespace()
|
||||
channel._client.api._http.request = AsyncMock(
|
||||
side_effect=ServerError("internal server error"),
|
||||
)
|
||||
|
||||
result = await channel._send_media(
|
||||
chat_id="user1",
|
||||
media_ref=tmp_path,
|
||||
msg_id="msg1",
|
||||
is_group=False,
|
||||
)
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_media_generic_runtime_error_returns_false() -> None:
|
||||
"""Generic RuntimeError (not network) should return False."""
|
||||
channel, tmp_path = _make_channel_with_local_file()
|
||||
|
||||
channel._client.api._http = SimpleNamespace()
|
||||
channel._client.api._http.request = AsyncMock(
|
||||
side_effect=RuntimeError("some API error"),
|
||||
)
|
||||
|
||||
result = await channel._send_media(
|
||||
chat_id="user1",
|
||||
media_ref=tmp_path,
|
||||
msg_id="msg1",
|
||||
is_group=False,
|
||||
)
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_media_value_error_returns_false() -> None:
|
||||
"""ValueError (bad API response data) should return False."""
|
||||
channel, tmp_path = _make_channel_with_local_file()
|
||||
|
||||
channel._client.api._http = SimpleNamespace()
|
||||
channel._client.api._http.request = AsyncMock(
|
||||
side_effect=ValueError("bad response data"),
|
||||
)
|
||||
|
||||
result = await channel._send_media(
|
||||
chat_id="user1",
|
||||
media_ref=tmp_path,
|
||||
msg_id="msg1",
|
||||
is_group=False,
|
||||
)
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_media_timeout_error_propagates() -> None:
|
||||
"""asyncio.TimeoutError inherits from Exception but not ClientError/OSError.
|
||||
However, aiohttp.ServerTimeoutError IS a ClientError subclass, so that propagates.
|
||||
For a plain TimeoutError (which is also OSError in Python 3.11+), it should propagate."""
|
||||
channel, tmp_path = _make_channel_with_local_file()
|
||||
|
||||
channel._client.api._http = SimpleNamespace()
|
||||
channel._client.api._http.request = AsyncMock(
|
||||
side_effect=aiohttp.ServerTimeoutError("request timed out"),
|
||||
)
|
||||
|
||||
with pytest.raises(aiohttp.ServerTimeoutError):
|
||||
await channel._send_media(
|
||||
chat_id="user1",
|
||||
media_ref=tmp_path,
|
||||
msg_id="msg1",
|
||||
is_group=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_fallback_text_on_api_error() -> None:
|
||||
"""When _send_media returns False (API error), send() should emit fallback text."""
|
||||
channel, tmp_path = _make_channel_with_local_file()
|
||||
|
||||
from botpy.errors import ServerError
|
||||
|
||||
channel._client.api._http = SimpleNamespace()
|
||||
channel._client.api._http.request = AsyncMock(
|
||||
side_effect=ServerError("internal server error"),
|
||||
)
|
||||
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="qq",
|
||||
chat_id="user1",
|
||||
content="",
|
||||
media=[tmp_path],
|
||||
metadata={"message_id": "msg1"},
|
||||
)
|
||||
)
|
||||
|
||||
# Should have sent a fallback text message
|
||||
assert len(channel._client.api.c2c_calls) == 1
|
||||
fallback_content = channel._client.api.c2c_calls[0]["content"]
|
||||
assert "Attachment send failed" in fallback_content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_propagates_network_error_no_fallback() -> None:
|
||||
"""When _send_media raises a network error, send() should NOT silently fallback."""
|
||||
channel, tmp_path = _make_channel_with_local_file()
|
||||
|
||||
channel._client.api._http = SimpleNamespace()
|
||||
channel._client.api._http.request = AsyncMock(
|
||||
side_effect=aiohttp.ServerDisconnectedError("connection lost"),
|
||||
)
|
||||
|
||||
with pytest.raises(aiohttp.ServerDisconnectedError):
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="qq",
|
||||
chat_id="user1",
|
||||
content="hello",
|
||||
media=[tmp_path],
|
||||
metadata={"message_id": "msg1"},
|
||||
)
|
||||
)
|
||||
|
||||
# No fallback text should have been sent
|
||||
assert len(channel._client.api.c2c_calls) == 0
|
||||
|
||||
Reference in New Issue
Block a user