feat: implement error handling middleware with request ID tracking for improved error responses

This commit is contained in:
Abhimanyu Saharan
2026-02-07 03:14:30 +05:30
parent a4442eb9d5
commit 19323e25de
5 changed files with 278 additions and 0 deletions

View 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,
)

View File

@@ -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]:

View File

@@ -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",

View 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
View File

@@ -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"