Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,14 @@ jobs:
pytest tests/ -v --ignore=tests/test_integration.py -k "not integration" \
--tb=short

# The API-first app has only the exception handler tests, so the
# app-wide coverage floor in its pytest.ini does not apply here.
- name: Run FastAPI API-first unit tests
run: |
cd reference-apps/fastapi-api-first
pip install -r requirements.txt
pytest tests/ -v --no-cov --tb=short

- name: Run Go unit tests
run: |
cd reference-apps/golang
Expand Down
108 changes: 48 additions & 60 deletions reference-apps/fastapi-api-first/app/middleware/exception_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
- Automatic logging of errors
- Prometheus metrics for error tracking
- Request ID correlation
- Debug mode support
- No exception text or traceback in responses; details go to the server log
"""

import logging
Expand All @@ -18,7 +18,6 @@
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException
from prometheus_client import Counter
import traceback

from app.exceptions import (
BaseAPIException,
Expand All @@ -27,34 +26,35 @@
DatabaseConnectionError,
CacheConnectionError,
MessageQueueError,
CircuitBreakerError
CircuitBreakerError,
)
from app.config import settings

logger = logging.getLogger(__name__)

# Prometheus metrics for error tracking
error_counter = Counter(
'http_errors_total',
'Total HTTP errors',
['error_type', 'status_code']
"http_errors_total", "Total HTTP errors", ["error_type", "status_code"]
)


def get_request_id(request: Request) -> str:
"""Extract request ID from request state"""
return getattr(request.state, 'request_id', 'unknown')
return getattr(request.state, "request_id", "unknown")


async def base_api_exception_handler(request: Request, exc: BaseAPIException) -> JSONResponse:
async def base_api_exception_handler(
request: Request, exc: BaseAPIException
) -> JSONResponse:
"""
Handler for all custom API exceptions.

Logs the error with request context and returns a structured JSON response.
"""
request_id = get_request_id(request)

# Log the error
# Log the error. In debug mode the traceback goes to the server log;
# it is never returned to the client.
logger.error(
f"{exc.__class__.__name__}: {exc.message}",
extra={
Expand All @@ -63,34 +63,30 @@ async def base_api_exception_handler(request: Request, exc: BaseAPIException) ->
"path": str(request.url.path),
"status_code": exc.status_code,
"error_type": exc.__class__.__name__,
"error_details": exc.details
}
"error_details": exc.details,
},
exc_info=exc if settings.DEBUG else None,
)

# Track error metric
error_counter.labels(
error_type=exc.__class__.__name__,
status_code=exc.status_code
error_type=exc.__class__.__name__, status_code=exc.status_code
).inc()

# Build response
response_data = exc.to_dict()
response_data["request_id"] = request_id

# Add stack trace in debug mode
if settings.DEBUG:
response_data["debug"] = {
"traceback": traceback.format_exc()
}

return JSONResponse(
status_code=exc.status_code,
content=response_data,
headers={"X-Request-ID": request_id}
headers={"X-Request-ID": request_id},
)


async def service_unavailable_handler(request: Request, exc: ServiceUnavailableError) -> JSONResponse:
async def service_unavailable_handler(
request: Request, exc: ServiceUnavailableError
) -> JSONResponse:
"""
Handler for service unavailable errors.

Expand All @@ -105,27 +101,30 @@ async def service_unavailable_handler(request: Request, exc: ServiceUnavailableE
"method": request.method,
"path": str(request.url.path),
"service": exc.service_name,
"error_details": exc.details
}
"error_details": exc.details,
},
)

error_counter.labels(
error_type=exc.__class__.__name__,
status_code=exc.status_code
error_type=exc.__class__.__name__, status_code=exc.status_code
).inc()

response_data = exc.to_dict()
response_data["request_id"] = request_id
response_data["retry_suggestion"] = "Please try again later or contact support if the issue persists"
response_data[
"retry_suggestion"
] = "Please try again later or contact support if the issue persists"

return JSONResponse(
status_code=exc.status_code,
content=response_data,
headers={"X-Request-ID": request_id}
headers={"X-Request-ID": request_id},
)


async def validation_error_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
async def validation_error_handler(
request: Request, exc: RequestValidationError
) -> JSONResponse:
"""
Handler for FastAPI request validation errors.

Expand All @@ -139,13 +138,12 @@ async def validation_error_handler(request: Request, exc: RequestValidationError
"request_id": request_id,
"method": request.method,
"path": str(request.url.path),
"validation_errors": exc.errors()
}
"validation_errors": exc.errors(),
},
)

error_counter.labels(
error_type="ValidationError",
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY
error_type="ValidationError", status_code=status.HTTP_422_UNPROCESSABLE_ENTITY
).inc()

return JSONResponse(
Expand All @@ -154,16 +152,16 @@ async def validation_error_handler(request: Request, exc: RequestValidationError
"error": "ValidationError",
"message": "Request validation failed",
"status_code": status.HTTP_422_UNPROCESSABLE_ENTITY,
"details": {
"validation_errors": exc.errors()
},
"request_id": request_id
"details": {"validation_errors": exc.errors()},
"request_id": request_id,
},
headers={"X-Request-ID": request_id}
headers={"X-Request-ID": request_id},
)


async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
async def http_exception_handler(
request: Request, exc: StarletteHTTPException
) -> JSONResponse:
"""
Handler for standard HTTP exceptions.

Expand All @@ -177,24 +175,21 @@ async def http_exception_handler(request: Request, exc: StarletteHTTPException)
"request_id": request_id,
"method": request.method,
"path": str(request.url.path),
"status_code": exc.status_code
}
"status_code": exc.status_code,
},
)

error_counter.labels(
error_type="HTTPException",
status_code=exc.status_code
).inc()
error_counter.labels(error_type="HTTPException", status_code=exc.status_code).inc()

return JSONResponse(
status_code=exc.status_code,
content={
"error": "HTTPException",
"message": exc.detail,
"status_code": exc.status_code,
"request_id": request_id
"request_id": request_id,
},
headers={"X-Request-ID": request_id}
headers={"X-Request-ID": request_id},
)


Expand All @@ -207,42 +202,35 @@ async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONR
"""
request_id = get_request_id(request)

# Log with full traceback
# Log with full traceback. The client gets only a generic message and
# the request ID to correlate with this log entry.
logger.error(
f"Unhandled exception: {exc}",
extra={
"request_id": request_id,
"method": request.method,
"path": str(request.url.path),
"exception_type": exc.__class__.__name__
"exception_type": exc.__class__.__name__,
},
exc_info=True
exc_info=True,
)

error_counter.labels(
error_type=exc.__class__.__name__,
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
).inc()

response_data = {
"error": "InternalServerError",
"message": "An unexpected error occurred",
"status_code": status.HTTP_500_INTERNAL_SERVER_ERROR,
"request_id": request_id
"request_id": request_id,
}

# Include exception details in debug mode
if settings.DEBUG:
response_data["debug"] = {
"exception_type": exc.__class__.__name__,
"exception_message": str(exc),
"traceback": traceback.format_exc()
}

return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content=response_data,
headers={"X-Request-ID": request_id}
headers={"X-Request-ID": request_id},
)


Expand Down
Loading
Loading