Files
crucible-agent-build-fastap…/app/checker.py

77 lines
3.2 KiB
Python

import logging
import time
from datetime import UTC, datetime
from urllib.parse import urljoin
import httpx
from app.config import Settings
from app.models import MonitorState, StatusSnapshot
from app.security import Resolver, UnsafeTarget, redact_url, system_resolver, validate_target
logger = logging.getLogger("monitor.checker")
class EndpointChecker:
def __init__(
self, settings: Settings, resolver: Resolver = system_resolver,
transport: httpx.AsyncBaseTransport | None = None,
) -> None:
self.settings = settings
self.resolver = resolver
self.transport = transport
async def check(self, monitor_id: str, url: str) -> StatusSnapshot:
started = time.perf_counter()
current = url
try:
timeout = httpx.Timeout(
self.settings.check_timeout_seconds,
connect=self.settings.connect_timeout_seconds,
)
async with httpx.AsyncClient(
timeout=timeout, transport=self.transport, follow_redirects=False,
) as client:
for hop in range(self.settings.max_redirects + 1):
await validate_target(current, self.resolver)
async with client.stream("GET", current) as response:
if response.is_redirect:
location = response.headers.get("location")
if not location:
return self._result(MonitorState.ERROR, started, error="bad_redirect")
if hop == self.settings.max_redirects:
return self._result(MonitorState.ERROR, started, error="too_many_redirects")
current = urljoin(current, location)
continue
state = MonitorState.UP if 200 <= response.status_code < 400 else MonitorState.DOWN
result = self._result(state, started, response.status_code)
self._log(monitor_id, url, result)
return result
except UnsafeTarget as exc:
result = self._result(MonitorState.BLOCKED, started, error=str(exc))
except httpx.TimeoutException:
result = self._result(MonitorState.ERROR, started, error="timeout")
except httpx.HTTPError:
result = self._result(MonitorState.ERROR, started, error="transport_error")
self._log(monitor_id, url, result)
return result
@staticmethod
def _result(
state: MonitorState, started: float, status: int | None = None,
error: str | None = None,
) -> StatusSnapshot:
return StatusSnapshot(
state=state, checked_at=datetime.now(UTC),
latency_ms=round((time.perf_counter() - started) * 1000, 3),
http_status=status, error=error,
)
@staticmethod
def _log(monitor_id: str, url: str, result: StatusSnapshot) -> None:
logger.info("check_complete", extra={
"monitor_id": monitor_id, "target": redact_url(url),
"state": result.state, "latency_ms": result.latency_ms,
"http_status": result.http_status, "error": result.error,
})