feat: implement error handling middleware with request ID tracking for improved error responses
This commit is contained in:
143
backend/app/core/error_handling.py
Normal file
143
backend/app/core/error_handling.py
Normal file
@@ -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,
|
||||
)
|
||||
@@ -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]:
|
||||
|
||||
@@ -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",
|
||||
|
||||
82
backend/tests/test_error_handling.py
Normal file
82
backend/tests/test_error_handling.py
Normal file
@@ -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"]
|
||||
49
backend/uv.lock
generated
49
backend/uv.lock
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user