Skip to content

Commit 6a976e0

Browse files
authored
Merge pull request #216 from grillazz/12-add-json-field-example
refine exception handlers
2 parents 5ceda55 + 978041c commit 6a976e0

File tree

7 files changed

+138
-82
lines changed

7 files changed

+138
-82
lines changed

app/exception_handlers.py

Lines changed: 0 additions & 82 deletions
This file was deleted.

app/exception_handlers/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from app.exception_handlers.registry import register_exception_handlers
2+
3+
__all__ = ["register_exception_handlers"]

app/exception_handlers/base.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import orjson
2+
from attrs import define, field
3+
from fastapi import Request
4+
from rotoger import AppStructLogger
5+
6+
logger = AppStructLogger().get_logger()
7+
8+
9+
@define(slots=True)
10+
class RequestInfo:
11+
"""Contains extracted request information."""
12+
13+
path: str = field()
14+
body: dict = field(default=None)
15+
16+
17+
@define(slots=True)
18+
class BaseExceptionHandler:
19+
"""Base class for all exception handlers with common functionality."""
20+
21+
@staticmethod
22+
async def extract_request_info(request: Request) -> RequestInfo:
23+
"""Extract common request information."""
24+
request_path = request.url.path
25+
request_body = None
26+
try:
27+
raw_body = await request.body()
28+
if raw_body:
29+
request_body = orjson.loads(raw_body)
30+
except orjson.JSONDecodeError:
31+
pass
32+
33+
return RequestInfo(path=request_path, body=request_body)
34+
35+
@classmethod
36+
async def log_error(cls, message: str, request_info: RequestInfo, **kwargs):
37+
"""Log error with standardized format."""
38+
await logger.aerror(
39+
message,
40+
request_url=request_info.path,
41+
request_body=request_info.body,
42+
**kwargs,
43+
)

app/exception_handlers/database.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from fastapi import Request
2+
from fastapi.responses import JSONResponse
3+
from sqlalchemy.exc import SQLAlchemyError
4+
5+
from app.exception_handlers.base import BaseExceptionHandler
6+
7+
8+
class SQLAlchemyExceptionHandler(BaseExceptionHandler):
9+
"""Handles SQLAlchemy database exceptions."""
10+
11+
@classmethod
12+
async def handle_exception(
13+
cls, request: Request, exc: SQLAlchemyError
14+
) -> JSONResponse:
15+
request_info = await cls.extract_request_info(request)
16+
17+
await cls.log_error(
18+
"Database error occurred", request_info, sql_error=repr(exc)
19+
)
20+
21+
return JSONResponse(
22+
status_code=500,
23+
content={"message": "A database error occurred. Please try again later."},
24+
)

app/exception_handlers/registry.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from fastapi import FastAPI
2+
from fastapi.exceptions import ResponseValidationError
3+
from sqlalchemy.exc import SQLAlchemyError
4+
5+
from app.exception_handlers.database import SQLAlchemyExceptionHandler
6+
from app.exception_handlers.validation import ResponseValidationExceptionHandler
7+
8+
9+
def register_exception_handlers(app: FastAPI) -> None:
10+
"""Register all exception handlers with the FastAPI app."""
11+
app.add_exception_handler(
12+
SQLAlchemyError, SQLAlchemyExceptionHandler.handle_exception
13+
)
14+
app.add_exception_handler(
15+
ResponseValidationError, ResponseValidationExceptionHandler.handle_exception
16+
)

app/exception_handlers/validation.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from fastapi import Request
2+
from fastapi.exceptions import ResponseValidationError
3+
from fastapi.responses import JSONResponse
4+
5+
from app.exception_handlers.base import BaseExceptionHandler
6+
7+
8+
class ResponseValidationExceptionHandler(BaseExceptionHandler):
9+
"""Handles response validation exceptions."""
10+
11+
@classmethod
12+
async def handle_exception(
13+
cls, request: Request, exc: ResponseValidationError
14+
) -> JSONResponse:
15+
request_info = await cls.extract_request_info(request)
16+
errors = exc.errors()
17+
18+
# Check if this is a None/null response case
19+
is_none_response = False
20+
for error in errors:
21+
if error.get("input") is None and "valid dictionary" in error.get(
22+
"msg", ""
23+
):
24+
is_none_response = True
25+
break
26+
27+
await cls.log_error(
28+
"Response validation error occurred",
29+
request_info,
30+
validation_errors=errors,
31+
is_none_response=is_none_response,
32+
)
33+
34+
if is_none_response:
35+
return JSONResponse(
36+
status_code=404,
37+
content={"no_response": "The requested resource was not found"},
38+
)
39+
else:
40+
return JSONResponse(
41+
status_code=422, content={"response_format_error": errors}
42+
)

tests/api/test_stuff.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,19 @@ async def test_add_stuff(client: AsyncClient):
2525
"description": stuff["description"],
2626
}
2727
)
28+
response = await client.post("/stuff", json=stuff)
29+
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
30+
assert response.json() == snapshot(
31+
{"message": "A database error occurred. Please try again later."}
32+
)
2833

2934

3035
async def test_get_stuff(client: AsyncClient):
36+
response = await client.get("/stuff/nonexistent")
37+
assert response.status_code == status.HTTP_404_NOT_FOUND
38+
assert response.json() == snapshot(
39+
{"no_response": "The requested resource was not found"}
40+
)
3141
stuff = StuffFactory.build(factory_use_constructors=True).model_dump(mode="json")
3242
await client.post("/stuff", json=stuff)
3343
name = stuff["name"]

0 commit comments

Comments
 (0)