From 26d906c60e84747d30c55592386a2ee298722e7b Mon Sep 17 00:00:00 2001 From: andrexibiza <84248988+andrexibiza@users.noreply.github.com> Date: Mon, 3 Aug 2026 03:03:07 -0500 Subject: [PATCH] refactor(gateway): extract transient-error helpers from run.py (slice 5 of #54962) Fifth slice of the gateway god-file unpacking: extract _is_transient_network_error and _gateway_loop_exception_handler into gateway/error_helpers.py. - Byte-identical extraction (AST-verified against origin/main): the classifier walks the exception cause chain (#31066/#31110); the loop handler wires it into the event-loop safety net - gateway/run.py: -76 lines; helpers imported at the extraction point - 8 tests pass (loop exception handler) Follow-up to #77433 (slice 1), #77438 (slice 2), #77452 (slice 3), Signed-off-by: andrexibiza <84248988+andrexibiza@users.noreply.github.com> #77450 (slice 4). Progress on #54962. --- gateway/error_helpers.py | 94 ++++++++++++++++++++++++++++++++++++++++ gateway/run.py | 81 +++------------------------------- 2 files changed, 99 insertions(+), 76 deletions(-) create mode 100644 gateway/error_helpers.py diff --git a/gateway/error_helpers.py b/gateway/error_helpers.py new file mode 100644 index 000000000000..3370ae975e45 --- /dev/null +++ b/gateway/error_helpers.py @@ -0,0 +1,94 @@ +"""Transient-error classification + loop handler extracted from gateway/run.py (#54962). + +Fifth slice of the gateway god-file unpacking: the transient network +error classifier and the asyncio loop-level exception handler that uses +it. The classifier is a pure function (walk the cause chain, match known +transient exception class names); the handler wires it into the event +loop's safety net so a transient peer error can never kill the gateway +process (#31066 / #31110). +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, Optional + +logger = logging.getLogger(__name__) + + +def _is_transient_network_error(exc: BaseException) -> bool: + """Return True for transient network errors safe to log + swallow. + + The crash class targeted by #31066 / #31110: an unhandled Telegram + ``TimedOut`` (or peer ``NetworkError`` / ``httpx`` connection error) + propagating to the event loop and killing the entire gateway + process. These are by definition transient — the next poll cycle or + user action recovers — so they must never crash the process. + + Walk the exception cause chain so wrapped errors (e.g. PTB's + ``NetworkError`` wrapping ``httpx.ConnectError``) are still + classified. The chain is bounded to avoid pathological cycles. + """ + seen: set[int] = set() + cur: Optional[BaseException] = exc + depth = 0 + transient_class_names = { + "TimedOut", + "NetworkError", + "ReadError", + "WriteError", + "ConnectError", + "ConnectTimeout", + "ReadTimeout", + "WriteTimeout", + "PoolTimeout", + "RemoteProtocolError", + "ServerDisconnectedError", + "ClientConnectorError", + "ClientOSError", + } + while cur is not None and depth < 12: + ident = id(cur) + if ident in seen: + break + seen.add(ident) + depth += 1 + name = type(cur).__name__ + if name in transient_class_names: + return True + cur = cur.__cause__ or cur.__context__ + return False + + +def _gateway_loop_exception_handler( + loop: "asyncio.AbstractEventLoop", context: Dict[str, Any] +) -> None: + """Loop-level safety net for transient network errors. + + Installed once during :func:`start_gateway`. Catches the + ``telegram.error.TimedOut`` crash class (issues #31066 / #31110) + and any peer transient network error before it can kill the + gateway process. Logs at WARNING with full traceback so the + originating call site stays diagnosable; non-transient errors + are forwarded to the default loop handler so real bugs still + surface. + """ + exc = context.get("exception") + if exc is not None and _is_transient_network_error(exc): + task = context.get("future") or context.get("task") + task_name = "" + if task is not None: + try: + task_name = task.get_name() if hasattr(task, "get_name") else repr(task) + except Exception: + task_name = repr(task) + logger.warning( + "Gateway swallowed transient network error from %s: %s: %s", + task_name or "", + type(exc).__name__, + exc, + exc_info=(type(exc), exc, exc.__traceback__), + ) + return + # Fall back to the default handler for anything we don't recognise. + loop.default_exception_handler(context) diff --git a/gateway/run.py b/gateway/run.py index 8c162f7dc9a4..7b8d04c87039 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -367,82 +367,11 @@ def _seed_hygiene_system_prompt( return bool(stored_prompt) -def _is_transient_network_error(exc: BaseException) -> bool: - """Return True for transient network errors safe to log + swallow. - - The crash class targeted by #31066 / #31110: an unhandled Telegram - ``TimedOut`` (or peer ``NetworkError`` / ``httpx`` connection error) - propagating to the event loop and killing the entire gateway - process. These are by definition transient — the next poll cycle or - user action recovers — so they must never crash the process. - - Walk the exception cause chain so wrapped errors (e.g. PTB's - ``NetworkError`` wrapping ``httpx.ConnectError``) are still - classified. The chain is bounded to avoid pathological cycles. - """ - seen: set[int] = set() - cur: Optional[BaseException] = exc - depth = 0 - transient_class_names = { - "TimedOut", - "NetworkError", - "ReadError", - "WriteError", - "ConnectError", - "ConnectTimeout", - "ReadTimeout", - "WriteTimeout", - "PoolTimeout", - "RemoteProtocolError", - "ServerDisconnectedError", - "ClientConnectorError", - "ClientOSError", - } - while cur is not None and depth < 12: - ident = id(cur) - if ident in seen: - break - seen.add(ident) - depth += 1 - name = type(cur).__name__ - if name in transient_class_names: - return True - cur = cur.__cause__ or cur.__context__ - return False - - -def _gateway_loop_exception_handler( - loop: "asyncio.AbstractEventLoop", context: Dict[str, Any] -) -> None: - """Loop-level safety net for transient network errors. - - Installed once during :func:`start_gateway`. Catches the - ``telegram.error.TimedOut`` crash class (issues #31066 / #31110) - and any peer transient network error before it can kill the - gateway process. Logs at WARNING with full traceback so the - originating call site stays diagnosable; non-transient errors - are forwarded to the default loop handler so real bugs still - surface. - """ - exc = context.get("exception") - if exc is not None and _is_transient_network_error(exc): - task = context.get("future") or context.get("task") - task_name = "" - if task is not None: - try: - task_name = task.get_name() if hasattr(task, "get_name") else repr(task) - except Exception: - task_name = repr(task) - logger.warning( - "Gateway swallowed transient network error from %s: %s: %s", - task_name or "", - type(exc).__name__, - exc, - exc_info=(type(exc), exc, exc.__traceback__), - ) - return - # Fall back to the default handler for anything we don't recognise. - loop.default_exception_handler(context) +# --- Transient-error helpers (extracted to gateway.error_helpers) --- +from gateway.error_helpers import ( # noqa: E402 + _gateway_loop_exception_handler, + _is_transient_network_error, +) def _redact_gateway_user_facing_secrets(text: str) -> str: