Files
crucible-agent-build-fastap…/app/main.py

157 lines
6.2 KiB
Python

import logging
from contextlib import asynccontextmanager
from typing import AsyncIterator
from uuid import UUID
from fastapi import FastAPI, Request, Response, status
from fastapi.responses import JSONResponse
from app.checker import (
CheckNetworkError,
CheckTimeoutError,
EndpointChecker,
TooManyRedirectsError,
UnsafeTargetError,
)
from app.config import Settings, get_settings
from app.logging_config import configure_logging
from app.models import CheckResult, CurrentStatus, Monitor, MonitorInput, State, utcnow
from app.store import CapacityError, MonitorStore, NotFoundError, StaleCheckError
logger = logging.getLogger("monitor.check")
def error(status_code: int, code: str, message: str) -> JSONResponse:
return JSONResponse(status_code=status_code, content={
"error": {"code": code, "message": message}
})
def create_app(settings: Settings | None = None) -> FastAPI:
config = settings or get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
configure_logging(config.log_level)
app.state.store = MonitorStore(config.max_monitors)
app.state.checker = EndpointChecker(
config.request_timeout_seconds, config.max_redirects
)
app.state.ready = True
yield
app.state.ready = False
api = FastAPI(title="Endpoint Monitor API", version="1.0.0", lifespan=lifespan)
@api.exception_handler(NotFoundError)
async def not_found(_: Request, __: NotFoundError) -> JSONResponse:
return error(404, "monitor_not_found", "monitor does not exist")
@api.exception_handler(CapacityError)
async def capacity(_: Request, __: CapacityError) -> JSONResponse:
return error(409, "capacity_exceeded", "monitor capacity reached")
@api.get("/healthz")
async def health() -> dict[str, str]:
return {"status": "ok"}
@api.get("/readyz")
async def readiness(request: Request) -> JSONResponse | dict[str, str]:
if not getattr(request.app.state, "ready", False):
return error(503, "not_ready", "service is not ready")
return {"status": "ok"}
@api.post("/monitors", response_model=Monitor, status_code=status.HTTP_201_CREATED)
async def create(data: MonitorInput, request: Request) -> Monitor:
return await request.app.state.store.create(data)
@api.get("/monitors", response_model=list[Monitor])
async def list_monitors(request: Request) -> list[Monitor]:
return await request.app.state.store.list()
@api.get("/monitors/{monitor_id}", response_model=Monitor)
async def get_monitor(monitor_id: UUID, request: Request) -> Monitor:
return await request.app.state.store.get(monitor_id)
@api.put("/monitors/{monitor_id}", response_model=Monitor)
async def replace(monitor_id: UUID, data: MonitorInput, request: Request) -> Monitor:
return await request.app.state.store.replace(monitor_id, data)
@api.delete("/monitors/{monitor_id}", status_code=204)
async def delete(monitor_id: UUID, request: Request) -> Response:
await request.app.state.store.delete(monitor_id)
return Response(status_code=204)
@api.get("/monitors/{monitor_id}/status", response_model=CurrentStatus)
async def current_status(monitor_id: UUID, request: Request) -> CurrentStatus:
return (await request.app.state.store.get(monitor_id)).current_status
@api.post("/monitors/{monitor_id}/check", response_model=CheckResult)
async def check(monitor_id: UUID, request: Request) -> CheckResult | JSONResponse:
store: MonitorStore = request.app.state.store
item = await store.get(monitor_id)
checked_url = str(item.url)
logger.info("check_started", extra={"fields": {"url": checked_url, "monitor_id": monitor_id}})
try:
outcome = await request.app.state.checker.check(checked_url)
current = CurrentStatus(
state=State.UP if 200 <= outcome.status_code < 400 else State.DOWN,
checked_at=utcnow(), latency_ms=outcome.latency_ms,
status_code=outcome.status_code,
)
await store.publish_status(monitor_id, checked_url, current)
logger.info("check_finished", extra={"fields": {
"url": checked_url, "monitor_id": monitor_id,
"status_code": outcome.status_code, "latency_ms": outcome.latency_ms,
}})
return CheckResult(monitor_id=monitor_id, **current.model_dump())
except UnsafeTargetError:
return await publish_error(store, monitor_id, checked_url, "unsafe_target", 400)
except CheckTimeoutError as exc:
return await publish_error(
store, monitor_id, checked_url, "check_timeout", 504, exc.latency_ms
)
except CheckNetworkError as exc:
return await publish_error(
store, monitor_id, checked_url, "network_error", 502, exc.latency_ms
)
except TooManyRedirectsError as exc:
return await publish_error(
store, monitor_id, checked_url, "redirect_limit", 502, exc.latency_ms
)
except StaleCheckError:
return error(409, "stale_check", "monitor URL changed during check")
return api
async def publish_error(
store: MonitorStore,
monitor_id: UUID,
checked_url: str,
code: str,
http_status: int,
latency_ms: float | None = None,
) -> JSONResponse:
current = CurrentStatus(
state=State.ERROR, checked_at=utcnow(), latency_ms=latency_ms, error_code=code
)
try:
await store.publish_status(monitor_id, checked_url, current)
except StaleCheckError:
return error(409, "stale_check", "monitor URL changed during check")
logger.warning("check_failed", extra={"fields": {
"url": checked_url, "monitor_id": monitor_id, "error_code": code,
"latency_ms": latency_ms,
}})
messages = {
"unsafe_target": "target is not permitted",
"check_timeout": "target request timed out",
"network_error": "target request failed",
"redirect_limit": "target exceeded redirect limit",
}
return error(http_status, code, messages[code])
app = create_app()