Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
82 changes: 0 additions & 82 deletions app/exception_handlers.py

This file was deleted.

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 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,
)
24 changes: 24 additions & 0 deletions app/exception_handlers/database.py
Original file line number Diff line number Diff line change
@@ -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."},
)
16 changes: 16 additions & 0 deletions app/exception_handlers/registry.py
Original file line number Diff line number Diff line change
@@ -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
)
42 changes: 42 additions & 0 deletions app/exception_handlers/validation.py
Original file line number Diff line number Diff line change
@@ -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}
)
10 changes: 10 additions & 0 deletions tests/api/test_stuff.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,19 @@ 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("/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