157 lines
4.6 KiB
Python
157 lines
4.6 KiB
Python
import asyncio
|
|
from datetime import UTC, datetime
|
|
from typing import Self
|
|
|
|
import pytest
|
|
|
|
from app import probes
|
|
from app.models import HealthStatus, ProbeResult, Service, ServiceKind
|
|
|
|
|
|
def _service() -> Service:
|
|
now = datetime.now(UTC)
|
|
return Service(
|
|
id="service-1",
|
|
name="API",
|
|
kind=ServiceKind.HTTP,
|
|
url="https://api.local",
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
|
|
|
|
def _tcp_service() -> Service:
|
|
now = datetime.now(UTC)
|
|
return Service(
|
|
id="tcp-service-1",
|
|
name="TCP service",
|
|
kind=ServiceKind.TCP,
|
|
host="tcp.local",
|
|
port=5432,
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("status_code", "expected"),
|
|
[
|
|
(200, HealthStatus.HEALTHY),
|
|
(302, HealthStatus.HEALTHY),
|
|
(404, HealthStatus.DEGRADED),
|
|
(503, HealthStatus.DOWN),
|
|
],
|
|
)
|
|
def test_http_status_classification(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
status_code: int,
|
|
expected: HealthStatus,
|
|
) -> None:
|
|
class FakeResponse:
|
|
def __init__(self, code: int) -> None:
|
|
self.status_code = code
|
|
|
|
class FakeClient:
|
|
async def __aenter__(self) -> Self:
|
|
return self
|
|
|
|
async def __aexit__(self, *args: object) -> None:
|
|
return None
|
|
|
|
async def get(self, url: str) -> FakeResponse:
|
|
assert url == "https://api.local"
|
|
return FakeResponse(status_code)
|
|
|
|
monkeypatch.setattr(probes.httpx, "AsyncClient", lambda **kwargs: FakeClient())
|
|
|
|
status, detail = asyncio.run(probes._probe_once(_service(), 1.0))
|
|
|
|
assert status == expected
|
|
assert detail == f"HTTP {status_code}"
|
|
|
|
|
|
def test_retry_latency_reports_final_attempt_not_backoff(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
calls = 0
|
|
|
|
async def fake_probe_once(service: Service, timeout_seconds: float) -> tuple[HealthStatus, str]:
|
|
nonlocal calls
|
|
calls += 1
|
|
if calls == 1:
|
|
raise OSError("temporary failure")
|
|
return HealthStatus.HEALTHY, "HTTP 200"
|
|
|
|
timings = iter([10.0, 10.05, 20.0, 20.02])
|
|
monkeypatch.setattr(probes, "_probe_once", fake_probe_once)
|
|
monkeypatch.setattr(probes, "perf_counter", lambda: next(timings))
|
|
|
|
result = asyncio.run(probes.probe_service(_service(), timeout_seconds=1.0, retries=1))
|
|
|
|
assert calls == 2
|
|
assert result.status == HealthStatus.HEALTHY
|
|
assert result.latency_ms == pytest.approx(20.0)
|
|
assert result.detail == "HTTP 200 after 2 attempts"
|
|
|
|
|
|
def test_tcp_probe_success_and_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
class FakeWriter:
|
|
def close(self) -> None:
|
|
return None
|
|
|
|
async def wait_closed(self) -> None:
|
|
return None
|
|
|
|
async def fake_open_connection(host: str | None, port: int | None) -> tuple[object, FakeWriter]:
|
|
assert (host, port) == ("tcp.local", 5432)
|
|
return object(), FakeWriter()
|
|
|
|
monkeypatch.setattr(probes.asyncio, "open_connection", fake_open_connection)
|
|
status, detail = asyncio.run(probes._probe_once(_tcp_service(), 1.0))
|
|
assert status == HealthStatus.HEALTHY
|
|
assert detail == "TCP tcp.local:5432 reachable"
|
|
|
|
async def failing_open_connection(host: str | None, port: int | None) -> tuple[object, FakeWriter]:
|
|
raise ConnectionRefusedError("refused")
|
|
|
|
monkeypatch.setattr(probes.asyncio, "open_connection", failing_open_connection)
|
|
result = asyncio.run(probes.probe_service(_tcp_service(), timeout_seconds=1.0, retries=0))
|
|
assert result.status == HealthStatus.DOWN
|
|
assert "ConnectionRefusedError" in result.detail
|
|
|
|
|
|
def test_probe_many_respects_concurrency_boundary(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
services = [
|
|
_service().model_copy(update={"id": f"service-{index}", "name": f"API-{index}"})
|
|
for index in range(5)
|
|
]
|
|
active = 0
|
|
maximum = 0
|
|
|
|
async def fake_probe_service(
|
|
service: Service,
|
|
*,
|
|
timeout_seconds: float,
|
|
retries: int,
|
|
) -> ProbeResult:
|
|
nonlocal active, maximum
|
|
active += 1
|
|
maximum = max(maximum, active)
|
|
await asyncio.sleep(0)
|
|
active -= 1
|
|
return ProbeResult(
|
|
id=f"probe-{service.id}",
|
|
service_id=service.id,
|
|
status=HealthStatus.HEALTHY,
|
|
latency_ms=1.0,
|
|
detail="ok",
|
|
checked_at=datetime.now(UTC),
|
|
)
|
|
|
|
monkeypatch.setattr(probes, "probe_service", fake_probe_service)
|
|
|
|
results = asyncio.run(probes.probe_many(services, concurrency=2))
|
|
|
|
assert len(results) == len(services)
|
|
assert maximum <= 2
|