test(matrix): add loop-level and attribute-narrowing coverage

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.

(cherry picked from commit f747bb4b5a6e6da1bb9136168f08d6e7af5ea64b)
This commit is contained in:
Stephen Chin
2026-08-05 20:32:07 -07:00
committed by kshitij
parent 104889ace6
commit f8085f2e03
2 changed files with 141 additions and 7 deletions

View File

@@ -203,6 +203,15 @@ _MATRIX_PERMANENT_ERRCODES = frozenset({
})
# Leading HTTP status on error strings formatted as ``"<status>: <body>"``.
_MATRIX_LEADING_STATUS_RE = re.compile(r"^\s*(\d{3})\b")
# Transport-level failures are always transient. Checked before any status or
# text inspection so no amount of unlucky digits or keywords in a message can
# ever promote a network timeout or dropped connection to "permanent".
_MATRIX_TRANSIENT_EXC_TYPES: tuple[type[BaseException], ...] = (
asyncio.TimeoutError,
TimeoutError,
ConnectionError, # covers ConnectionResetError, ConnectionRefusedError, ...
OSError,
)
def _is_permanent_matrix_auth_error(exc: object) -> bool:
@@ -215,6 +224,12 @@ def _is_permanent_matrix_auth_error(exc: object) -> bool:
the sync loop permanently on a passing blip. Classify on the real HTTP
status / errcode instead, and retry everything that is not 401/403.
"""
# Transport failures are never permanent, regardless of what their
# message text says. A ConnectionError raised while retrying a call whose
# URL happens to contain "403" must still be retried.
if isinstance(exc, _MATRIX_TRANSIENT_EXC_TYPES):
return False
# Prefer structured attributes when the exception carries them.
errcode = getattr(exc, "errcode", None)
if (
@@ -222,21 +237,33 @@ def _is_permanent_matrix_auth_error(exc: object) -> bool:
and errcode.strip().lower() in _MATRIX_PERMANENT_ERRCODES
):
return True
for attr in ("http_status", "status", "status_code", "code"):
val = getattr(exc, attr, None)
if isinstance(val, int):
return val in (401, 403)
# mautrix's MatrixRequestError exposes ``.http_status``. Deliberately not
# ``.status``/``.status_code``/``.code``: those belong to unrelated
# exception shapes (aiohttp responses, OS errno, generic process exit
# codes) and a first-int-wins probe across all of them can misclassify on
# a coincidental integer that has nothing to do with the HTTP response.
status = getattr(exc, "http_status", None)
if isinstance(status, int):
return status in (401, 403)
# Fall back to parsing a leading status code off the string form
# (e.g. ``"502: <!DOCTYPE html>..."``). A known status is authoritative:
# (e.g. ``"502: <!DOCTYPE html>...``). A known status is authoritative:
# only 401/403 are permanent; 5xx/429/etc. must retry regardless of body.
text = str(exc)
m = _MATRIX_LEADING_STATUS_RE.match(text)
if m:
return int(m.group(1)) in (401, 403)
# No status available: trust only whole-word auth errcodes/keywords found in
# a bounded prefix, so a large HTML body cannot smuggle a false positive.
# No structured status available at all: fall back to a bounded,
# whole-word text scan. This is a deliberate divergence from a
# structured-only classifier. Some transports (a bare Exception wrapping a
# homeserver error) never expose http_status/errcode, so without this
# fallback we'd silently treat every unstructured auth failure as
# retryable forever.
# Bounded to the first 200 characters with \b word boundaries so a large
# HTML error body (which can run to kilobytes) has no way to smuggle a
# false positive the way the old unbounded substring check did.
head = text[:200].lower()
return bool(
re.search(

View File

@@ -1340,6 +1340,79 @@ class TestMatrixSyncLoop:
assert captured[0].text == "hello"
assert captured[0].source.chat_type == "dm"
@pytest.mark.asyncio
async def test_sync_loop_retries_transient_error_instead_of_stopping(self):
"""A transient error containing a false-positive status digit must be retried, not fatal.
This is the regression the false-positive substring match caused in
production: an HTML error body with an embedded coordinate like
"40.4302" contains the digits "403", so a naive `"403" in str(exc)`
check stopped the sync loop permanently on what was really a passing
502 blip. This test drives `_sync_loop` itself (not just the
classifier function) so a regression in how the loop wires the
classifier in would also be caught here.
"""
adapter = _make_adapter()
adapter._closing = False
real_502 = (
'502: <!DOCTYPE html><svg><path d="M17.4517 40.4302'
'C12.7214 40.4302 9.82339 41.8182 7.98048 44.0001"/></svg>'
)
calls = {"n": 0}
async def _sync_side_effect(**kwargs):
calls["n"] += 1
if calls["n"] == 1:
raise Exception(real_502)
# Second call: stop the loop cleanly after proving the retry
# happened, without needing a real sync payload.
adapter._closing = True
return {"next_batch": "s1"}
mock_sync_store = MagicMock()
mock_sync_store.get_next_batch = AsyncMock(return_value=None)
mock_sync_store.put_next_batch = AsyncMock()
fake_client = MagicMock()
fake_client.sync = AsyncMock(side_effect=_sync_side_effect)
fake_client.sync_store = mock_sync_store
adapter._client = fake_client
with patch("asyncio.sleep", new=AsyncMock()) as mock_sleep:
await adapter._sync_loop()
# Retried rather than returning after the first (transient) error.
assert calls["n"] == 2
# The 5s backoff sleep on the retry path (not the 0s dispatch-yield).
assert 5 in [call.args[0] for call in mock_sleep.await_args_list]
@pytest.mark.asyncio
async def test_sync_loop_stops_on_real_permanent_auth_error(self):
"""A genuine 401/403 auth failure must stop the loop, not retry forever."""
adapter = _make_adapter()
adapter._closing = False
auth_exc = Exception("M_FORBIDDEN: Invalid access token")
auth_exc.errcode = "M_FORBIDDEN"
mock_sync_store = MagicMock()
mock_sync_store.get_next_batch = AsyncMock(return_value=None)
mock_sync_store.put_next_batch = AsyncMock()
fake_client = MagicMock()
fake_client.sync = AsyncMock(side_effect=auth_exc)
fake_client.sync_store = mock_sync_store
adapter._client = fake_client
with patch("asyncio.sleep", new=AsyncMock()) as mock_sleep:
await adapter._sync_loop()
# No retry backoff: the loop returned on the first, permanent error.
assert fake_client.sync.await_count == 1
assert 5 not in [call.args[0] for call in mock_sleep.await_args_list]
@pytest.mark.asyncio
async def test_connect_receives_dm_from_initial_sync_dispatch(self):
"""A DM delivered by initial sync should reach the message handler after connect."""
@@ -3397,3 +3470,37 @@ class TestMatrixPermanentAuthClassifier:
def test_bare_auth_keyword_without_status_stops_sync(self):
assert self._fn()(Exception("Unauthorized")) is True
def test_misleading_code_attribute_is_not_misclassified(self):
# A non-Matrix exception with a bare `.code` that happens to be 401
# must NOT be treated as an auth failure. `.code` on an arbitrary
# exception can be an errno or an exit code; only `.http_status`
# is trustworthy for HTTP status classification.
exc = Exception("subprocess failed")
exc.code = 401
assert self._fn()(exc) is False
def test_status_attribute_is_ignored_in_favor_of_http_status(self):
# aiohttp response objects and similar carry `.status`, which is a
# different namespace than mautrix's `.http_status`. A bare `.status`
# must not drive the classification.
exc = Exception("some http client error")
exc.status = 403
assert self._fn()(exc) is False
@pytest.mark.parametrize(
"exc_factory",
[
lambda: asyncio.TimeoutError("401 in the pagination token url"),
lambda: TimeoutError("403 in the pagination token url"),
lambda: ConnectionError("... forbidden ..."),
lambda: ConnectionResetError("Connection reset, status 401"),
lambda: OSError("network unreachable, code 403"),
],
)
def test_transient_exception_types_never_classify_as_permanent(self, exc_factory):
# Transport-level exception types are short-circuited to "transient"
# before any text/status inspection runs, so a coincidental "401"
# or the keyword "forbidden" inside their message can never flip
# them to permanent.
assert self._fn()(exc_factory()) is False