fix(mattermost): remove unsafe substring auth-error fallback in WS reconnect loop - #80489
fix(mattermost): remove unsafe substring auth-error fallback in WS reconnect loop#80489steveonjava wants to merge 2 commits into
Conversation
The WS reconnect loop had a fallback check that looked for "401", "403",
or "unauthorized" as substrings anywhere in an exception's string form.
A transient error whose message happens to contain those digits (a proxy
body, a stack trace, anything) got treated as a permanent auth failure
and stopped reconnection for good.
I removed the substring fallback and kept only the structured check:
aiohttp.WSServerHandshakeError with status in {401, 403}. That's the only
signal that reliably means the server rejected our credentials.
Added two regression tests: one proving a transient error containing
"401" in its text still retries, and one confirming the existing
_closing early-return path is untouched by the removal.
…ify fix Independent-verifier boundary probes for commit fdd1a11ac5, covering cases the implementer's regression tests did not exercise: - WSServerHandshakeError(status=403) also stops the loop (only 401 tested) - WSServerHandshakeError(status=500) does NOT stop the loop (structured check must not over-match on type alone) - transient error containing the word 'unauthorized' (not digit substring) now retries correctly - 5 consecutive transient errors all retry, not just the first Verified these 2nd/4th tests fail against the pre-fix baseline commit (01a1037) and pass against the fix (fdd1a11ac5), confirming they have real signal.
|
This was generated by AI during triage. Summary: Problems:
Solution: Checked against |
|
You're right that the "zero line overlap" line is wrong, and I'll fix it. I ran a real three-way merge to check: with But the framing of "the opposite classification for the same error class" is where I'd push back, because these are not the same error class. That distinction matters a great deal here, because what's on Now stack #35645 on top of that unchanged: it converts the same false positive into One correction on the other side. #57375 is not merged, and my description was wrong to imply it was, but the substring problem it fixes is live on Matrix today too, so this is a pattern across adapters and not a one-off. The reconciliation is straightforward and I'll do the work: make the branch an attribute check rather than deleting it, then escalate through |
…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.
|
Merged via PR #85157 (#85157) — both of your commits were cherry-picked onto current main with your authorship preserved in git history, including the adversarial verifier-probe file. Your review-thread analysis was exactly right on all three points (structured vs substring error classes, the silent-zombie Thanks for an unusually thorough contribution — the write-up in the comments here was better than most PR bodies. |
Summary
The Mattermost WebSocket reconnect loop in
_ws_loop()misclassifies transienterrors as permanent auth failures. A substring fallback sits below the correct
WSServerHandshakeError/.statuscheck. Any exception whose stringified messagehappens to contain "401", "403", or "unauthorized" kills the reconnect loop
forever, even when the underlying cause is temporary.
Root cause
The exception handler in
plugins/platforms/mattermost/adapter.pyfirst checksisinstance(exc, aiohttp.WSServerHandshakeError) and exc.status in {401, 403},which correctly catches the one case where the server explicitly rejects auth.
Below that, a catch-all substring check on
str(exc).lower()treats anycoincidental mention of "401", "403", or "unauthorized" as a permanent failure:
A proxy returning a 401 in its response body but then resetting the connection,
or an unrelated error whose message happens to contain the word "unauthorized",
both get classified as permanent auth failures and stop the retry loop
permanently.
Fix
Delete the redundant substring fallback. The structured
WSServerHandshakeErrorcheck already catches the one legitimate permanent-auth case (HTTP 401/403 from
the Mattermost server). No other exception in the aiohttp exception hierarchy
simultaneously signals permanent auth failure and evades the structured check.
Regression tests
Added in
tests/gateway/test_ws_auth_retry.py:test_transient_401_substring_does_not_stop_reconnect: aRuntimeErrorwhose message contains "401" now retries instead of returning.
test_closing_flag_prevents_further_connect_attempts: the existing_closingearly-return path is untouched.Added in
tests/gateway/test_ws_auth_retry_verifier_probe.py(independentverifier, adversarial edge cases):
test_403_handshake_stops_reconnect: status=403 still stops the loop.test_non_auth_handshake_status_does_not_stop_reconnect: status=500does not stop the loop.
test_unauthorized_substring_no_longer_stops_reconnect: the word"unauthorized" no longer trips a false permanent-fatal.
test_repeated_transient_errors_all_retry: five consecutive transienterrors all retry (not just the first one).
Files changed
plugins/platforms/mattermost/adapter.py: removed 4 lines (substring fallback)tests/gateway/test_ws_auth_retry.py: added 2 new tests (+57 lines)tests/gateway/test_ws_auth_retry_verifier_probe.py: new file, 4 boundary-case probes (+142 lines)Refs #35645
This PR conflicts with #35645 (open). Both change the same
_ws_loop()exceptionhandler in opposite directions, and an earlier version of this description
incorrectly claimed zero line overlap. Verified with a three-way merge against
main(b3aa561f), merging this PR'sbd674b78with #35645'sc94baf52:exactly one conflict in
plugins/platforms/mattermost/adapter.py, on thesubstring branch.
#35645 keeps the substring-detected branch and escalates it through a new
_escalate_ws_fatalwithretryable=False. This PR deletes the branch so thesame exceptions fall through to the reconnect path. The escalation in #35645 is
correct for the structured
WSServerHandshakeErrorcase and fixes a real zombieadapter bug. The disagreement is only about the substring fallback underneath it.
These are two different error classes, which is the reason for the split:
WSServerHandshakeErrorwithexc.status in {401, 403}is a structured authrejection from the server, and both PRs keep that branch untouched. A
RuntimeErrorwhose message merely contains "401" is a transient network failurethat happens to include three digits.
Ordering hazard for whoever resolves the conflict: this PR also removes the
err_str = str(exc).lower()assignment, which becomes unused once the branch isgone. Keeping #35645's side of the hunk without restoring that assignment raises
NameErroron the first WebSocket exception. It fails on the error path, sohappy-path tests will not catch it.
Proposed reconciliation: convert the branch to a structured attribute check
rather than deleting it, then escalate through
_escalate_ws_fatalwhen itgenuinely fires. That preserves #35645's escalation contract while preventing
transient errors from being classified as permanent.
The Matrix adapter has the same substring bug on
maintoday. #57375(
fix(matrix): stop classifying auth errors by substring match) proposes theattribute-check approach there, switching to
http_statusanderrcodeon theexception object. That PR is still open and not merged; an earlier version of
this description described it as a fix Matrix had already received, which was
wrong.