github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
5.41 kB
# Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
# you can obtain one at http://mozilla.org/MPL/2.0/.
# SPDX-License-Identifier: MPL-2.0
"""Unified API error contract (D2, 2026-07).
Before this module the backend collapsed every failure into ``HTTP 400``
with a bare ``{detail: str(exception)}`` — five different client-visible
shapes across the surface, absolute server paths leaking through
``str(e)``, and no machine-readable discriminator for the one error the
frontend actually branches on (the post-reload
``action-variant-diagram`` 400 that triggers a live re-simulation).
This introduces ONE error envelope — ``{"detail": <human string>,
"code": <STABLE_SLUG>}`` — produced in ONE place:
- Every ``HTTPException`` (raised anywhere) is rendered with a ``code``.
Callers that care about the discriminator raise :class:`AppHTTPException`
with an explicit ``code``; everything else gets a code derived from the
status via :data:`_DEFAULT_CODE_BY_STATUS`. ``detail`` is unchanged, so
existing clients / tests that read ``response.json()["detail"]`` keep
working — ``code`` is purely additive.
- Any UNCAUGHT exception is turned into a clean ``500`` with a GENERIC
detail (no ``str(e)`` path leak) and ``code="INTERNAL"``, logged
server-side via ``logger.exception``.
Register both handlers once with :func:`install_error_handlers(app)`.
Stable codes (the wire contract — do not rename without a frontend +
`openapi.snapshot.json` update):
"""
from __future__ import annotations
import logging
from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
logger = logging.getLogger(__name__)
# --- Stable error codes (wire contract) ---
CODE_BAD_REQUEST = "BAD_REQUEST"
CODE_NOT_FOUND = "NOT_FOUND"
CODE_STUDY_BUSY = "STUDY_BUSY"
CODE_VALIDATION = "VALIDATION"
CODE_INTERNAL = "INTERNAL"
# The frontend branches on THIS one: an expected post-reload / manual-action
# condition where the backend has no cached post-action observation, so the
# client falls back to /api/simulate-and-variant-diagram (a live simulation).
CODE_ACTION_RESULT_UNAVAILABLE = "ACTION_RESULT_UNAVAILABLE"
# A filesystem-touching RPC that is disabled on a locked-down (hosted,
# non-local) deployment — see the D7 lockdown profile in main.py.
CODE_LOCKED_DOWN = "LOCKED_DOWN"
_DEFAULT_CODE_BY_STATUS = {
400: CODE_BAD_REQUEST,
403: CODE_LOCKED_DOWN,
404: CODE_NOT_FOUND,
409: CODE_STUDY_BUSY,
422: CODE_VALIDATION,
500: CODE_INTERNAL,
}
class AppHTTPException(HTTPException):
"""An ``HTTPException`` carrying an explicit stable error ``code``.
Use when a client branches on the specific failure (rather than just
the status). Renders as ``{"detail": ..., "code": <code>}``.
"""
def __init__(self, status_code: int, detail: str, code: str):
super().__init__(status_code=status_code, detail=detail)
self.code = code
def _code_for(exc: HTTPException) -> str:
explicit = getattr(exc, "code", None)
if explicit:
return str(explicit)
return _DEFAULT_CODE_BY_STATUS.get(exc.status_code, CODE_BAD_REQUEST)
async def _http_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Render every HTTPException as ``{detail, code}`` (code additive).
Typed as ``Exception`` to match Starlette's ``add_exception_handler``
signature; the ``isinstance`` guard narrows it (this handler is only
ever dispatched for ``HTTPException``).
"""
if not isinstance(exc, HTTPException): # pragma: no cover - dispatch invariant
raise exc
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail, "code": _code_for(exc)},
headers=getattr(exc, "headers", None),
)
async def _validation_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""FastAPI request-validation errors keep their rich ``detail`` list
but gain the uniform ``code`` so the frontend extractor is universal."""
if not isinstance(exc, RequestValidationError): # pragma: no cover - dispatch invariant
raise exc
return JSONResponse(
status_code=422,
content={"detail": exc.errors(), "code": CODE_VALIDATION},
)
async def _unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Any uncaught exception → clean 500 with a GENERIC message.
Never echoes ``str(exc)`` to the client — that leaked absolute server
paths (QW6 / the security review). The full traceback is logged
server-side instead.
"""
logger.exception("Unhandled error on %s %s", request.method, request.url.path)
return JSONResponse(
status_code=500,
content={"detail": "Internal server error.", "code": CODE_INTERNAL},
)
def install_error_handlers(app: FastAPI) -> None:
"""Register the unified error handlers on ``app`` (call once at import)."""
app.add_exception_handler(HTTPException, _http_exception_handler)
app.add_exception_handler(RequestValidationError, _validation_exception_handler)
app.add_exception_handler(Exception, _unhandled_exception_handler)