77 lines
3.2 KiB
Python
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,
|
|
})
|