fix(mattermost): escalate genuine WS auth failures through the fatal-error hook

Follow-up to the salvaged #80489 substring-fallback removal: the
structured 401/403 branch still exited with a bare return, leaving
_running True — dead listener, healthy-looking is_connected(), gateway
never told (the zombie half of the bug, OOF-156 class). It now sets a
non-retryable mattermost_auth_error with token guidance and notifies
the gateway fatal handler.

Also: pytest.importorskip for aiohttp in the verifier probe file
(module-level import crashed collection in envs without the optional
dep), and probe fixtures updated for the escalation attributes.
This commit is contained in:
Teknium 2026-08-13 01:42:20 -07:00
parent 684c18b428
commit f4749a77a5
3 changed files with 51 additions and 3 deletions

View File

@ -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)

View File

@ -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

View File

@ -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