fix(telegram): gate polling health on getUpdates progress

This commit is contained in:
Roseyco-management 2026-07-13 04:27:20 +01:00 committed by kshitij
parent 202be02ac9
commit b8295cf6f7
6 changed files with 1347 additions and 214 deletions

View file

@ -17,6 +17,7 @@ import os
import html as _html
import re
import threading
from contextvars import ContextVar
from datetime import datetime, timezone
from typing import Dict, List, Optional, Set, Any
@ -474,6 +475,16 @@ _UPDATER_STOP_TIMEOUT = 15.0
# reconnect ladder from stalling indefinitely and allows the heartbeat loop to
# trigger its own recovery path. Refs: NousResearch/hermes-agent#59614
_UPDATER_START_TIMEOUT = 30.0
# A generation is not healthy until the dedicated getUpdates request returns
# successfully. This exceeds a normal long-poll cycle for healthy idle bots.
_POLLING_PROGRESS_TIMEOUT = 60.0
_POLLING_GENERATION_CONTEXT: ContextVar[Optional[int]] = ContextVar(
"telegram_polling_generation", default=None
)
class _PollingLifecycleAbort(RuntimeError):
"""Internal control flow for polling startup fenced by teardown."""
class TelegramAdapter(BasePlatformAdapter):
@ -637,6 +648,7 @@ class TelegramAdapter(BasePlatformAdapter):
self._polling_progress_event = asyncio.Event()
self._polling_progress_accepting: bool = False
self._polling_progress_verifier_task: Optional[asyncio.Task] = None
self._polling_teardown_started: bool = False
self._polling_error_callback_ref = None
self._polling_heartbeat_task: Optional[asyncio.Task] = None
# Consecutive heartbeat probes that saw queued updates the running
@ -652,10 +664,9 @@ class TelegramAdapter(BasePlatformAdapter):
# error_callback ever fires and the gateway silently stops receiving
# messages with the process still alive (#55769).
self._polling_not_running_count: int = 0
# After sustained reconnect storms the PTB httpx pool can return
# SendResult(success=True) for sends that never actually transmit.
# _handle_polling_network_error sets this; _verify_polling_after_reconnect
# clears it once getMe() confirms the Bot client is healthy.
# A polling generation stays degraded until the dedicated getUpdates
# request makes successful progress. start_polling() return and getMe()
# success on the general request path are not polling-health signals.
# While True, send() short-circuits to a failure so callers
# (cron live-adapter branch) fall through to standalone delivery.
self._send_path_degraded: bool = False
@ -1950,11 +1961,20 @@ class TelegramAdapter(BasePlatformAdapter):
def _begin_polling_generation(self) -> tuple[int, asyncio.Event]:
"""Start accepting progress for a new getUpdates polling generation."""
verifier = self._polling_progress_verifier_task
if getattr(self, "_polling_teardown_started", False):
self._polling_progress_accepting = False
self._send_path_degraded = True
progress = getattr(self, "_polling_progress_event", None)
if progress is None:
progress = asyncio.Event()
self._polling_progress_event = progress
return getattr(self, "_polling_generation", 0), progress
verifier = getattr(self, "_polling_progress_verifier_task", None)
if verifier is not None and not verifier.done():
verifier.cancel()
self._polling_progress_verifier_task = None
self._polling_generation += 1
self._polling_generation = getattr(self, "_polling_generation", 0) + 1
self._polling_progress_event = asyncio.Event()
self._polling_progress_accepting = True
self._send_path_degraded = True
@ -1962,12 +1982,15 @@ class TelegramAdapter(BasePlatformAdapter):
def _record_polling_progress(self, generation: int) -> None:
"""Record successful getUpdates I/O for the current generation only."""
if getattr(self, "_polling_teardown_started", False):
return
if not self._polling_progress_accepting:
return
if generation != self._polling_generation:
return
self._polling_progress_event.set()
self._polling_network_error_count = 0
self._polling_conflict_count = 0
self._send_path_degraded = False
def _instrument_polling_request(self, request):
@ -1975,16 +1998,100 @@ class TelegramAdapter(BasePlatformAdapter):
do_request = request.do_request
async def _do_request(*args, **kwargs):
generation = self._polling_generation
generation = _POLLING_GENERATION_CONTEXT.get()
result = await do_request(*args, **kwargs)
status_code, _ = result
if 200 <= status_code < 300:
self._record_polling_progress(generation)
status_code, payload = result
if generation is not None and 200 <= status_code < 300:
try:
# Use the request's own parser so health observation agrees
# exactly with PTB's authoritative response handling (e.g.
# UTF-8 replacement decoding and BOM rejection).
envelope = request.parse_json_payload(payload)
except Exception:
# Instrumentation is observational: PTB still parses the
# untouched payload and owns the resulting exception.
pass
else:
if (
isinstance(envelope, dict)
and envelope.get("ok") is True
and "result" in envelope
):
self._record_polling_progress(generation)
return result
request.do_request = _do_request
return request
async def _start_polling_once(
self,
app,
*,
drop_pending_updates: bool,
error_callback,
) -> None:
"""Start one generation and verify real getUpdates progress."""
if getattr(self, "_polling_teardown_started", False):
raise _PollingLifecycleAbort("Telegram polling teardown started")
generation, progress = self._begin_polling_generation()
if not self._polling_progress_accepting:
raise _PollingLifecycleAbort("Telegram polling teardown started")
def _generation_error_callback(error: Exception) -> None:
if getattr(self, "_polling_teardown_started", False):
return
if generation != self._polling_generation:
return
if error_callback is not None:
callback_context_token = _POLLING_GENERATION_CONTEXT.set(None)
try:
error_callback(error)
finally:
_POLLING_GENERATION_CONTEXT.reset(callback_context_token)
context_token = _POLLING_GENERATION_CONTEXT.set(generation)
try:
await asyncio.wait_for(
app.updater.start_polling(
allowed_updates=Update.ALL_TYPES,
drop_pending_updates=drop_pending_updates,
error_callback=_generation_error_callback,
),
timeout=_UPDATER_START_TIMEOUT,
)
finally:
_POLLING_GENERATION_CONTEXT.reset(context_token)
if getattr(self, "_polling_teardown_started", False):
self._polling_progress_accepting = False
self._send_path_degraded = True
raise _PollingLifecycleAbort("Telegram polling teardown started")
self._schedule_polling_progress_verifier(generation, progress)
def _schedule_polling_progress_verifier(
self, generation: int, progress: asyncio.Event
) -> None:
"""Own exactly one tracked verifier for the current generation."""
if getattr(self, "_polling_teardown_started", False):
self._polling_progress_accepting = False
self._send_path_degraded = True
return
previous = getattr(self, "_polling_progress_verifier_task", None)
if previous is not None and not previous.done():
previous.cancel()
task = asyncio.get_running_loop().create_task(
self._verify_polling_after_reconnect(generation, progress)
)
self._polling_progress_verifier_task = task
self._background_tasks.add(task)
def _clear_finished_verifier(finished: asyncio.Task) -> None:
self._background_tasks.discard(finished)
if self._polling_progress_verifier_task is finished:
self._polling_progress_verifier_task = None
task.add_done_callback(_clear_finished_verifier)
def _get_general_request_drain_lock(self) -> asyncio.Lock:
lock = getattr(self, "_general_request_drain_lock", None)
if lock is None:
@ -2037,6 +2144,8 @@ class TelegramAdapter(BasePlatformAdapter):
adapter: the gateway process stays alive and the existing reconnect
ladder (``_handle_polling_network_error``) recovers in the background.
"""
if getattr(self, "_polling_teardown_started", False):
return
if self.has_fatal_error:
return
if self._polling_error_task and not self._polling_error_task.done():
@ -2088,6 +2197,8 @@ class TelegramAdapter(BasePlatformAdapter):
network error was scheduled for background recovery instead of raising
(keeping the gateway process alive).
"""
if getattr(self, "_polling_teardown_started", False):
return False
if not (self._app and self._app.updater):
raise RuntimeError("Telegram application/updater not initialized")
try:
@ -2097,16 +2208,17 @@ class TelegramAdapter(BasePlatformAdapter):
# TimeoutError (OSError subclass), so the except below classifies
# it via _looks_like_network_error and schedules background
# recovery instead of blocking connect() indefinitely.
await asyncio.wait_for(
self._app.updater.start_polling(
allowed_updates=Update.ALL_TYPES,
drop_pending_updates=drop_pending_updates,
error_callback=error_callback,
),
timeout=_UPDATER_START_TIMEOUT,
await self._start_polling_once(
self._app,
drop_pending_updates=drop_pending_updates,
error_callback=error_callback,
)
return True
except _PollingLifecycleAbort:
return False
except Exception as err:
if getattr(self, "_polling_teardown_started", False):
return False
if self._looks_like_polling_conflict(err):
logger.warning(
"[%s] Telegram polling bootstrap conflict; gateway stays alive "
@ -2135,6 +2247,8 @@ class TelegramAdapter(BasePlatformAdapter):
MAX_NETWORK_RETRIES attempts, then mark the adapter retryable-fatal so
the supervisor restarts the gateway process.
"""
if getattr(self, "_polling_teardown_started", False):
return
if self.has_fatal_error:
return
@ -2164,6 +2278,9 @@ class TelegramAdapter(BasePlatformAdapter):
)
await asyncio.sleep(delay)
if getattr(self, "_polling_teardown_started", False):
return
# Capture a stable local reference: self._app can be reassigned to None
# by a concurrent disconnect() while we're suspended across the awaits
# below, and re-reading self._app after that point would silently swap
@ -2194,64 +2311,39 @@ class TelegramAdapter(BasePlatformAdapter):
except Exception:
pass
if getattr(self, "_polling_teardown_started", False):
return
await self._drain_polling_connections()
if getattr(self, "_polling_teardown_started", False):
return
try:
if not app:
raise RuntimeError("Telegram application was torn down during reconnect")
# Guard start_polling() with a timeout: when the connection pool is
# in a degraded state (e.g., after _drain_polling_connections()), the
# httpx client may hold a stale socket that neither connects nor times
# out within PTB's internal flow. Bounding start_polling() prevents
# the reconnect ladder from stalling indefinitely and allows the
# heartbeat loop to trigger its own recovery path.
# Refs: NousResearch/hermes-agent#59614
try:
await asyncio.wait_for(
app.updater.start_polling(
allowed_updates=Update.ALL_TYPES,
drop_pending_updates=False,
error_callback=self._polling_error_callback_ref,
),
timeout=_UPDATER_START_TIMEOUT,
)
except asyncio.TimeoutError:
raise RuntimeError(
"start_polling() timed out — connection pool may be wedged"
)
await self._start_polling_once(
app,
drop_pending_updates=False,
error_callback=self._polling_error_callback_ref,
)
logger.info(
"[%s] Telegram polling resumed after network error (attempt %d)",
"[%s] Telegram polling restarted after network error (attempt %d); "
"health pending getUpdates progress",
self.name, attempt,
)
self._polling_network_error_count = 0
# start_polling() succeeding IS the recovery signal: the long-poll
# connection is live again, so clear the degraded flag immediately
# rather than blocking all outbound sends for the full
# HEARTBEAT_PROBE_DELAY window. The deferred probe below is a
# defensive re-check — if it later detects a silent wedge (PTB
# running=True but consumer task dead) it re-enters the ladder,
# which re-sets _send_path_degraded. Without this clear here, a
# clean reconnect leaves the flag stuck True until the 60s probe
# (or forever, if the probe is never scheduled), blocking the send
# path even though the bot has fully recovered. See #35205.
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
# connection and never makes progress. No error_callback fires
# in that state, so the reconnect ladder won't advance on its
# own. Schedule a deferred probe to detect the wedge and
# re-enter the ladder if needed.
if not self.has_fatal_error:
probe = asyncio.ensure_future(self._verify_polling_after_reconnect())
self._background_tasks.add(probe)
probe.add_done_callback(self._background_tasks.discard)
except _PollingLifecycleAbort:
return
except Exception as retry_err:
if getattr(self, "_polling_teardown_started", False):
return
safe_retry_error = _redact_telegram_error_text(retry_err)
logger.warning("[%s] Telegram polling reconnect failed: %s", self.name, safe_retry_error)
# start_polling failed — polling is dead and no further error
# callbacks will fire, so schedule the next retry ourselves.
if not self.has_fatal_error:
if (
not self.has_fatal_error
and not getattr(self, "_polling_teardown_started", False)
):
task = asyncio.ensure_future(
self._handle_polling_network_error(retry_err)
)
@ -2281,9 +2373,9 @@ class TelegramAdapter(BasePlatformAdapter):
``_handle_polling_network_error`` the same path triggered by PTB's own
``error_callback`` which drains the dead pool and restarts polling.
Unlike ``_verify_polling_after_reconnect`` (a one-shot probe scheduled
only after an explicit reconnect), this loop runs for the full lifetime
of the polling connection, so it catches a socket that wedges during
Unlike the generation verifier (a one-shot progress deadline after
every polling start), this loop runs for the full lifetime of the
polling connection, so it catches a socket that wedges later during
steady-state operation without any prior error event.
"""
HEARTBEAT_INTERVAL = 90 # seconds between probes
@ -2292,6 +2384,8 @@ class TelegramAdapter(BasePlatformAdapter):
while True:
try:
await asyncio.sleep(HEARTBEAT_INTERVAL)
if getattr(self, "_polling_teardown_started", False):
return
if self.has_fatal_error:
return
bot = self._app.bot if self._app else None
@ -2348,6 +2442,8 @@ class TelegramAdapter(BasePlatformAdapter):
report a queue against a live consumer. We detect the stopped updater
directly and feed the same ladder (#55769).
"""
if getattr(self, "_polling_teardown_started", False):
return
# Only meaningful in polling mode; in webhook mode Telegram pushes
# updates and holds no server-side queue.
if self._webhook_mode:
@ -2381,6 +2477,8 @@ class TelegramAdapter(BasePlatformAdapter):
)
if self._polling_not_running_count >= 2:
self._polling_not_running_count = 0
if getattr(self, "_polling_teardown_started", False):
return
logger.warning(
"[%s] Telegram updater is not running (long-poll task "
"gone); triggering polling restart",
@ -2415,6 +2513,8 @@ class TelegramAdapter(BasePlatformAdapter):
)
if self._polling_pending_stuck_count >= 2:
self._polling_pending_stuck_count = 0
if getattr(self, "_polling_teardown_started", False):
return
logger.warning(
"[%s] getUpdates consumer appears wedged (queue not draining); "
"triggering polling restart",
@ -2427,66 +2527,96 @@ class TelegramAdapter(BasePlatformAdapter):
)
)
async def _verify_polling_after_reconnect(self) -> None:
"""Heartbeat probe scheduled after a successful reconnect.
async def _verify_polling_after_reconnect(
self,
generation: Optional[int] = None,
progress: Optional[asyncio.Event] = None,
) -> None:
"""Require getUpdates progress, using getMe only to classify failure.
PTB's Updater can survive a botched stop()+start_polling() cycle
with `running=True` but a wedged consumer task. No error callback
fires, so the reconnect ladder doesn't advance on its own. This
probe detects the wedge by:
1. Sleeping HEARTBEAT_PROBE_DELAY so a healthy long-poll has time
to complete at least one cycle.
2. Verifying `Updater.running` is still True.
3. Probing the bot endpoint with a tight asyncio timeout. A
wedged httpx pool fails this probe; a healthy one returns
well under the timeout.
On connectivity failure, re-enter the reconnect ladder (via
``_schedule_polling_recovery`` so the in-flight guard and task
bookkeeping apply) and let the existing MAX_NETWORK_RETRIES path
ultimately escalate to fatal-error. Auth/validation failures
(``InvalidToken``, ``BadRequest``, ...) are not connectivity symptoms
and must not trigger reconnect churn same policy as the heartbeat
loop and the pending-update probe (#62098, #63243).
The generation-bound event is set only by a successful response on the
dedicated getUpdates request. A general-path getMe success can classify
connectivity, but cannot heal polling health. Connectivity failures
enter the guarded recovery ladder; auth/validation errors do not churn.
"""
HEARTBEAT_PROBE_DELAY = 60
PROBE_TIMEOUT = 10
await asyncio.sleep(HEARTBEAT_PROBE_DELAY)
if self.has_fatal_error:
if getattr(self, "_polling_teardown_started", False):
return
if not (self._app and self._app.updater and self._app.updater.running):
if generation is None:
generation = self._polling_generation
if progress is None:
progress = self._polling_progress_event
try:
await asyncio.wait_for(
progress.wait(), timeout=_POLLING_PROGRESS_TIMEOUT
)
except asyncio.TimeoutError:
pass
if getattr(self, "_polling_teardown_started", False):
return
if progress.is_set() or self.has_fatal_error:
return
if not self._polling_progress_accepting:
return
if generation != self._polling_generation:
return
if progress is not self._polling_progress_event:
return
app = self._app
if not (app and app.updater and app.updater.running):
logger.warning(
"[%s] Updater not running %ds after reconnect — treating as wedged",
self.name, HEARTBEAT_PROBE_DELAY,
"[%s] Updater made no getUpdates progress and is not running",
self.name,
)
self._schedule_polling_recovery(
RuntimeError("Updater not running after reconnect heartbeat"),
reason="post-reconnect probe: updater not running",
RuntimeError("Updater not running after polling progress deadline"),
reason="polling progress verifier: updater not running",
)
return
try:
await asyncio.wait_for(self._app.bot.get_me(), PROBE_TIMEOUT)
self._send_path_degraded = False
await asyncio.wait_for(app.bot.get_me(), PROBE_TIMEOUT)
except Exception as probe_err:
if getattr(self, "_polling_teardown_started", False):
return
if self.has_fatal_error or not self._polling_progress_accepting:
return
if generation != self._polling_generation:
return
if progress is not self._polling_progress_event or progress.is_set():
return
if not self._looks_like_network_error(probe_err):
logger.warning(
"[%s] Post-reconnect probe hit a non-connectivity error"
"[%s] Polling progress verifier hit a non-connectivity error"
" (not retrying): %s",
self.name, _redact_telegram_error_text(probe_err),
)
return
logger.warning(
"[%s] Polling heartbeat probe failed %ds after reconnect: %s",
self.name, HEARTBEAT_PROBE_DELAY,
_redact_telegram_error_text(probe_err),
"[%s] Polling progress verifier connectivity probe failed: %s",
self.name, _redact_telegram_error_text(probe_err),
)
self._schedule_polling_recovery(
probe_err, reason="post-reconnect probe failure"
probe_err,
reason="polling progress verifier connectivity failure",
)
return
if getattr(self, "_polling_teardown_started", False):
return
if self.has_fatal_error or not self._polling_progress_accepting:
return
if generation != self._polling_generation:
return
if progress is not self._polling_progress_event or progress.is_set():
return
self._schedule_polling_recovery(
RuntimeError("getUpdates made no progress before verifier deadline"),
reason="polling progress verifier: general path healthy but getUpdates stalled",
)
def _disarm_ptb_retry_loop(self) -> None:
"""Synchronously stop PTB's internal polling retry loop.
@ -2552,6 +2682,8 @@ class TelegramAdapter(BasePlatformAdapter):
)
async def _handle_polling_conflict(self, error: Exception) -> None:
if getattr(self, "_polling_teardown_started", False):
return
if self.has_fatal_error and self.fatal_error_code == "telegram_polling_conflict":
return
# Transient 409 Conflict errors arise when the previous gateway process
@ -2607,7 +2739,11 @@ class TelegramAdapter(BasePlatformAdapter):
pass
await asyncio.sleep(RETRY_DELAY)
if getattr(self, "_polling_teardown_started", False):
return
await self._drain_polling_connections()
if getattr(self, "_polling_teardown_started", False):
return
# Capture a stable local reference: self._app can be reassigned to
# None by a concurrent disconnect() while we're suspended across
@ -2619,31 +2755,22 @@ class TelegramAdapter(BasePlatformAdapter):
try:
if not app:
raise RuntimeError("Telegram application was torn down during conflict reconnect")
# Same watchdog bound as the network-error ladder: an
# exhausted pool hangs start_polling() on the conflict path
# identically (#59614). Timeout converts to RuntimeError so
# the except below logs a readable message and schedules the
# next conflict attempt instead of wedging attempt N forever.
try:
await asyncio.wait_for(
app.updater.start_polling(
allowed_updates=Update.ALL_TYPES,
drop_pending_updates=False,
error_callback=self._polling_error_callback_ref,
),
timeout=_UPDATER_START_TIMEOUT,
)
except asyncio.TimeoutError:
raise RuntimeError(
"start_polling() timed out — connection pool may be wedged"
)
await self._start_polling_once(
app,
drop_pending_updates=False,
error_callback=self._polling_error_callback_ref,
)
logger.info(
"[%s] Telegram polling resumed after conflict retry %d/%d",
"[%s] Telegram polling restarted after conflict retry %d/%d; "
"health pending getUpdates progress",
self.name, self._polling_conflict_count, MAX_CONFLICT_RETRIES,
)
self._polling_conflict_count = 0 # reset counter on success
return
except _PollingLifecycleAbort:
return
except Exception as retry_err:
if getattr(self, "_polling_teardown_started", False):
return
logger.warning(
"[%s] Telegram polling retry %d/%d failed: %s. "
"Scheduling next attempt.",
@ -2655,7 +2782,10 @@ class TelegramAdapter(BasePlatformAdapter):
# a fatal error leaves the adapter in a limbo state: the
# gateway process is alive and reports "connected" but
# no messages are received or sent.
if self._polling_conflict_count < MAX_CONFLICT_RETRIES:
if (
self._polling_conflict_count < MAX_CONFLICT_RETRIES
and not getattr(self, "_polling_teardown_started", False)
):
# We are inside a running coroutine, so the running loop is
# guaranteed to exist. asyncio.get_event_loop() is deprecated
# and raises "RuntimeError: There is no current event loop in
@ -2669,6 +2799,9 @@ class TelegramAdapter(BasePlatformAdapter):
return
# Fall through to fatal on the last retry.
if getattr(self, "_polling_teardown_started", False):
return
# Exhausted all retries — declare a fatal error so the gateway
# runner can surface this clearly and the user knows to act.
message = (
@ -3125,6 +3258,14 @@ class TelegramAdapter(BasePlatformAdapter):
TELEGRAM_WEBHOOK_PORT Local listen port (default 8443)
TELEGRAM_WEBHOOK_SECRET Secret token for update verification
"""
# Explicit connect() is the only operation allowed to reopen polling
# after a completed, serialized teardown. Background recovery never
# clears this fence.
self._polling_teardown_started = False
# Mode selection is re-evaluated on every explicit connection. Keep
# webhook state false unless this connection starts its webhook.
self._webhook_mode = False
if not TELEGRAM_AVAILABLE:
logger.error(
"[%s] python-telegram-bot not installed. Run: pip install python-telegram-bot",
@ -3420,6 +3561,8 @@ class TelegramAdapter(BasePlatformAdapter):
drop_pending_updates=not is_reconnect,
)
self._webhook_mode = True
self._polling_progress_accepting = False
self._send_path_degraded = False
logger.info(
"[%s] Webhook server listening on 0.0.0.0:%d%s",
self.name, webhook_port, webhook_path,
@ -3436,6 +3579,8 @@ class TelegramAdapter(BasePlatformAdapter):
loop = asyncio.get_running_loop()
def _polling_error_callback(error: Exception) -> None:
if getattr(self, "_polling_teardown_started", False):
return
if self._polling_error_task and not self._polling_error_task.done():
return
if self._looks_like_polling_conflict(error):
@ -3569,7 +3714,8 @@ class TelegramAdapter(BasePlatformAdapter):
collect(task)
for task in list(self._pending_text_batch_tasks.values()):
collect(task)
collect(self._polling_error_task)
collect(getattr(self, "_polling_error_task", None))
collect(getattr(self, "_polling_progress_verifier_task", None))
for task in pending_tasks:
task.cancel()
@ -3582,8 +3728,10 @@ class TelegramAdapter(BasePlatformAdapter):
self._pending_photo_batches.clear()
self._pending_text_batch_tasks.clear()
self._pending_text_batches.clear()
if self._polling_error_task is not current_task:
if getattr(self, "_polling_error_task", None) is not current_task:
self._polling_error_task = None
if getattr(self, "_polling_progress_verifier_task", None) is not current_task:
self._polling_progress_verifier_task = None
async def disconnect(self) -> None:
"""Stop polling/webhook, cancel pending delayed deliveries, and disconnect."""
@ -3591,6 +3739,42 @@ class TelegramAdapter(BasePlatformAdapter):
# that wins the race against teardown and prevents new delayed tasks
# from being scheduled by late update handlers.
self._mark_disconnected()
self._polling_teardown_started = True
self._polling_progress_accepting = False
self._polling_generation = getattr(self, "_polling_generation", 0) + 1
self._polling_progress_event = asyncio.Event()
self._send_path_degraded = True
# Recovery can be suspended in stop/drain/start while disconnect begins.
# Cancel and await both polling lifecycle owners immediately after the
# fence, before any other teardown await lets them start a new generation.
current_task = asyncio.current_task()
lifecycle_tasks: list[asyncio.Task] = []
lifecycle_seen: set[int] = set()
for task in (
getattr(self, "_polling_error_task", None),
getattr(self, "_polling_progress_verifier_task", None),
):
if not task or task.done() or task is current_task:
continue
marker = id(task)
if marker in lifecycle_seen:
continue
lifecycle_seen.add(marker)
task.cancel()
if asyncio.isfuture(task) or asyncio.iscoroutine(task):
lifecycle_tasks.append(task)
if lifecycle_tasks:
await asyncio.gather(*lifecycle_tasks, return_exceptions=True)
if getattr(self, "_polling_error_task", None) is not current_task:
self._polling_error_task = None
if getattr(self, "_polling_progress_verifier_task", None) is not current_task:
self._polling_progress_verifier_task = None
# Cancellation callbacks may have run while awaited; the teardown fence
# remains authoritative regardless of their finalizers.
self._polling_progress_accepting = False
self._send_path_degraded = True
# Cancel deferred post-connect housekeeping (command-menu / DM-topic /
# status-indicator Bot API calls) so it cannot fire into a half-torn-down
@ -3604,10 +3788,11 @@ class TelegramAdapter(BasePlatformAdapter):
# Cancel the heartbeat before tearing down the app so the probe task
# cannot fire get_me() into a half-shutdown bot client.
if self._polling_heartbeat_task and not self._polling_heartbeat_task.done():
self._polling_heartbeat_task.cancel()
polling_heartbeat_task = getattr(self, "_polling_heartbeat_task", None)
if polling_heartbeat_task and not polling_heartbeat_task.done():
polling_heartbeat_task.cancel()
try:
await self._polling_heartbeat_task
await polling_heartbeat_task
except asyncio.CancelledError:
pass
self._polling_heartbeat_task = None

View file

@ -137,14 +137,13 @@ async def test_polling_conflict_retries_before_fatal(monkeypatch):
# First conflict: should retry, NOT be fatal
captured["error_callback"](conflict("Conflict: terminated by other getUpdates request"))
await asyncio.sleep(0)
await asyncio.sleep(0)
# Give the scheduled task a chance to run
for _ in range(10):
await asyncio.sleep(0)
await adapter._polling_error_task
assert adapter.has_fatal_error is False, "First conflict should not be fatal"
assert adapter._polling_conflict_count == 0, "Count should reset after successful retry"
assert adapter._polling_conflict_count == 1, (
"Count must remain until the retried generation makes getUpdates progress"
)
assert adapter._send_path_degraded is True
# connect() now starts a lifetime _polling_heartbeat_loop task. With
# asyncio.sleep mocked to instant above, it must not be left running or it
@ -152,6 +151,54 @@ async def test_polling_conflict_retries_before_fatal(monkeypatch):
await _cancel_heartbeat(adapter)
@pytest.mark.asyncio
async def test_current_generation_conflicts_accumulate_after_start_returns(monkeypatch):
"""A later async 409 must advance the retry ladder after PTB start returns."""
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="***"))
callbacks = []
conflict_tasks = []
async def capture_start(**kwargs):
callbacks.append(kwargs["error_callback"])
updater = SimpleNamespace(
start_polling=AsyncMock(side_effect=capture_start),
stop=AsyncMock(),
running=False,
)
app = SimpleNamespace(updater=updater)
adapter._app = app
adapter._drain_polling_connections = AsyncMock()
monkeypatch.setattr("asyncio.sleep", AsyncMock())
def dispatch_conflict(error):
conflict_tasks.append(
asyncio.create_task(adapter._handle_polling_conflict(error))
)
adapter._polling_error_callback_ref = dispatch_conflict
await adapter._start_polling_once(
app,
drop_pending_updates=False,
error_callback=dispatch_conflict,
)
conflict = type("Conflict", (Exception,), {})
try:
callbacks[0](conflict("first async conflict"))
await conflict_tasks[-1]
assert adapter._polling_conflict_count == 1
callbacks[1](conflict("second async conflict"))
await conflict_tasks[-1]
assert adapter._polling_conflict_count == 2
finally:
verifier = adapter._polling_progress_verifier_task
if verifier is not None and not verifier.done():
verifier.cancel()
await asyncio.gather(verifier, return_exceptions=True)
@pytest.mark.asyncio
async def test_polling_conflict_becomes_fatal_after_retries(monkeypatch):
"""After exhausting retries, the conflict should become fatal."""

View file

@ -35,6 +35,7 @@ def _ensure_telegram_mock():
_ensure_telegram_mock()
from plugins.platforms.telegram import adapter as tg_adapter # noqa: E402
from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402
@ -50,6 +51,13 @@ def _make_adapter() -> TelegramAdapter:
return TelegramAdapter(PlatformConfig(enabled=True, token="test-token"))
async def _complete_current_polling_generation(adapter: TelegramAdapter) -> None:
verifier = adapter._polling_progress_verifier_task
adapter._record_polling_progress(adapter._polling_generation)
if verifier is not None:
await verifier
@pytest.mark.asyncio
async def test_reconnect_self_schedules_on_start_polling_failure():
"""
@ -157,9 +165,9 @@ async def test_reconnect_chained_retry_updates_polling_error_task():
@pytest.mark.asyncio
async def test_reconnect_success_resets_error_count():
async def test_reconnect_success_waits_for_progress_to_reset_error_count():
"""
When start_polling() succeeds, _polling_network_error_count should reset to 0.
start_polling() return alone cannot reset the network-error count.
"""
adapter = _make_adapter()
adapter._polling_network_error_count = 3
@ -177,7 +185,12 @@ async def test_reconnect_success_resets_error_count():
with patch("asyncio.sleep", new_callable=AsyncMock):
await adapter._handle_polling_network_error(Exception("Bad Gateway"))
assert adapter._polling_network_error_count == 4
assert adapter._send_path_degraded is True
await _complete_current_polling_generation(adapter)
assert adapter._polling_network_error_count == 0
assert adapter._send_path_degraded is False
# Clean up the heartbeat-probe task scheduled after a successful reconnect.
pending = [t for t in adapter._background_tasks if not t.done()]
@ -263,6 +276,8 @@ async def test_reconnect_drains_polling_request_only():
# Reconnect must still succeed
mock_app.updater.start_polling.assert_called_once()
assert adapter._polling_network_error_count == 2
await _complete_current_polling_generation(adapter)
assert adapter._polling_network_error_count == 0
@ -283,6 +298,8 @@ async def test_reconnect_continues_if_drain_fails():
# start_polling must still be called despite drain failure
mock_app.updater.start_polling.assert_called_once()
assert adapter._polling_network_error_count == 2
await _complete_current_polling_generation(adapter)
assert adapter._polling_network_error_count == 0
@ -339,10 +356,9 @@ async def test_drain_helper_noop_without_app():
@pytest.mark.asyncio
async def test_heartbeat_probe_no_op_when_polling_healthy():
async def test_polling_verifier_exits_on_matching_progress(monkeypatch):
"""
Probe scheduled after a successful reconnect: Updater.running=True and
bot.get_me() returns quickly recovery confirmed, no further action.
Matching getUpdates progress exits without probing the general path.
"""
adapter = _make_adapter()
@ -355,19 +371,20 @@ async def test_heartbeat_probe_no_op_when_polling_healthy():
adapter._app = mock_app
adapter._handle_polling_network_error = AsyncMock()
generation, progress = adapter._begin_polling_generation()
adapter._record_polling_progress(generation)
monkeypatch.setattr(tg_adapter, "_POLLING_PROGRESS_TIMEOUT", 0)
with patch("asyncio.sleep", new_callable=AsyncMock):
await adapter._verify_polling_after_reconnect()
await adapter._verify_polling_after_reconnect(generation, progress)
mock_app.bot.get_me.assert_awaited_once()
mock_app.bot.get_me.assert_not_awaited()
adapter._handle_polling_network_error.assert_not_awaited()
@pytest.mark.asyncio
async def test_heartbeat_probe_reenters_ladder_when_updater_not_running():
async def test_heartbeat_probe_reenters_ladder_when_updater_not_running(monkeypatch):
"""
If Updater.running has flipped to False by the heartbeat delay, treat
as wedged: re-enter the reconnect ladder.
If Updater.running is False at the progress deadline, re-enter recovery.
"""
adapter = _make_adapter()
@ -380,9 +397,10 @@ async def test_heartbeat_probe_reenters_ladder_when_updater_not_running():
adapter._app = mock_app
adapter._handle_polling_network_error = AsyncMock()
generation, progress = adapter._begin_polling_generation()
monkeypatch.setattr(tg_adapter, "_POLLING_PROGRESS_TIMEOUT", 0)
with patch("asyncio.sleep", new_callable=AsyncMock):
await adapter._verify_polling_after_reconnect()
await adapter._verify_polling_after_reconnect(generation, progress)
mock_app.bot.get_me.assert_not_called()
# Recovery is scheduled through _schedule_polling_recovery (#63243), so
@ -397,7 +415,7 @@ async def test_heartbeat_probe_reenters_ladder_when_updater_not_running():
@pytest.mark.asyncio
async def test_heartbeat_probe_reenters_ladder_when_get_me_times_out():
async def test_heartbeat_probe_reenters_ladder_when_get_me_times_out(monkeypatch):
"""
If bot.get_me() hangs longer than PROBE_TIMEOUT, treat as wedged.
Simulates the connection-pool wedge that motivated this fix.
@ -416,15 +434,16 @@ async def test_heartbeat_probe_reenters_ladder_when_get_me_times_out():
adapter._app = mock_app
adapter._handle_polling_network_error = AsyncMock()
generation, progress = adapter._begin_polling_generation()
monkeypatch.setattr(tg_adapter, "_POLLING_PROGRESS_TIMEOUT", 0)
async def fast_wait_for(coro, timeout):
if asyncio.iscoroutine(coro):
coro.close()
raise asyncio.TimeoutError()
with patch("asyncio.sleep", new_callable=AsyncMock):
with patch("plugins.platforms.telegram.adapter.asyncio.wait_for", new=fast_wait_for):
await adapter._verify_polling_after_reconnect()
with patch("plugins.platforms.telegram.adapter.asyncio.wait_for", new=fast_wait_for):
await adapter._verify_polling_after_reconnect(generation, progress)
task = adapter._polling_error_task
assert task is not None
@ -433,7 +452,7 @@ async def test_heartbeat_probe_reenters_ladder_when_get_me_times_out():
@pytest.mark.asyncio
async def test_heartbeat_probe_reenters_ladder_on_get_me_network_error():
async def test_heartbeat_probe_reenters_ladder_on_get_me_network_error(monkeypatch):
"""
Any exception raised by bot.get_me() (NetworkError, ConnectionError, etc.)
should re-enter the reconnect ladder with the original exception.
@ -449,9 +468,10 @@ async def test_heartbeat_probe_reenters_ladder_on_get_me_network_error():
adapter._app = mock_app
adapter._handle_polling_network_error = AsyncMock()
generation, progress = adapter._begin_polling_generation()
monkeypatch.setattr(tg_adapter, "_POLLING_PROGRESS_TIMEOUT", 0)
with patch("asyncio.sleep", new_callable=AsyncMock):
await adapter._verify_polling_after_reconnect()
await adapter._verify_polling_after_reconnect(generation, progress)
task = adapter._polling_error_task
assert task is not None
@ -466,7 +486,7 @@ async def test_heartbeat_probe_reenters_ladder_on_get_me_network_error():
@pytest.mark.asyncio
async def test_heartbeat_probe_ignores_auth_errors():
async def test_heartbeat_probe_ignores_auth_errors(monkeypatch):
"""
Auth/validation failures from the post-reconnect probe must not enter the
network-reconnect ladder (#63243): a revoked token would otherwise churn
@ -487,16 +507,17 @@ async def test_heartbeat_probe_ignores_auth_errors():
adapter._app = mock_app
adapter._handle_polling_network_error = AsyncMock()
generation, progress = adapter._begin_polling_generation()
monkeypatch.setattr(tg_adapter, "_POLLING_PROGRESS_TIMEOUT", 0)
with patch("asyncio.sleep", new_callable=AsyncMock):
await adapter._verify_polling_after_reconnect()
await adapter._verify_polling_after_reconnect(generation, progress)
assert adapter._polling_error_task is None
adapter._handle_polling_network_error.assert_not_awaited()
@pytest.mark.asyncio
async def test_heartbeat_probe_defers_to_inflight_recovery():
async def test_heartbeat_probe_defers_to_inflight_recovery(monkeypatch):
"""
A probe failure while another recovery is mid-flight must not start a
second concurrent stop/drain/start_polling sequence (#63243) — overlapping
@ -517,16 +538,17 @@ async def test_heartbeat_probe_defers_to_inflight_recovery():
adapter._polling_error_task = inflight
adapter._handle_polling_network_error = AsyncMock()
generation, progress = adapter._begin_polling_generation()
monkeypatch.setattr(tg_adapter, "_POLLING_PROGRESS_TIMEOUT", 0)
with patch("asyncio.sleep", new_callable=AsyncMock):
await adapter._verify_polling_after_reconnect()
await adapter._verify_polling_after_reconnect(generation, progress)
assert adapter._polling_error_task is inflight
adapter._handle_polling_network_error.assert_not_awaited()
@pytest.mark.asyncio
async def test_heartbeat_probe_skips_when_already_fatal():
async def test_heartbeat_probe_skips_when_already_fatal(monkeypatch):
"""
If the adapter is already in fatal-error state by the time the probe
delay elapses, the probe should bail without further action.
@ -539,9 +561,10 @@ async def test_heartbeat_probe_skips_when_already_fatal():
adapter._app = mock_app
adapter._handle_polling_network_error = AsyncMock()
generation, progress = adapter._begin_polling_generation()
monkeypatch.setattr(tg_adapter, "_POLLING_PROGRESS_TIMEOUT", 0)
with patch("asyncio.sleep", new_callable=AsyncMock):
await adapter._verify_polling_after_reconnect()
await adapter._verify_polling_after_reconnect(generation, progress)
mock_app.bot.get_me.assert_not_called()
adapter._handle_polling_network_error.assert_not_awaited()
@ -1060,4 +1083,3 @@ async def test_handle_polling_network_error_updater_stop_timeout():
# The reconnect ladder must have advanced past the hung stop().
assert drain_called, "_drain_polling_connections was not called after stop() timeout"
assert start_polling_called, "start_polling was not called after stop() timeout"

View file

@ -1,6 +1,8 @@
"""Behavior contract for generation-safe Telegram polling progress."""
import asyncio
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
@ -14,6 +16,11 @@ class _ControlledRequest:
instances = []
@staticmethod
def parse_json_payload(payload):
"""Match PTB's response authority used by the progress observer."""
return json.loads(payload.decode("utf-8", "replace"))
def __init__(self, *args, result=None, error=None, entered=None, release=None, **kwargs):
self.result = result
self.error = error
@ -37,18 +44,183 @@ def _make_adapter() -> TelegramAdapter:
return TelegramAdapter(PlatformConfig(enabled=True, token="test-token"))
def _mock_polling_app(*, get_me=None):
app = MagicMock()
app.updater = MagicMock()
app.updater.running = True
app.updater.stop = AsyncMock()
app.updater.start_polling = AsyncMock()
app.bot = MagicMock()
app.bot.get_me = get_me or AsyncMock(return_value=MagicMock())
app.running = False
app.shutdown = AsyncMock()
return app
class _LifecycleBuilder:
def __init__(self, app):
self.app = app
self.polling_request = None
def token(self, _token):
return self
def request(self, _request):
return self
def get_updates_request(self, request):
self.polling_request = request
return self
def build(self):
return self.app
def _lifecycle_app():
app = MagicMock()
app.updater = MagicMock()
app.updater.running = True
app.updater.start_polling = AsyncMock()
app.updater.start_webhook = AsyncMock()
app.updater.stop = AsyncMock()
app.bot = MagicMock()
app.bot.delete_webhook = AsyncMock()
app.initialize = AsyncMock()
app.start = AsyncMock()
app.stop = AsyncMock()
app.shutdown = AsyncMock()
app.running = True
return app
def _configure_lifecycle_connect(monkeypatch, adapter, apps):
builders = [_LifecycleBuilder(app) for app in apps]
remaining = iter(builders)
class _Application:
@staticmethod
def builder():
return next(remaining)
async def _no_fallback_ips():
return []
monkeypatch.setattr(tg_adapter, "Application", _Application)
monkeypatch.setattr(tg_adapter, "HTTPXRequest", _ControlledRequest)
monkeypatch.setattr(tg_adapter, "discover_fallback_ips", _no_fallback_ips)
monkeypatch.setattr(tg_adapter, "resolve_proxy_url", lambda *args, **kwargs: None)
monkeypatch.setattr(adapter, "_acquire_platform_lock", lambda *args, **kwargs: True)
monkeypatch.setattr(adapter, "_release_platform_lock", MagicMock())
monkeypatch.setattr(adapter, "_fallback_ips", lambda: [])
monkeypatch.setattr(adapter, "_start_post_connect_housekeeping", MagicMock())
return builders
async def _cancel_task(task):
if task is None or task.done():
return
task.cancel()
await asyncio.gather(task, return_exceptions=True)
async def _request_for_generation(generation, request, *args):
"""Run a direct request double under the production polling context."""
generation_context = tg_adapter._POLLING_GENERATION_CONTEXT
token = generation_context.set(generation)
try:
return await request.do_request(*args)
finally:
generation_context.reset(token)
@pytest.mark.asyncio
async def test_polling_disconnect_webhook_reconnect_heals_webhook_send_path(monkeypatch):
adapter = _make_adapter()
polling_app = _lifecycle_app()
webhook_app = _lifecycle_app()
_configure_lifecycle_connect(monkeypatch, adapter, [polling_app, webhook_app])
monkeypatch.delenv("TELEGRAM_WEBHOOK_URL", raising=False)
monkeypatch.delenv("TELEGRAM_WEBHOOK_SECRET", raising=False)
assert await adapter.connect() is True
assert adapter._webhook_mode is False
assert adapter._send_path_degraded is True
await adapter.disconnect()
monkeypatch.setenv("TELEGRAM_WEBHOOK_URL", "https://example.test/telegram")
monkeypatch.setenv("TELEGRAM_WEBHOOK_SECRET", "test-secret")
try:
assert await adapter.connect(is_reconnect=True) is True
webhook_app.updater.start_webhook.assert_awaited_once()
assert adapter._webhook_mode is True
assert adapter._polling_progress_accepting is False
assert adapter._send_path_degraded is False
finally:
await adapter.disconnect()
@pytest.mark.asyncio
async def test_webhook_disconnect_polling_reconnect_resets_mode_and_waits_for_progress(
monkeypatch,
):
adapter = _make_adapter()
webhook_app = _lifecycle_app()
polling_app = _lifecycle_app()
builders = _configure_lifecycle_connect(
monkeypatch, adapter, [webhook_app, polling_app]
)
heartbeat_started = asyncio.Event()
heartbeat_modes = []
async def heartbeat():
heartbeat_modes.append(adapter._webhook_mode)
heartbeat_started.set()
await asyncio.Event().wait()
monkeypatch.setattr(adapter, "_polling_heartbeat_loop", heartbeat)
monkeypatch.setenv("TELEGRAM_WEBHOOK_URL", "https://example.test/telegram")
monkeypatch.setenv("TELEGRAM_WEBHOOK_SECRET", "test-secret")
assert await adapter.connect() is True
assert adapter._webhook_mode is True
assert adapter._polling_heartbeat_task is None
await adapter.disconnect()
monkeypatch.delenv("TELEGRAM_WEBHOOK_URL")
monkeypatch.delenv("TELEGRAM_WEBHOOK_SECRET")
try:
assert await adapter.connect(is_reconnect=True) is True
assert adapter._webhook_mode is False
assert adapter._polling_heartbeat_task is not None
assert not adapter._polling_heartbeat_task.done()
await asyncio.wait_for(heartbeat_started.wait(), timeout=1)
assert heartbeat_modes == [False]
assert adapter._send_path_degraded is True
generation = adapter._polling_generation
polling_request = builders[1].polling_request
polling_request.result = (200, b'{"ok":true,"result":[]}')
await _request_for_generation(generation, polling_request, "getUpdates")
await asyncio.wait_for(adapter._polling_progress_verifier_task, timeout=1)
assert adapter._send_path_degraded is False
finally:
await adapter.disconnect()
@pytest.mark.asyncio
async def test_current_polling_generation_success_records_progress():
adapter = _make_adapter()
generation, progress = adapter._begin_polling_generation()
adapter._polling_network_error_count = 3
request = _ControlledRequest(result=(200, b'{"ok":true}'))
request = _ControlledRequest(result=(200, b'{"ok":true,"result":[]}'))
instrumented = adapter._instrument_polling_request(request)
result = await instrumented.do_request("https://api.telegram.org/getUpdates")
result = await _request_for_generation(
generation, instrumented, "https://api.telegram.org/getUpdates"
)
assert instrumented is request
assert result == (200, b'{"ok":true}')
assert result == (200, b'{"ok":true,"result":[]}')
assert progress.is_set()
assert adapter._polling_network_error_count == 0
assert adapter._send_path_degraded is False
@ -59,14 +231,16 @@ async def test_current_polling_generation_success_records_progress():
@pytest.mark.parametrize("error_type", [RuntimeError, asyncio.CancelledError])
async def test_unsuccessful_polling_request_does_not_record_progress(error_type):
adapter = _make_adapter()
_, progress = adapter._begin_polling_generation()
generation, progress = adapter._begin_polling_generation()
adapter._polling_network_error_count = 3
request = adapter._instrument_polling_request(
_ControlledRequest(error=error_type("request did not complete"))
)
with pytest.raises(error_type):
await request.do_request("https://api.telegram.org/getUpdates")
await _request_for_generation(
generation, request, "https://api.telegram.org/getUpdates"
)
assert not progress.is_set()
assert adapter._polling_network_error_count == 3
@ -76,13 +250,15 @@ async def test_unsuccessful_polling_request_does_not_record_progress(error_type)
@pytest.mark.asyncio
async def test_http_error_response_does_not_record_polling_progress():
adapter = _make_adapter()
_, progress = adapter._begin_polling_generation()
generation, progress = adapter._begin_polling_generation()
adapter._polling_network_error_count = 3
request = adapter._instrument_polling_request(
_ControlledRequest(result=(500, b"bad"))
)
result = await request.do_request("https://api.telegram.org/getUpdates")
result = await _request_for_generation(
generation, request, "https://api.telegram.org/getUpdates"
)
assert result == (500, b"bad")
assert not progress.is_set()
@ -155,17 +331,444 @@ async def test_late_previous_generation_completion_cannot_heal_current_generatio
entered = asyncio.Event()
release = asyncio.Event()
request = adapter._instrument_polling_request(
_ControlledRequest(result=(200, b'{"ok":true}'), entered=entered, release=release)
_ControlledRequest(
result=(200, b'{"ok":true,"result":[]}'),
entered=entered,
release=release,
)
)
completion = asyncio.create_task(request.do_request("getUpdates"))
completion = asyncio.create_task(
_request_for_generation(generation_1, request, "getUpdates")
)
await entered.wait()
generation_2, progress_2 = adapter._begin_polling_generation()
adapter._polling_network_error_count = 4
release.set()
assert await completion == (200, b'{"ok":true}')
assert await completion == (200, b'{"ok":true,"result":[]}')
assert generation_2 == generation_1 + 1
assert not progress_2.is_set()
assert adapter._polling_network_error_count == 4
assert adapter._send_path_degraded is True
@pytest.mark.asyncio
async def test_old_polling_child_keeps_generation_when_request_entry_is_delayed():
adapter = _make_adapter()
adapter._app = _mock_polling_app()
release_old_request = asyncio.Event()
start_count = 0
old_child = None
request = adapter._instrument_polling_request(
_ControlledRequest(result=(200, b'{"ok":true,"result":[]}'))
)
async def start_polling(**_kwargs):
nonlocal start_count, old_child
start_count += 1
if start_count == 1:
async def delayed_old_request():
await release_old_request.wait()
return await request.do_request("getUpdates")
old_child = asyncio.create_task(delayed_old_request())
adapter._app.updater.start_polling = start_polling
await adapter._start_polling_once(
adapter._app,
drop_pending_updates=False,
error_callback=MagicMock(),
)
generation_1 = adapter._polling_generation
await adapter._start_polling_once(
adapter._app,
drop_pending_updates=False,
error_callback=MagicMock(),
)
generation_2 = adapter._polling_generation
progress_2 = adapter._polling_progress_event
verifier_2 = adapter._polling_progress_verifier_task
try:
release_old_request.set()
assert await old_child == (200, b'{"ok":true,"result":[]}')
assert generation_2 == generation_1 + 1
assert not progress_2.is_set()
assert adapter._send_path_degraded is True
finally:
await _cancel_task(verifier_2)
@pytest.mark.asyncio
async def test_error_callback_is_bound_to_its_polling_generation():
adapter = _make_adapter()
adapter._app = _mock_polling_app()
callbacks = []
delegated = MagicMock()
async def capture_start(**kwargs):
callbacks.append(kwargs["error_callback"])
adapter._app.updater.start_polling = capture_start
await adapter._start_polling_once(
adapter._app,
drop_pending_updates=False,
error_callback=delegated,
)
await adapter._start_polling_once(
adapter._app,
drop_pending_updates=False,
error_callback=delegated,
)
verifier = adapter._polling_progress_verifier_task
stale_error = ConnectionError("stale generation")
current_error = ConnectionError("current generation")
try:
callbacks[0](stale_error)
delegated.assert_not_called()
callbacks[1](current_error)
delegated.assert_called_once_with(current_error)
finally:
await _cancel_task(verifier)
@pytest.mark.asyncio
async def test_cold_start_waits_for_get_updates_progress_before_healing():
adapter = _make_adapter()
adapter._app = _mock_polling_app()
started = await adapter._start_polling_resilient(
drop_pending_updates=True,
error_callback=MagicMock(),
)
verifier = adapter._polling_progress_verifier_task
assert started is True
assert adapter._app.updater.running is True
assert adapter._send_path_degraded is True
assert verifier is not None and not verifier.done()
assert verifier in adapter._background_tasks
assert [task for task in adapter._background_tasks if not task.done()] == [verifier]
await _cancel_task(verifier)
@pytest.mark.asyncio
async def test_matching_get_updates_progress_heals_and_stops_verifier(monkeypatch):
adapter = _make_adapter()
adapter._app = _mock_polling_app()
recovery = MagicMock()
monkeypatch.setattr(adapter, "_schedule_polling_recovery", recovery)
await adapter._start_polling_resilient(
drop_pending_updates=False,
error_callback=MagicMock(),
)
verifier = adapter._polling_progress_verifier_task
request = adapter._instrument_polling_request(
_ControlledRequest(result=(200, b'{"ok":true,"result":[]}'))
)
await _request_for_generation(
adapter._polling_generation, request, "getUpdates"
)
await asyncio.wait_for(verifier, timeout=1)
assert adapter._send_path_degraded is False
assert verifier.done()
recovery.assert_not_called()
@pytest.mark.asyncio
async def test_general_path_success_without_get_updates_progress_recovers_once(monkeypatch):
adapter = _make_adapter()
adapter._app = _mock_polling_app()
recovery = MagicMock()
monkeypatch.setattr(tg_adapter, "_POLLING_PROGRESS_TIMEOUT", 0.01, raising=False)
monkeypatch.setattr(adapter, "_schedule_polling_recovery", recovery)
await adapter._start_polling_resilient(
drop_pending_updates=False,
error_callback=MagicMock(),
)
verifier = adapter._polling_progress_verifier_task
await asyncio.wait_for(verifier, timeout=1)
assert adapter._app.bot.get_me.await_count == 1
recovery.assert_called_once()
error = recovery.call_args.args[0]
assert isinstance(error, RuntimeError)
assert str(error) == "getUpdates made no progress before verifier deadline"
assert adapter._send_path_degraded is True
@pytest.mark.asyncio
@pytest.mark.parametrize(
("probe_error", "should_recover"),
[
(ConnectionError("pool wedged"), True),
(type("InvalidToken", (Exception,), {})("token revoked"), False),
],
)
async def test_general_path_error_only_recovers_connectivity_failures(
monkeypatch, probe_error, should_recover
):
adapter = _make_adapter()
adapter._app = _mock_polling_app(get_me=AsyncMock(side_effect=probe_error))
recovery = MagicMock()
monkeypatch.setattr(tg_adapter, "_POLLING_PROGRESS_TIMEOUT", 0.01, raising=False)
monkeypatch.setattr(adapter, "_schedule_polling_recovery", recovery)
await adapter._start_polling_resilient(
drop_pending_updates=False,
error_callback=MagicMock(),
)
await asyncio.wait_for(adapter._polling_progress_verifier_task, timeout=1)
assert recovery.called is should_recover
if should_recover:
assert recovery.call_args.args[0] is probe_error
assert adapter._send_path_degraded is True
@pytest.mark.asyncio
@pytest.mark.parametrize("retry_kind", ["network", "conflict"])
async def test_retry_start_requires_matching_progress_to_heal(monkeypatch, retry_kind):
adapter = _make_adapter()
adapter._app = _mock_polling_app()
adapter._polling_error_callback_ref = MagicMock()
adapter._polling_network_error_count = 3
monkeypatch.setattr(tg_adapter.asyncio, "sleep", AsyncMock())
if retry_kind == "network":
await adapter._handle_polling_network_error(ConnectionError("offline"))
assert adapter._polling_network_error_count == 4
else:
await adapter._handle_polling_conflict(
RuntimeError("Conflict: terminated by other getUpdates request")
)
assert adapter._polling_network_error_count == 3
assert adapter._polling_conflict_count == 1
generation = adapter._polling_generation
verifier = adapter._polling_progress_verifier_task
assert generation > 0
assert verifier is not None and not verifier.done()
assert adapter._send_path_degraded is True
request = adapter._instrument_polling_request(
_ControlledRequest(result=(200, b'{"ok":true,"result":[]}'))
)
await _request_for_generation(generation, request, "getUpdates")
await asyncio.wait_for(verifier, timeout=1)
assert adapter._polling_network_error_count == 0
assert adapter._polling_conflict_count == 0
assert adapter._send_path_degraded is False
@pytest.mark.asyncio
async def test_repeated_starts_replace_verifier_and_stale_verifier_cannot_heal():
adapter = _make_adapter()
adapter._app = _mock_polling_app()
await adapter._start_polling_resilient(
drop_pending_updates=False, error_callback=MagicMock()
)
generation_1 = adapter._polling_generation
progress_1 = adapter._polling_progress_event
verifier_1 = adapter._polling_progress_verifier_task
await adapter._start_polling_resilient(
drop_pending_updates=False, error_callback=MagicMock()
)
verifier_2 = adapter._polling_progress_verifier_task
await asyncio.sleep(0)
assert adapter._polling_generation == generation_1 + 1
assert verifier_1.cancelled()
assert verifier_2 is not verifier_1 and not verifier_2.done()
assert [task for task in adapter._background_tasks if not task.done()] == [verifier_2]
progress_1.set()
await asyncio.sleep(0)
assert adapter._send_path_degraded is True
await _cancel_task(verifier_2)
@pytest.mark.asyncio
async def test_disconnect_fences_verifier_and_late_progress_completion():
adapter = _make_adapter()
adapter._app = _mock_polling_app()
await adapter._start_polling_resilient(
drop_pending_updates=False, error_callback=MagicMock()
)
generation = adapter._polling_generation
verifier = adapter._polling_progress_verifier_task
entered = asyncio.Event()
release = asyncio.Event()
request = adapter._instrument_polling_request(
_ControlledRequest(
result=(200, b'{"ok":true,"result":[]}'),
entered=entered,
release=release,
)
)
completion = asyncio.create_task(
_request_for_generation(generation, request, "getUpdates")
)
await entered.wait()
await adapter.disconnect()
assert verifier.done()
assert adapter._polling_progress_verifier_task is None
assert adapter._polling_progress_accepting is False
assert adapter._polling_generation > generation
assert adapter._send_path_degraded is True
release.set()
assert await completion == (200, b'{"ok":true,"result":[]}')
assert adapter._send_path_degraded is True
@pytest.mark.asyncio
async def test_disconnect_during_polling_start_returns_false_without_recovery():
adapter = _make_adapter()
adapter._app = _mock_polling_app()
entered = asyncio.Event()
release = asyncio.Event()
recovery = MagicMock()
adapter._schedule_polling_recovery = recovery
async def blocked_start(**_kwargs):
entered.set()
await release.wait()
adapter._app.updater.start_polling = blocked_start
start = asyncio.create_task(
adapter._start_polling_resilient(
drop_pending_updates=False,
error_callback=MagicMock(),
)
)
await entered.wait()
await adapter.disconnect()
release.set()
result = await start
assert result is False
recovery.assert_not_called()
assert adapter._polling_error_task is None
assert not adapter.has_fatal_error
assert adapter._send_path_degraded is True
@pytest.mark.asyncio
async def test_caller_cancellation_during_polling_start_still_propagates():
adapter = _make_adapter()
adapter._app = _mock_polling_app()
entered = asyncio.Event()
release = asyncio.Event()
recovery = MagicMock()
adapter._schedule_polling_recovery = recovery
async def blocked_start(**_kwargs):
entered.set()
await release.wait()
adapter._app.updater.start_polling = blocked_start
start = asyncio.create_task(
adapter._start_polling_resilient(
drop_pending_updates=False,
error_callback=MagicMock(),
)
)
await entered.wait()
start.cancel()
with pytest.raises(asyncio.CancelledError):
await start
assert start.cancelled()
recovery.assert_not_called()
assert not adapter.has_fatal_error
assert adapter._send_path_degraded is True
await adapter.disconnect()
@pytest.mark.asyncio
async def test_disconnect_cancels_recovery_before_it_can_rearm_progress(monkeypatch):
adapter = _make_adapter()
adapter._app = _mock_polling_app()
adapter._app.updater.running = False
adapter._polling_error_callback_ref = MagicMock()
drain_entered = asyncio.Event()
release_drain = asyncio.Event()
start_entered = asyncio.Event()
release_start = asyncio.Event()
teardown_paused = asyncio.Event()
release_teardown = asyncio.Event()
async def immediate_backoff(_delay):
return None
async def blocked_drain():
drain_entered.set()
await release_drain.wait()
async def blocked_start_polling(**_kwargs):
start_entered.set()
await release_start.wait()
async def blocked_status_indicator(*, online):
assert online is False
teardown_paused.set()
await release_teardown.wait()
monkeypatch.setattr(tg_adapter.asyncio, "sleep", immediate_backoff)
monkeypatch.setattr(adapter, "_drain_polling_connections", blocked_drain)
monkeypatch.setattr(
adapter._app.updater, "start_polling", blocked_start_polling
)
monkeypatch.setattr(adapter, "_set_status_indicator", blocked_status_indicator)
recovery = asyncio.create_task(
adapter._handle_polling_network_error(ConnectionError("offline"))
)
adapter._polling_error_task = recovery
await drain_entered.wait()
disconnect = asyncio.create_task(adapter.disconnect())
await teardown_paused.wait()
try:
# Before the fix, disconnect pauses here before cancelling recovery.
# Releasing the recovery lets it begin a fresh generation after the
# teardown fence, and matching progress can then heal the adapter.
if not recovery.done():
release_drain.set()
await start_entered.wait()
rearmed_after_fence = adapter._polling_progress_accepting
adapter._record_polling_progress(adapter._polling_generation)
assert rearmed_after_fence is False
assert getattr(adapter, "_polling_teardown_started", False) is True
assert adapter._polling_progress_accepting is False
assert adapter._send_path_degraded is True
assert recovery.done()
finally:
release_drain.set()
release_start.set()
release_teardown.set()
for task in (recovery, disconnect):
if not task.done():
task.cancel()
await asyncio.gather(recovery, disconnect, return_exceptions=True)

View file

@ -65,49 +65,32 @@ 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 while reconnecting; if the
reconnect attempt itself raises (polling not yet healthy), the flag stays
True until a later successful heartbeat probe in
_verify_polling_after_reconnect clears it."""
async def test_get_me_success_without_polling_progress_does_not_heal(monkeypatch):
"""A responsive general Bot API path is not proof that getUpdates works."""
adapter = _make_adapter()
adapter._app = MagicMock()
adapter._app.updater = MagicMock()
adapter._app.updater.running = True
adapter._app.updater.stop = AsyncMock()
# First start_polling attempt fails — the reconnect handler must leave the
# flag set (path still unhealthy) and not clear it prematurely.
adapter._app.updater.start_polling = AsyncMock(side_effect=OSError("still down"))
adapter._app.bot = MagicMock()
adapter._app.bot.get_me = AsyncMock(return_value=MagicMock())
adapter._polling_error_callback_ref = AsyncMock()
monkeypatch.setattr(
"plugins.platforms.telegram.adapter.Update", MagicMock(ALL_TYPES=[])
)
# Suppress the self-rescheduled retry so the test doesn't recurse.
monkeypatch.setattr(
"plugins.platforms.telegram.adapter.asyncio.ensure_future", MagicMock()
)
with patch("plugins.platforms.telegram.adapter.asyncio.sleep", new_callable=AsyncMock):
await adapter._handle_polling_network_error(OSError("Bad Gateway"))
# start_polling failed → path still degraded.
generation, progress = adapter._begin_polling_generation()
recovery = MagicMock()
monkeypatch.setattr(adapter, "_schedule_polling_recovery", recovery)
monkeypatch.setattr(
"plugins.platforms.telegram.adapter._POLLING_PROGRESS_TIMEOUT", 0,
raising=False,
)
await adapter._verify_polling_after_reconnect(generation, progress)
adapter._app.bot.get_me.assert_awaited_once()
recovery.assert_called_once()
assert adapter._send_path_degraded is True
# Now the deferred probe runs against a recovered (running) updater and
# a responsive bot — it clears the flag.
adapter._app.updater.running = True
with patch("plugins.platforms.telegram.adapter.asyncio.sleep", new_callable=AsyncMock):
await adapter._verify_polling_after_reconnect()
assert adapter._send_path_degraded is False
@pytest.mark.asyncio
async def test_successful_reconnect_clears_flag_without_probe(monkeypatch):
"""Regression for #35205: a successful start_polling() clears
_send_path_degraded immediately, so outbound sends are not blocked for
the full HEARTBEAT_PROBE_DELAY window (and never get stuck True if the
deferred probe is never scheduled / never runs)."""
async def test_successful_reconnect_waits_for_get_updates_progress(monkeypatch):
"""start_polling() return alone cannot heal; matching progress can."""
adapter = _make_adapter()
adapter._app = MagicMock()
adapter._app.updater = MagicMock()
@ -120,17 +103,18 @@ async def test_successful_reconnect_clears_flag_without_probe(monkeypatch):
monkeypatch.setattr(
"plugins.platforms.telegram.adapter.Update", MagicMock(ALL_TYPES=[])
)
# Don't let the deferred probe run — prove the clear happens in the
# reconnect handler itself, not in _verify_polling_after_reconnect.
monkeypatch.setattr(
adapter, "_verify_polling_after_reconnect", AsyncMock()
)
with patch("plugins.platforms.telegram.adapter.asyncio.sleep", new_callable=AsyncMock):
await adapter._handle_polling_network_error(OSError("Bad Gateway"))
verifier = adapter._polling_progress_verifier_task
assert adapter._send_path_degraded is True
assert adapter._polling_network_error_count == 1
blocked = await adapter.send("123", "hello")
assert blocked.success is False
adapter._record_polling_progress(adapter._polling_generation)
await verifier
assert adapter._send_path_degraded is False
assert adapter._polling_network_error_count == 0
# And send() works again right away.
result = await adapter.send("123", "hello")
assert result.success is True

View file

@ -0,0 +1,292 @@
"""Integration coverage for polling progress against the installed PTB runtime."""
import asyncio
import pytest
from telegram.error import Conflict, TelegramError
from telegram.request import BaseRequest
from gateway.config import PlatformConfig
from plugins.platforms.telegram import adapter as tg_adapter
from plugins.platforms.telegram.adapter import TelegramAdapter
class _GeneralRequest(BaseRequest):
@property
def read_timeout(self):
return 10
async def initialize(self):
return None
async def shutdown(self):
return None
async def do_request(self, url, method, request_data=None, **_kwargs):
if url.endswith("/getMe"):
return (
200,
b'{"ok":true,"result":{"id":1,"is_bot":true,'
b'"first_name":"Test","username":"test_bot"}}',
)
return 200, b'{"ok":true,"result":true}'
class _GetUpdatesRequest(BaseRequest):
def __init__(self):
self.initial_conflict_sent = False
self.replacement_enabled = False
self.replacement_progress_sent = False
self.cleanup_calls = 0
self.block = asyncio.Event()
@property
def read_timeout(self):
return 10
async def initialize(self):
return None
async def shutdown(self):
return None
async def do_request(self, url, method, request_data=None, **_kwargs):
parameters = request_data.parameters if request_data is not None else {}
timeout = parameters.get("timeout")
timeout_seconds = (
timeout.total_seconds() if hasattr(timeout, "total_seconds") else timeout
)
if timeout_seconds == 0:
self.cleanup_calls += 1
return 200, b'{"ok":true,"result":[]}'
if not self.initial_conflict_sent:
self.initial_conflict_sent = True
return (
409,
b'{"ok":false,"error_code":409,'
b'"description":"Conflict: another getUpdates request"}',
)
if self.replacement_enabled and not self.replacement_progress_sent:
self.replacement_progress_sent = True
return 200, b'{"ok":true,"result":[]}'
await self.block.wait()
return 200, b'{"ok":true,"result":[]}'
class _EnvelopeRequest(BaseRequest):
def __init__(self, payload):
self.payload = payload
@property
def read_timeout(self):
return 10
async def initialize(self):
return None
async def shutdown(self):
return None
async def do_request(self, url, method, request_data=None, **_kwargs):
return 200, self.payload
async def _cancel_task(task):
if task is None or task.done():
return
task.cancel()
await asyncio.gather(task, return_exceptions=True)
@pytest.mark.asyncio
async def test_real_base_request_invalid_200_body_cannot_record_progress():
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="123456:test-token"))
generation, progress = adapter._begin_polling_generation()
adapter._polling_network_error_count = 4
adapter._polling_conflict_count = 3
request = adapter._instrument_polling_request(_EnvelopeRequest(b"not-json"))
context_token = tg_adapter._POLLING_GENERATION_CONTEXT.set(generation)
try:
with pytest.raises(TelegramError, match="Invalid server response"):
await request.post("https://api.telegram.org/bot-token/getUpdates")
finally:
tg_adapter._POLLING_GENERATION_CONTEXT.reset(context_token)
assert not progress.is_set()
assert adapter._polling_network_error_count == 4
assert adapter._polling_conflict_count == 3
assert adapter._send_path_degraded is True
@pytest.mark.asyncio
async def test_real_base_request_bom_rejected_by_ptb_cannot_record_progress():
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="123456:test-token"))
generation, progress = adapter._begin_polling_generation()
adapter._polling_network_error_count = 4
adapter._polling_conflict_count = 3
request = adapter._instrument_polling_request(
_EnvelopeRequest(b'\xef\xbb\xbf{"ok":true,"result":[]}')
)
context_token = tg_adapter._POLLING_GENERATION_CONTEXT.set(generation)
try:
with pytest.raises(TelegramError, match="Invalid server response"):
await request.post("https://api.telegram.org/bot-token/getUpdates")
finally:
tg_adapter._POLLING_GENERATION_CONTEXT.reset(context_token)
assert not progress.is_set()
assert adapter._polling_network_error_count == 4
assert adapter._polling_conflict_count == 3
assert adapter._send_path_degraded is True
@pytest.mark.asyncio
async def test_real_base_request_ptb_replacement_decode_records_progress():
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="123456:test-token"))
generation, progress = adapter._begin_polling_generation()
adapter._polling_network_error_count = 4
adapter._polling_conflict_count = 3
request = adapter._instrument_polling_request(
_EnvelopeRequest(b'{"ok":true,"result":[],"note":"\xff"}')
)
context_token = tg_adapter._POLLING_GENERATION_CONTEXT.set(generation)
try:
result = await request.post("https://api.telegram.org/bot-token/getUpdates")
finally:
tg_adapter._POLLING_GENERATION_CONTEXT.reset(context_token)
assert result == []
assert progress.is_set()
assert adapter._polling_network_error_count == 0
assert adapter._polling_conflict_count == 0
assert adapter._send_path_degraded is False
@pytest.mark.asyncio
@pytest.mark.parametrize(
("payload", "missing_result"),
[
(b'{"ok":false,"result":[]}', False),
(b'{"ok":true}', True),
],
)
async def test_real_base_request_unsuccessful_200_envelope_cannot_record_progress(
payload, missing_result
):
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="123456:test-token"))
generation, progress = adapter._begin_polling_generation()
adapter._polling_network_error_count = 4
adapter._polling_conflict_count = 3
request = adapter._instrument_polling_request(_EnvelopeRequest(payload))
context_token = tg_adapter._POLLING_GENERATION_CONTEXT.set(generation)
try:
if missing_result:
with pytest.raises(KeyError, match="result"):
await request.post("https://api.telegram.org/bot-token/getUpdates")
else:
assert await request.post(
"https://api.telegram.org/bot-token/getUpdates"
) == []
finally:
tg_adapter._POLLING_GENERATION_CONTEXT.reset(context_token)
assert not progress.is_set()
assert adapter._polling_network_error_count == 4
assert adapter._polling_conflict_count == 3
assert adapter._send_path_degraded is True
@pytest.mark.asyncio
async def test_real_base_request_valid_success_envelope_records_progress():
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="123456:test-token"))
generation, progress = adapter._begin_polling_generation()
adapter._polling_network_error_count = 4
adapter._polling_conflict_count = 3
request = adapter._instrument_polling_request(
_EnvelopeRequest(b'{"ok":true,"result":[]}')
)
context_token = tg_adapter._POLLING_GENERATION_CONTEXT.set(generation)
try:
result = await request.post(
"https://api.telegram.org/bot-token/getUpdates"
)
finally:
tg_adapter._POLLING_GENERATION_CONTEXT.reset(context_token)
assert result == []
assert progress.is_set()
assert adapter._polling_network_error_count == 0
assert adapter._polling_conflict_count == 0
assert adapter._send_path_degraded is False
@pytest.mark.asyncio
async def test_real_ptb_stop_cleanup_cannot_heal_recovery_generation():
assert tg_adapter.TELEGRAM_AVAILABLE is True
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="123456:test-token"))
polling_request = _GetUpdatesRequest()
app = (
tg_adapter.Application.builder()
.token("123456:test-token")
.request(_GeneralRequest())
.get_updates_request(adapter._instrument_polling_request(polling_request))
.build()
)
adapter._app = app
adapter._polling_network_error_count = 4
adapter._polling_conflict_count = 3
callback_called = asyncio.Event()
recovery_task = None
async def stop_for_recovery():
await app.updater.stop()
def schedule_recovery(error):
nonlocal recovery_task
assert isinstance(error, Conflict)
recovery_task = asyncio.create_task(stop_for_recovery())
callback_called.set()
await app.initialize()
try:
await adapter._start_polling_once(
app,
drop_pending_updates=False,
error_callback=schedule_recovery,
)
generation = adapter._polling_generation
progress = adapter._polling_progress_event
await asyncio.wait_for(callback_called.wait(), timeout=2)
await asyncio.wait_for(recovery_task, timeout=3)
assert polling_request.cleanup_calls == 1
assert not progress.is_set()
assert adapter._polling_network_error_count == 4
assert adapter._polling_conflict_count == 3
assert adapter._send_path_degraded is True
polling_request.replacement_enabled = True
await adapter._start_polling_once(
app,
drop_pending_updates=False,
error_callback=schedule_recovery,
)
replacement_generation = adapter._polling_generation
replacement_progress = adapter._polling_progress_event
await asyncio.wait_for(replacement_progress.wait(), timeout=2)
assert replacement_generation == generation + 1
assert adapter._polling_network_error_count == 0
assert adapter._polling_conflict_count == 0
assert adapter._send_path_degraded is False
finally:
polling_request.block.set()
if app.updater.running:
await app.updater.stop()
await _cancel_task(adapter._polling_progress_verifier_task)
await app.shutdown()