From ffdc39e7e54f46f3bc81103400166f626e377b41 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Fri, 10 Jul 2026 04:41:18 +0300 Subject: [PATCH] =?UTF-8?q?test(media):=20=D0=BF=D0=BE=D0=BA=D1=80=D1=8B?= =?UTF-8?q?=D1=82=D1=8C=20backoff-=D0=BA=D0=B0=D0=BF=20+=20best-effort=20r?= =?UTF-8?q?estart-=D0=BA=D0=BE=D0=BB=D0=B1=D1=8D=D0=BA=20(=D1=80=D0=B5?= =?UTF-8?q?=D0=B2=D1=8C=D1=8E=20#48)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_backoff_capped_at_max: 8 фейлов подряд (сырая экспонента 7680с) -> _download_backoff_remaining <= _DOWNLOAD_BACKOFF_MAX (600с). Мутация: убрать min()-кап -> тест краснеет (7680>600). - test_restart_callback_error_does_not_abort: бросающий restart-колбэк на verified-рестарте -> recovery завершается (watchdog пере-армлен, _restarting сброшен, SIGTERM-fallback не сработал), ошибка залогирована. Мутация: убрать try/except вокруг колбэка -> исключение уходит наружу -> тест краснеет. Только тесты; прод-код байт-в-байт не тронут. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_media_selfheal.py | 79 ++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/tests/test_media_selfheal.py b/tests/test_media_selfheal.py index 0b286da..7656c8c 100644 --- a/tests/test_media_selfheal.py +++ b/tests/test_media_selfheal.py @@ -258,3 +258,82 @@ def test_download_failures_lru_bounded(monkeypatch): assert remaining == [("chan", 2, "fid"), ("chan", 3, "fid"), ("chan", 4, "fid")] finally: api_server._download_failures.clear() + + +# --------------------------------------------------------------------------- # +# Backoff is capped at _DOWNLOAD_BACKOFF_MAX (min() cap at api_server.py:163) +# --------------------------------------------------------------------------- # +def test_backoff_capped_at_max(): + """The exponential backoff grows as BASE * 2**(fails-1), but must never exceed + _DOWNLOAD_BACKOFF_MAX. With BASE=60s and MAX=600s the raw exponential overtakes the cap + at the 5th failure (60*2**4 = 960s > 600s), so after that many consecutive failures the + effective backoff must stay pinned at the cap. This test FAILS if the min(MAX, ...) cap + is removed (the raw exponential would then blow past 600s).""" + key = ("selfheal_chan", 99, "fid_cap") + api_server._download_failures.pop(key, None) + + # Record well past the point where the raw exponential exceeds the cap: 8 failures -> + # raw 60*2**7 = 7680s, an order of magnitude above the 600s cap. + for _ in range(8): + api_server._record_download_failure(key) + + assert api_server._download_failures[key][0] == 8 # counter really climbed that high + remaining = api_server._download_backoff_remaining(key) + # The cap holds: remaining is bounded by MAX (with a tiny slack for monotonic drift since + # retry_not_before was stamped). Without the min() cap this would be ~7680s. + assert remaining <= api_server._DOWNLOAD_BACKOFF_MAX + assert remaining > api_server._DOWNLOAD_BACKOFF_MAX - 5 # and it IS pinned near the cap + + api_server._clear_download_failure(key) + + +# --------------------------------------------------------------------------- # +# Restart-verified callback is best-effort (try/except at telegram_client.py:250-254) +# --------------------------------------------------------------------------- # +@pytest.mark.asyncio +async def test_restart_callback_error_does_not_abort(monkeypatch, caplog): + """A registered restart-verified callback that RAISES must not abort recovery: the error is + swallowed + logged, and the restart still completes normally (watchdog re-armed, no SIGTERM + fallback, _restarting reset). This test FAILS if the try/except around the callback is + removed — the exception would then propagate to the outer handler, skip the watchdog re-arm + and trigger the process-restart fallback instead.""" + c = TelegramClient() + + def exploding_callback(): + raise RuntimeError("boom in verified-restart callback") + + c.set_restart_callback(exploding_callback) + + # Drive a VERIFIED restart: connected client, restart + get_me both succeed so verify_ok is + # True and the callback fires. + monkeypatch.setattr(c.client, "is_connected", True) + + async def fake_client_restart(): + return None + + async def fake_get_me(): + return type("Me", (), {"id": 42})() + + monkeypatch.setattr(c.client, "restart", fake_client_restart) + monkeypatch.setattr(c.client, "get_me", fake_get_me) + + # Observe the success path (watchdog re-arm) vs the failure path (SIGTERM fallback) without + # actually killing the test process. + watchdog_rearmed = [] + monkeypatch.setattr(c, "_start_watchdog", lambda: watchdog_rearmed.append(True)) + sigterm_calls = [] + monkeypatch.setattr(c, "_restart_app", lambda: sigterm_calls.append(True)) + + with caplog.at_level("WARNING"): + await c._restart_client(reason="test callback raises") + + # (a) Recovery completed on the SUCCESS path despite the callback raising: watchdog re-armed, + # no process-restart fallback, restart flag cleared. + assert watchdog_rearmed == [True] + assert sigterm_calls == [] + assert c._restarting is False + # (b) The callback error was logged (not silently dropped). + assert any( + "callback raised" in r.getMessage() and "RuntimeError" in r.getMessage() + for r in caplog.records + )