import logging import time from urllib.parse import urljoin import httpx from app.config import Settings from app.models import CurrentStatus, MonitorState, utc_now from app.security import Resolver, UnsafeTarget, redact_url, system_resolver, validate_target logger = logging.getLogger("endpoint_monitor.checker") _REDIRECTS = {301, 302, 303, 307, 308} class EndpointChecker: def __init__( self, settings: Settings, *, transport: httpx.AsyncBaseTransport | None = None, resolver: Resolver = system_resolver, ) -> None: self.settings = settings self.transport = transport self.resolver = resolver async def check(self, monitor_id: str, target_url: str) -> CurrentStatus: started = time.monotonic() current_url = target_url timeout = httpx.Timeout( connect=self.settings.connect_timeout_seconds, read=self.settings.read_timeout_seconds, write=self.settings.write_timeout_seconds, pool=self.settings.pool_timeout_seconds, ) limits = httpx.Limits(max_connections=self.settings.max_connections) try: async with httpx.AsyncClient( timeout=timeout, limits=limits, transport=self.transport, follow_redirects=False, headers={"User-Agent": self.settings.user_agent}, ) as client: for redirect_count in range(self.settings.max_redirects + 1): await validate_target(current_url, self.resolver) async with client.stream("GET", current_url) as response: if response.status_code in _REDIRECTS and response.headers.get("location"): if redirect_count >= self.settings.max_redirects: raise httpx.TooManyRedirects("redirect limit exceeded") current_url = urljoin(current_url, response.headers["location"]) continue state = MonitorState.UP if response.status_code < 400 else MonitorState.DOWN result = self._status(started, state, response.status_code) self._log(monitor_id, current_url, result) return result except UnsafeTarget: logger.warning( "check blocked", extra={"event": "check_blocked", "monitor_id": monitor_id, "url": redact_url(current_url)}, ) raise except (httpx.HTTPError, OSError) as exc: result = self._status(started, MonitorState.ERROR, error=str(exc)[:500]) self._log(monitor_id, current_url, result) return result raise RuntimeError("unreachable redirect loop") @staticmethod def _status( started: float, state: MonitorState, http_status: int | None = None, error: str | None = None, ) -> CurrentStatus: return CurrentStatus( state=state, checked_at=utc_now(), latency_ms=round((time.monotonic() - started) * 1000, 3), http_status=http_status, error=error, ) @staticmethod def _log(monitor_id: str, url: str, result: CurrentStatus) -> None: logger.info( "check completed", extra={ "event": "check_completed", "monitor_id": monitor_id, "url": redact_url(url), "outcome": result.state.value, "latency_ms": result.latency_ms, "http_status": result.http_status, "error": result.error, }, )