From 60674e326f36913213ac430f31e8a7c522efb522 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Sat, 30 May 2026 18:07:42 +0800 Subject: [PATCH] fix(telegram): clear send_path_degraded immediately on successful reconnect After a network error, _send_path_degraded was only cleared by the 60-second heartbeat probe in _verify_polling_after_reconnect(). During that window, all outbound send() calls short-circuited as failures, even though polling had already resumed successfully. Clear the flag immediately after start_polling() succeeds. If the send-side httpx pool is also stale, send()'s own retry and error_callback will re-enter the reconnect ladder. Fixes #35205 --- gateway/platforms/telegram.py | 6 ++ .../gateway/test_telegram_send_path_health.py | 55 +++++++++++++++++-- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 7b4d00e818fc..10aca46118ef 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -962,6 +962,12 @@ async def _handle_polling_network_error(self, error: Exception) -> None: self.name, attempt, ) self._polling_network_error_count = 0 + # Polling is confirmed working — clear the degraded flag so + # send() can attempt delivery immediately instead of waiting + # 60 s for the heartbeat probe. If the send-side httpx pool + # is also stale, send()'s own retry + error_callback will + # re-enter the reconnect ladder. + self._send_path_degraded = False # start_polling() returning is necessary but not sufficient: # PTB's Updater can be left in a state where `running` is True # but the underlying long-poll task is wedged on a stale httpx diff --git a/tests/gateway/test_telegram_send_path_health.py b/tests/gateway/test_telegram_send_path_health.py index 05972bdba437..77545d340f54 100644 --- a/tests/gateway/test_telegram_send_path_health.py +++ b/tests/gateway/test_telegram_send_path_health.py @@ -5,6 +5,7 @@ but nothing reaches the recipient. ``_send_path_degraded`` short-circuits ``send()`` so cron's live-adapter branch falls through to standalone HTTP. """ +import asyncio import sys from unittest.mock import AsyncMock, MagicMock, patch @@ -65,15 +66,19 @@ async def test_send_short_circuits_when_path_degraded(): @pytest.mark.asyncio -async def test_reconnect_storm_sets_and_heartbeat_clears_flag(monkeypatch): - """_handle_polling_network_error sets the flag; a successful heartbeat - probe in _verify_polling_after_reconnect clears it.""" +async def test_reconnect_clears_degraded_flag_on_successful_start_polling(monkeypatch): + """After a successful start_polling() reconnect, _send_path_degraded must + be cleared immediately — not deferred to the 60-second heartbeat probe. + + Regression test for #35205: send_path_degraded stayed True for 60 s after + reconnect, blocking all outbound messages. + """ adapter = _make_adapter() adapter._app = MagicMock() adapter._app.updater = MagicMock() adapter._app.updater.running = True adapter._app.updater.stop = AsyncMock() - adapter._app.updater.start_polling = AsyncMock() + adapter._app.updater.start_polling = AsyncMock() # succeeds adapter._app.bot = MagicMock() adapter._app.bot.get_me = AsyncMock(return_value=MagicMock()) adapter._polling_error_callback_ref = AsyncMock() @@ -82,8 +87,48 @@ async def test_reconnect_storm_sets_and_heartbeat_clears_flag(monkeypatch): ) await adapter._handle_polling_network_error(OSError("Bad Gateway")) - assert adapter._send_path_degraded is True + # Flag must be cleared immediately after successful start_polling() + assert adapter._send_path_degraded is False + # Heartbeat probe still runs and confirms the flag stays cleared with patch("gateway.platforms.telegram.asyncio.sleep", new_callable=AsyncMock): await adapter._verify_polling_after_reconnect() assert adapter._send_path_degraded is False + + +@pytest.mark.asyncio +async def test_degraded_flag_stays_true_when_start_polling_fails(monkeypatch): + """When start_polling() fails, _send_path_degraded must remain True + so send() continues to short-circuit until recovery succeeds.""" + adapter = _make_adapter() + adapter._polling_network_error_count = 1 + + mock_updater = MagicMock() + mock_updater.running = True + mock_updater.stop = AsyncMock() + mock_updater.start_polling = AsyncMock(side_effect=Exception("Timed out")) + + mock_app = MagicMock() + mock_app.updater = mock_updater + adapter._app = mock_app + adapter._polling_error_callback_ref = AsyncMock() + monkeypatch.setattr( + "gateway.platforms.telegram.Update", MagicMock(ALL_TYPES=[]) + ) + + with patch("asyncio.sleep", new_callable=AsyncMock): + await adapter._handle_polling_network_error(Exception("Bad Gateway")) + + # Flag must stay True since reconnect failed + assert adapter._send_path_degraded is True + + # Clean up pending retry tasks + for t in list(adapter._background_tasks): + t.cancel() + try: + await t + except (asyncio.CancelledError, Exception): + pass + + +