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 / container (push) Has been cancelled
ci / quality (push) Has been cancelled

This commit is contained in:
2026-08-09 16:03:47 +00:00
parent 3a7127c720
commit 7a2e91aa80

View File

@@ -1,116 +1,71 @@
import asyncio
import ipaddress
import logging import logging
import socket
import time import time
from datetime import datetime, timezone from datetime import datetime
from typing import Awaitable, Callable from urllib.parse import urljoin
from urllib.parse import urljoin, urlsplit
import httpx import httpx
from app.config import Settings from .logging_config import redact_url
from app.logging import redact_url from .models import CheckResult, MonitorStatus, utcnow
from app.models import CheckStatus from .security import Resolver, UnsafeTarget, system_resolver, validate_target
logger = logging.getLogger(__name__) logger = logging.getLogger("monitor.checker")
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__(self, client: httpx.AsyncClient, settings: Settings, def __init__(
resolver: Resolver = system_resolver) -> None: self,
client: httpx.AsyncClient,
timeout: float,
max_redirects: int,
max_response_bytes: int,
resolver: Resolver = system_resolver,
) -> None:
self.client = client self.client = client
self.settings = settings self.timeout = timeout
self.max_redirects = max_redirects
self.max_response_bytes = max_response_bytes
self.resolver = resolver self.resolver = resolver
async def check(self, url: str) -> CheckStatus: async def check(self, monitor_id: str, initial_url: str) -> CheckResult:
started = time.monotonic() started = time.perf_counter()
current = url checked_at: datetime = utcnow()
current_url = initial_url
try: try:
for redirect_count in range(self.settings.max_redirects + 1): for redirect_count in range(self.max_redirects + 1):
await validate_target(current, self.resolver) await validate_target(current_url, self.resolver)
remaining = self.settings.check_timeout_seconds - (time.monotonic() - started)
if remaining <= 0:
raise httpx.TimeoutException("total check timeout exceeded")
async with self.client.stream( async with self.client.stream(
"GET", current, follow_redirects=False, timeout=remaining, "GET", current_url, follow_redirects=False, timeout=self.timeout,
headers={"User-Agent": "endpoint-monitor/1.0"}, headers={"User-Agent": "endpoint-monitor/1"},
) as response: ) as response:
body_size = 0 consumed = 0
async for chunk in response.aiter_bytes(): async for chunk in response.aiter_bytes():
body_size += len(chunk) consumed += len(chunk)
if body_size > self.settings.max_response_bytes: if consumed >= self.max_response_bytes:
raise httpx.TooManyRedirects("response exceeded configured limit") break
if response.is_redirect: if response.is_redirect and response.headers.get("location"):
location = response.headers.get("location") if redirect_count == self.max_redirects:
if not location: return self._result(started, checked_at, MonitorStatus.error, error="too_many_redirects")
return self._result(started, "error", error="redirect missing Location") current_url = urljoin(str(response.url), response.headers["location"])
if redirect_count >= self.settings.max_redirects:
return self._result(started, "error", error="redirect limit exceeded")
current = urljoin(current, location)
continue continue
state = "up" if 200 <= response.status_code < 400 else "down" status = MonitorStatus.up if 200 <= response.status_code < 400 else MonitorStatus.down
result = self._result(started, state, status_code=response.status_code) return self._result(started, checked_at, status, response.status_code)
logger.info("endpoint_check_complete", extra={ except UnsafeTarget as exc:
"target": redact_url(current), "state": state, result = self._result(started, checked_at, MonitorStatus.error, error=str(exc))
"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(started, "error", error="request timed out") result = self._result(started, checked_at, MonitorStatus.error, error="timeout")
except httpx.HTTPError: except httpx.HTTPError:
result = self._result(started, "error", error="HTTP transport error") result = self._result(started, checked_at, MonitorStatus.error, error="network_error")
logger.info("endpoint_check_failed", extra={ except Exception:
"target": redact_url(current), "state": result.state, logger.exception("check_unexpected_error", extra={"monitor_id": monitor_id, "url": redact_url(initial_url)})
"error": result.error, "latency_ms": result.latency_ms, result = self._result(started, checked_at, MonitorStatus.error, error="internal_error")
}) self._log(monitor_id, initial_url, result)
return result return result
@staticmethod @staticmethod
def _result(started: float, state: str, status_code: int | None = None, def _result(started: float, checked_at: datetime, status: MonitorStatus, http_status: int | None = None, error: str | None = None) -> CheckResult:
error: str | None = None) -> CheckStatus: return CheckResult(status=status, checked_at=checked_at, latency_ms=max(0.0, (time.perf_counter() - started) * 1000), http_status=http_status, error=error)
return CheckStatus(state=state, checked_at=datetime.now(timezone.utc),
status_code=status_code, @staticmethod
latency_ms=round((time.monotonic() - started) * 1000, 3), def _log(monitor_id: str, url: str, result: CheckResult) -> None:
error=error) logger.info("check_completed", extra={"monitor_id": monitor_id, "url": url, **result.model_dump()})