Skip to content
Open
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
81 changes: 73 additions & 8 deletions src/core/app/error_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

# type: ignore[unreachable]
import logging
from collections.abc import Mapping
from typing import Any

from fastapi import FastAPI, Request
Expand Down Expand Up @@ -60,6 +61,62 @@ async def validation_exception_handler(
)


def _normalize_http_exception_detail(
detail: Any,
) -> tuple[str, str, Any | None]:
"""Extract a stable message/type/details triple from HTTPException detail."""

default_type = "HttpError"
if isinstance(detail, Mapping):
# Handle FastAPI-style payloads where the detail already contains an
# ``error`` structure with message/type/details fields.
nested_error = detail.get("error")
if isinstance(nested_error, Mapping):
message = nested_error.get("message")
error_type = nested_error.get("type", default_type)
details = nested_error.get("details")
extras = {k: v for k, v in detail.items() if k != "error"}
if extras:
if details is None:
details = extras
elif isinstance(details, Mapping):
details = {**extras, **details}
else:
details = {"value": details, "extras": extras}
if message is None:
message = str({k: v for k, v in detail.items() if k != "error"})
return str(message), str(error_type), details

# Generic mapping: look for common keys first.
message = detail.get("message")
error_type = detail.get("type", default_type)
details = detail.get("details")

# Preserve any remaining fields as part of the details payload so that
# callers do not lose structured context.
extras = {
key: value
for key, value in detail.items()
if key not in {"message", "type", "details"}
}
if extras:
if details is None:
details = extras
elif isinstance(details, Mapping):
# Merge extras with details when both are mapping-like.
details = {**extras, **details}
else:
details = {"value": details, "extras": extras}

if message is None:
message = str({k: v for k, v in detail.items() if k != "details"})

return str(message), str(error_type), details

# Fallback: treat the detail as a simple string payload.
return str(detail), default_type, None


async def http_exception_handler(request: Request, exc: HTTPException) -> Response:
"""Handle FastAPI HTTP exceptions.

Expand All @@ -78,6 +135,8 @@ async def http_exception_handler(request: Request, exc: HTTPException) -> Respon
if request.url.path.endswith("/chat/completions"):
is_chat_completions = True

message, error_type, extra_details = _normalize_http_exception_detail(exc.detail)

if is_chat_completions:
# Return OpenAI-compatible error response with choices for chat completions
import time
Expand All @@ -92,27 +151,33 @@ async def http_exception_handler(request: Request, exc: HTTPException) -> Respon
"index": 0,
"message": {
"role": "assistant",
"content": f"Error: {exc.detail!s}",
"content": f"Error: {message}",
},
"finish_reason": "error",
}
],
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
"error": {
"message": str(exc.detail),
"type": "HttpError",
"message": message,
"type": error_type,
"status_code": exc.status_code,
},
}
if extra_details is not None:
content["error"]["details"] = extra_details
else:
# Standard error response for non-chat completions endpoints
error_payload: dict[str, Any] = {
"message": message,
"type": error_type,
"status_code": exc.status_code,
}
if extra_details is not None:
error_payload["details"] = extra_details

content = {
"detail": {
"error": {
"message": str(exc.detail),
"type": "HttpError",
"status_code": exc.status_code,
}
"error": error_payload,
}
}

Expand Down
98 changes: 98 additions & 0 deletions tests/unit/core/app/test_error_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,37 @@ def test_http_exception_handler_standard_response(
}


def test_http_exception_handler_includes_details_from_mapping(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("time.time", lambda: 1700000000)
request = make_request("/v1/models")
exc = HTTPException(
status_code=422,
detail={
"message": "Invalid payload",
"type": "ValidationError",
"details": {"field": "prompt"},
"hint": "Provide prompt text",
},
)

response = call_handler(http_exception_handler, request, exc)

assert response.status_code == 422
payload = parse_json_response(response)
assert payload == {
"detail": {
"error": {
"message": "Invalid payload",
"type": "ValidationError",
"status_code": 422,
"details": {"hint": "Provide prompt text", "field": "prompt"},
}
}
}


def test_http_exception_handler_chat_completions(
monkeypatch: pytest.MonkeyPatch,
) -> None:
Expand All @@ -159,6 +190,73 @@ def test_http_exception_handler_chat_completions(
}


def test_http_exception_handler_chat_completions_with_structured_detail(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("time.time", lambda: 1700000000)
request = make_request("/v1/chat/completions")
exc = HTTPException(
status_code=503,
detail={
"error": {
"message": "Upstream unavailable",
"type": "UpstreamError",
"details": {"backend": "alpha"},
}
},
)

response = call_handler(http_exception_handler, request, exc)

assert response.status_code == 503
payload = parse_json_response(response)
assert payload["choices"][0]["message"]["content"] == "Error: Upstream unavailable"
assert payload["error"] == {
"message": "Upstream unavailable",
"type": "UpstreamError",
"status_code": 503,
"details": {"backend": "alpha"},
}


def test_http_exception_handler_preserves_outer_metadata_from_error_mapping(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("time.time", lambda: 1700000000)
request = make_request("/v1/models")
exc = HTTPException(
status_code=500,
detail={
"error": {
"message": "Database failure",
"type": "BackendError",
"details": {"retries": 2},
},
"trace_id": "abc123",
"correlation": {"request": "req-1"},
},
)

response = call_handler(http_exception_handler, request, exc)

assert response.status_code == 500
payload = parse_json_response(response)
assert payload == {
"detail": {
"error": {
"message": "Database failure",
"type": "BackendError",
"status_code": 500,
"details": {
"trace_id": "abc123",
"correlation": {"request": "req-1"},
"retries": 2,
},
}
}
}


def test_http_exception_handler_logs_warning(caplog: pytest.LogCaptureFixture) -> None:
request = make_request("/v1/models")
exc = HTTPException(status_code=400, detail="Missing field")
Expand Down
Loading