"""Tests for with_retries async function.""" import asyncio import pytest from meshmon.retry import with_retries @pytest.fixture def sleep_spy(monkeypatch): """Capture asyncio.sleep calls without waiting.""" calls = [] async def fake_sleep(delay): calls.append(delay) monkeypatch.setattr("meshmon.retry.asyncio.sleep", fake_sleep) return calls class TestWithRetriesSuccess: """Tests for successful operation scenarios.""" @pytest.mark.asyncio async def test_returns_result_on_success(self): """Returns result when operation succeeds.""" async def success_fn(): return "result" success, result, exception = await with_retries(success_fn) assert success is True assert result == "result" assert exception is None @pytest.mark.asyncio async def test_single_attempt_on_success(self): """Only calls function once when successful.""" call_count = 0 async def counting_fn(): nonlocal call_count call_count += 1 return "done" await with_retries(counting_fn, attempts=3) assert call_count == 1 @pytest.mark.asyncio async def test_returns_complex_result(self): """Returns complex result types correctly.""" async def complex_fn(): return {"status": "ok", "data": [1, 2, 3]} success, result, _ = await with_retries(complex_fn) assert result == {"status": "ok", "data": [1, 2, 3]} @pytest.mark.asyncio async def test_returns_none_result(self): """Returns None result correctly (distinct from failure).""" async def none_fn(): return None success, result, exception = await with_retries(none_fn) assert success is True assert result is None assert exception is None class TestWithRetriesFailure: """Tests for failure scenarios.""" @pytest.mark.asyncio async def test_returns_false_on_exhausted_attempts(self): """Returns failure when all attempts exhausted.""" async def failing_fn(): raise ValueError("always fails") success, result, exception = await with_retries( failing_fn, attempts=3, backoff_s=0 ) assert success is False assert result is None assert isinstance(exception, ValueError) @pytest.mark.asyncio async def test_retries_specified_times(self): """Retries the specified number of times.""" call_count = 0 async def failing_fn(): nonlocal call_count call_count += 1 raise RuntimeError("fail") await with_retries(failing_fn, attempts=5, backoff_s=0) assert call_count == 5 @pytest.mark.asyncio async def test_returns_last_exception(self): """Returns the exception from the last attempt.""" attempt = 0 async def changing_error_fn(): nonlocal attempt attempt += 1 raise ValueError(f"error {attempt}") success, result, exception = await with_retries( changing_error_fn, attempts=3, backoff_s=0 ) assert str(exception) == "error 3" class TestWithRetriesRetryBehavior: """Tests for retry behavior.""" @pytest.mark.asyncio async def test_succeeds_on_retry(self): """Succeeds if operation succeeds on retry.""" attempt = 0 async def eventually_succeeds(): nonlocal attempt attempt += 1 if attempt < 3: raise RuntimeError("not yet") return "success" success, result, exception = await with_retries( eventually_succeeds, attempts=5, backoff_s=0 ) assert success is True assert result == "success" assert exception is None assert attempt == 3 @pytest.mark.asyncio async def test_backoff_timing(self, sleep_spy): """Waits backoff_s between retries.""" async def failing_fn(): raise RuntimeError("fail") await with_retries(failing_fn, attempts=3, backoff_s=0.1) assert sleep_spy == [0.1, 0.1] @pytest.mark.asyncio async def test_no_backoff_after_last_attempt(self, sleep_spy): """Does not wait after final failed attempt.""" async def failing_fn(): raise RuntimeError("fail") await with_retries(failing_fn, attempts=2, backoff_s=0.5) assert sleep_spy == [0.5] class TestWithRetriesParameters: """Tests for parameter handling.""" @pytest.mark.asyncio async def test_default_attempts(self): """Uses default of 2 attempts.""" call_count = 0 async def failing_fn(): nonlocal call_count call_count += 1 raise RuntimeError("fail") await with_retries(failing_fn, backoff_s=0) assert call_count == 2 @pytest.mark.asyncio async def test_single_attempt(self): """Works with single attempt (no retry).""" call_count = 0 async def failing_fn(): nonlocal call_count call_count += 1 raise RuntimeError("fail") await with_retries(failing_fn, attempts=1, backoff_s=0) assert call_count == 1 @pytest.mark.asyncio async def test_zero_backoff(self): """Works with zero backoff.""" call_count = 0 async def failing_fn(): nonlocal call_count call_count += 1 raise RuntimeError("fail") await with_retries(failing_fn, attempts=3, backoff_s=0) assert call_count == 3 @pytest.mark.asyncio async def test_name_parameter_for_logging(self, monkeypatch, sleep_spy): """Name parameter is used in logging.""" messages = [] def fake_info(msg): messages.append(msg) def fake_debug(msg): messages.append(msg) monkeypatch.setattr("meshmon.retry.log.info", fake_info) monkeypatch.setattr("meshmon.retry.log.debug", fake_debug) async def failing_fn(): raise RuntimeError("fail") await with_retries( failing_fn, attempts=2, backoff_s=0.1, name="test_operation" ) assert any("test_operation" in msg for msg in messages) class TestWithRetriesExceptionTypes: """Tests for different exception types.""" @pytest.mark.asyncio async def test_handles_value_error(self): """Handles ValueError correctly.""" async def fn(): raise ValueError("value error") success, _, exception = await with_retries(fn, attempts=1) assert success is False assert isinstance(exception, ValueError) @pytest.mark.asyncio async def test_handles_runtime_error(self): """Handles RuntimeError correctly.""" async def fn(): raise RuntimeError("runtime error") success, _, exception = await with_retries(fn, attempts=1) assert success is False assert isinstance(exception, RuntimeError) @pytest.mark.asyncio async def test_handles_timeout_error(self): """Handles asyncio.TimeoutError correctly.""" async def fn(): raise TimeoutError("timeout") success, _, exception = await with_retries(fn, attempts=1) assert success is False assert isinstance(exception, asyncio.TimeoutError) @pytest.mark.asyncio async def test_handles_os_error(self): """Handles OSError correctly.""" async def fn(): raise OSError("os error") success, _, exception = await with_retries(fn, attempts=1) assert success is False assert isinstance(exception, OSError) @pytest.mark.asyncio async def test_handles_custom_exception(self): """Handles custom exception types correctly.""" class CustomError(Exception): pass async def fn(): raise CustomError("custom") success, _, exception = await with_retries(fn, attempts=1) assert success is False assert isinstance(exception, CustomError) class TestWithRetriesAsyncBehavior: """Tests for async-specific behavior.""" @pytest.mark.asyncio async def test_concurrent_retries_independent(self): """Multiple concurrent retry operations are independent.""" calls_a = 0 calls_b = 0 async def fn_a(): nonlocal calls_a calls_a += 1 if calls_a < 2: raise RuntimeError("a fails first") return "a" async def fn_b(): nonlocal calls_b calls_b += 1 if calls_b < 3: raise RuntimeError("b fails more") return "b" results = await asyncio.gather( with_retries(fn_a, attempts=3, backoff_s=0.01), with_retries(fn_b, attempts=4, backoff_s=0.01), ) assert results[0] == (True, "a", None) assert results[1] == (True, "b", None) assert calls_a == 2 assert calls_b == 3 @pytest.mark.asyncio async def test_does_not_block_event_loop(self): """Backoff uses asyncio.sleep, not blocking sleep.""" events = [] async def fn(): events.append("fn") raise RuntimeError("fail") async def background(): await asyncio.sleep(0.05) events.append("bg") await asyncio.sleep(0.05) events.append("bg") await asyncio.gather( with_retries(fn, attempts=2, backoff_s=0.08), background(), ) # Background task should interleave with retry backoff assert "bg" in events