"""Tests for the resilient adapter wrapper. Validates retry logic, backoff computation, rate-limit coordination, and retryable error classification. """ from datetime import datetime, timezone from typing import Any import pytest from services.adapters.base import AdapterResult, BaseAdapter from services.adapters.resilient import ( ResilientAdapter, RetryConfig, compute_delay, ) # --- Helpers --- def _make_result( ok: bool = True, error: str | None = None, http_status: int | None = None, metadata: dict[str, Any] | None = None, ) -> AdapterResult: return AdapterResult( source_type="market_api", ticker="AAPL", items=[{"price": 150}] if ok else [], raw_payload=b'{"ok":true}' if ok else b"", content_hash="abc" if ok else "", fetched_at=datetime.now(timezone.utc), error=error, http_status=http_status, metadata=metadata or {}, ) class FakeAdapter(BaseAdapter): """Adapter that returns a sequence of pre-configured results.""" def __init__(self, results: list[AdapterResult]) -> None: self._results = list(results) self._call_count = 0 @property def call_count(self) -> int: return self._call_count async def fetch(self, ticker: str, config: dict[str, Any]) -> AdapterResult: idx = min(self._call_count, len(self._results) - 1) self._call_count += 1 return self._results[idx] def source_type(self) -> str: return "market_api" # --- Tests --- class TestComputeDelay: def test_first_attempt_is_base_delay_plus_jitter(self): cfg = RetryConfig(base_delay=1.0, max_delay=60.0, jitter_factor=0.0) delay = compute_delay(0, cfg) assert delay == pytest.approx(1.0, abs=0.01) def test_exponential_growth(self): cfg = RetryConfig(base_delay=1.0, max_delay=60.0, jitter_factor=0.0) d0 = compute_delay(0, cfg) d1 = compute_delay(1, cfg) d2 = compute_delay(2, cfg) assert d1 == pytest.approx(2.0, abs=0.01) assert d2 == pytest.approx(4.0, abs=0.01) assert d2 > d1 > d0 def test_capped_at_max_delay(self): cfg = RetryConfig(base_delay=1.0, max_delay=10.0, jitter_factor=0.0) delay = compute_delay(10, cfg) assert delay <= 10.0 def test_jitter_adds_randomness(self): cfg = RetryConfig(base_delay=1.0, max_delay=60.0, jitter_factor=1.0) delays = {compute_delay(0, cfg) for _ in range(20)} # With jitter_factor=1.0, we should see some variation assert len(delays) > 1 class TestRetryableClassification: def setup_method(self) -> None: adapter = FakeAdapter([_make_result()]) self.resilient = ResilientAdapter(adapter) def test_ok_result_not_retryable(self): result = _make_result(ok=True) assert self.resilient._is_retryable(result) is False def test_429_is_retryable(self): result = _make_result(ok=False, error="rate limited", http_status=429) assert self.resilient._is_retryable(result) is True def test_500_is_retryable(self): result = _make_result(ok=False, error="server error", http_status=500) assert self.resilient._is_retryable(result) is True def test_503_is_retryable(self): result = _make_result(ok=False, error="unavailable", http_status=503) assert self.resilient._is_retryable(result) is True def test_400_not_retryable(self): result = _make_result(ok=False, error="bad request", http_status=400) assert self.resilient._is_retryable(result) is False def test_401_not_retryable(self): result = _make_result(ok=False, error="unauthorized", http_status=401) assert self.resilient._is_retryable(result) is False def test_timeout_error_retryable(self): result = _make_result(ok=False, error="timeout: read timed out") assert self.resilient._is_retryable(result) is True def test_connection_error_retryable(self): result = _make_result(ok=False, error="Connection refused") assert self.resilient._is_retryable(result) is True def test_generic_error_not_retryable(self): result = _make_result(ok=False, error="invalid JSON response") assert self.resilient._is_retryable(result) is False @pytest.mark.asyncio class TestResilientFetch: async def test_success_on_first_try(self): adapter = FakeAdapter([_make_result(ok=True)]) resilient = ResilientAdapter( adapter, retry_config=RetryConfig(max_retries=2, base_delay=0.01) ) result = await resilient.fetch("AAPL", {}) assert result.ok assert adapter.call_count == 1 assert result.metadata["retry_stats"]["attempts"] == 1 async def test_retries_on_retryable_then_succeeds(self): results = [ _make_result(ok=False, error="server error", http_status=500), _make_result(ok=False, error="server error", http_status=500), _make_result(ok=True), ] adapter = FakeAdapter(results) resilient = ResilientAdapter( adapter, retry_config=RetryConfig(max_retries=3, base_delay=0.01) ) result = await resilient.fetch("AAPL", {}) assert result.ok assert adapter.call_count == 3 assert result.metadata["retry_stats"]["attempts"] == 3 async def test_exhausts_retries(self): fail = _make_result(ok=False, error="server error", http_status=500) adapter = FakeAdapter([fail, fail, fail, fail]) resilient = ResilientAdapter( adapter, retry_config=RetryConfig(max_retries=2, base_delay=0.01) ) result = await resilient.fetch("AAPL", {}) assert not result.ok assert adapter.call_count == 3 # initial + 2 retries assert result.metadata["retry_stats"]["exhausted"] is True async def test_no_retry_on_non_retryable(self): fail = _make_result(ok=False, error="bad request", http_status=400) adapter = FakeAdapter([fail]) resilient = ResilientAdapter( adapter, retry_config=RetryConfig(max_retries=3, base_delay=0.01) ) result = await resilient.fetch("AAPL", {}) assert not result.ok assert adapter.call_count == 1 async def test_retry_after_respected_for_429(self): fail_429 = _make_result( ok=False, error="rate limited", http_status=429, metadata={"retry_after": 0.05}, ) results = [fail_429, _make_result(ok=True)] adapter = FakeAdapter(results) resilient = ResilientAdapter( adapter, retry_config=RetryConfig(max_retries=2, base_delay=0.01) ) result = await resilient.fetch("AAPL", {}) assert result.ok assert adapter.call_count == 2 # Should have waited at least the retry_after amount assert result.metadata["retry_stats"]["total_delay"] >= 0.04 async def test_source_type_passthrough(self): adapter = FakeAdapter([_make_result()]) resilient = ResilientAdapter(adapter) assert resilient.source_type() == "market_api" async def test_default_config_for_known_source_type(self): adapter = FakeAdapter([_make_result()]) resilient = ResilientAdapter(adapter) # market_api default is 30 rate limit max assert resilient.config.rate_limit_max == 30 async def test_custom_config_overrides_default(self): adapter = FakeAdapter([_make_result()]) custom = RetryConfig(max_retries=5, rate_limit_max=100) resilient = ResilientAdapter(adapter, retry_config=custom) assert resilient.config.max_retries == 5 assert resilient.config.rate_limit_max == 100