From 71d90aa1e3544bb1755fb71693bcbc8aa60e1683 Mon Sep 17 00:00:00 2001 From: grillazz Date: Sun, 24 Aug 2025 09:41:55 +0200 Subject: [PATCH 1/4] add simple exception handlers tests --- app/exception handlers/__init__.py | 0 tests/api/test_stuff.py | 6 ++++++ 2 files changed, 6 insertions(+) create mode 100644 app/exception handlers/__init__.py diff --git a/app/exception handlers/__init__.py b/app/exception handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/test_stuff.py b/tests/api/test_stuff.py index e1352c2..9445c7e 100644 --- a/tests/api/test_stuff.py +++ b/tests/api/test_stuff.py @@ -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"] From ee637b53e0d612248d47f23f8ab8f2056741e2d7 Mon Sep 17 00:00:00 2001 From: grillazz Date: Sun, 24 Aug 2025 09:49:04 +0200 Subject: [PATCH 2/4] add excpetion_handlers module --- app/exception handlers/__init__.py | 0 app/exception_handlers/__init__.py | 3 +++ app/exception_handlers/base.py | 34 ++++++++++++++++++++++++ app/exception_handlers/database.py | 24 +++++++++++++++++ app/exception_handlers/registry.py | 11 ++++++++ app/exception_handlers/validation.py | 39 ++++++++++++++++++++++++++++ 6 files changed, 111 insertions(+) delete mode 100644 app/exception handlers/__init__.py create mode 100644 app/exception_handlers/__init__.py create mode 100644 app/exception_handlers/base.py create mode 100644 app/exception_handlers/database.py create mode 100644 app/exception_handlers/registry.py create mode 100644 app/exception_handlers/validation.py diff --git a/app/exception handlers/__init__.py b/app/exception handlers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/exception_handlers/__init__.py b/app/exception_handlers/__init__.py new file mode 100644 index 0000000..e954044 --- /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"] \ No newline at end of file diff --git a/app/exception_handlers/base.py b/app/exception_handlers/base.py new file mode 100644 index 0000000..b8229b8 --- /dev/null +++ b/app/exception_handlers/base.py @@ -0,0 +1,34 @@ +# app/exception_handlers/base.py +import orjson +from fastapi import Request +from fastapi.responses import JSONResponse +from rotoger import AppStructLogger + +logger = AppStructLogger().get_logger() + + +class BaseExceptionHandler: + """Base class for all exception handlers with common functionality.""" + + @staticmethod + async def extract_request_info(request: Request): + """Extract common request information.""" + 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 + + return request_path, request_body + + @classmethod + async def log_error(cls, message, request_info, **kwargs): + """Log error with standardized format.""" + request_path, request_body = request_info + await logger.aerror( + message, + request_url=request_path, + request_body=request_body, + **kwargs + ) \ No newline at end of file diff --git a/app/exception_handlers/database.py b/app/exception_handlers/database.py new file mode 100644 index 0000000..95062e4 --- /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."} + ) \ No newline at end of file diff --git a/app/exception_handlers/registry.py b/app/exception_handlers/registry.py new file mode 100644 index 0000000..8de9d45 --- /dev/null +++ b/app/exception_handlers/registry.py @@ -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) \ No newline at end of file diff --git a/app/exception_handlers/validation.py b/app/exception_handlers/validation.py new file mode 100644 index 0000000..cfd2a29 --- /dev/null +++ b/app/exception_handlers/validation.py @@ -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} + ) \ No newline at end of file From 9c7db17da83ad676189aaeae7ec0ffee311c976d Mon Sep 17 00:00:00 2001 From: grillazz Date: Sun, 24 Aug 2025 15:36:02 +0200 Subject: [PATCH 3/4] add exception_handlers module --- app/exception_handlers/base.py | 29 +++++++++++++++++++---------- app/exception_handlers/database.py | 1 - 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/app/exception_handlers/base.py b/app/exception_handlers/base.py index b8229b8..f105f16 100644 --- a/app/exception_handlers/base.py +++ b/app/exception_handlers/base.py @@ -1,34 +1,43 @@ -# app/exception_handlers/base.py import orjson from fastapi import Request -from fastapi.responses import JSONResponse 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): + 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() - request_body = orjson.loads(raw_body) if raw_body else None + if raw_body: + request_body = orjson.loads(raw_body) except orjson.JSONDecodeError: - request_body = None + pass - return request_path, request_body + return RequestInfo(path=request_path, body=request_body) @classmethod - async def log_error(cls, message, request_info, **kwargs): + async def log_error(cls, message: str, request_info: RequestInfo, **kwargs): """Log error with standardized format.""" - request_path, request_body = request_info await logger.aerror( message, - request_url=request_path, - request_body=request_body, + request_url=request_info.path, + request_body=request_info.body, **kwargs ) \ No newline at end of file diff --git a/app/exception_handlers/database.py b/app/exception_handlers/database.py index 95062e4..9750ad3 100644 --- a/app/exception_handlers/database.py +++ b/app/exception_handlers/database.py @@ -1,7 +1,6 @@ from fastapi import Request from fastapi.responses import JSONResponse from sqlalchemy.exc import SQLAlchemyError - from app.exception_handlers.base import BaseExceptionHandler From 978041c6ee8ec671c4ccfe97e3d7ef82bb1da195 Mon Sep 17 00:00:00 2001 From: grillazz Date: Sun, 24 Aug 2025 15:40:06 +0200 Subject: [PATCH 4/4] lint and format --- app/exception_handlers.py | 82 ---------------------------- app/exception_handlers/__init__.py | 2 +- app/exception_handlers/base.py | 8 +-- app/exception_handlers/database.py | 13 +++-- app/exception_handlers/registry.py | 11 +++- app/exception_handlers/validation.py | 17 +++--- tests/api/test_stuff.py | 10 +++- 7 files changed, 37 insertions(+), 106 deletions(-) delete mode 100644 app/exception_handlers.py 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 index e954044..e628985 100644 --- a/app/exception_handlers/__init__.py +++ b/app/exception_handlers/__init__.py @@ -1,3 +1,3 @@ from app.exception_handlers.registry import register_exception_handlers -__all__ = ["register_exception_handlers"] \ No newline at end of file +__all__ = ["register_exception_handlers"] diff --git a/app/exception_handlers/base.py b/app/exception_handlers/base.py index f105f16..9e63e6a 100644 --- a/app/exception_handlers/base.py +++ b/app/exception_handlers/base.py @@ -1,8 +1,7 @@ import orjson +from attrs import define, field from fastapi import Request from rotoger import AppStructLogger -from attrs import define, field - logger = AppStructLogger().get_logger() @@ -10,6 +9,7 @@ logger = AppStructLogger().get_logger() @define(slots=True) class RequestInfo: """Contains extracted request information.""" + path: str = field() body: dict = field(default=None) @@ -39,5 +39,5 @@ class BaseExceptionHandler: message, request_url=request_info.path, request_body=request_info.body, - **kwargs - ) \ No newline at end of file + **kwargs, + ) diff --git a/app/exception_handlers/database.py b/app/exception_handlers/database.py index 9750ad3..76bd0b9 100644 --- a/app/exception_handlers/database.py +++ b/app/exception_handlers/database.py @@ -1,6 +1,7 @@ from fastapi import Request from fastapi.responses import JSONResponse from sqlalchemy.exc import SQLAlchemyError + from app.exception_handlers.base import BaseExceptionHandler @@ -8,16 +9,16 @@ class SQLAlchemyExceptionHandler(BaseExceptionHandler): """Handles SQLAlchemy database exceptions.""" @classmethod - async def handle_exception(cls, request: Request, exc: SQLAlchemyError) -> JSONResponse: + 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) + "Database error occurred", request_info, sql_error=repr(exc) ) return JSONResponse( status_code=500, - content={"message": "A database error occurred. Please try again later."} - ) \ No newline at end of file + content={"message": "A database error occurred. Please try again later."}, + ) diff --git a/app/exception_handlers/registry.py b/app/exception_handlers/registry.py index 8de9d45..9ba42b2 100644 --- a/app/exception_handlers/registry.py +++ b/app/exception_handlers/registry.py @@ -1,11 +1,16 @@ from fastapi import FastAPI -from sqlalchemy.exc import SQLAlchemyError 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) \ No newline at end of file + 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 index cfd2a29..d081af4 100644 --- a/app/exception_handlers/validation.py +++ b/app/exception_handlers/validation.py @@ -9,14 +9,18 @@ class ResponseValidationExceptionHandler(BaseExceptionHandler): """Handles response validation exceptions.""" @classmethod - async def handle_exception(cls, request: Request, exc: ResponseValidationError) -> JSONResponse: + 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", ""): + if error.get("input") is None and "valid dictionary" in error.get( + "msg", "" + ): is_none_response = True break @@ -24,16 +28,15 @@ class ResponseValidationExceptionHandler(BaseExceptionHandler): "Response validation error occurred", request_info, validation_errors=errors, - is_none_response=is_none_response + is_none_response=is_none_response, ) if is_none_response: return JSONResponse( status_code=404, - content={"no_response": "The requested resource was not found"} + content={"no_response": "The requested resource was not found"}, ) else: return JSONResponse( - status_code=422, - content={"response_format_error": errors} - ) \ No newline at end of file + status_code=422, content={"response_format_error": errors} + ) diff --git a/tests/api/test_stuff.py b/tests/api/test_stuff.py index 9445c7e..e420b07 100644 --- a/tests/api/test_stuff.py +++ b/tests/api/test_stuff.py @@ -27,13 +27,17 @@ async def test_add_stuff(client: AsyncClient): ) 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.'}) + 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") + 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'}) + 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"]