import logging from time import perf_counter from urllib.parse import urljoin import httpx from app.models import CheckResult, CurrentStatus, Monitor, State, now_utc from app.security import Resolver, UnsafeTargetError, redacted_url, resolve_addresses, validate_target logger = logging.getLogger(__name__) REDIRECTS = {301, 302, 303, 307, 308} class EndpointChecker: def __init__( self, timeout_seconds: float, max_redirects: int, resolver: Resolver = resolve_addresses, transport: httpx.AsyncBaseTransport | None = None, ) -> None: self.timeout_seconds = timeout_seconds self.max_redirects = max_redirects self.resolver = resolver self.transport = transport async def check(self, monitor: Monitor) -> CheckResult: started = perf_counter() url = str(monitor.url) state = State.ERROR observed: int | None = None error: str | None = None try: async with httpx.AsyncClient( transport=self.transport, timeout=httpx.Timeout(self.timeout_seconds), follow_redirects=False, ) as client: for hop in range(self.max_redirects + 1): await validate_target(url, self.resolver) async with client.stream( "GET", url, headers={"user-agent": "endpoint-monitor/0.1"} ) as response: observed = response.status_code if observed not in REDIRECTS: state = ( State.UP if observed == monitor.expected_status else State.DOWN ) break location = response.headers.get("location") if not location: error = "redirect response omitted Location" break if hop == self.max_redirects: error = "redirect limit exceeded" break url = urljoin(url, location) except UnsafeTargetError: state = State.BLOCKED error = "target blocked by outbound request policy" except httpx.TimeoutException: error = "outbound request timed out" except httpx.HTTPError: error = "outbound HTTP request failed" except OSError: error = "outbound network operation failed" latency = round((perf_counter() - started) * 1000, 3) status = CurrentStatus( state=state, checked_at=now_utc(), observed_status=observed, latency_ms=latency, error=error, ) logger.info( "endpoint_check", extra={ "event_data": { "event": "endpoint_check", "monitor_id": str(monitor.id), "url": redacted_url(str(monitor.url)), "state": state.value, "observed_status": observed, "latency_ms": latency, } }, ) return CheckResult(monitor_id=monitor.id, **status.model_dump())