· FastAPI· Python· fejlhåndtering· HTTPException· API· backend· pydantic
FastAPI exception handlers — komplet guide til fejlhåndtering
FastAPI exception handlers: HTTPException, custom exceptions, 422 validation errors, RequestValidationError, global handlers og konsistent fejl-API til frontend.
Af M-Bus Gateway
Konsistente fejlresponser er kritiske for SaaS API-kvalitet. FastAPI giver full kontrol over exception handling.
Standard HTTPException
# server/src/properties/router.py
from fastapi import APIRouter, HTTPException, status
from uuid import UUID
router = APIRouter(prefix="/properties", tags=["properties"])
@router.get("/{property_id}")
async def get_property(
property_id: UUID,
user: TokenPayload = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
prop = await session.get(Property, property_id)
if prop is None or prop.deleted_at is not None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ejendom ikke fundet",
)
if prop.tenant_id != user.tenant_id and user.role != "super_admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Du har ikke adgang til denne ejendom",
)
return prop
Custom exception klasser
# server/src/exceptions.py
from fastapi import HTTPException, status
class NotFoundError(HTTPException):
def __init__(self, resource: str, resource_id: str = ""):
detail = f"{resource} ikke fundet"
if resource_id:
detail += f" (id: {resource_id})"
super().__init__(status_code=status.HTTP_404_NOT_FOUND, detail=detail)
class ForbiddenError(HTTPException):
def __init__(self, reason: str = "Manglende adgang"):
super().__init__(status_code=status.HTTP_403_FORBIDDEN, detail=reason)
class ConflictError(HTTPException):
def __init__(self, detail: str):
super().__init__(status_code=status.HTTP_409_CONFLICT, detail=detail)
class UnprocessableError(HTTPException):
def __init__(self, detail: str, code: str | None = None):
super().__init__(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={"message": detail, "code": code},
)
# Brug i router:
raise NotFoundError("Ejendom", str(property_id))
raise ForbiddenError("Du ejer ikke denne ejendom")
raise ConflictError("Der er allerede en aktiv lejer i lejligheden")
Global exception handlers
# server/src/main.py
from fastapi import FastAPI, Request, status
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from pydantic import ValidationError
import structlog
log = structlog.get_logger()
app = FastAPI(...)
# Standard fejlformat til alle responses:
def error_response(status_code: int, detail: str, code: str | None = None):
return JSONResponse(
status_code=status_code,
content={"detail": detail, "code": code},
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""
Pydantic 422-fejl fra request body/query params.
Standardiserer output til ét konsistent format.
"""
errors = []
for error in exc.errors():
field = ".".join(str(loc) for loc in error["loc"][1:]) # Skip "body"
errors.append({
"field": field,
"message": error["msg"],
"type": error["type"],
})
log.warning("Validation error", path=str(request.url), errors=errors)
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={"detail": "Ugyldig forespørgsel", "errors": errors},
)
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
"""Catch-all for uventede fejl — aldrig eksponer stack trace til klient."""
log.exception("Unhandled exception", path=str(request.url), exc=str(exc))
return error_response(
status_code=500,
detail="En intern fejl opstod. Prøv igen om lidt.",
code="internal_error",
)
Domæne-specifikke exceptions
# server/src/settlements/exceptions.py
class SettlementAlreadySentError(Exception):
"""Afregning er allerede sendt — kan ikke gendannes."""
def __init__(self, settlement_id: str):
self.settlement_id = settlement_id
super().__init__(f"Settlement {settlement_id} er allerede sendt")
class MissingAnnualInputError(Exception):
"""Ingen valideret fjernvarmeregning for perioden."""
def __init__(self, property_id: str, period: str):
self.property_id = property_id
self.period = period
super().__init__(f"Ingen annual_input for {property_id}, periode {period}")
# Registrér som handlers i main.py:
@app.exception_handler(SettlementAlreadySentError)
async def settlement_sent_handler(request: Request, exc: SettlementAlreadySentError):
return error_response(
status_code=status.HTTP_409_CONFLICT,
detail="Afregningen er allerede sendt til lejerne og kan ikke ændres.",
code="settlement_already_sent",
)
@app.exception_handler(MissingAnnualInputError)
async def missing_input_handler(request: Request, exc: MissingAnnualInputError):
return error_response(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Ingen godkendt fjernvarmeregning for perioden {exc.period}.",
code="missing_annual_input",
)
Frontend-integration: maskinlæsbare fejlkoder
// ui/src/api.ts
export interface ApiError {
detail: string;
code?: string;
errors?: Array<{ field: string; message: string; type: string }>;
}
export class ApiException extends Error {
constructor(
public status: number,
public detail: string,
public code?: string,
public errors?: ApiError["errors"]
) {
super(detail);
}
}
async function apiRequest<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(url, options);
if (!res.ok) {
const body: ApiError = await res.json().catch(() => ({ detail: "Ukendt fejl" }));
throw new ApiException(res.status, body.detail, body.code, body.errors);
}
return res.json();
}
// I React komponent:
try {
await api.settlements.generate(propertyId);
} catch (err) {
if (err instanceof ApiException) {
if (err.code === "missing_annual_input") {
setError("Tilføj årets fjernvarmeregning inden du genererer afregning.");
} else if (err.code === "settlement_already_sent") {
setError("Afregningen er allerede afsendt.");
} else if (err.status === 422 && err.errors) {
setFieldErrors(err.errors);
} else {
setError(err.detail);
}
}
}
Test af exception handlers
# server/tests/test_exception_handlers.py
import pytest
from httpx import AsyncClient
async def test_404_returns_correct_format(client: AsyncClient):
resp = await client.get("/api/v1/properties/00000000-0000-0000-0000-000000000000")
assert resp.status_code == 404
body = resp.json()
assert "detail" in body
assert body["detail"] == "Ejendom ikke fundet (id: 00000000-0000-0000-0000-000000000000)"
async def test_422_returns_field_errors(client: AsyncClient, auth_headers: dict):
resp = await client.post(
"/api/v1/properties",
json={"name": ""}, # Mangler påkrævede felter
headers=auth_headers,
)
assert resp.status_code == 422
body = resp.json()
assert "errors" in body
assert any(e["field"] == "address" for e in body["errors"])
async def test_settlement_already_sent_returns_409(client: AsyncClient, sent_settlement, auth_headers):
resp = await client.post(
f"/api/v1/settlements/{sent_settlement.id}/send",
headers=auth_headers,
)
assert resp.status_code == 409
assert resp.json()["code"] == "settlement_already_sent"
Konklusion
Konsistente exception handlers er fundamentet for et professionelt API. Brug RequestValidationError handler til at standardisere Pydantic 422-fejl, domæne-specifikke exception klasser til forretningsregler og en global catch-all der aldrig eksponerer stack traces til klienter. Maskinlæsbare code-felter gør frontend-fejlhåndtering præcis og lokalisérbar.
Se FastAPI dependency injection guide eller FastAPI OpenAPI dokumentation.