M-Bus Gateway
← Tilbage til blog
· 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.