Files

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