diff --git a/plugins/platforms/mattermost/adapter.py b/plugins/platforms/mattermost/adapter.py index bc6288871b22e..4e80958b35165 100644 --- a/plugins/platforms/mattermost/adapter.py +++ b/plugins/platforms/mattermost/adapter.py @@ -753,6 +753,21 @@ class MattermostAdapter(BasePlatformAdapter): import aiohttp if isinstance(exc, aiohttp.WSServerHandshakeError) and exc.status in {401, 403}: logger.error("Mattermost WS auth failed (HTTP %d) — stopping reconnect", exc.status) + # Escalate through the fatal-error hook instead of a bare + # return: the old silent exit left _running True, so + # is_connected() kept reporting healthy while the listener + # was dead and the gateway was never told (OOF-156 class). + # Type-based only — the substring fallback that used to sit + # below this branch misclassified transient errors whose + # message merely contained "401" (#80489). + self._set_fatal_error( + "mattermost_auth_error", + f"Mattermost WebSocket authentication rejected (HTTP {exc.status}). " + "The bot token is invalid, revoked, or lacks permission — check " + "MATTERMOST_TOKEN and the bot account in the System Console.", + retryable=False, + ) + await self._notify_fatal_error() return logger.warning("Mattermost WS error: %s — reconnecting in %.0fs", exc, delay) diff --git a/tests/gateway/test_ws_auth_retry.py b/tests/gateway/test_ws_auth_retry.py index 9f8b3ce04b13e..f07bba85630dc 100644 --- a/tests/gateway/test_ws_auth_retry.py +++ b/tests/gateway/test_ws_auth_retry.py @@ -19,8 +19,11 @@ class TestMattermostWSAuthRetry: """gateway/platforms/mattermost.py — _ws_loop()""" def test_401_handshake_stops_reconnect(self): - """A WSServerHandshakeError with status 401 should stop the loop.""" + """A WSServerHandshakeError with status 401 should stop the loop — + AND escalate through the fatal-error hook (a bare return used to + leave _running True: dead listener, healthy-looking adapter).""" import aiohttp + from gateway.config import Platform exc = aiohttp.WSServerHandshakeError( request_info=MagicMock(), @@ -34,6 +37,14 @@ class TestMattermostWSAuthRetry: adapter = MattermostAdapter.__new__(MattermostAdapter) adapter._closing = False + adapter._running = True + adapter.platform = Platform.MATTERMOST + notified = [] + + async def fatal_handler(a): + notified.append(a) + + adapter._fatal_error_handler = fatal_handler call_count = 0 @@ -44,10 +55,20 @@ class TestMattermostWSAuthRetry: adapter._ws_connect_and_listen = fake_connect - asyncio.run(adapter._ws_loop()) + async def run(): + await adapter._ws_loop() + # Let the detached fatal-handler task complete. + await asyncio.sleep(0) + + asyncio.run(run()) # Should have attempted once and stopped, not retried assert call_count == 1 + # Escalated: fatal error recorded, _running cleared, handler notified. + assert adapter._fatal_error_code == "mattermost_auth_error" + assert adapter._fatal_error_retryable is False + assert adapter._running is False + assert notified == [adapter] def test_transient_401_substring_does_not_stop_reconnect(self): """A transient exception whose stringified message merely contains diff --git a/tests/gateway/test_ws_auth_retry_verifier_probe.py b/tests/gateway/test_ws_auth_retry_verifier_probe.py index c5389bc8a8b85..514e62a1565e1 100644 --- a/tests/gateway/test_ws_auth_retry_verifier_probe.py +++ b/tests/gateway/test_ws_auth_retry_verifier_probe.py @@ -25,7 +25,11 @@ implementer thought to test: import asyncio from unittest.mock import AsyncMock, MagicMock, patch -import aiohttp +import pytest + +# Optional dep: CI installs [all] extras, but local envs may lack aiohttp — +# skip cleanly instead of crashing collection. +aiohttp = pytest.importorskip("aiohttp") from plugins.platforms.mattermost.adapter import MattermostAdapter @@ -33,6 +37,14 @@ from plugins.platforms.mattermost.adapter import MattermostAdapter def _make_adapter(closing: bool = False) -> MattermostAdapter: adapter = MattermostAdapter.__new__(MattermostAdapter) adapter._closing = closing + # The genuine-auth path now escalates via _set_fatal_error + + # _notify_fatal_error (OOF-156 follow-up), which touch these attributes + # that __init__ would normally provide. + from gateway.config import Platform + + adapter.platform = Platform.MATTERMOST + adapter._running = True + adapter._fatal_error_handler = None return adapter