Skip to content

Commit 19123a6

Browse files
committed
feat: implement correlation ID tracking and RFC 7807 error handling
1 parent 57a85c1 commit 19123a6

File tree

3 files changed

+80
-35
lines changed

3 files changed

+80
-35
lines changed

docker-compose.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ services:
1414
- ADMIN_ROLE_NAME=$ADMIN_ROLE_NAME
1515
- ES_USER=$ES_USER
1616
- ES_PASSWORD=$ES_PASSWORD
17+
- PYTHONUNBUFFERED=1
1718
ports:
1819
- ${AIOD_REST_PORT}:8000
1920
command: python main.py
Lines changed: 40 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
11
import json
22
import logging
33
import traceback
4-
import uuid
54
from http import HTTPStatus
65

76
from fastapi import HTTPException, status
8-
from pydantic import BaseModel
97
from starlette.responses import JSONResponse
108

11-
129
def as_http_exception(exception: Exception) -> HTTPException:
1310
if isinstance(exception, HTTPException):
1411
return exception
@@ -21,33 +18,46 @@ def as_http_exception(exception: Exception) -> HTTPException:
2118
),
2219
)
2320

24-
25-
class ErrorSchema(BaseModel):
26-
detail: str
27-
reference: str
28-
29-
3021
async def http_exception_handler(request, exc):
31-
reference = uuid.uuid4().hex
32-
error = ErrorSchema(detail=exc.detail, reference=reference)
33-
content = error.dict()
34-
22+
# 1.access the 'Single Source of Truth' id from your middleware this is the 'correlation_id' we set in the CorrelationIdMiddleware
23+
correlation_id = getattr(request.state, "correlation_id", "unknown")
24+
25+
# 2.hey team built the rfc7807 'Problem Details' structure this should help
26+
27+
content = {
28+
"type": "about:blank",
29+
"title": HTTPStatus(exc.status_code).phrase,
30+
"status": exc.status_code,
31+
"detail": exc.detail,
32+
"instance": request.url.path,
33+
"correlation_id": correlation_id
34+
}
35+
36+
# 3.enhanced logging logic log the error with all relevant details, including the correlation id, request method, path, and exception details. This will help in debugging and tracing issues effectively.
3537
body_content = "<Data Stream with unknown content>"
3638
if not request._stream_consumed:
37-
body = await request.body()
38-
body_content = json.dumps(json.loads(body)) if body else ""
39-
40-
log_message = str(
41-
dict(
42-
reference=reference,
43-
exception=f"{str(exc)!r}",
44-
method=request.scope["method"],
45-
path=request.scope["path"],
46-
body=body_content,
47-
)
48-
)
49-
log_level = logging.DEBUG
50-
if exc.status_code == HTTPStatus.INTERNAL_SERVER_ERROR:
51-
log_level = logging.WARNING
52-
logging.log(log_level, log_message)
53-
return JSONResponse(content, status_code=exc.status_code)
39+
try:
40+
body = await request.body()
41+
body_content = json.loads(body) if body else ""
42+
except Exception:
43+
body_content = "<Unparseable Body>"
44+
45+
log_data = {
46+
"correlation_id": correlation_id,
47+
"status_code": exc.status_code,
48+
"method": request.method,
49+
"path": request.url.path,
50+
"exception": str(exc.detail),
51+
"body": body_content,
52+
}
53+
54+
#log as WARNING for server errors (500+) and info for (400+) just to avoid log noise, but still capture all details for debugging when needed.
55+
log_level = logging.ERROR if exc.status_code >= 500 else logging.INFO
56+
logging.log(log_level, f"API Error: {json.dumps(log_data)}")
57+
58+
#4.return with the specific 'problem+json' media type as per rfc7807
59+
return JSONResponse(
60+
content=content,
61+
status_code=exc.status_code,
62+
media_type="application/problem+json"
63+
)

