Files
Jorijn Schrijvershof ca13e31aae test: stabilize suite and broaden integration coverage (#32)
* tests: cache integration/report fixtures to speed up tests

* fix: speed up yearly aggregation and refresh timings report

* chore: remove the report

* fix: unrecognized named-value: 'runner'. Located at position 1 within expression: runner.temp

* fix: ruff linting error

* test: strengthen assertions and stabilize tests

* test(integration): expand rendered chart metrics
2026-01-08 21:20:34 +01:00

353 lines
9.6 KiB
Python

"""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