Skip to content

fix(matrix): classify sync auth failures by status and errcode, not substring (salvage #66878) - #80532

Open
steveonjava wants to merge 4 commits into
NousResearch:mainfrom
steveonjava:feat/matrix-sync-401-salvage
Open

fix(matrix): classify sync auth failures by status and errcode, not substring (salvage #66878)#80532
steveonjava wants to merge 4 commits into
NousResearch:mainfrom
steveonjava:feat/matrix-sync-401-salvage

Conversation

@steveonjava

@steveonjava steveonjava commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

The Matrix sync loop used a naive substring match on str(exc) to detect permanent auth failures. Any 401 or 403 in an exception message stopped the loop permanently. In production, an Umbrel app-proxy 502 response included an SVG coordinate, 40.4302, whose embedded 403 triggered the old check. Outbound messages still worked, but inbound sync was dead.

The fix classifies real authentication failures from structured errcode and http_status values, retries transient transport errors, and uses a bounded whole-word message scan only when structured signals are absent. It also keeps the result-object path defensive for compatibility with a future client swap.

Related Issue

This PR carries forward gmoranxyz's work from #66878, including the original diagnosis and patch. It consolidates the related work from #57375, #78039, #61206, and #66878. GottZ's consolidation triage identified #66878 as the surviving implementation and recommended closing #57375 as a duplicate.

The sync watchdog that would restart a dead or stalled _sync_task remains out of scope and should be handled by a separate follow-up.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • plugins/platforms/matrix/adapter.py: replace substring-based auth detection with a layered classifier. Transport exceptions are always transient, structured errcode and http_status values take precedence, and unstructured messages use a bounded word-boundary fallback.
  • plugins/platforms/matrix/adapter.py: route compatibility result objects through the same classifier, preserving structured checks and using .message only when structured fields are absent.
  • tests/gateway/test_matrix.py: add loop-level regression tests for the pagination-token false positive, real permanent auth failures, result-object errcodes and HTTP status, message-only compatibility results, transient structured 502 responses, attribute narrowing, and transient exception types.
  • Credit: the original diagnosis and patch belong to gmoranxyz. The PR retains that contribution with a Co-authored-by trailer.

How to Test

  1. Run the Matrix test suite:
    python -m pytest tests/gateway/test_matrix.py -v
    The recorded PR-head result was 133 passed.
  2. Run the full suite:
    python -m pytest -x
    The recorded run found 24,328 passed, 1,210 failed, 324 skipped, 1 xfailed, and 17 errors. The failures were documented as pre-existing or order-dependent, with no overlap on the Matrix tests.
  3. Run lint on the touched files:
    ruff check plugins/platforms/matrix/adapter.py tests/gateway/test_matrix.py
    Result: all checks passed.
  4. Run git diff --check. Result: passed.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
    • The related PR search and consolidation are documented under Related Issue.
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass
    • The full run was executed with python -m pytest -x, but it recorded pre-existing or order-dependent failures. The Matrix suite itself had 133 passed.
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: the recorded validation was on the contributor's development environment; CI completed the repository's platform checks.

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — N/A (no user-facing documentation change was needed)
  • I've updated cli-config.yaml.example if I added/changed config keys — N/A (no config keys changed)
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — N/A (no contributor workflow changed)
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — CI completed the Windows footgun and platform test checks
  • I've updated tool descriptions/schemas if I changed tool behavior — N/A (no tool schema changed)

Screenshots / Logs

N/A. The regression is covered by automated tests and the verification results are listed above.

@alt-glitch alt-glitch added type/bug Something isn't working comp/plugins Plugin system and bundled plugins platform/matrix Matrix adapter (E2EE) P3 Low — cosmetic, nice to have needs-decision Awaiting maintainer decision before any implementation sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Aug 6, 2026
@steveonjava
steveonjava marked this pull request as ready for review August 6, 2026 23:49
steveonjava and others added 3 commits August 7, 2026 05:00
I hit a bug where the Matrix sync loop treated a passing 502 from
Umbrel's app proxy as a permanent auth failure and stopped syncing for
good. The old check did a naive "403" in str(exc) substring match, and
the 502 HTML error body embedded an SVG path with the coordinate
40.4302, which contains the digit sequence 403.

I replaced the substring check with a layered classifier. Transport
exceptions like TimeoutError, ConnectionError, and OSError are always
treated as transient regardless of their message text. Structured
signals take priority next: the errcode attribute against a known set
of permanent Matrix error codes, then the http_status attribute
against 401/403 specifically (not status, status_code, or code, which
belong to unrelated exception shapes and risk coincidental integer
matches). Only when none of those are present does it fall back to a
bounded, word-boundary-safe text scan on the first 200 characters.

Added tests covering the attribute narrowing, the transient exception
types, and two loop-level tests exercising _sync_loop directly to
confirm it retries on a transient error and stops on a genuine 401/403.
I added two more classifier unit tests for the attribute narrowing:
a bare .code attribute that happens to be 401, and a bare .status
attribute that happens to be 403, both must stay classified as
transient since only .http_status is trustworthy. I also added a
parametrized test for the five transient exception types the sync
loop now short-circuits on.

On top of that I added two tests that exercise _sync_loop directly
instead of just the classifier function in isolation. One replays the
real 502 Umbrel repro string through a mocked client.sync and confirms
the loop retries with the 5s backoff. The other raises a genuine
M_FORBIDDEN error and confirms the loop stops on the first call with
no retry sleep. These catch a regression in how the loop wires the
classifier in, not just a regression in the classifier itself.
… test

The independent-verifier caught that my first loop-level test did not
actually prove anything. The 502/SVG coordinate fixture I reused from
gmoranxyz's unit-level test does not contain the substring 403 once
case-folded, so the old naive substring classifier already treated it
as transient. A test that passes under both the buggy code and the
fix proves nothing about the fix.

I replaced the fixture with a plain connection timeout whose message
wraps the real Matrix sync pagination token, an arbitrary digit
string that happens to contain 401. I verified this directly: with
the pre-fix classifier restored, the retry test now fails (the old
code stops the loop on this fixture), and with the fix in place it
passes (the loop retries as it should). That is the RED/GREEN proof
the maintainer originally asked for.

I also documented in the stop test's docstring that it does not
discriminate old from new, since the word forbidden in its message
trips the old naive check too. It is still worth keeping as a
regression test proving genuine auth errors stop the loop, just not
as proof of this specific fix.

While I was in there I also fixed a stale comment above the
M_UNKNOWN_TOKEN sync-object pre-check. It said nio returns SyncError
objects, but the dependency here is mautrix, not matrix-nio, and
importing nio raises ModuleNotFoundError in this codebase. The
pre-check logic itself was already correct and untouched.

Co-authored-by: gmoranxyz <gmoranxyz@users.noreply.github.com>
@steveonjava
steveonjava force-pushed the feat/matrix-sync-401-salvage branch from 7e503bd to 4e16313 Compare August 7, 2026 13:01
@spfcraze

spfcraze commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

This was generated by AI during triage.

Summary:
The classifier's only call site is the raised-exception path; the result-object branch the new comment describes as the auth-failure route still stops the loop only on the substring unknown_token, so an M_FORBIDDEN or M_MISSING_TOKEN result object falls through and re-syncs immediately.

Problems:

  • _is_permanent_matrix_auth_error is called only from except Exception in _sync_loop; the branch above it (getattr(sync_data, "message", None) at plugins/platforms/matrix/adapter.py:3008 on main) still tests "m_unknown_token" in _lower or "unknown_token" in _lower and nothing else.
  • _MATRIX_PERMANENT_ERRCODES declares m_forbidden and m_missing_token permanent, but no call site applies that set to a message-carrying result object: a 403/M_FORBIDDEN sync response arriving as a result object bypasses the classifier, falls through the isinstance(sync_data, dict) check, and the loop re-syncs immediately on the next iteration (the 5s backoff applies only to the except path).

Solution:
Route the result-object branch through _is_permanent_matrix_auth_error as well (or extend its substring test to the same errcode set), so M_FORBIDDEN / M_MISSING_TOKEN result objects stop the loop the way the raised-exception path now does.


Checked against 4e16313 — the tip of feat/matrix-sync-401-salvage when this was written — and b3aa561, main at the same moment.

@steveonjava

Copy link
Copy Markdown
Contributor Author

Thanks for digging into the call sites. Three of your structural facts are correct and I want to confirm them before I get to where I think the conclusion breaks. _is_permanent_matrix_auth_error does have exactly one call site, the except Exception path at adapter.py:3129. The result-object branch above it does still test only "m_unknown_token" in _lower or "unknown_token" in _lower. And _MATRIX_PERMANENT_ERRCODES genuinely is not applied to anything but a raised exception. I checked that branch byte for byte against main and it is unchanged by this PR, so nothing here is a regression I introduced in the logic.

Where the conclusion does not hold is the premise that a 403 or M_FORBIDDEN sync response can arrive as a result object. With mautrix[encryption]==0.21.0, the version pinned in pyproject.toml, it cannot. HTTPAPI._send in mautrix/api.py checks if response.status < 200 or response.status >= 300 and then calls raise make_request_error(...). The only other exit from that function is return await response.json(), which is a dict. Client.sync() in mautrix/client/api/events.py is typed -> Awaitable[JSON] and does nothing but forward to self.api.request. There is no path in the pinned library that returns an error object carrying a message. An M_FORBIDDEN is raised as MForbidden, it reaches line 3129, and the classifier handles it on .errcode and .http_status exactly as intended.

That branch is dead code inherited from the earlier matrix-nio implementation, where SyncError result objects were real. Here is the part that is my fault, and it is the reason your analysis reads the way it does. main documented that branch honestly as "nio returns SyncError objects (not exceptions) for auth failures". My PR rewrote that comment to say "mautrix's Client.sync() returns a plain dict on success but an object carrying a message string", which is not true of mautrix. I described a nio behaviour as a mautrix behaviour, and anyone reading the file would reasonably conclude what you concluded. That comment is wrong and I will fix it.

On the remedy, your two options are not equivalent and I would rather not take the second one. Routing the object through the classifier works, because the classifier is typed exc: object and reads .errcode and .http_status by getattr. Extending the substring test to the same errcode set is strictly worse: real Matrix errors put the human sentence in message and the code in errcode, so an M_MISSING_TOKEN whose message is "Missing token" fails a word-boundary scan for m_missing_token and falls through anyway. I confirmed that against the classifier's actual fallback regex. Worth adding that routing the object alone is also incomplete, since an object with no errcode and a default __repr__ classifies as transient, so the message string needs to stay in play as a fallback rather than replace the structured check.

What I would like to do is fix the incorrect comment, route the branch through the classifier with the message text as a secondary fallback so the code is correct under either library, and add a test that pins it. That is defence in depth against a future library swap rather than a live 403 bypass, so I do not think it should gate this PR, which fixes a failure that is happening now: a 502 HTML body containing the digits 403 in an SVG coordinate stops inbound sync permanently while the adapter still reports healthy. Would you rather I fold that cleanup into this PR, or keep this one scoped to the classifier and open a follow-up for the dead nio branch?

…the classifier

The comment above the result-object branch in _sync_loop claimed mautrix's
Client.sync() returns an object carrying a message string for auth failures.
That is wrong. In the pinned mautrix 0.21.0, HTTPAPI._send raises
make_request_error() for any non-2xx and otherwise returns parsed JSON, so a
real M_FORBIDDEN arrives as an exception and is handled by the except branch.
The claim was introduced by this PR, which rewrote an accurate comment about
the earlier matrix-nio client (whose SyncError result objects were genuine).

The branch itself is kept as defense in depth against a future client swap,
but it now classifies with the same errcode/http_status logic as the
exception path instead of a lone "unknown_token" substring test, which
silently missed M_MISSING_TOKEN and M_FORBIDDEN and resynced forever
against a credential that can never succeed.

A structured errcode/http_status is authoritative; the message text is only
consulted when the object exposes neither, since str(object) is an opaque
repr. The text scan deliberately cannot override a structured verdict, so a
transient 502 whose HTML body contains "Forbidden" is still retried.

Adds four tests. Three are discriminating RED/GREEN cases that fail against
the old substring branch (M_MISSING_TOKEN errcode, http_status=401 with no
keyword in the message, and an unstructured object whose only signal is
.message). The fourth pins the precedence rule and passes either way.

Verified: 136 passed / 1 failed in tests/gateway/test_matrix.py; the single
failure (test_password_login_uses_device_id) fails identically at the
pristine PR head and is unrelated.
@steveonjava

Copy link
Copy Markdown
Contributor Author

Thanks for the review. The requested cleanup is addressed in 2f389ca98.

  • Corrected the comment: the pinned mautrix client raises on non-2xx responses, so the result-object path is inherited compatibility code from matrix-nio.
  • Routed result objects through the same classifier used by the exception path.
  • Kept structured errcode and http_status checks authoritative, with .message as the fallback when neither is present.
  • Added four regression tests covering permanent errcodes, HTTP 401, message-only results, and transient HTTP 502 results containing Forbidden.

The focused compatibility tests pass 4/4. Ruff and git diff --check also pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/plugins Plugin system and bundled plugins needs-decision Awaiting maintainer decision before any implementation P3 Low — cosmetic, nice to have platform/matrix Matrix adapter (E2EE) sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants