## 27) Error response body shape is inconsistent
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user