157 lines
6.2 KiB
Python
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()
|