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:
Teknium
2026-05-30 22:58:16 -07:00
committed by GitHub
parent 02d1da49de
commit dc4de14377
2 changed files with 99 additions and 4 deletions
@@ -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."""