ming commited on
Commit
fa85955
·
1 Parent(s): 0d683e2

feat(middleware): add request ID logging and error handling

Browse files
app/api/v1/summarize.py CHANGED
@@ -2,6 +2,7 @@
2
  Summarization endpoints.
3
  """
4
  from fastapi import APIRouter, HTTPException
 
5
  from app.api.v1.schemas import SummarizeRequest, SummarizeResponse
6
  from app.services.summarizer import ollama_service
7
 
@@ -18,7 +19,8 @@ async def summarize(payload: SummarizeRequest) -> SummarizeResponse:
18
  prompt=payload.prompt or "Summarize the following text concisely:",
19
  )
20
  return SummarizeResponse(**result)
21
- except Exception as e:
 
22
  raise HTTPException(status_code=502, detail=f"Summarization failed: {str(e)}")
23
 
24
 
 
2
  Summarization endpoints.
3
  """
4
  from fastapi import APIRouter, HTTPException
5
+ import httpx
6
  from app.api.v1.schemas import SummarizeRequest, SummarizeResponse
7
  from app.services.summarizer import ollama_service
8
 
 
19
  prompt=payload.prompt or "Summarize the following text concisely:",
20
  )
21
  return SummarizeResponse(**result)
22
+ except httpx.HTTPError as e:
23
+ # Upstream (Ollama) error
24
  raise HTTPException(status_code=502, detail=f"Summarization failed: {str(e)}")
25
 
26
 
app/core/errors.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Exception handlers and error response shaping.
3
+ """
4
+ from fastapi import FastAPI, Request
5
+ from fastapi.responses import JSONResponse
6
+
7
+ from app.api.v1.schemas import ErrorResponse
8
+ from app.core.logging import get_logger
9
+
10
+
11
+ logger = get_logger(__name__)
12
+
13
+
14
+ def init_exception_handlers(app: FastAPI) -> None:
15
+ @app.exception_handler(Exception)
16
+ async def unhandled_exception_handler(request: Request, exc: Exception):
17
+ request_id = getattr(request.state, "request_id", None)
18
+ logger.exception(f"Unhandled error: {exc}")
19
+ payload = ErrorResponse(
20
+ detail="Internal server error",
21
+ code="INTERNAL_ERROR",
22
+ request_id=request_id,
23
+ ).dict()
24
+ return JSONResponse(status_code=500, content=payload)
25
+
26
+
app/core/middleware.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Custom middlewares for request ID and timing/logging.
3
+ """
4
+ import time
5
+ import uuid
6
+ from typing import Callable
7
+
8
+ from fastapi import Request, Response
9
+
10
+ from app.core.logging import get_logger, RequestLogger
11
+
12
+
13
+ logger = get_logger(__name__)
14
+ request_logger = RequestLogger(logger)
15
+
16
+
17
+ async def request_context_middleware(request: Request, call_next: Callable) -> Response:
18
+ """Attach a request id and perform basic request/response logging."""
19
+ request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
20
+ request.state.request_id = request_id
21
+
22
+ start = time.time()
23
+ request_logger.log_request(request.method, request.url.path, request_id)
24
+ try:
25
+ response = await call_next(request)
26
+ except Exception as exc: # Let exception handlers format the response
27
+ request_logger.log_error(request_id, str(exc))
28
+ raise
29
+ finally:
30
+ duration_ms = (time.time() - start) * 1000
31
+ # Note: response may not exist if exception raised; guarded above
32
+ try:
33
+ status = getattr(locals().get("response", None), "status_code", 500)
34
+ request_logger.log_response(request_id, status, duration_ms)
35
+ except Exception:
36
+ pass
37
+
38
+ # propagate request id header
39
+ response.headers["X-Request-ID"] = request_id
40
+ return response
41
+
42
+
app/main.py CHANGED
@@ -7,6 +7,8 @@ from fastapi.middleware.cors import CORSMiddleware
7
  from app.core.config import settings
8
  from app.core.logging import setup_logging, get_logger
9
  from app.api.v1.routes import api_router
 
 
10
 
11
  # Set up logging
12
  setup_logging()
@@ -30,6 +32,12 @@ app.add_middleware(
30
  allow_headers=["*"],
31
  )
32
 
 
 
 
 
 
 
33
  # Include API routes
34
  app.include_router(api_router, prefix="/api/v1")
35
 
 
7
  from app.core.config import settings
8
  from app.core.logging import setup_logging, get_logger
9
  from app.api.v1.routes import api_router
10
+ from app.core.middleware import request_context_middleware
11
+ from app.core.errors import init_exception_handlers
12
 
13
  # Set up logging
14
  setup_logging()
 
32
  allow_headers=["*"],
33
  )
34
 
35
+ # Add request context middleware
36
+ app.middleware("http")(request_context_middleware)
37
+
38
+ # Initialize exception handlers
39
+ init_exception_handlers(app)
40
+
41
  # Include API routes
42
  app.include_router(api_router, prefix="/api/v1")
43
 
tests/conftest.py CHANGED
@@ -5,7 +5,7 @@ import pytest
5
  import asyncio
6
  from typing import AsyncGenerator, Generator
7
  from httpx import AsyncClient
8
- from fastapi.testclient import TestClient
9
 
10
  from app.main import app
11
 
 
5
  import asyncio
6
  from typing import AsyncGenerator, Generator
7
  from httpx import AsyncClient
8
+ from starlette.testclient import TestClient
9
 
10
  from app.main import app
11
 
tests/test_api.py CHANGED
@@ -3,7 +3,7 @@ Integration tests for API endpoints.
3
  """
4
  import pytest
5
  from unittest.mock import patch
6
- from fastapi.testclient import TestClient
7
  from app.main import app
8
 
9
  from tests.test_services import StubAsyncClient, StubAsyncResponse
 
3
  """
4
  import pytest
5
  from unittest.mock import patch
6
+ from starlette.testclient import TestClient
7
  from app.main import app
8
 
9
  from tests.test_services import StubAsyncClient, StubAsyncResponse
tests/test_api_errors.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for error handling and request id propagation.
3
+ """
4
+ import pytest
5
+ from unittest.mock import patch
6
+ from starlette.testclient import TestClient
7
+ from app.main import app
8
+
9
+ from tests.test_services import StubAsyncClient
10
+
11
+
12
+ client = TestClient(app)
13
+
14
+
15
+ @pytest.mark.integration
16
+ def test_httpx_error_returns_502():
17
+ """Test that httpx errors return 502 status."""
18
+ # This will fail to connect to Ollama, triggering httpx.HTTPError
19
+ resp = client.post("/api/v1/summarize/", json={"text": "hi"})
20
+ assert resp.status_code == 502
21
+ data = resp.json()
22
+ assert "Summarization failed" in data["detail"]
23
+
24
+
25
+ def test_request_id_header_propagated(sample_text, mock_ollama_response):
26
+ """Verify X-Request-ID appears in response headers."""
27
+ from tests.test_services import StubAsyncResponse
28
+
29
+ stub_response = StubAsyncResponse(json_data=mock_ollama_response)
30
+ with patch('httpx.AsyncClient', return_value=StubAsyncClient(post_result=stub_response)):
31
+ resp = client.post("/api/v1/summarize/", json={"text": sample_text})
32
+ assert resp.status_code == 200
33
+ assert resp.headers.get("X-Request-ID")
34
+
35
+