diff --git a/database/logging_system/__init__.py b/database/logging_system/__init__.py index 58f4c4d..86195e8 100644 --- a/database/logging_system/__init__.py +++ b/database/logging_system/__init__.py @@ -19,15 +19,32 @@ ] try: - from .exception_handler import global_exception_handler + from .exception_handler import ( + global_exception_handler, + http_exception_handler, + not_found_exception_handler, + register_exception_handlers, + validation_exception_handler, + value_error_handler, + ) from .request_middleware import RequestLoggingMiddleware except ImportError: # FastAPI/Starlette not installed yet. global_exception_handler = None + http_exception_handler = None + not_found_exception_handler = None + register_exception_handlers = None + validation_exception_handler = None + value_error_handler = None RequestLoggingMiddleware = None else: __all__.extend( [ "RequestLoggingMiddleware", "global_exception_handler", + "http_exception_handler", + "not_found_exception_handler", + "register_exception_handlers", + "validation_exception_handler", + "value_error_handler", ] ) diff --git a/database/logging_system/exception_handler.py b/database/logging_system/exception_handler.py index e12359c..076f9c7 100644 --- a/database/logging_system/exception_handler.py +++ b/database/logging_system/exception_handler.py @@ -4,10 +4,13 @@ from __future__ import annotations +from typing import Any + from .logger import get_error_logger try: - from fastapi import Request + from fastapi import FastAPI, HTTPException, Request, status + from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse except ImportError as exc: # pragma: no cover raise ImportError( @@ -18,11 +21,95 @@ error_logger = get_error_logger("exceptions") +def _request_id(request: Request) -> str: + return getattr(request.state, "request_id", "unknown") + + +def _error_response( + *, + request: Request, + status_code: int, + code: str, + message: str, + details: Any | None = None, +) -> JSONResponse: + content = { + "error": { + "code": code, + "message": message, + "request_id": _request_id(request), + } + } + + if details: + content["error"]["details"] = details + + return JSONResponse(status_code=status_code, content=content) + + +async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse: + """ + Convert FastAPI HTTPException instances into the shared error response shape. + """ + message = exc.detail if isinstance(exc.detail, str) else "Request failed" + + return _error_response( + request=request, + status_code=exc.status_code, + code=f"HTTP_{exc.status_code}", + message=message, + details=exc.detail if isinstance(exc.detail, dict) else None, + ) + + +async def validation_exception_handler( + request: Request, + exc: RequestValidationError, +) -> JSONResponse: + """ + Return validation failures with a stable 422 response body. + """ + return _error_response( + request=request, + status_code=422, + code="VALIDATION_ERROR", + message="Invalid request data", + details={"errors": exc.errors()}, + ) + + +async def value_error_handler(request: Request, exc: ValueError) -> JSONResponse: + """ + Treat ValueError as a client-side bad request. + """ + return _error_response( + request=request, + status_code=status.HTTP_400_BAD_REQUEST, + code="BAD_REQUEST", + message=str(exc) or "Bad request", + ) + + +async def not_found_exception_handler( + request: Request, + exc: KeyError | FileNotFoundError, +) -> JSONResponse: + """ + Treat missing keys/files as not-found errors without leaking internal paths. + """ + return _error_response( + request=request, + status_code=status.HTTP_404_NOT_FOUND, + code="NOT_FOUND", + message="Requested resource was not found", + ) + + async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse: """ Log unexpected exceptions and return a safe response to the client. """ - request_id = getattr(request.state, "request_id", "unknown") + request_id = _request_id(request) error_logger.exception( "event=unhandled_exception request_id=%s method=%s path=%s error=%s", @@ -33,9 +120,24 @@ async def global_exception_handler(request: Request, exc: Exception) -> JSONResp ) return JSONResponse( - status_code=500, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={ - "message": "Internal server error", - "request_id": request_id, + "error": { + "code": "INTERNAL_SERVER_ERROR", + "message": "Internal server error", + "request_id": request_id, + } }, ) + + +def register_exception_handlers(app: FastAPI) -> None: + """ + Register all centralised Food Remedy API exception handlers. + """ + app.add_exception_handler(HTTPException, http_exception_handler) + app.add_exception_handler(RequestValidationError, validation_exception_handler) + app.add_exception_handler(ValueError, value_error_handler) + app.add_exception_handler(KeyError, not_found_exception_handler) + app.add_exception_handler(FileNotFoundError, not_found_exception_handler) + app.add_exception_handler(Exception, global_exception_handler) diff --git a/requirements.txt b/requirements.txt index 000485e..5222463 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,5 +2,8 @@ jsonschema>=4.0.0 PyYAML>=5.3.1 pandas>=2.0.0 +fastapi>=0.110.0 +httpx>=0.27.0 +pytest>=8.0.0 # Add other project-specific deps as needed diff --git a/test/test_exception_handler.py b/test/test_exception_handler.py new file mode 100644 index 0000000..b4ecc1e --- /dev/null +++ b/test/test_exception_handler.py @@ -0,0 +1,104 @@ +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient +from pydantic import BaseModel + +from database.logging_system.exception_handler import register_exception_handlers + + +class ProductPayload(BaseModel): + barcode: str + + +def create_app() -> FastAPI: + app = FastAPI() + register_exception_handlers(app) + + @app.get("/missing-http") + async def missing_http(): + raise HTTPException(status_code=404, detail="Product not found") + + @app.get("/bad-value") + async def bad_value(): + raise ValueError("Barcode is required") + + @app.get("/missing-key") + async def missing_key(): + raise KeyError("barcode") + + @app.get("/unexpected") + async def unexpected(): + raise RuntimeError("database connection failed") + + @app.post("/products") + async def create_product(payload: ProductPayload): + return payload + + return app + + +def test_http_exception_uses_consistent_error_shape(): + client = TestClient(create_app()) + + response = client.get("/missing-http") + + assert response.status_code == 404 + assert response.json() == { + "error": { + "code": "HTTP_404", + "message": "Product not found", + "request_id": "unknown", + } + } + + +def test_validation_error_returns_422_with_details(): + client = TestClient(create_app()) + + response = client.post("/products", json={}) + body = response.json() + + assert response.status_code == 422 + assert body["error"]["code"] == "VALIDATION_ERROR" + assert body["error"]["message"] == "Invalid request data" + assert body["error"]["request_id"] == "unknown" + assert body["error"]["details"]["errors"][0]["loc"] == ["body", "barcode"] + + +def test_value_error_returns_bad_request(): + client = TestClient(create_app()) + + response = client.get("/bad-value") + + assert response.status_code == 400 + assert response.json()["error"] == { + "code": "BAD_REQUEST", + "message": "Barcode is required", + "request_id": "unknown", + } + + +def test_missing_resource_errors_return_not_found_without_internal_details(): + client = TestClient(create_app()) + + response = client.get("/missing-key") + + assert response.status_code == 404 + assert response.json()["error"] == { + "code": "NOT_FOUND", + "message": "Requested resource was not found", + "request_id": "unknown", + } + + +def test_unexpected_exception_returns_safe_internal_error(): + client = TestClient(create_app(), raise_server_exceptions=False) + + response = client.get("/unexpected") + + assert response.status_code == 500 + assert response.json()["error"] == { + "code": "INTERNAL_SERVER_ERROR", + "message": "Internal server error", + "request_id": "unknown", + } + assert "database connection failed" not in response.text