diff --git a/app/exception_handlers.py b/app/exception_handlers.py deleted file mode 100644 index 6298a3e..0000000 --- a/app/exception_handlers.py +++ /dev/null @@ -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 - ) diff --git a/app/exception_handlers/__init__.py b/app/exception_handlers/__init__.py new file mode 100644 index 0000000..e628985 --- /dev/null +++ b/app/exception_handlers/__init__.py @@ -0,0 +1,3 @@ +from app.exception_handlers.registry import register_exception_handlers + +__all__ = ["register_exception_handlers"] diff --git a/app/exception_handlers/base.py b/app/exception_handlers/base.py new file mode 100644 index 0000000..9e63e6a --- /dev/null +++ b/app/exception_handlers/base.py @@ -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, + ) diff --git a/app/exception_handlers/database.py b/app/exception_handlers/database.py new file mode 100644 index 0000000..76bd0b9 --- /dev/null +++ b/app/exception_handlers/database.py @@ -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."}, + ) diff --git a/app/exception_handlers/registry.py b/app/exception_handlers/registry.py new file mode 100644 index 0000000..9ba42b2 --- /dev/null +++ b/app/exception_handlers/registry.py @@ -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 + ) diff --git a/app/exception_handlers/validation.py b/app/exception_handlers/validation.py new file mode 100644 index 0000000..d081af4 --- /dev/null +++ b/app/exception_handlers/validation.py @@ -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} + ) diff --git a/tests/api/test_stuff.py b/tests/api/test_stuff.py index e1352c2..e420b07 100644 --- a/tests/api/test_stuff.py +++ b/tests/api/test_stuff.py @@ -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"]