src/main.py

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,19 @@
44
Note: order matters for overloaded paths
55
(https://fastapi.tiangolo.com/tutorial/path-params/#order-matters).
66
"""
7+
import uuid
8+
from starlette.middleware.base import BaseHTTPMiddleware
9+
from fastapi import Request
10+
class CorrelationIdMiddleware(BaseHTTPMiddleware):
11+
async def dispatch(self, request: Request, call_next):
12+
#1.it generate the unique id i tried to use only single id for the request and response cycle, so that we can easily trace the logs and the response for a particular request.
13+
correlation_id = str(uuid.uuid4())
14+
request.state.correlation_id = correlation_id
15+
#2.pass the request to the next person in line
16+
response = await call_next(request)
17+
#3.inject the correlation id into response headers for client side tracking (Egress) stamped the id on the way out so the user sees it
18+
response.headers["X-Correlation-ID"] = getattr(request.state, "correlation_id", "not-started")
19+
return response
720

821
import argparse
922
import logging
@@ -12,6 +25,9 @@
1225
from importlib.metadata import version as pkg_version, PackageNotFoundError
1326
import uvicorn
1427
from fastapi import Depends, FastAPI, HTTPException
28+
from starlette.exceptions import HTTPException as StarletteHTTPException
29+
from fastapi.exceptions import HTTPException as FastAPIHTTPException
30+
from error_handling.error_handling import http_exception_handler
1531
from fastapi.responses import HTMLResponse
1632
from sqlmodel import select, SQLModel
1733
from starlette.requests import Request
@@ -33,7 +49,7 @@
3349
from setup_logger import setup_logger
3450
from taxonomies.synchronize_taxonomy import synchronize_taxonomy_from_file
3551
from triggers import disable_review_process, enable_review_process
36-
from error_handling import http_exception_handler
52+
from error_handling.error_handling import http_exception_handler
3753
from routers import (
3854
resource_routers,
3955
parent_routers,
@@ -53,6 +69,16 @@
5369
add_deprecation_and_sunset_middleware,
5470
Version,
5571
)
72+
import logging
73+
import sys
74+
75+
#just to be sure we can look for particular correlation ids in the logs without having to parse the entire log line
76+
logging.basicConfig(
77+
level=logging.INFO,
78+
format="%(levelname)s: %(message)s",
79+
stream=sys.stdout,
80+
force=True #this overrides any existing hidden configs
81+
)
5682

5783

5884
def add_routes(app: FastAPI, version: Version, url_prefix=""):
@@ -133,6 +159,8 @@ def create_app() -> FastAPI:
133159
except PackageNotFoundError:
134160
dist_version = "dev"
135161
app = build_app(url_prefix=DEV_CONFIG.get("url_prefix", ""), version=dist_version)
162+
163+
136164
return app
137165

138166

@@ -159,6 +187,7 @@ def build_app(*, url_prefix: str = "", version: str = "dev"):
159187
version="latest",
160188
**kwargs,
161189
)
190+
main_app.add_middleware(CorrelationIdMiddleware)
162191
versioned_apps = [
163192
(
164193
FastAPI(
@@ -173,15 +202,20 @@ def build_app(*, url_prefix: str = "", version: str = "dev"):
173202
]
174203
for app, version in [(main_app, Version.LATEST)] + versioned_apps:
175204
add_routes(app, version=version)
176-
app.add_exception_handler(HTTPException, http_exception_handler)
205+
206+
app.exception_handlers[FastAPIHTTPException] = http_exception_handler
207+
#this is needed to catch exceptions raised by Starlette, such as 404s for non existent endpoints which are not caught by FastAPI HTTPException handler
208+
app.exception_handlers[StarletteHTTPException] = http_exception_handler
209+
app.add_exception_handler(404, http_exception_handler)
210+
177211
add_deprecation_and_sunset_middleware(app)
178212
add_version_to_openapi(app)
179213

180214
Instrumentator().instrument(main_app).expose(
181215
main_app, endpoint="/metrics", include_in_schema=False
182216
)
183-
# Since all traffic goes through the main app, this middleware only
184-
# needs to be registered with the main app and not the mounted apps.
217+
#Since all traffic goes through the main app this middleware only
218+
#needs to be registered with the main app and not the mounted apps
185219
main_app.add_middleware(AccessLogMiddleware)
186220

187221
for app, _ in versioned_apps:
@@ -231,4 +265,4 @@ def main():
231265

232266

233267
if __name__ == "__main__":
234-
main()
268+
main()

0 commit comments

Comments
 (0)