fix(telegram): retry on httpx pool timeout instead of dropping the send (#35664)
When PTB's general httpx pool is exhausted, it converts httpx.PoolTimeout into telegram.error.TimedOut whose message states the request was *not* sent to Telegram. The send retry loop treated all non-connect TimedOut as non-retryable, so a pool timeout raised immediately, skipped all 3 retry attempts, and was returned as retryable=False -- silently dropping the message (agent responses, cron reports, etc.). A pool timeout means the request never left the process, making it the safest case to retry. Add _looks_like_pool_timeout() and treat it like a connect timeout in both the in-loop retry decision and the outer retryable determination, so pool timeouts flow through the existing backoff loop and stay retryable on exhaustion. Reported-by: q3874758 (#35610)
This commit is contained in:
@@ -1278,6 +1278,60 @@ async def test_send_marks_wrapped_connect_timeout_retryable_after_exhaustion():
|
||||
assert attempt[0] == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_retries_pool_timeout():
|
||||
"""Retry TimedOut when it is an httpx pool-timeout (request not sent).
|
||||
|
||||
PTB wraps ``httpx.PoolTimeout`` into ``TimedOut`` with a message that
|
||||
explicitly states the request was *not* sent to Telegram. Re-sending is
|
||||
safe and prevents a silent drop when the pool frees up.
|
||||
"""
|
||||
adapter = _make_adapter()
|
||||
|
||||
attempt = [0]
|
||||
|
||||
async def mock_send_message(**kwargs):
|
||||
attempt[0] += 1
|
||||
if attempt[0] < 3:
|
||||
raise FakeTimedOut(
|
||||
"Pool timeout: All connections in the connection pool are "
|
||||
"occupied. Request was *not* sent to Telegram. Consider "
|
||||
"adjusting the connection pool size or the pool timeout."
|
||||
)
|
||||
return SimpleNamespace(message_id=202)
|
||||
|
||||
adapter._bot = SimpleNamespace(send_message=mock_send_message)
|
||||
|
||||
result = await adapter.send(chat_id="123", content="test message")
|
||||
|
||||
assert result.success is True
|
||||
assert result.message_id == "202"
|
||||
assert attempt[0] == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_marks_pool_timeout_retryable_after_exhaustion():
|
||||
"""Pool timeout that never clears stays retryable for outer retry handling."""
|
||||
adapter = _make_adapter()
|
||||
|
||||
attempt = [0]
|
||||
|
||||
async def mock_send_message(**kwargs):
|
||||
attempt[0] += 1
|
||||
raise FakeTimedOut(
|
||||
"Pool timeout: All connections in the connection pool are occupied. "
|
||||
"Request was *not* sent to Telegram."
|
||||
)
|
||||
|
||||
adapter._bot = SimpleNamespace(send_message=mock_send_message)
|
||||
|
||||
result = await adapter.send(chat_id="123", content="test message")
|
||||
|
||||
assert result.success is False
|
||||
assert result.retryable is True
|
||||
assert attempt[0] == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thread_fallback_only_fires_once():
|
||||
"""After clearing thread_id, subsequent chunks should also use None."""
|
||||
|
||||
Reference in New Issue
Block a user