"""Unit tests for the shared transient-error retry decorator.""" from unittest.mock import AsyncMock import pytest from nextcloud_mcp_server.providers import _retry class _FakeError(Exception): """Stand-in for an SDK exception with an HTTP status code attached.""" def __init__(self, status_code: int): super().__init__(f"status {status_code}") self.status_code = status_code @pytest.fixture(autouse=True) def _no_real_sleep(monkeypatch): """Replace anyio.sleep with an awaitable no-op so retries don't waste time.""" monkeypatch.setattr(_retry.anyio, "sleep", AsyncMock(return_value=None)) @pytest.mark.unit async def test_retry_succeeds_after_429(): """A 429 followed by success returns the success value.""" calls = {"n": 0} @_retry.retry_on_transient(_FakeError, should_retry=lambda e: e.status_code == 429) async def flaky(): calls["n"] += 1 if calls["n"] < 3: raise _FakeError(429) return "ok" result = await flaky() assert result == "ok" assert calls["n"] == 3 @pytest.mark.unit async def test_retry_reraises_when_predicate_returns_false(): """An error the predicate rejects is re-raised on first hit (no retry).""" calls = {"n": 0} @_retry.retry_on_transient(_FakeError, should_retry=lambda e: e.status_code == 429) async def boom(): calls["n"] += 1 raise _FakeError(500) with pytest.raises(_FakeError, match="status 500"): await boom() assert calls["n"] == 1 # No retries on non-429. @pytest.mark.unit async def test_retry_gives_up_after_max_retries(): """After MAX_RETRIES failed attempts the last error is re-raised.""" calls = {"n": 0} @_retry.retry_on_transient(_FakeError, should_retry=lambda e: e.status_code == 429) async def always_429(): calls["n"] += 1 raise _FakeError(429) with pytest.raises(_FakeError, match="status 429"): await always_429() assert calls["n"] == _retry.MAX_RETRIES @pytest.mark.unit async def test_retry_default_predicate_treats_all_as_rate_limit(): """Default predicate (`lambda _: True`) retries every caught exception.""" calls = {"n": 0} @_retry.retry_on_transient(_FakeError) async def fail_once(): calls["n"] += 1 if calls["n"] < 2: raise _FakeError(503) return "recovered" result = await fail_once() assert result == "recovered" assert calls["n"] == 2 @pytest.mark.unit async def test_retry_does_not_catch_unrelated_exceptions(): """Exceptions of a different class bypass the decorator entirely.""" @_retry.retry_on_transient(_FakeError) async def value_error(): raise ValueError("nope") with pytest.raises(ValueError, match="nope"): await value_error() class _ConnError(Exception): pass @pytest.mark.unit async def test_retry_accepts_tuple_of_exception_types(): """A tuple of exception classes is caught (the OpenAI transient set shape).""" calls = {"n": 0} @_retry.retry_on_transient((_FakeError, _ConnError)) async def flaky(): calls["n"] += 1 if calls["n"] == 1: raise _ConnError("dropped") if calls["n"] == 2: raise _FakeError(503) return "ok" assert await flaky() == "ok" assert calls["n"] == 3 @pytest.mark.unit async def test_retry_on_rate_limit_is_backcompat_alias(): """The old name still resolves to the generalized helper.""" assert _retry.retry_on_rate_limit is _retry.retry_on_transient