mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix(telegram): prevent duplicate message delivery on send timeout (#5153)
TimedOut is a subclass of NetworkError in python-telegram-bot. The inner retry loop in send() and the outer _send_with_retry() in base.py both treated it as a transient connection error and retried — but send_message is not idempotent. When the request reaches Telegram but the HTTP response times out, the message is already delivered. Retrying sends duplicates. Worst case: up to 9 copies (inner 3x × outer 3x). Inner loop (telegram.py): - Import TimedOut separately, isinstance-check before generic NetworkError retry (same pattern as BadRequest carve-out from #3390) - Re-raise immediately — no retry - Mark as retryable=False in outer exception handler Outer loop (base.py): - Remove 'timeout', 'timed out', 'readtimeout', 'writetimeout' from _RETRYABLE_ERROR_PATTERNS (read/write timeouts are delivery-ambiguous) - Add 'connecttimeout' (safe — connection never established) - Keep 'network' (other platforms still need it) - Add _is_timeout_error() + early return to prevent plain-text fallback on timeout errors (would also cause duplicate delivery) Connection errors (ConnectionReset, ConnectError, etc.) are still retried — these fail before the request reaches the server. Credit: tmdgusya (PR #3899), barun1997 (PR #3904) for identifying the bug and proposing fixes. Closes #3899, closes #3904.
This commit is contained in:
parent
c8220e69a1
commit
85cefc7a5a
4 changed files with 132 additions and 11 deletions
|
|
@ -33,11 +33,16 @@ class FakeBadRequest(FakeNetworkError):
|
|||
pass
|
||||
|
||||
|
||||
class FakeTimedOut(FakeNetworkError):
|
||||
pass
|
||||
|
||||
|
||||
# Build a fake telegram module tree so the adapter's internal imports work
|
||||
_fake_telegram = types.ModuleType("telegram")
|
||||
_fake_telegram_error = types.ModuleType("telegram.error")
|
||||
_fake_telegram_error.NetworkError = FakeNetworkError
|
||||
_fake_telegram_error.BadRequest = FakeBadRequest
|
||||
_fake_telegram_error.TimedOut = FakeTimedOut
|
||||
_fake_telegram.error = _fake_telegram_error
|
||||
_fake_telegram_constants = types.ModuleType("telegram.constants")
|
||||
_fake_telegram_constants.ParseMode = SimpleNamespace(MARKDOWN_V2="MarkdownV2")
|
||||
|
|
@ -168,6 +173,34 @@ async def test_send_retries_network_errors_normally():
|
|||
assert attempt[0] == 3 # Two retries then success
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_does_not_retry_timeout():
|
||||
"""TimedOut (subclass of NetworkError) should NOT be retried in send().
|
||||
|
||||
The request may have already been delivered to the user — retrying
|
||||
would send duplicate messages.
|
||||
"""
|
||||
adapter = _make_adapter()
|
||||
|
||||
attempt = [0]
|
||||
|
||||
async def mock_send_message(**kwargs):
|
||||
attempt[0] += 1
|
||||
raise FakeTimedOut("Timed out waiting for Telegram response")
|
||||
|
||||
adapter._bot = SimpleNamespace(send_message=mock_send_message)
|
||||
|
||||
result = await adapter.send(
|
||||
chat_id="123",
|
||||
content="test message",
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert "Timed out" in result.error
|
||||
# CRITICAL: only 1 attempt — no retry for TimedOut
|
||||
assert attempt[0] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thread_fallback_only_fires_once():
|
||||
"""After clearing thread_id, subsequent chunks should also use None."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue