diff --git a/backend/app/core/error_handling.py b/backend/app/core/error_handling.py new file mode 100644 index 00000000..cfa53854 --- /dev/null +++ b/backend/app/core/error_handling.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Callable +from typing import Any, Final, cast +from uuid import uuid4 + +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError, ResponseValidationError +from fastapi.responses import JSONResponse +from starlette.exceptions import HTTPException as StarletteHTTPException +from starlette.responses import Response +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +logger = logging.getLogger(__name__) + +REQUEST_ID_HEADER: Final[str] = "X-Request-Id" + +ExceptionHandler = Callable[[Request, Exception], Response | Awaitable[Response]] + + +class RequestIdMiddleware: + def __init__(self, app: ASGIApp, *, header_name: str = REQUEST_ID_HEADER) -> None: + self._app = app + self._header_name = header_name + self._header_name_bytes = header_name.lower().encode("latin-1") + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self._app(scope, receive, send) + return + + request_id = self._get_or_create_request_id(scope) + + async def send_with_request_id(message: Message) -> None: + if message["type"] == "http.response.start": + # Starlette uses `list[tuple[bytes, bytes]]` here. + headers: list[tuple[bytes, bytes]] = message.setdefault("headers", []) + if not any(key.lower() == self._header_name_bytes for key, _ in headers): + headers.append((self._header_name_bytes, request_id.encode("latin-1"))) + await send(message) + + await self._app(scope, receive, send_with_request_id) + + def _get_or_create_request_id(self, scope: Scope) -> str: + # Accept a client-provided request id if present. + request_id: str | None = None + for key, value in scope.get("headers", []): + if key.lower() == self._header_name_bytes: + candidate = value.decode("latin-1").strip() + if candidate: + request_id = candidate + break + + if request_id is None: + request_id = uuid4().hex + + # `Request.state` is backed by `scope["state"]`. + state = scope.setdefault("state", {}) + state["request_id"] = request_id + return request_id + + +def install_error_handling(app: FastAPI) -> None: + # Important: add request-id middleware last so it's the outermost middleware. + # This ensures it still runs even if another middleware (e.g. CORS preflight) returns early. + app.add_middleware(RequestIdMiddleware) + + app.add_exception_handler( + RequestValidationError, + cast(ExceptionHandler, _request_validation_handler), + ) + app.add_exception_handler( + ResponseValidationError, + cast(ExceptionHandler, _response_validation_handler), + ) + app.add_exception_handler( + StarletteHTTPException, + cast(ExceptionHandler, _http_exception_handler), + ) + app.add_exception_handler(Exception, _unhandled_exception_handler) + + +def _get_request_id(request: Request) -> str | None: + request_id = getattr(request.state, "request_id", None) + if isinstance(request_id, str) and request_id: + return request_id + return None + + +def _error_payload(*, detail: Any, request_id: str | None) -> dict[str, Any]: + payload: dict[str, Any] = {"detail": detail} + if request_id: + payload["request_id"] = request_id + return payload + + +async def _request_validation_handler(request: Request, exc: RequestValidationError) -> JSONResponse: + # `RequestValidationError` is expected user input; don't log at ERROR. + request_id = _get_request_id(request) + return JSONResponse( + status_code=422, + content=_error_payload(detail=exc.errors(), request_id=request_id), + ) + + +async def _response_validation_handler(request: Request, exc: ResponseValidationError) -> JSONResponse: + request_id = _get_request_id(request) + logger.exception( + "response_validation_error", + extra={ + "request_id": request_id, + "method": request.method, + "path": request.url.path, + "errors": exc.errors(), + }, + ) + return JSONResponse( + status_code=500, + content=_error_payload(detail="Internal Server Error", request_id=request_id), + ) + + +async def _http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse: + request_id = _get_request_id(request) + return JSONResponse( + status_code=exc.status_code, + content=_error_payload(detail=exc.detail, request_id=request_id), + headers=exc.headers, + ) + + +async def _unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse: + request_id = _get_request_id(request) + logger.exception( + "unhandled_exception", + extra={"request_id": request_id, "method": request.method, "path": request.url.path}, + ) + return JSONResponse( + status_code=500, + content=_error_payload(detail="Internal Server Error", request_id=request_id), + headers={REQUEST_ID_HEADER: request_id} if request_id else None, + ) diff --git a/backend/app/main.py b/backend/app/main.py index b785f43a..ad889d4e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -21,6 +21,7 @@ from app.api.metrics import router as metrics_router from app.api.tasks import router as tasks_router from app.api.users import router as users_router from app.core.config import settings +from app.core.error_handling import install_error_handling from app.core.logging import configure_logging from app.db.session import init_db @@ -45,6 +46,8 @@ if origins: allow_headers=["*"], ) +install_error_handling(app) + @app.get("/health") def health() -> dict[str, bool]: diff --git a/backend/pyproject.toml b/backend/pyproject.toml index b18b206e..0f501c7a 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ dev = [ "black==24.10.0", "flake8==7.1.1", + "httpx==0.27.0", "isort==5.13.2", "mypy==1.11.2", "pytest==8.3.3", diff --git a/backend/tests/test_error_handling.py b/backend/tests/test_error_handling.py new file mode 100644 index 00000000..daaa5ed6 --- /dev/null +++ b/backend/tests/test_error_handling.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient +from pydantic import BaseModel, Field + +from app.core.error_handling import REQUEST_ID_HEADER, install_error_handling + + +def test_request_validation_error_includes_request_id(): + app = FastAPI() + install_error_handling(app) + + @app.get("/needs-int") + def needs_int(limit: int) -> dict[str, int]: + return {"limit": limit} + + client = TestClient(app) + resp = client.get("/needs-int?limit=abc") + + assert resp.status_code == 422 + body = resp.json() + assert isinstance(body.get("detail"), list) + assert isinstance(body.get("request_id"), str) and body["request_id"] + assert resp.headers.get(REQUEST_ID_HEADER) == body["request_id"] + + +def test_http_exception_includes_request_id(): + app = FastAPI() + install_error_handling(app) + + @app.get("/nope") + def nope() -> None: + raise HTTPException(status_code=404, detail="nope") + + client = TestClient(app) + resp = client.get("/nope") + + assert resp.status_code == 404 + body = resp.json() + assert body["detail"] == "nope" + assert isinstance(body.get("request_id"), str) and body["request_id"] + assert resp.headers.get(REQUEST_ID_HEADER) == body["request_id"] + + +def test_unhandled_exception_returns_500_with_request_id(): + app = FastAPI() + install_error_handling(app) + + @app.get("/boom") + def boom() -> None: + raise RuntimeError("boom") + + client = TestClient(app, raise_server_exceptions=False) + resp = client.get("/boom") + + assert resp.status_code == 500 + body = resp.json() + assert body["detail"] == "Internal Server Error" + assert isinstance(body.get("request_id"), str) and body["request_id"] + assert resp.headers.get(REQUEST_ID_HEADER) == body["request_id"] + + +def test_response_validation_error_returns_500_with_request_id(): + class Out(BaseModel): + name: str = Field(min_length=1) + + app = FastAPI() + install_error_handling(app) + + @app.get("/bad", response_model=Out) + def bad() -> dict[str, str]: + return {"name": ""} + + client = TestClient(app, raise_server_exceptions=False) + resp = client.get("/bad") + + assert resp.status_code == 500 + body = resp.json() + assert body["detail"] == "Internal Server Error" + assert isinstance(body.get("request_id"), str) and body["request_id"] + assert resp.headers.get(REQUEST_ID_HEADER) == body["request_id"] diff --git a/backend/uv.lock b/backend/uv.lock index e2e11337..b7af7610 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -71,6 +71,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8d/a7/4b27c50537ebca8bec139b872861f9d2bf501c5ec51fcf897cb924d9e264/black-24.10.0-py3-none-any.whl", hash = "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d", size = 206898, upload-time = "2024-10-07T19:20:48.317Z" }, ] +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + [[package]] name = "cffi" version = "2.0.0" @@ -289,6 +298,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + [[package]] name = "httptools" version = "0.7.1" @@ -318,6 +340,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, ] +[[package]] +name = "httpx" +version = "0.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/3da5bdf4408b8b2800061c339f240c1802f2e82d55e50bd39c5a881f47f0/httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5", size = 126413, upload-time = "2024-02-21T13:07:52.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/7b/ddacf6dcebb42466abd03f368782142baa82e08fc0c1f8eaa05b4bae87d5/httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5", size = 75590, upload-time = "2024-02-21T13:07:50.455Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -494,6 +532,7 @@ dependencies = [ dev = [ { name = "black" }, { name = "flake8" }, + { name = "httpx" }, { name = "isort" }, { name = "mypy" }, { name = "pytest" }, @@ -509,6 +548,7 @@ requires-dist = [ { name = "fastapi-clerk-auth", specifier = "==0.0.9" }, { name = "fastapi-pagination", specifier = "==0.15.9" }, { name = "flake8", marker = "extra == 'dev'", specifier = "==7.1.1" }, + { name = "httpx", marker = "extra == 'dev'", specifier = "==0.27.0" }, { name = "isort", marker = "extra == 'dev'", specifier = "==5.13.2" }, { name = "jinja2", specifier = "==3.1.6" }, { name = "mypy", marker = "extra == 'dev'", specifier = "==1.11.2" }, @@ -864,6 +904,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3e/14/fd026bc74ded05e2351681545a5f626e78ef831f8edce064d61acd2e6ec7/ruff-0.6.9-py3-none-win_arm64.whl", hash = "sha256:a9641e31476d601f83cd602608739a0840e348bda93fec9f1ee816f8b6798b93", size = 8679879, upload-time = "2024-10-04T13:40:25.797Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "sqlalchemy" version = "2.0.34"