Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/exception_handlers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from app.exception_handlers.registry import register_exception_handlers

__all__ = ["register_exception_handlers"]
43 changes: 43 additions & 0 deletions app/exception_handlers/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import orjson
from fastapi import Request
from rotoger import AppStructLogger
from attrs import define, field


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
)
23 changes: 23 additions & 0 deletions app/exception_handlers/database.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
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."}
)
11 changes: 11 additions & 0 deletions app/exception_handlers/registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from fastapi import FastAPI
from sqlalchemy.exc import SQLAlchemyError
from fastapi.exceptions import ResponseValidationError

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)
39 changes: 39 additions & 0 deletions app/exception_handlers/validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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}
Copy link
Preview

Copilot AI Aug 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exposing raw validation errors in the API response could leak internal implementation details. Consider sanitizing or formatting these errors for public consumption.

Suggested change
return JSONResponse(
status_code=422,
content={"response_format_error": errors}
# Sanitize errors for public consumption
sanitized_errors = [
{"loc": error.get("loc"), "msg": error.get("msg")}
for error in errors
]
return JSONResponse(
status_code=422,
content={"response_format_error": sanitized_errors}

Copilot uses AI. Check for mistakes.

)
6 changes: 6 additions & 0 deletions tests/api/test_stuff.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,15 @@ async def test_add_stuff(client: AsyncClient):
"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):
response = await client.get(f"/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")
await client.post("/stuff", json=stuff)
name = stuff["name"]
Expand Down
Loading