# 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": , "code": }`` — 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": }``. """ 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)