decomposer: generate deliverable files for Define the service contract and project architecture for the FastAPI endpoint monitoring service.; Implement the typed monitor CRUD API and concurrency-safe in-memory state according to the service design.; Implement secure on-demand endpoint checks with status updates, latency measurement, robust error handling, and redacted structured logs.; Add operational API endpoints and environment-driven runtime configuration to the monitoring service.; Create automated tests for the monitoring service.; Package the service with Docker and developer documentation.; Validate the complete project.
Some checks failed
ci / test (push) Has been cancelled

This commit is contained in:
2026-08-09 16:00:56 +00:00
parent 0693731ee7
commit dfebce19ac

View File

@@ -1,76 +1,116 @@
import asyncio
import ipaddress
import logging import logging
import socket
import time import time
from datetime import UTC, datetime from datetime import datetime, timezone
from urllib.parse import urljoin from typing import Awaitable, Callable
from urllib.parse import urljoin, urlsplit
import httpx import httpx
from app.config import Settings from app.config import Settings
from app.models import MonitorState, StatusSnapshot from app.logging import redact_url
from app.security import Resolver, UnsafeTarget, redact_url, system_resolver, validate_target from app.models import CheckStatus
logger = logging.getLogger("monitor.checker") logger = logging.getLogger(__name__)
Resolver = Callable[[str, int], Awaitable[list[str]]]
class UnsafeTarget(ValueError):
pass
async def system_resolver(host: str, port: int) -> list[str]:
loop = asyncio.get_running_loop()
answers = await loop.run_in_executor(
None, lambda: socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
)
return sorted({answer[4][0] for answer in answers})
async def validate_target(url: str, resolver: Resolver) -> None:
parsed = urlsplit(url)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise UnsafeTarget("only absolute HTTP(S) targets are allowed")
if parsed.username is not None or parsed.password is not None:
raise UnsafeTarget("target user information is not allowed")
try:
port = parsed.port or (443 if parsed.scheme == "https" else 80)
except ValueError as exc:
raise UnsafeTarget("invalid target port") from exc
try:
addresses = await resolver(parsed.hostname, port)
except (OSError, socket.gaierror) as exc:
raise UnsafeTarget("target DNS resolution failed") from exc
if not addresses:
raise UnsafeTarget("target DNS resolution returned no addresses")
try:
unsafe = [address for address in addresses if not ipaddress.ip_address(address).is_global]
except ValueError as exc:
raise UnsafeTarget("target DNS returned an invalid address") from exc
if unsafe:
raise UnsafeTarget("target resolves to a non-public address")
class EndpointChecker: class EndpointChecker:
def __init__( def __init__(self, client: httpx.AsyncClient, settings: Settings,
self, settings: Settings, resolver: Resolver = system_resolver, resolver: Resolver = system_resolver) -> None:
transport: httpx.AsyncBaseTransport | None = None, self.client = client
) -> None:
self.settings = settings self.settings = settings
self.resolver = resolver self.resolver = resolver
self.transport = transport
async def check(self, monitor_id: str, url: str) -> StatusSnapshot: async def check(self, url: str) -> CheckStatus:
started = time.perf_counter() started = time.monotonic()
current = url current = url
try: try:
timeout = httpx.Timeout( for redirect_count in range(self.settings.max_redirects + 1):
self.settings.check_timeout_seconds, await validate_target(current, self.resolver)
connect=self.settings.connect_timeout_seconds, remaining = self.settings.check_timeout_seconds - (time.monotonic() - started)
) if remaining <= 0:
async with httpx.AsyncClient( raise httpx.TimeoutException("total check timeout exceeded")
timeout=timeout, transport=self.transport, follow_redirects=False, async with self.client.stream(
) as client: "GET", current, follow_redirects=False, timeout=remaining,
for hop in range(self.settings.max_redirects + 1): headers={"User-Agent": "endpoint-monitor/1.0"},
await validate_target(current, self.resolver) ) as response:
async with client.stream("GET", current) as response: body_size = 0
if response.is_redirect: async for chunk in response.aiter_bytes():
location = response.headers.get("location") body_size += len(chunk)
if not location: if body_size > self.settings.max_response_bytes:
return self._result(MonitorState.ERROR, started, error="bad_redirect") raise httpx.TooManyRedirects("response exceeded configured limit")
if hop == self.settings.max_redirects: if response.is_redirect:
return self._result(MonitorState.ERROR, started, error="too_many_redirects") location = response.headers.get("location")
current = urljoin(current, location) if not location:
continue return self._result(started, "error", error="redirect missing Location")
state = MonitorState.UP if 200 <= response.status_code < 400 else MonitorState.DOWN if redirect_count >= self.settings.max_redirects:
result = self._result(state, started, response.status_code) return self._result(started, "error", error="redirect limit exceeded")
self._log(monitor_id, url, result) current = urljoin(current, location)
return result continue
except UnsafeTarget as exc: state = "up" if 200 <= response.status_code < 400 else "down"
result = self._result(MonitorState.BLOCKED, started, error=str(exc)) result = self._result(started, state, status_code=response.status_code)
logger.info("endpoint_check_complete", extra={
"target": redact_url(current), "state": state,
"status_code": response.status_code,
"latency_ms": result.latency_ms,
})
return result
except UnsafeTarget:
logger.warning("endpoint_check_blocked", extra={"target": redact_url(current)})
raise
except httpx.TimeoutException: except httpx.TimeoutException:
result = self._result(MonitorState.ERROR, started, error="timeout") result = self._result(started, "error", error="request timed out")
except httpx.HTTPError: except httpx.HTTPError:
result = self._result(MonitorState.ERROR, started, error="transport_error") result = self._result(started, "error", error="HTTP transport error")
self._log(monitor_id, url, result) logger.info("endpoint_check_failed", extra={
"target": redact_url(current), "state": result.state,
"error": result.error, "latency_ms": result.latency_ms,
})
return result return result
@staticmethod @staticmethod
def _result( def _result(started: float, state: str, status_code: int | None = None,
state: MonitorState, started: float, status: int | None = None, error: str | None = None) -> CheckStatus:
error: str | None = None, return CheckStatus(state=state, checked_at=datetime.now(timezone.utc),
) -> StatusSnapshot: status_code=status_code,
return StatusSnapshot( latency_ms=round((time.monotonic() - started) * 1000, 3),
state=state, checked_at=datetime.now(UTC), error=error)
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,
})