90 lines
3.3 KiB
Python
90 lines
3.3 KiB
Python
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())
|