From 9aecbea68b0bc537f50a4e4283fcfcffece51a0a Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 13 Feb 2026 17:30:05 +0100 Subject: [PATCH] Add consistent API error handling and documentation Introduced standardized error response formats for API errors, including middleware for consistent request IDs and exception handlers. Updated the frontend to parse and process these error responses, and documented the error format in the README for reference. --- README.md | 31 +++++++++++++++++ backend/app/core/errors.py | 31 +++++++++++++++++ backend/app/main.py | 70 +++++++++++++++++++++++++++++++++++++- frontend/src/api.js | 17 +++++++-- 4 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 backend/app/core/errors.py diff --git a/README.md b/README.md index 6b2d6e5..1138054 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ It combines FastAPI, React, and PostgreSQL in a Docker Compose stack with RBAC, - [Service Information](#service-information) - [Target Owner Notifications](#target-owner-notifications) - [API Overview](#api-overview) +- [API Error Format](#api-error-format) - [`pg_stat_statements` Requirement](#pg_stat_statements-requirement) - [Reverse Proxy / SSL Guidance](#reverse-proxy--ssl-guidance) - [PostgreSQL Compatibility Smoke Test](#postgresql-compatibility-smoke-test) @@ -319,6 +320,36 @@ Email alert routing is target-specific: - `GET /api/v1/service/info` - `POST /api/v1/service/info/check` +## API Error Format + +All 4xx/5xx responses use a consistent JSON payload: + +```json +{ + "code": "validation_error", + "message": "Request validation failed", + "details": [], + "request_id": "c8f0f888-2365-4b86-a5de-b3f0e9df4a4b" +} +``` + +Common fields: + +- `code`: stable machine-readable error code +- `message`: human-readable summary +- `details`: optional extra context (validation list, debug context, etc.) +- `request_id`: request correlation ID (also returned in `X-Request-ID` header) + +Common error codes: + +- `bad_request` (`400`) +- `unauthorized` (`401`) +- `forbidden` (`403`) +- `not_found` (`404`) +- `conflict` (`409`) +- `validation_error` (`422`) +- `internal_error` (`500`) + ## `pg_stat_statements` Requirement Query Insights requires `pg_stat_statements` on the monitored target: diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py new file mode 100644 index 0000000..a4c1f3c --- /dev/null +++ b/backend/app/core/errors.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import Any + + +def error_payload(code: str, message: str, details: Any, request_id: str) -> dict[str, Any]: + return { + "code": code, + "message": message, + "details": details, + "request_id": request_id, + } + + +def http_status_to_code(status_code: int) -> str: + mapping = { + 400: "bad_request", + 401: "unauthorized", + 403: "forbidden", + 404: "not_found", + 405: "method_not_allowed", + 409: "conflict", + 422: "validation_error", + 429: "rate_limited", + 500: "internal_error", + 502: "bad_gateway", + 503: "service_unavailable", + 504: "gateway_timeout", + } + return mapping.get(status_code, f"http_{status_code}") + diff --git a/backend/app/main.py b/backend/app/main.py index d1420ed..a70b02c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,12 +1,17 @@ import asyncio import logging +from uuid import uuid4 from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException, Request +from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from starlette.exceptions import HTTPException as StarletteHTTPException from sqlalchemy import select from app.api.router import api_router from app.core.config import get_settings from app.core.db import SessionLocal +from app.core.errors import error_payload, http_status_to_code from app.core.logging import configure_logging from app.core.security import hash_password from app.models.models import User @@ -57,4 +62,67 @@ app.add_middleware( allow_methods=["*"], allow_headers=["*"], ) + + +@app.middleware("http") +async def request_id_middleware(request: Request, call_next): + request_id = request.headers.get("x-request-id") or str(uuid4()) + request.state.request_id = request_id + response = await call_next(request) + response.headers["X-Request-ID"] = request_id + return response + + +@app.exception_handler(HTTPException) +@app.exception_handler(StarletteHTTPException) +async def http_exception_handler(request: Request, exc: HTTPException | StarletteHTTPException): + request_id = getattr(request.state, "request_id", str(uuid4())) + code = http_status_to_code(exc.status_code) + message = "Request failed" + details = None + + if isinstance(exc.detail, str): + message = exc.detail + elif isinstance(exc.detail, dict): + code = str(exc.detail.get("code", code)) + message = str(exc.detail.get("message", message)) + details = exc.detail.get("details") + elif isinstance(exc.detail, list): + message = "Request validation failed" + details = exc.detail + + return JSONResponse( + status_code=exc.status_code, + content=error_payload(code=code, message=message, details=details, request_id=request_id), + ) + + +@app.exception_handler(RequestValidationError) +async def request_validation_exception_handler(request: Request, exc: RequestValidationError): + request_id = getattr(request.state, "request_id", str(uuid4())) + return JSONResponse( + status_code=422, + content=error_payload( + code="validation_error", + message="Request validation failed", + details=exc.errors(), + request_id=request_id, + ), + ) + + +@app.exception_handler(Exception) +async def unhandled_exception_handler(request: Request, exc: Exception): + request_id = getattr(request.state, "request_id", str(uuid4())) + logger.exception("unhandled_exception request_id=%s", request_id, exc_info=exc) + return JSONResponse( + status_code=500, + content=error_payload( + code="internal_error", + message="Internal server error", + details=None, + request_id=request_id, + ), + ) + app.include_router(api_router, prefix=settings.api_v1_prefix) diff --git a/frontend/src/api.js b/frontend/src/api.js index 5ee9bfb..6a1ca4d 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -35,8 +35,21 @@ export async function apiFetch(path, options = {}, tokens, onUnauthorized) { } } if (!res.ok) { - const txt = await res.text(); - throw new Error(txt || `HTTP ${res.status}`); + const raw = await res.text(); + let parsed = null; + try { + parsed = raw ? JSON.parse(raw) : null; + } catch { + parsed = null; + } + + const message = parsed?.message || raw || `HTTP ${res.status}`; + const err = new Error(message); + err.status = res.status; + err.code = parsed?.code || null; + err.details = parsed?.details || null; + err.requestId = parsed?.request_id || res.headers.get("x-request-id") || null; + throw err; } if (res.status === 204) return null; return res.json();