From e2f01f89c5b028976a2d1abccf49e5270f5801ff Mon Sep 17 00:00:00 2001 From: grillazz Date: Sat, 23 Aug 2025 20:04:23 +0200 Subject: [PATCH 1/3] add response_validation_exception_handler --- app/api/nonsense.py | 5 +++-- app/api/stuff.py | 9 ++------ app/database.py | 16 +++++++------- app/exception_handlers.py | 44 +++++++++++++++++++++++++++++++++++++++ app/models/nonsense.py | 20 ++++-------------- app/models/stuff.py | 2 +- 6 files changed, 62 insertions(+), 34 deletions(-) diff --git a/app/api/nonsense.py b/app/api/nonsense.py index 0c77a06..869aab6 100644 --- a/app/api/nonsense.py +++ b/app/api/nonsense.py @@ -22,11 +22,12 @@ async def create_nonsense( @router.get("/", response_model=NonsenseResponse) -async def find_nonsense( +async def get_nonsense( name: str, db_session: AsyncSession = Depends(get_db), ): - return await Nonsense.find(db_session, name) + nonsense = await Nonsense.get_by_name(db_session, name) + return nonsense @router.delete("/") diff --git a/app/api/stuff.py b/app/api/stuff.py index 83fedf3..ef9efe7 100644 --- a/app/api/stuff.py +++ b/app/api/stuff.py @@ -52,13 +52,8 @@ async def create_stuff( @router.get("/{name}", response_model=StuffResponse) -async def find_stuff(name: str, db_session: AsyncSession = Depends(get_db)): - result = await Stuff.find(db_session, name) - if not result: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Stuff with name {name} not found.", - ) +async def get_stuff(name: str, db_session: AsyncSession = Depends(get_db)): + result = await Stuff.get_by_name(db_session, name) return result diff --git a/app/database.py b/app/database.py index 8e00277..fefa544 100644 --- a/app/database.py +++ b/app/database.py @@ -3,6 +3,7 @@ from collections.abc import AsyncGenerator from rotoger import AppStructLogger from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from fastapi.exceptions import ResponseValidationError from app.config import settings as global_settings @@ -26,15 +27,14 @@ AsyncSessionFactory = async_sessionmaker( # Dependency async def get_db() -> AsyncGenerator: async with AsyncSessionFactory() as session: - # logger.debug(f"ASYNC Pool: {engine.pool.status()}") try: yield session await session.commit() + except SQLAlchemyError: + # Re-raise SQLAlchemy errors to be handled by the global handler + raise except Exception as ex: - if isinstance(ex, SQLAlchemyError): - # Re-raise SQLAlchemyError directly without handling - raise - else: - # Handle other exceptions - await logger.aerror(f"NonSQLAlchemyError: {repr(ex)}") - raise # Re-raise after logging + # Only log actual database-related issues, not response validation + if not isinstance(ex, ResponseValidationError): + await logger.aerror(f"Database-related error: {repr(ex)}") + raise # Re-raise to be handled by appropriate handlers diff --git a/app/exception_handlers.py b/app/exception_handlers.py index 492556e..33c76e1 100644 --- a/app/exception_handlers.py +++ b/app/exception_handlers.py @@ -3,6 +3,7 @@ from fastapi import FastAPI, Request from fastapi.responses import JSONResponse from rotoger import AppStructLogger from sqlalchemy.exc import SQLAlchemyError +from fastapi.exceptions import ResponseValidationError logger = AppStructLogger().get_logger() @@ -30,6 +31,49 @@ async def sqlalchemy_exception_handler( ) +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) \ No newline at end of file diff --git a/app/models/nonsense.py b/app/models/nonsense.py index a70a067..8cebc47 100644 --- a/app/models/nonsense.py +++ b/app/models/nonsense.py @@ -20,22 +20,10 @@ class Nonsense(Base): # TODO: apply relation to other tables @classmethod - async def find(cls, db_session: AsyncSession, name: str): - """ - - :param db_session: - :param name: - :return: - """ + async def get_by_name(cls, db_session: AsyncSession, name: str): stmt = select(cls).where(cls.name == name) result = await db_session.execute(stmt) instance = result.scalars().first() - if instance is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail={ - "Record not found": f"There is no record for requested name value : {name}" - }, - ) - else: - return instance + return instance + + diff --git a/app/models/stuff.py b/app/models/stuff.py index 1edf5c1..d4d5e1d 100644 --- a/app/models/stuff.py +++ b/app/models/stuff.py @@ -35,7 +35,7 @@ class Stuff(Base): @classmethod @compile_sql_or_scalar - async def find(cls, db_session: AsyncSession, name: str, compile_sql=False): + async def get_by_name(cls, db_session: AsyncSession, name: str, compile_sql=False): stmt = select(cls).options(joinedload(cls.nonsense)).where(cls.name == name) return stmt From 700a2d49a6f4159087bd29fc9882d127c329bb7b Mon Sep 17 00:00:00 2001 From: grillazz Date: Sat, 23 Aug 2025 20:04:45 +0200 Subject: [PATCH 2/3] format and lint --- app/database.py | 2 +- app/exception_handlers.py | 11 +++++++---- app/models/nonsense.py | 3 --- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/database.py b/app/database.py index fefa544..ea56a86 100644 --- a/app/database.py +++ b/app/database.py @@ -1,9 +1,9 @@ from collections.abc import AsyncGenerator +from fastapi.exceptions import ResponseValidationError from rotoger import AppStructLogger from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine -from fastapi.exceptions import ResponseValidationError from app.config import settings as global_settings diff --git a/app/exception_handlers.py b/app/exception_handlers.py index 33c76e1..6298a3e 100644 --- a/app/exception_handlers.py +++ b/app/exception_handlers.py @@ -1,13 +1,14 @@ 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 -from fastapi.exceptions import ResponseValidationError logger = AppStructLogger().get_logger() -#TODO: add reasoning for this in readme plus higligh using re-raise in db session + +# 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: @@ -32,7 +33,7 @@ async def sqlalchemy_exception_handler( async def response_validation_exception_handler( - request: Request, exc: ResponseValidationError + request: Request, exc: ResponseValidationError ) -> JSONResponse: request_path = request.url.path try: @@ -76,4 +77,6 @@ async def response_validation_exception_handler( 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) \ No newline at end of file + app.add_exception_handler( + ResponseValidationError, response_validation_exception_handler + ) diff --git a/app/models/nonsense.py b/app/models/nonsense.py index 8cebc47..65f79dc 100644 --- a/app/models/nonsense.py +++ b/app/models/nonsense.py @@ -1,6 +1,5 @@ import uuid -from fastapi import HTTPException, status from sqlalchemy import String, select from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.ext.asyncio import AsyncSession @@ -25,5 +24,3 @@ class Nonsense(Base): result = await db_session.execute(stmt) instance = result.scalars().first() return instance - - From 7d122f70629eea8387a40cbbe7caeb872557e72d Mon Sep 17 00:00:00 2001 From: grillazz Date: Sun, 24 Aug 2025 09:34:11 +0200 Subject: [PATCH 3/3] fix --- app/api/stuff.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/api/stuff.py b/app/api/stuff.py index ef9efe7..d36bcd2 100644 --- a/app/api/stuff.py +++ b/app/api/stuff.py @@ -84,7 +84,7 @@ async def find_stuff_pool( HTTPException: If the 'Stuff' object is not found or an SQLAlchemyError occurs. """ try: - stmt = await Stuff.find(db_session, name, compile_sql=True) + stmt = await Stuff.get_by_name(db_session, name, compile_sql=True) result = await request.app.postgres_pool.fetchrow(str(stmt)) except SQLAlchemyError as ex: raise HTTPException( @@ -100,7 +100,7 @@ async def find_stuff_pool( @router.delete("/{name}") async def delete_stuff(name: str, db_session: AsyncSession = Depends(get_db)): - stuff = await Stuff.find(db_session, name) + stuff = await Stuff.get_by_name(db_session, name) return await Stuff.delete(stuff, db_session) @@ -110,6 +110,6 @@ async def update_stuff( name: str, db_session: AsyncSession = Depends(get_db), ): - stuff = await Stuff.find(db_session, name) + stuff = await Stuff.get_by_name(db_session, name) await stuff.update(**payload.model_dump()) return stuff