From 54a0f07101a4024b03cd33e73d9d499e94d61113 Mon Sep 17 00:00:00 2001 From: Yuan Li Date: Wed, 22 Jul 2026 03:56:18 -0700 Subject: [PATCH] fix(gateway): mark unconfigured platforms as non-retryable to stop reconnect loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A platform with a missing dependency or missing credentials can never succeed on retry, but connect() returned bare False, so the gateway treated the failure as transient and queued it for background reconnection — looping forever at the backoff cap. Set _set_fatal_error(..., retryable=False) for missing-dependency and missing-credential failures in the Slack, Telegram, and Discord adapters so the reconnect watcher drops them from the retry queue. Salvaged from PR #31057 by @dskwe (reapplied onto the plugin-migrated adapter paths). Fixes #31049. --- plugins/platforms/discord/adapter.py | 2 + plugins/platforms/slack/adapter.py | 1 + plugins/platforms/telegram/adapter.py | 2 + tests/gateway/test_discord_connect.py | 34 +++++++++++++ tests/gateway/test_telegram_connect.py | 66 ++++++++++++++++++++++++++ 5 files changed, 105 insertions(+) create mode 100644 tests/gateway/test_telegram_connect.py diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index f843565cc771a..a64231ff7267a 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -1037,6 +1037,7 @@ class DiscordAdapter(BasePlatformAdapter): """Connect to Discord and start receiving events.""" if not DISCORD_AVAILABLE: logger.error("[%s] discord.py not installed. Run: pip install discord.py", self.name) + self._set_fatal_error("missing_dependency", "discord.py not installed", retryable=False) return False # Load opus codec for voice channel support @@ -1073,6 +1074,7 @@ class DiscordAdapter(BasePlatformAdapter): if not self.config.token: logger.error("[%s] No bot token configured", self.name) + self._set_fatal_error("missing_credentials", "No bot token configured", retryable=False) return False try: diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index e78d3c57524c5..342aade8e1a9c 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -1115,6 +1115,7 @@ class SlackAdapter(BasePlatformAdapter): logger.error( "[Slack] slack-bolt not installed. Run: pip install slack-bolt", ) + self._set_fatal_error("missing_dependency", "slack-bolt not installed", retryable=False) return False raw_token = self.config.token diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 05883c0e2ffe4..1254e453c8112 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -3434,10 +3434,12 @@ class TelegramAdapter(BasePlatformAdapter): "[%s] python-telegram-bot not installed. Run: pip install python-telegram-bot", self.name, ) + self._set_fatal_error("missing_dependency", "python-telegram-bot not installed", retryable=False) return False if not self.config.token: logger.error("[%s] No bot token configured", self.name) + self._set_fatal_error("missing_credentials", "No bot token configured", retryable=False) return False try: diff --git a/tests/gateway/test_discord_connect.py b/tests/gateway/test_discord_connect.py index 657b9d244af00..22bae2c35d11f 100644 --- a/tests/gateway/test_discord_connect.py +++ b/tests/gateway/test_discord_connect.py @@ -1076,3 +1076,37 @@ async def test_safe_sync_detects_contexts_drift(): fake_http.edit_global_command.assert_not_awaited() fake_http.delete_global_command.assert_awaited_once_with(999, 77) fake_http.upsert_global_command.assert_awaited_once_with(999, desired) + + +# ============================================================================ +# #31049: unconfigured platform skips reconnection (non-retryable fatal error) +# ============================================================================ + +class TestDiscordUnconfiguredNonRetryable: + """Verify that missing dependency/token sets a non-retryable fatal error + so the gateway does not queue the platform for background reconnection.""" + + @pytest.mark.asyncio + async def test_no_discord_lib_sets_non_retryable_fatal(self, monkeypatch): + """connect() with discord.py unavailable → non-retryable fatal error.""" + _ensure_discord_mock() + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="fake")) + # Simulate discord.py not installed + monkeypatch.setattr(discord_platform, "DISCORD_AVAILABLE", False) + result = await adapter.connect() + assert result is False + assert adapter.has_fatal_error is True + assert adapter.fatal_error_retryable is False + assert adapter.fatal_error_code == "missing_dependency" + + @pytest.mark.asyncio + async def test_no_bot_token_sets_non_retryable_fatal(self, monkeypatch): + """connect() with empty token → non-retryable fatal error.""" + _ensure_discord_mock() + monkeypatch.setattr(discord_platform, "DISCORD_AVAILABLE", True) + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="")) + result = await adapter.connect() + assert result is False + assert adapter.has_fatal_error is True + assert adapter.fatal_error_retryable is False + assert adapter.fatal_error_code == "missing_credentials" diff --git a/tests/gateway/test_telegram_connect.py b/tests/gateway/test_telegram_connect.py new file mode 100644 index 0000000000000..f99c676eb709d --- /dev/null +++ b/tests/gateway/test_telegram_connect.py @@ -0,0 +1,66 @@ +"""Tests for Telegram connect() non-retryable fatal error on missing credentials. + +When Telegram has no bot token or no python-telegram-bot installed, connect() +must set a non-retryable fatal error so the gateway does not queue it for +background reconnection (#31049). +""" + +import sys +from unittest.mock import MagicMock + +import pytest + +from gateway.config import PlatformConfig + + +def _ensure_telegram_mock(): + if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"): + return + + telegram_mod = MagicMock() + telegram_mod.ext.ContextTypes.DEFAULT_TYPE = type(None) + telegram_mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2" + telegram_mod.constants.ChatType.GROUP = "group" + telegram_mod.constants.ChatType.SUPERGROUP = "supergroup" + telegram_mod.constants.ChatType.CHANNEL = "channel" + telegram_mod.constants.ChatType.PRIVATE = "private" + + telegram_mod.error.NetworkError = type("NetworkError", (OSError,), {}) + telegram_mod.error.TimedOut = type("TimedOut", (OSError,), {}) + telegram_mod.error.BadRequest = type("BadRequest", (Exception,), {}) + + for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"): + sys.modules.setdefault(name, telegram_mod) + sys.modules.setdefault("telegram.error", telegram_mod.error) + + +_ensure_telegram_mock() + +import plugins.platforms.telegram.adapter as telegram_mod # noqa: E402 +from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402 + + +class TestTelegramUnconfiguredNonRetryable: + """Verify that missing dependency/token sets a non-retryable fatal error.""" + + @pytest.mark.asyncio + async def test_no_telegram_lib_sets_non_retryable_fatal(self, monkeypatch): + """connect() with python-telegram-bot unavailable → non-retryable fatal error.""" + adapter = TelegramAdapter(PlatformConfig(enabled=True, token="fake")) + monkeypatch.setattr(telegram_mod, "TELEGRAM_AVAILABLE", False) + result = await adapter.connect() + assert result is False + assert adapter.has_fatal_error is True + assert adapter.fatal_error_retryable is False + assert adapter.fatal_error_code == "missing_dependency" + + @pytest.mark.asyncio + async def test_no_bot_token_sets_non_retryable_fatal(self, monkeypatch): + """connect() with empty token → non-retryable fatal error.""" + monkeypatch.setattr(telegram_mod, "TELEGRAM_AVAILABLE", True) + adapter = TelegramAdapter(PlatformConfig(enabled=True, token="")) + result = await adapter.connect() + assert result is False + assert adapter.has_fatal_error is True + assert adapter.fatal_error_retryable is False + assert adapter.fatal_error_code == "missing_credentials"