Merge pull request #216 from grillazz/12-add-json-field-example

refine exception handlers
This commit is contained in:
Ordinary Hobbit 2025-08-24 15:41:11 +02:00 committed by GitHub
commit 6a976e0123
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 138 additions and 82 deletions

View File

@ -1,82 +0,0 @@
import orjson
from fastapi import FastAPI, Request
from fastapi.exceptions import ResponseValidationError
from fastapi.responses import JSONResponse
from rotoger import AppStructLogger
from sqlalchemy.exc import SQLAlchemyError
logger = AppStructLogger().get_logger()
# TODO: add reasoning for this in readme plus higligh using re-raise in db session
async def sqlalchemy_exception_handler(
request: Request, exc: SQLAlchemyError
) -> JSONResponse:
request_path = request.url.path
try:
raw_body = await request.body()
request_body = orjson.loads(raw_body) if raw_body else None
except orjson.JSONDecodeError:
request_body = None
await logger.aerror(
"Database error occurred",
sql_error=repr(exc),
request_url=request_path,
request_body=request_body,
)
return JSONResponse(
status_code=500,
content={"message": "A database error occurred. Please try again later."},
)
async def response_validation_exception_handler(
request: Request, exc: ResponseValidationError
) -> JSONResponse:
request_path = request.url.path
try:
raw_body = await request.body()
request_body = orjson.loads(raw_body) if raw_body else None
except orjson.JSONDecodeError:
request_body = None
errors = exc.errors()
# Check if this is a None/null response case
is_none_response = False
for error in errors:
# Check for null input pattern
if error.get("input") is None and "valid dictionary" in error.get("msg", ""):
is_none_response = True
break
await logger.aerror(
"Response validation error occurred",
validation_errors=errors,
request_url=request_path,
request_body=request_body,
is_none_response=is_none_response,
)
if is_none_response:
# Return 404 when response is None (resource not found)
return JSONResponse(
status_code=404,
content={"no_response": "The requested resource was not found"},
)
else:
# Return 422 when response exists but doesn't match expected format
return JSONResponse(
status_code=422,
content={"response_format_error": errors},
)
def register_exception_handlers(app: FastAPI) -> None:
"""Register all exception handlers with the FastAPI app."""
app.add_exception_handler(SQLAlchemyError, sqlalchemy_exception_handler)
app.add_exception_handler(
ResponseValidationError, response_validation_exception_handler
)

View File

@ -0,0 +1,3 @@
from app.exception_handlers.registry import register_exception_handlers
__all__ = ["register_exception_handlers"]

View File

@ -0,0 +1,43 @@
import orjson
from attrs import define, field
from fastapi import Request
from rotoger import AppStructLogger
logger = AppStructLogger().get_logger()
@define(slots=True)
class RequestInfo:
"""Contains extracted request information."""
path: str = field()
body: dict = field(default=None)
@define(slots=True)
class BaseExceptionHandler:
"""Base class for all exception handlers with common functionality."""
@staticmethod
async def extract_request_info(request: Request) -> RequestInfo:
"""Extract common request information."""
request_path = request.url.path
request_body = None
try:
raw_body = await request.body()
if raw_body:
request_body = orjson.loads(raw_body)
except orjson.JSONDecodeError:
pass
return RequestInfo(path=request_path, body=request_body)
@classmethod
async def log_error(cls, message: str, request_info: RequestInfo, **kwargs):
"""Log error with standardized format."""
await logger.aerror(
message,
request_url=request_info.path,
request_body=request_info.body,
**kwargs,
)

View File

@ -0,0 +1,24 @@
from fastapi import Request
from fastapi.responses import JSONResponse
from sqlalchemy.exc import SQLAlchemyError
from app.exception_handlers.base import BaseExceptionHandler
class SQLAlchemyExceptionHandler(BaseExceptionHandler):
"""Handles SQLAlchemy database exceptions."""
@classmethod
async def handle_exception(
cls, request: Request, exc: SQLAlchemyError
) -> JSONResponse:
request_info = await cls.extract_request_info(request)
await cls.log_error(
"Database error occurred", request_info, sql_error=repr(exc)
)
return JSONResponse(
status_code=500,
content={"message": "A database error occurred. Please try again later."},
)

View File

@ -0,0 +1,16 @@
from fastapi import FastAPI
from fastapi.exceptions import ResponseValidationError
from sqlalchemy.exc import SQLAlchemyError
from app.exception_handlers.database import SQLAlchemyExceptionHandler
from app.exception_handlers.validation import ResponseValidationExceptionHandler
def register_exception_handlers(app: FastAPI) -> None:
"""Register all exception handlers with the FastAPI app."""
app.add_exception_handler(
SQLAlchemyError, SQLAlchemyExceptionHandler.handle_exception
)
app.add_exception_handler(
ResponseValidationError, ResponseValidationExceptionHandler.handle_exception
)

View File

@ -0,0 +1,42 @@
from fastapi import Request
from fastapi.exceptions import ResponseValidationError
from fastapi.responses import JSONResponse
from app.exception_handlers.base import BaseExceptionHandler
class ResponseValidationExceptionHandler(BaseExceptionHandler):
"""Handles response validation exceptions."""
@classmethod
async def handle_exception(
cls, request: Request, exc: ResponseValidationError
) -> JSONResponse:
request_info = await cls.extract_request_info(request)
errors = exc.errors()
# Check if this is a None/null response case
is_none_response = False
for error in errors:
if error.get("input") is None and "valid dictionary" in error.get(
"msg", ""
):
is_none_response = True
break
await cls.log_error(
"Response validation error occurred",
request_info,
validation_errors=errors,
is_none_response=is_none_response,
)
if is_none_response:
return JSONResponse(
status_code=404,
content={"no_response": "The requested resource was not found"},
)
else:
return JSONResponse(
status_code=422, content={"response_format_error": errors}
)

View File

@ -25,9 +25,19 @@ async def test_add_stuff(client: AsyncClient):
"description": stuff["description"], "description": stuff["description"],
} }
) )
response = await client.post("/stuff", json=stuff)
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert response.json() == snapshot(
{"message": "A database error occurred. Please try again later."}
)
async def test_get_stuff(client: AsyncClient): async def test_get_stuff(client: AsyncClient):
response = await client.get("/stuff/nonexistent")
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == snapshot(
{"no_response": "The requested resource was not found"}
)
stuff = StuffFactory.build(factory_use_constructors=True).model_dump(mode="json") stuff = StuffFactory.build(factory_use_constructors=True).model_dump(mode="json")
await client.post("/stuff", json=stuff) await client.post("/stuff", json=stuff)
name = stuff["name"] name = stuff["name"]