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();