fix(matrix): stop sync loop on irrecoverable auth errors

When the Matrix homeserver returns M_UNKNOWN_TOKEN / M_FORBIDDEN /
M_UNAUTHORIZED (or soft_logout), the previous _sync_loop kept retrying
sync_forever every 2 seconds forever, spamming the homeserver and
filling logs (#1851). The auth state cannot recover by retrying, so
this is pure noise and a soft DoS on the homeserver.

- Extract `_is_fatal_auth_response()` helper
- In `_on_sync_error`, on fatal auth: set `_running=False` and call
  `stop_sync_forever()` so the loop exits cleanly
- Add exponential backoff (2s → 60s cap) to the generic exception path
  in `_sync_loop` so transient network blips also stop hammering

Closes #1851

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
coldxiangyu
2026-05-01 23:59:09 +08:00
committed by Xubin Ren
co-authored by Claude Opus 4.7
parent 539d82eadc
commit 4860a9a6c9
2 changed files with 76 additions and 5 deletions
+56 -1
View File
@@ -7,7 +7,7 @@ import pytest
pytest.importorskip("nio")
pytest.importorskip("nh3")
pytest.importorskip("mistune")
from nio import RoomSendResponse
from nio import RoomSendResponse, SyncError
from nanobot.channels.matrix import _build_matrix_text_content
@@ -266,6 +266,61 @@ async def test_start_disables_e2ee_when_configured(
await channel.stop()
@pytest.mark.asyncio
async def test_on_sync_error_stops_loop_on_unknown_token() -> None:
channel = MatrixChannel(_make_config(), MessageBus())
client = _FakeAsyncClient("", "", "", None)
channel.client = client
channel._running = True
await channel._on_sync_error(SyncError(message="bad", status_code="M_UNKNOWN_TOKEN"))
assert channel._running is False
assert client.stop_sync_forever_called is True
@pytest.mark.asyncio
async def test_on_sync_error_keeps_running_on_transient_error() -> None:
channel = MatrixChannel(_make_config(), MessageBus())
client = _FakeAsyncClient("", "", "", None)
channel.client = client
channel._running = True
await channel._on_sync_error(SyncError(message="oops", status_code="M_LIMIT_EXCEEDED"))
assert channel._running is True
assert client.stop_sync_forever_called is False
@pytest.mark.asyncio
async def test_sync_loop_backs_off_on_repeated_errors(monkeypatch) -> None:
channel = MatrixChannel(_make_config(), MessageBus())
sleeps: list[float] = []
async def _fake_sleep(delay: float) -> None:
sleeps.append(delay)
monkeypatch.setattr(matrix_module.asyncio, "sleep", _fake_sleep)
call_count = {"n": 0}
class _BoomClient:
async def sync_forever(self, **_kwargs) -> None:
call_count["n"] += 1
if call_count["n"] > 4:
channel._running = False
return
raise RuntimeError("boom")
channel.client = _BoomClient()
channel._running = True
await channel._sync_loop()
assert sleeps == [2.0, 4.0, 8.0, 16.0]
@pytest.mark.asyncio
async def test_stop_stops_sync_forever_before_close(monkeypatch) -> None:
channel = MatrixChannel(_make_config(device_id="DEVICE"), MessageBus())