Project started 🍾
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Driving adapter — FastAPI routers, schemas, dependency wiring."""
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Shared FastAPI dependencies — the composition root for request-scoped wiring.
|
||||
|
||||
Concrete adapters are bound to ports here so routers and services stay
|
||||
decoupled from infrastructure. Repository/service providers are added in
|
||||
later steps as the domain grows.
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.infrastructure.db import get_sessionmaker
|
||||
|
||||
|
||||
async def get_session() -> AsyncIterator[AsyncSession]:
|
||||
"""Request-scoped DB session. Commits on success, rolls back on exception."""
|
||||
session = get_sessionmaker()()
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Maps domain exceptions to HTTP responses. The only place that knows both."""
|
||||
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.domain.errors import (
|
||||
AlreadyExistsError,
|
||||
AuthenticationError,
|
||||
ConflictError,
|
||||
DependencyUnavailableError,
|
||||
DomainError,
|
||||
NotFoundError,
|
||||
PermissionDeniedError,
|
||||
ValidationError,
|
||||
)
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
_STATUS_BY_ERROR: dict[type[DomainError], int] = {
|
||||
NotFoundError: status.HTTP_404_NOT_FOUND,
|
||||
AlreadyExistsError: status.HTTP_409_CONFLICT,
|
||||
ConflictError: status.HTTP_409_CONFLICT,
|
||||
ValidationError: status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
AuthenticationError: status.HTTP_401_UNAUTHORIZED,
|
||||
PermissionDeniedError: status.HTTP_403_FORBIDDEN,
|
||||
DependencyUnavailableError: status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
}
|
||||
|
||||
|
||||
def _error_body(code: str, message: str) -> dict[str, dict[str, str]]:
|
||||
return {"error": {"code": code, "message": message}}
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI) -> None:
|
||||
@app.exception_handler(DomainError)
|
||||
async def _handle_domain_error(_request: Request, exc: DomainError) -> JSONResponse:
|
||||
http_status = _STATUS_BY_ERROR.get(type(exc), status.HTTP_400_BAD_REQUEST)
|
||||
return JSONResponse(
|
||||
status_code=http_status,
|
||||
content=_error_body(exc.code, exc.message),
|
||||
)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def _handle_unexpected(_request: Request, exc: Exception) -> JSONResponse:
|
||||
log.error("unhandled_exception", exc_info=exc)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content=_error_body("internal_error", "An unexpected error occurred."),
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Health & readiness endpoints — used by compose healthchecks and the admin UI.
|
||||
|
||||
* ``/health`` — liveness: the process is up. Always 200 if serving.
|
||||
* ``/health/ready`` — readiness: checks DB, Redis, and (optionally) ML.
|
||||
Returns 503 if a *required* dependency is down. ML is optional — its absence
|
||||
degrades, never fails, readiness (graceful degradation, see plan §6.5).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Response, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.logging import get_logger
|
||||
from app.infrastructure.cache import get_redis
|
||||
from app.infrastructure.db import get_sessionmaker
|
||||
|
||||
log = get_logger(__name__)
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
CheckStatus = Literal["ok", "down", "skipped"]
|
||||
|
||||
# A readiness probe must answer fast and never hang — bound every dependency
|
||||
# check. A check that exceeds this is reported "down".
|
||||
CHECK_TIMEOUT_SECONDS = 2.0
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: Literal["ok"] = "ok"
|
||||
|
||||
|
||||
class ReadinessResponse(BaseModel):
|
||||
status: Literal["ready", "degraded"]
|
||||
checks: dict[str, CheckStatus]
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse)
|
||||
async def health() -> HealthResponse:
|
||||
return HealthResponse()
|
||||
|
||||
|
||||
async def _check_db() -> CheckStatus:
|
||||
try:
|
||||
async with asyncio.timeout(CHECK_TIMEOUT_SECONDS):
|
||||
async with get_sessionmaker()() as session:
|
||||
await session.execute(text("SELECT 1"))
|
||||
return "ok"
|
||||
except Exception as exc:
|
||||
log.warning("healthcheck_db_down", error=str(exc))
|
||||
return "down"
|
||||
|
||||
|
||||
async def _check_redis() -> CheckStatus:
|
||||
try:
|
||||
async with asyncio.timeout(CHECK_TIMEOUT_SECONDS):
|
||||
await get_redis().ping()
|
||||
return "ok"
|
||||
except Exception as exc:
|
||||
log.warning("healthcheck_redis_down", error=str(exc))
|
||||
return "down"
|
||||
|
||||
|
||||
async def _check_ml() -> CheckStatus:
|
||||
# Optional dependency. A real client lands in step 12; absence is fine.
|
||||
return "skipped" if get_settings().ml_service_url is None else "ok"
|
||||
|
||||
|
||||
@router.get("/health/ready", response_model=ReadinessResponse)
|
||||
async def readiness(response: Response) -> ReadinessResponse:
|
||||
db, redis, ml = await asyncio.gather(_check_db(), _check_redis(), _check_ml())
|
||||
checks: dict[str, CheckStatus] = {"database": db, "redis": redis, "ml": ml}
|
||||
|
||||
required_down = db == "down" or redis == "down"
|
||||
if required_down:
|
||||
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
|
||||
return ReadinessResponse(
|
||||
status="degraded" if required_down else "ready",
|
||||
checks=checks,
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""HTTP middleware: bind a correlation id and log each request."""
|
||||
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
from app.core.logging import correlation_id, get_logger
|
||||
|
||||
log = get_logger("http")
|
||||
|
||||
_HEADER = "x-correlation-id"
|
||||
|
||||
|
||||
class CorrelationIdMiddleware:
|
||||
"""Pure-ASGI middleware: reuse inbound ``X-Correlation-Id`` or mint one,
|
||||
bind it for downstream logs, echo it back, and log request completion."""
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
headers = dict(scope["headers"])
|
||||
inbound = headers.get(_HEADER.encode())
|
||||
cid = inbound.decode() if inbound else uuid.uuid4().hex
|
||||
token = correlation_id.set(cid)
|
||||
started = time.perf_counter()
|
||||
status_code = 0
|
||||
|
||||
async def send_wrapper(message: Message) -> None:
|
||||
nonlocal status_code
|
||||
if message["type"] == "http.response.start":
|
||||
status_code = message["status"]
|
||||
message.setdefault("headers", [])
|
||||
message["headers"].append((_HEADER.encode(), cid.encode()))
|
||||
await send(message)
|
||||
|
||||
try:
|
||||
await self.app(scope, receive, send_wrapper)
|
||||
finally:
|
||||
log.info(
|
||||
"request",
|
||||
method=scope["method"],
|
||||
path=scope["path"],
|
||||
status=status_code,
|
||||
duration_ms=round((time.perf_counter() - started) * 1000, 1),
|
||||
)
|
||||
correlation_id.reset(token)
|
||||
Reference in New Issue
Block a user