phase 14-15: docker build validation and helm deployment
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user