From 05e03db903bdf6303c31ec729f91d2618246f73c Mon Sep 17 00:00:00 2001 From: Cizz22 Date: Thu, 12 Feb 2026 15:42:48 +0700 Subject: [PATCH] refactor: improve exception handling, introduce structured logging, and refine uvicorn access logging. --- run.py | 2 +- src/api.py | 7 ----- src/exceptions.py | 68 +++++++++++++++++++++++++++++++++++++---------- src/logging.py | 12 ++++++--- src/main.py | 39 +++++++++++++++++++++------ 5 files changed, 94 insertions(+), 34 deletions(-) diff --git a/run.py b/run.py index ff0b5a7..9b82e7b 100644 --- a/run.py +++ b/run.py @@ -3,4 +3,4 @@ import uvicorn from src.config import HOST, PORT if __name__ == "__main__": - uvicorn.run("src.main:app", host=HOST, port=PORT, reload=True) + uvicorn.run("src.main:app", host=HOST, port=PORT, reload=True,) diff --git a/src/api.py b/src/api.py index 16b2baf..149c500 100644 --- a/src/api.py +++ b/src/api.py @@ -21,13 +21,6 @@ class ErrorResponse(BaseModel): api_router = APIRouter( default_response_class=JSONResponse, - responses={ - 400: {"model": ErrorResponse}, - 401: {"model": ErrorResponse}, - 403: {"model": ErrorResponse}, - 404: {"model": ErrorResponse}, - 500: {"model": ErrorResponse}, - }, ) diff --git a/src/exceptions.py b/src/exceptions.py index e5aca3d..8484dcf 100644 --- a/src/exceptions.py +++ b/src/exceptions.py @@ -5,6 +5,7 @@ from typing import Any, Dict, List, Optional from asyncpg.exceptions import DataError as AsyncPGDataError from asyncpg.exceptions import PostgresError from fastapi import FastAPI, HTTPException, Request +from starlette.exceptions import HTTPException as StarletteHTTPException from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from pydantic import BaseModel, Field @@ -14,6 +15,7 @@ from sqlalchemy.exc import DataError, DBAPIError, IntegrityError, SQLAlchemyErro from src.enums import ResponseStatus +log = logging.getLogger(__name__) class ErrorDetail(BaseModel): field: Optional[str] = Field(None, max_length=100) @@ -56,7 +58,7 @@ def get_request_context(request: Request): return { "endpoint": request.url.path, - "url": request.url, + "url": str(request.url), "method": request.method, "remote_addr": get_client_ip(), } @@ -102,30 +104,63 @@ def handle_exception(request: Request, exc: Exception): Global exception handler for Fastapi application. """ request_info = get_request_context(request) + if isinstance(exc, RateLimitExceeded): - _rate_limit_exceeded_handler(request, exc) - if isinstance(exc, HTTPException): - logging.error( - f"HTTP exception | Code: {exc.status_code} | Error: {exc.detail} | Request: {request_info}", - extra={"error_category": "http"}, + return _rate_limit_exceeded_handler(request, exc) + if isinstance(exc, RequestValidationError): + log.error( + "Validation error occurred", + extra={ + "error_category": "validation", + "errors": exc.errors(), + "request": request_info, + }, + ) + return JSONResponse( + status_code=422, + content={ + "data": None, + "message": "Validation Error", + "status": ResponseStatus.ERROR, + "errors": exc.errors(), + }, + ) + if isinstance(exc, (HTTPException, StarletteHTTPException)): + log.error( + "HTTP exception occurred", + extra={ + "error_category": "http", + "status_code": exc.status_code, + "detail": exc.detail if hasattr(exc, "detail") else str(exc), + "request": request_info, + }, ) return JSONResponse( status_code=exc.status_code, content={ "data": None, - "message": str(exc.detail), + "message": str(exc.detail) if hasattr(exc, "detail") else str(exc), "status": ResponseStatus.ERROR, - "errors": [ErrorDetail(message=str(exc.detail)).model_dump()], + "errors": [ + ErrorDetail( + message=str(exc.detail) if hasattr(exc, "detail") else str(exc) + ).model_dump() + ], }, ) if isinstance(exc, SQLAlchemyError): error_message, status_code = handle_sqlalchemy_error(exc) - logging.error( - f"Database Error | Error: {str(error_message)} | Request: {request_info}", - extra={"error_category": "database"}, + log.error( + "Database error occurred", + extra={ + "error_category": "database", + "error_message": error_message, + "request": request_info, + "exception": str(exc), + }, ) return JSONResponse( @@ -139,9 +174,14 @@ def handle_exception(request: Request, exc: Exception): ) # Log unexpected errors - logging.error( - f"Unexpected Error | Error: {str(exc)} | Request: {request_info}", - extra={"error_category": "unexpected"}, + log.error( + "Unexpected error occurred", + extra={ + "error_category": "unexpected", + "error_message": str(exc), + "request": request_info, + }, + exc_info=True, ) return JSONResponse( diff --git a/src/logging.py b/src/logging.py index 8ecf884..e815405 100644 --- a/src/logging.py +++ b/src/logging.py @@ -117,13 +117,17 @@ def configure_logging(): handler.setFormatter(formatter) root_logger.addHandler(handler) - # Reconfigure uvicorn loggers to use our JSON formatter - for logger_name in ["uvicorn", "uvicorn.access", "uvicorn.error", "fastapi"]: + for logger_name in ["uvicorn", "uvicorn.error", "fastapi"]: logger = logging.getLogger(logger_name) logger.handlers = [] logger.propagate = True - # sometimes the slack client can be too verbose - logging.getLogger("slack_sdk.web.base_client").setLevel(logging.CRITICAL) + for logger_name in ["uvicorn.access"]: + logger = logging.getLogger(logger_name) + logger.handlers = [] + logger.propagate = False + + # # sometimes the slack client can be too verbose + # logging.getLogger("slack_sdk.web.base_client").setLevel(logging.CRITICAL) \ No newline at end of file diff --git a/src/main.py b/src/main.py index d64404b..b683894 100644 --- a/src/main.py +++ b/src/main.py @@ -7,6 +7,7 @@ from uuid import uuid1 from typing import Optional, Final from fastapi import FastAPI, HTTPException, status, Path +from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from pydantic import ValidationError @@ -32,21 +33,29 @@ from src.database.core import engine, async_session, async_aeros_session from src.exceptions import handle_exception from src.middleware import RequestValidationMiddleware from src.context import set_request_id, reset_request_id, get_request_id +from sqlalchemy.exc import SQLAlchemyError log = logging.getLogger(__name__) +from starlette.exceptions import HTTPException as StarletteHTTPException + # we configure the logging level and format configure_logging() -# we define the exception handlers -exception_handlers = {Exception: handle_exception} - # we create the ASGI for the app -app = FastAPI(exception_handlers=exception_handlers, openapi_url="", title="LCCA API", +app = FastAPI(openapi_url="", title="LCCA API", description="Welcome to RBD's API documentation!", version="0.1.0") -app.state.limiter = limiter + +# we define the exception handlers +app.add_exception_handler(Exception, handle_exception) +app.add_exception_handler(HTTPException, handle_exception) +app.add_exception_handler(StarletteHTTPException, handle_exception) +app.add_exception_handler(RequestValidationError, handle_exception) app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) +app.add_exception_handler(SQLAlchemyError, handle_exception) + +app.state.limiter = limiter app.add_middleware(GZipMiddleware, minimum_size=2000) app.add_middleware(RequestValidationMiddleware) @@ -72,11 +81,25 @@ async def db_session_middleware(request: Request, call_next): process_time = (time.time() - start_time) * 1000 log.info( - f"Request: {request.method} {request.url.path} Status: {response.status_code} Duration: {process_time:.2f}ms" + "Request finished", + extra={ + "method": request.method, + "path": request.url.path, + "status_code": response.status_code, + "duration_ms": round(process_time, 2), + }, ) - + except Exception as e: - log.error(f"Request failed: {request.method} {request.url.path} Error: {str(e)}") + log.error( + "Request failed", + extra={ + "method": request.method, + "path": request.url.path, + "error": str(e), + }, + exc_info=True, + ) raise e from None finally: await request.state.db.close()