Spaces:
Running
Running
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 +3 -1
- app/core/errors.py +26 -0
- app/core/middleware.py +42 -0
- app/main.py +8 -0
- tests/conftest.py +1 -1
- tests/test_api.py +1 -1
- tests/test_api_errors.py +35 -0
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
|
|
|
|
| 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
|
| 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
|
| 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 |
+
|