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.
This commit is contained in:
31
README.md
31
README.md
@@ -17,6 +17,7 @@ It combines FastAPI, React, and PostgreSQL in a Docker Compose stack with RBAC,
|
|||||||
- [Service Information](#service-information)
|
- [Service Information](#service-information)
|
||||||
- [Target Owner Notifications](#target-owner-notifications)
|
- [Target Owner Notifications](#target-owner-notifications)
|
||||||
- [API Overview](#api-overview)
|
- [API Overview](#api-overview)
|
||||||
|
- [API Error Format](#api-error-format)
|
||||||
- [`pg_stat_statements` Requirement](#pg_stat_statements-requirement)
|
- [`pg_stat_statements` Requirement](#pg_stat_statements-requirement)
|
||||||
- [Reverse Proxy / SSL Guidance](#reverse-proxy--ssl-guidance)
|
- [Reverse Proxy / SSL Guidance](#reverse-proxy--ssl-guidance)
|
||||||
- [PostgreSQL Compatibility Smoke Test](#postgresql-compatibility-smoke-test)
|
- [PostgreSQL Compatibility Smoke Test](#postgresql-compatibility-smoke-test)
|
||||||
@@ -319,6 +320,36 @@ Email alert routing is target-specific:
|
|||||||
- `GET /api/v1/service/info`
|
- `GET /api/v1/service/info`
|
||||||
- `POST /api/v1/service/info/check`
|
- `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
|
## `pg_stat_statements` Requirement
|
||||||
|
|
||||||
Query Insights requires `pg_stat_statements` on the monitored target:
|
Query Insights requires `pg_stat_statements` on the monitored target:
|
||||||
|
|||||||
31
backend/app/core/errors.py
Normal file
31
backend/app/core/errors.py
Normal file
@@ -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}")
|
||||||
|
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
from uuid import uuid4
|
||||||
from contextlib import asynccontextmanager
|
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.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from app.api.router import api_router
|
from app.api.router import api_router
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.db import SessionLocal
|
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.logging import configure_logging
|
||||||
from app.core.security import hash_password
|
from app.core.security import hash_password
|
||||||
from app.models.models import User
|
from app.models.models import User
|
||||||
@@ -57,4 +62,67 @@ app.add_middleware(
|
|||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
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)
|
app.include_router(api_router, prefix=settings.api_v1_prefix)
|
||||||
|
|||||||
@@ -35,8 +35,21 @@ export async function apiFetch(path, options = {}, tokens, onUnauthorized) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const txt = await res.text();
|
const raw = await res.text();
|
||||||
throw new Error(txt || `HTTP ${res.status}`);
|
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;
|
if (res.status === 204) return null;
|
||||||
return res.json();
|
return res.json();
|
||||||
|
|||||||
Reference in New Issue
Block a user