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 / validate (push) Has been cancelled

This commit is contained in:
2026-08-09 15:46:48 +00:00
parent 1a2d954365
commit 0bf9f75bda

View File

@@ -1,89 +1,107 @@
import logging import logging
from time import perf_counter import time
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
from urllib.parse import urljoin from urllib.parse import urljoin
import httpx import httpx
from app.models import CheckResult, CurrentStatus, Monitor, State, now_utc from app.config import Settings
from app.security import Resolver, UnsafeTargetError, redacted_url, resolve_addresses, validate_target from app.models import CurrentStatus, State
from app.security import (
DestinationRejected,
Resolver,
redacted_url,
require_public_destination,
system_resolver,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
REDIRECTS = {301, 302, 303, 307, 308} RedirectCodes = {301, 302, 303, 307, 308}
class EndpointChecker: class EndpointChecker:
def __init__( def __init__(
self, self,
timeout_seconds: float, settings: Settings,
max_redirects: int, resolver: Resolver = system_resolver,
resolver: Resolver = resolve_addresses,
transport: httpx.AsyncBaseTransport | None = None, transport: httpx.AsyncBaseTransport | None = None,
) -> None: ) -> None:
self.timeout_seconds = timeout_seconds self.settings = settings
self.max_redirects = max_redirects
self.resolver = resolver self.resolver = resolver
self.transport = transport self.transport = transport
async def check(self, monitor: Monitor) -> CheckResult: async def check(self, monitor_id: str, url: str) -> CurrentStatus:
started = perf_counter() started = time.monotonic()
url = str(monitor.url) safe_url = redacted_url(url)
state = State.ERROR
observed: int | None = None
error: str | None = None
try: try:
async with httpx.AsyncClient( result = await self._request(url, started)
transport=self.transport, except DestinationRejected:
timeout=httpx.Timeout(self.timeout_seconds), self._log(monitor_id, safe_url, State.ERROR, started, None)
follow_redirects=False, raise
) as client: except (httpx.TimeoutException, httpx.NetworkError, httpx.ProtocolError) as exc:
for hop in range(self.max_redirects + 1): result = self._error(started, self._safe_error(exc))
await validate_target(url, self.resolver) self._log(monitor_id, safe_url, result.state, started, result.status_code)
async with client.stream( return result
"GET", url, headers={"user-agent": "endpoint-monitor/0.1"}
) as response: async def _request(self, initial_url: str, started: float) -> CurrentStatus:
observed = response.status_code timeout = httpx.Timeout(
if observed not in REDIRECTS: self.settings.request_timeout_seconds,
state = ( connect=self.settings.connect_timeout_seconds,
State.UP if observed == monitor.expected_status else State.DOWN )
current = initial_url
async with httpx.AsyncClient(
timeout=timeout, follow_redirects=False, transport=self.transport
) as client:
for hop in range(self.settings.max_redirects + 1):
await require_public_destination(current, self.resolver)
async with client.stream("GET", current) as response:
if response.status_code not in RedirectCodes:
state = State.UP if 200 <= response.status_code < 400 else State.DOWN
return CurrentStatus(
state=state,
checked_at=datetime.now(UTC),
status_code=response.status_code,
latency_ms=self._elapsed(started),
) )
break
location = response.headers.get("location") location = response.headers.get("location")
if not location: if not location:
error = "redirect response omitted Location" return self._error(started, "redirect response omitted Location")
break if hop == self.settings.max_redirects:
if hop == self.max_redirects: return self._error(started, "redirect limit exceeded")
error = "redirect limit exceeded" current = urljoin(current, location)
break return self._error(started, "redirect limit exceeded") # pragma: no cover
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) @staticmethod
status = CurrentStatus( def _elapsed(started: float) -> float:
state=state, return round((time.monotonic() - started) * 1000, 3)
checked_at=now_utc(),
observed_status=observed, def _error(self, started: float, message: str) -> CurrentStatus:
latency_ms=latency, return CurrentStatus(
error=error, state=State.ERROR,
checked_at=datetime.now(UTC),
latency_ms=self._elapsed(started),
error=message[:200],
) )
@staticmethod
def _safe_error(exc: Exception) -> str:
if isinstance(exc, httpx.TimeoutException):
return "outbound request timed out"
return f"outbound request failed: {type(exc).__name__}"
@staticmethod
def _log(
monitor_id: str, url: str, state: State, started: float, status_code: int | None
) -> None:
logger.info( logger.info(
"endpoint_check", "endpoint check completed",
extra={ extra={
"event_data": {
"event": "endpoint_check", "event": "endpoint_check",
"monitor_id": str(monitor.id), "monitor_id": monitor_id,
"url": redacted_url(str(monitor.url)), "url": url,
"state": state.value, "state": state,
"observed_status": observed, "latency_ms": EndpointChecker._elapsed(started),
"latency_ms": latency, "status_code": status_code,
}
}, },
) )
return CheckResult(monitor_id=monitor.id, **status.model_dump())