NX-10x: Reliability, error handling, runtime UX hardening, and migration safety gate (NX-101, NX-102, NX-103, NX-104) #32
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)
|
||||
- [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:
|
||||
|
||||
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 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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user