Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion database/logging_system/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
)
112 changes: 107 additions & 5 deletions database/logging_system/exception_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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",
Expand All @@ -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)
3 changes: 3 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
104 changes: 104 additions & 0 deletions test/test_exception_handler.py
Original file line number Diff line number Diff line change
@@ -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