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, })