From 217bed3c5d2e6a518d93003ea6c29aee5b0d9c1b Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Wed, 10 Jun 2026 13:04:04 +0300 Subject: [PATCH] fix(admin): avoid stale Telegram webhook alerts Only surface Telegram delivery errors while updates are still pending, and register the webhook after the aiohttp webhook site starts listening. --- backend/bot/app/web/web_server.py | 5 ++++ backend/bot/main_bot.py | 24 +++++++++++++++---- backend/bot/services/config_health_service.py | 8 +++++-- tests/test_admin_config_health.py | 12 +++++++++- tests/test_main_bot_startup.py | 23 ++++++++++++++++++ 5 files changed, 64 insertions(+), 8 deletions(-) diff --git a/backend/bot/app/web/web_server.py b/backend/bot/app/web/web_server.py index 797a531..c50d331 100644 --- a/backend/bot/app/web/web_server.py +++ b/backend/bot/app/web/web_server.py @@ -2,6 +2,7 @@ import asyncio import functools import hmac import logging +from typing import Awaitable, Callable, Optional from aiogram import Bot, Dispatcher from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application @@ -84,6 +85,8 @@ async def build_and_start_web_app( bot: Bot, settings: Settings, async_session_factory: sessionmaker, + *, + after_webhooks_started: Optional[Callable[[], Awaitable[None]]] = None, ): app = web.Application() _inject_shared_instances(app, dp, bot, settings, async_session_factory) @@ -159,6 +162,8 @@ async def build_and_start_web_app( logging.info( f"AIOHTTP server started on http://{settings.WEB_SERVER_HOST}:{settings.WEB_SERVER_PORT}" ) + if after_webhooks_started is not None: + await after_webhooks_started() if settings.WEBAPP_ENABLED: from bot.app.web.subscription_webapp import create_subscription_webapp_application diff --git a/backend/bot/main_bot.py b/backend/bot/main_bot.py index 987416d..e21a848 100644 --- a/backend/bot/main_bot.py +++ b/backend/bot/main_bot.py @@ -91,12 +91,9 @@ async def register_all_routers(dp: Dispatcher, settings: Settings): logging.info("All application routers registered.") -async def on_startup_configured(dispatcher: Dispatcher): +async def configure_telegram_webhook(dispatcher: Dispatcher) -> None: bot: Bot = dispatcher["bot_instance"] settings: Settings = dispatcher["settings"] - i18n_instance: JsonI18n = dispatcher["i18n_instance"] - - logging.info("STARTUP: on_startup_configured executing...") telegram_webhook_url_to_set = settings.WEBHOOK_BASE_URL if telegram_webhook_url_to_set: @@ -152,6 +149,14 @@ async def on_startup_configured(dispatcher: Dispatcher): ) raise SystemExit("WEBHOOK_BASE_URL is required. Polling mode is disabled.") + +async def on_startup_configured(dispatcher: Dispatcher): + bot: Bot = dispatcher["bot_instance"] + settings: Settings = dispatcher["settings"] + i18n_instance: JsonI18n = dispatcher["i18n_instance"] + + logging.info("STARTUP: on_startup_configured executing...") + if settings.SUBSCRIPTION_MINI_APP_URL: async def _configure_mini_app_menu() -> None: @@ -331,8 +336,17 @@ async def run_bot(settings_param: Settings): _yk_path, ) + async def _after_webhooks_started() -> None: + await configure_telegram_webhook(dp) + async def web_server_task(): - await build_and_start_web_app(dp, bot, settings_param, local_async_session_factory) + await build_and_start_web_app( + dp, + bot, + settings_param, + local_async_session_factory, + after_webhooks_started=_after_webhooks_started, + ) main_tasks = [asyncio.create_task(web_server_task(), name="AIOHTTPServerTask")] diff --git a/backend/bot/services/config_health_service.py b/backend/bot/services/config_health_service.py index ea8311e..a7a1687 100644 --- a/backend/bot/services/config_health_service.py +++ b/backend/bot/services/config_health_service.py @@ -385,6 +385,7 @@ async def telegram_alerts(bot: Any, settings: Any) -> List[ConfigAlert]: ) ) + pending = int(getattr(info, "pending_update_count", 0) or 0) last_error_date = getattr(info, "last_error_date", None) last_error_ts: Optional[float] = None if last_error_date is not None: @@ -393,7 +394,11 @@ async def telegram_alerts(bot: Any, settings: Any) -> List[ConfigAlert]: if hasattr(last_error_date, "timestamp") else float(last_error_date) ) - if last_error_ts and (time.time() - last_error_ts) < _WEBHOOK_ERROR_RECENT_SECONDS: + if ( + pending > 0 + and last_error_ts + and (time.time() - last_error_ts) < _WEBHOOK_ERROR_RECENT_SECONDS + ): alerts.append( ConfigAlert( id="telegram_webhook_error", @@ -403,7 +408,6 @@ async def telegram_alerts(bot: Any, settings: Any) -> List[ConfigAlert]: ) ) - pending = int(getattr(info, "pending_update_count", 0) or 0) if pending > _WEBHOOK_PENDING_THRESHOLD: alerts.append( ConfigAlert( diff --git a/tests/test_admin_config_health.py b/tests/test_admin_config_health.py index c6092e6..d6cd6ab 100644 --- a/tests/test_admin_config_health.py +++ b/tests/test_admin_config_health.py @@ -239,16 +239,26 @@ class TelegramAlertsTests(unittest.IsolatedAsyncioTestCase): alerts = await health.telegram_alerts(bot, _settings()) self.assertEqual(_alert_ids(alerts), ["telegram_webhook_mismatch"]) - async def test_recent_delivery_error_reported(self): + async def test_recent_delivery_error_with_pending_update_reported(self): info = self._webhook_info( last_error_date=datetime.now(timezone.utc), last_error_message="SSL error", + pending_update_count=1, ) bot = SimpleNamespace(get_webhook_info=AsyncMock(return_value=info)) alerts = await health.telegram_alerts(bot, _settings()) self.assertEqual(_alert_ids(alerts), ["telegram_webhook_error"]) self.assertEqual(alerts[0].params["error"], "SSL error") + async def test_recent_delivery_error_without_pending_update_not_reported(self): + info = self._webhook_info( + last_error_date=datetime.now(timezone.utc), + last_error_message="Connection refused", + pending_update_count=0, + ) + bot = SimpleNamespace(get_webhook_info=AsyncMock(return_value=info)) + self.assertEqual(await health.telegram_alerts(bot, _settings()), []) + async def test_stale_delivery_error_not_reported(self): info = self._webhook_info(last_error_date=time.time() - 7200) bot = SimpleNamespace(get_webhook_info=AsyncMock(return_value=info)) diff --git a/tests/test_main_bot_startup.py b/tests/test_main_bot_startup.py index 144a82f..a1f0ac0 100644 --- a/tests/test_main_bot_startup.py +++ b/tests/test_main_bot_startup.py @@ -45,6 +45,29 @@ def test_worker_starts_backup_task_without_enabled_guard(): assert guarded_backup_tasks == [] +def test_telegram_webhook_configuration_is_deferred_until_site_start(): + main_source = Path("backend/bot/main_bot.py").read_text(encoding="utf-8") + web_source = Path("backend/bot/app/web/web_server.py").read_text(encoding="utf-8") + tree = ast.parse(main_source) + startup_node = next( + node + for node in ast.walk(tree) + if isinstance(node, ast.AsyncFunctionDef) and node.name == "on_startup_configured" + ) + + startup_set_webhook_calls = [ + node + for node in ast.walk(startup_node) + if isinstance(node, ast.Attribute) and node.attr == "set_webhook" + ] + + assert startup_set_webhook_calls == [] + assert "after_webhooks_started=_after_webhooks_started" in main_source + assert web_source.index("await site.start()") < web_source.index( + "await after_webhooks_started()" + ) + + def test_telegram_startup_network_error_retries_until_success_without_traceback(caplog): calls = []