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 15:57:44 +00:00
parent 509d1312bd
commit 16da6032bf

View File

@@ -1,50 +1,76 @@
import logging import logging
from time import monotonic import time
from datetime import UTC, datetime
from urllib.parse import urljoin from urllib.parse import urljoin
import httpx import httpx
from .config import Settings from app.config import Settings
from .logging import log_event, redacted_url from app.models import MonitorState, StatusSnapshot
from .models import CheckSnapshot, State, now from app.security import Resolver, UnsafeTarget, redact_url, system_resolver, validate_target
from .security import UnsafeDestination, validate_destination
logger = logging.getLogger("endpoint_monitor.checker") logger = logging.getLogger("monitor.checker")
async def check_url(url: str, timeout: float, settings: Settings, class EndpointChecker:
transport: httpx.AsyncBaseTransport | None = None) -> CheckSnapshot: def __init__(
started = monotonic() 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 current = url
try: try:
async with httpx.AsyncClient(transport=transport, follow_redirects=False, timeout = httpx.Timeout(
timeout=httpx.Timeout(timeout)) as client: self.settings.check_timeout_seconds,
for redirect_count in range(settings.max_redirects + 1): connect=self.settings.connect_timeout_seconds,
await validate_destination(current) )
response = await client.get(current) 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: if response.is_redirect:
location = response.headers.get("location") location = response.headers.get("location")
if not location: if not location:
break return self._result(MonitorState.ERROR, started, error="bad_redirect")
if redirect_count == settings.max_redirects: if hop == self.settings.max_redirects:
raise httpx.TooManyRedirects("redirect limit exceeded") return self._result(MonitorState.ERROR, started, error="too_many_redirects")
current = urljoin(current, location) current = urljoin(current, location)
continue continue
elapsed = round((monotonic() - started) * 1000, 3) state = MonitorState.UP if 200 <= response.status_code < 400 else MonitorState.DOWN
state = State.up if 200 <= response.status_code < 400 else State.down result = self._result(state, started, response.status_code)
result = CheckSnapshot(state=state, checked_at=now(), latency_ms=elapsed, self._log(monitor_id, url, result)
http_status=response.status_code)
log_event(logger, "check_complete", url=redacted_url(current), state=state,
latency_ms=elapsed, http_status=response.status_code)
return result return result
raise httpx.RemoteProtocolError("redirect response missing location") except UnsafeTarget as exc:
except UnsafeDestination: result = self._result(MonitorState.BLOCKED, started, error=str(exc))
log_event(logger, "check_blocked", url=redacted_url(current)) except httpx.TimeoutException:
raise result = self._result(MonitorState.ERROR, started, error="timeout")
except (httpx.HTTPError, TimeoutError) as exc: except httpx.HTTPError:
elapsed = round((monotonic() - started) * 1000, 3) result = self._result(MonitorState.ERROR, started, error="transport_error")
result = CheckSnapshot(state=State.error, checked_at=now(), latency_ms=elapsed, self._log(monitor_id, url, result)
error=type(exc).__name__)
log_event(logger, "check_error", url=redacted_url(current), state=State.error,
error=type(exc).__name__, latency_ms=elapsed)
return 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,
})