Align API error envelopes
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-06-18 02:04:31 +02:00
parent 694913a280
commit 7adcdd76c7
8 changed files with 160 additions and 16 deletions
+42 -10
View File
@@ -11,13 +11,17 @@ from app.core.errors import AppError
from app.core.logging import configure_logging
def _to_error_payload(code: str, message: str, details: dict | list | None = None) -> dict:
def _to_error_payload(
code: str,
message: str,
details: dict | list | None = None,
request_id: str | None = None,
) -> dict:
return {
"error": {
"code": code,
"message": message,
"details": details or {},
},
"error": code,
"message": message,
"details": details or {},
"request_id": request_id,
}
@@ -58,28 +62,56 @@ def create_app() -> FastAPI:
async def app_error(request: Request, exc: AppError): # noqa: ARG001
return JSONResponse(
status_code=exc.status_code,
content=_to_error_payload(exc.code, exc.message, exc.details),
content=_to_error_payload(
exc.code,
exc.message,
exc.details,
request_id=request.headers.get("x-request-id"),
),
)
@app.exception_handler(HTTPException)
async def http_error(request: Request, exc: HTTPException): # noqa: ARG001
code = "HTTP_ERROR"
message = str(exc.detail)
details = {}
if isinstance(exc.detail, dict):
code = str(exc.detail.get("error") or exc.detail.get("code") or code)
message = str(exc.detail.get("message") or message)
raw_details = exc.detail.get("details")
details = raw_details if isinstance(raw_details, (dict, list)) else {}
return JSONResponse(
status_code=exc.status_code,
content=_to_error_payload("HTTP_ERROR", str(exc.detail), {}),
content=_to_error_payload(
code,
message,
details,
request_id=request.headers.get("x-request-id"),
),
)
@app.exception_handler(RequestValidationError)
async def validation_error(request: Request, exc: RequestValidationError): # noqa: ARG001
return JSONResponse(
status_code=422,
content=_to_error_payload("VALIDATION_ERROR", "Validation failed", exc.errors()),
content=_to_error_payload(
"VALIDATION_ERROR",
"Validation failed",
exc.errors(),
request_id=request.headers.get("x-request-id"),
),
)
@app.exception_handler(Exception)
async def unexpected_error(request: Request, exc: Exception): # noqa: ARG001
return JSONResponse(
status_code=500,
content=_to_error_payload("INTERNAL_ERROR", "Unexpected server error", {"type": exc.__class__.__name__}),
content=_to_error_payload(
"INTERNAL_ERROR",
"Unexpected server error",
{"type": exc.__class__.__name__},
request_id=request.headers.get("x-request-id"),
),
)
return app
@@ -0,0 +1,49 @@
from __future__ import annotations
from fastapi import HTTPException
from fastapi.testclient import TestClient
from app.main import app, create_app
def assert_contract_error(payload: dict, code: str) -> None:
assert payload["error"] == code
assert isinstance(payload["message"], str)
assert "details" in payload
assert "request_id" in payload
def test_app_error_uses_top_level_api_error_contract() -> None:
client = TestClient(app)
response = client.get("/api/v1/external/providers/unknown")
assert response.status_code == 404
assert_contract_error(response.json(), "PROVIDER_NOT_FOUND")
assert response.json()["message"] == "Provider not found"
def test_http_exception_uses_top_level_api_error_contract() -> None:
test_app = create_app()
@test_app.get("/__test__/http-error")
def raise_http_error() -> None:
raise HTTPException(status_code=404, detail="Project not found")
client = TestClient(test_app)
response = client.get("/__test__/http-error")
assert response.status_code == 404
assert_contract_error(response.json(), "HTTP_ERROR")
assert response.json()["message"] == "Project not found"
def test_validation_error_uses_top_level_api_error_contract() -> None:
client = TestClient(app)
response = client.get("/api/v1/projects/not-a-uuid")
assert response.status_code == 422
assert_contract_error(response.json(), "VALIDATION_ERROR")
assert response.json()["message"] == "Validation failed"
assert isinstance(response.json()["details"], list)
@@ -0,0 +1,22 @@
from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def test_frontend_api_client_accepts_top_level_error_contract() -> None:
client = (ROOT / "frontend" / "src" / "services" / "api" / "client.ts").read_text(encoding="utf-8")
assert 'typeof payload?.error === "string"' in client
assert "payload?.message" in client
assert "payload?.details" in client
def test_frontend_api_client_remains_legacy_error_tolerant() -> None:
client = (ROOT / "frontend" / "src" / "services" / "api" / "client.ts").read_text(encoding="utf-8")
assert "legacyError?.code" in client
assert "legacyError?.message" in client
assert "legacyError?.details" in client
@@ -103,7 +103,8 @@ def test_provider_api_envelopes_and_invalid_provider() -> None:
invalid_response = client.get("/api/v1/external/providers/unknown")
assert invalid_response.status_code == 404
assert invalid_response.json()["error"]["code"] == "PROVIDER_NOT_FOUND"
assert invalid_response.json()["error"] == "PROVIDER_NOT_FOUND"
assert invalid_response.json()["message"] == "Provider not found"
def test_provider_import_api_returns_clear_not_configured_response() -> None: