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)
|
- [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