98 lines
3.7 KiB
Python
98 lines
3.7 KiB
Python
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,
|
|
},
|
|
)
|