## 27) Error response body shape is inconsistent

This commit is contained in:
2026-04-28 22:28:02 +02:00
parent a2129bb9bd
commit 1e2576af2a
16 changed files with 632 additions and 99 deletions

View File

@@ -22,7 +22,7 @@ if TYPE_CHECKING:
from starlette.responses import Response as StarletteResponse
import structlog
from fastapi import FastAPI, Request, status
from fastapi import FastAPI, HTTPException, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, RedirectResponse
from starlette.middleware.base import BaseHTTPMiddleware
@@ -30,12 +30,14 @@ from starlette.middleware.base import BaseHTTPMiddleware
from app import __version__
from app.config import Settings, get_settings
from app.exceptions import (
AuthenticationError,
BadRequestError,
ConflictError,
Fail2BanConnectionError,
Fail2BanProtocolError,
NotFoundError,
OperationError,
RateLimitError,
ServiceUnavailableError,
)
from app.middleware.csrf import CsrfMiddleware
@@ -54,6 +56,7 @@ from app.routers import (
setup,
)
from app.startup import startup_shared_resources
from app.models.response import ErrorResponse
from app.utils.rate_limiter import RateLimiter
from app.utils.runtime_state import ApplicationState, RuntimeState
from app.utils.session_cache import InMemorySessionCache, NoOpSessionCache
@@ -163,6 +166,43 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# ---------------------------------------------------------------------------
def _get_error_code(exc: Exception) -> str:
"""Get the machine-readable error code from an exception.
First checks if the exception has an error_code class attribute.
Falls back to converting the exception class name to snake_case.
Args:
exc: The exception instance.
Returns:
A snake_case error code string.
"""
if hasattr(exc, "error_code"):
return exc.error_code
exc_name = exc.__class__.__name__
import re
snake_case = re.sub(r"(?<!^)(?=[A-Z])", "_", exc_name).lower()
return snake_case
def _get_error_metadata(exc: Exception) -> dict[str, str | int | float | bool | None]:
"""Get structured metadata from an exception.
Calls the exception's get_error_metadata() method if available.
Args:
exc: The exception instance.
Returns:
A dictionary of metadata safe for API responses.
"""
if hasattr(exc, "get_error_metadata") and callable(exc.get_error_metadata):
return exc.get_error_metadata()
return {}
async def _unhandled_exception_handler(
request: Request,
exc: Exception,
@@ -185,9 +225,14 @@ async def _unhandled_exception_handler(
method=request.method,
exc_info=exc,
)
error_response = ErrorResponse(
code="internal_error",
detail="An unexpected error occurred. Please try again later.",
metadata={},
)
return JSONResponse(
status_code=500,
content={"detail": "An unexpected error occurred. Please try again later."},
content=error_response.model_dump(),
)
@@ -210,9 +255,14 @@ async def _fail2ban_connection_handler(
method=request.method,
error=str(exc),
)
error_response = ErrorResponse(
code="fail2ban_unreachable",
detail="Cannot reach the fail2ban service. Check the server status page.",
metadata={"socket_path": exc.socket_path},
)
return JSONResponse(
status_code=502,
content={"detail": "Cannot reach the fail2ban service. Check the server status page."},
content=error_response.model_dump(),
)
@@ -235,9 +285,14 @@ async def _fail2ban_protocol_handler(
method=request.method,
error=str(exc),
)
error_response = ErrorResponse(
code="fail2ban_protocol_error",
detail="Cannot reach the fail2ban service. Check the server status page.",
metadata={},
)
return JSONResponse(
status_code=502,
content={"detail": "Cannot reach the fail2ban service. Check the server status page."},
content=error_response.model_dump(),
)
@@ -260,9 +315,14 @@ async def _not_found_handler(
method=request.method,
error=str(exc),
)
error_response = ErrorResponse(
code=_get_error_code(exc),
detail=str(exc),
metadata=_get_error_metadata(exc),
)
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={"detail": str(exc)},
content=error_response.model_dump(),
)
@@ -285,9 +345,14 @@ async def _bad_request_handler(
method=request.method,
error=str(exc),
)
error_response = ErrorResponse(
code=_get_error_code(exc),
detail=str(exc),
metadata=_get_error_metadata(exc),
)
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"detail": str(exc)},
content=error_response.model_dump(),
)
@@ -302,9 +367,14 @@ async def _conflict_handler(
method=request.method,
error=str(exc),
)
error_response = ErrorResponse(
code=_get_error_code(exc),
detail=str(exc),
metadata=_get_error_metadata(exc),
)
return JSONResponse(
status_code=status.HTTP_409_CONFLICT,
content={"detail": str(exc)},
content=error_response.model_dump(),
)
@@ -320,9 +390,14 @@ async def _domain_error_handler(
error=str(exc),
exc_info=exc,
)
error_response = ErrorResponse(
code=_get_error_code(exc),
detail=str(exc),
metadata=_get_error_metadata(exc),
)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": str(exc)},
content=error_response.model_dump(),
)
@@ -345,9 +420,14 @@ async def _value_error_handler(
method=request.method,
error=str(exc),
)
error_response = ErrorResponse(
code="invalid_input",
detail=str(exc),
metadata={},
)
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"detail": str(exc)},
content=error_response.model_dump(),
)
@@ -370,9 +450,126 @@ async def _service_unavailable_handler(
method=request.method,
error=str(exc),
)
error_response = ErrorResponse(
code=_get_error_code(exc),
detail=str(exc),
metadata=_get_error_metadata(exc),
)
return JSONResponse(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
content={"detail": str(exc)},
content=error_response.model_dump(),
)
async def _authentication_error_handler(
request: Request,
exc: AuthenticationError,
) -> JSONResponse:
"""Return a ``401 Unauthorized`` response for authentication failures.
Args:
request: The incoming FastAPI request.
exc: The :class:`~app.exceptions.AuthenticationError`.
Returns:
A :class:`fastapi.responses.JSONResponse` with status 401.
"""
log.warning(
"authentication_error",
path=request.url.path,
method=request.method,
error=str(exc),
)
error_response = ErrorResponse(
code=_get_error_code(exc),
detail=str(exc),
metadata=_get_error_metadata(exc),
)
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content=error_response.model_dump(),
)
async def _rate_limit_error_handler(
request: Request,
exc: RateLimitError,
) -> JSONResponse:
"""Return a ``429 Too Many Requests`` response for rate limit exceeded errors.
Args:
request: The incoming FastAPI request.
exc: The :class:`~app.exceptions.RateLimitError`.
Returns:
A :class:`fastapi.responses.JSONResponse` with status 429 and Retry-After header.
"""
log.warning(
"rate_limit_exceeded",
path=request.url.path,
method=request.method,
error=str(exc),
)
error_response = ErrorResponse(
code=_get_error_code(exc),
detail=str(exc),
metadata=_get_error_metadata(exc),
)
return JSONResponse(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
content=error_response.model_dump(),
headers={"Retry-After": "60"},
)
async def _http_exception_handler(
request: Request,
exc: HTTPException,
) -> JSONResponse:
"""Return a standardized error response for FastAPI HTTPException.
This handler standardizes responses from FastAPI validation errors,
path parameter mismatches, and other built-in validation failures
to use the ErrorResponse envelope with a machine-readable error code.
Args:
request: The incoming FastAPI request.
exc: The :class:`fastapi.HTTPException`.
Returns:
A :class:`fastapi.responses.JSONResponse` with the original status code.
"""
log.warning(
"http_exception",
path=request.url.path,
method=request.method,
status_code=exc.status_code,
error=exc.detail,
)
error_code_map = {
status.HTTP_400_BAD_REQUEST: "invalid_input",
status.HTTP_401_UNAUTHORIZED: "authentication_required",
status.HTTP_403_FORBIDDEN: "forbidden",
status.HTTP_404_NOT_FOUND: "not_found",
status.HTTP_409_CONFLICT: "conflict",
status.HTTP_422_UNPROCESSABLE_ENTITY: "invalid_input",
status.HTTP_429_TOO_MANY_REQUESTS: "rate_limit_exceeded",
status.HTTP_500_INTERNAL_SERVER_ERROR: "internal_error",
status.HTTP_503_SERVICE_UNAVAILABLE: "service_unavailable",
}
error_code = error_code_map.get(exc.status_code, "internal_error")
error_response = ErrorResponse(
code=error_code,
detail=exc.detail,
metadata={},
)
return JSONResponse(
status_code=exc.status_code,
content=error_response.model_dump(),
headers=exc.headers or {},
)
@@ -509,11 +706,14 @@ def create_app(settings: Settings | None = None) -> FastAPI:
# rather than falling through to the generic 500 handler.
app.add_exception_handler(Fail2BanConnectionError, _fail2ban_connection_handler) # type: ignore[arg-type]
app.add_exception_handler(Fail2BanProtocolError, _fail2ban_protocol_handler) # type: ignore[arg-type]
app.add_exception_handler(AuthenticationError, _authentication_error_handler) # type: ignore[arg-type]
app.add_exception_handler(RateLimitError, _rate_limit_error_handler) # type: ignore[arg-type]
app.add_exception_handler(NotFoundError, _not_found_handler)
app.add_exception_handler(BadRequestError, _bad_request_handler)
app.add_exception_handler(ConflictError, _conflict_handler)
app.add_exception_handler(OperationError, _domain_error_handler)
app.add_exception_handler(ServiceUnavailableError, _service_unavailable_handler)
app.add_exception_handler(HTTPException, _http_exception_handler) # type: ignore[arg-type]
app.add_exception_handler(ValueError, _value_error_handler) # type: ignore[arg-type]
app.add_exception_handler(Exception, _unhandled_exception_handler)