Typed pydantic-settings config, structlog JSON logging with request/trace id propagation and redaction of phone numbers, tokens and face keys, the unified response envelope and error-code table from 架构文档 §9.2, async SQLAlchemy session management, ORM models for the eight foundation tables, and the OpenTelemetry/Prometheus wiring. packages/domain stays pure logic -- snowflake ids, the quota three-phase protocol and the tk_task state machine -- so it can be tested without a database and is held to mypy strict.
70 lines
2.6 KiB
Python
Executable File
70 lines
2.6 KiB
Python
Executable File
"""FastAPI exception handlers producing the unified envelope (架构文档 §9.2).
|
|
|
|
No handler is allowed to leak a stack trace to the client (FR-SYS-02).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.exceptions import RequestValidationError
|
|
from fastapi.responses import JSONResponse
|
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
|
|
from packages.shared.errors import ERROR_META, AppError, ErrorCode
|
|
from packages.shared.logging import get_logger
|
|
from packages.shared.response import fail
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
_HTTP_STATUS_TO_CODE: dict[int, ErrorCode] = {
|
|
400: ErrorCode.BAD_REQUEST,
|
|
401: ErrorCode.UNAUTHENTICATED,
|
|
403: ErrorCode.FORBIDDEN,
|
|
404: ErrorCode.NOT_FOUND,
|
|
405: ErrorCode.BAD_REQUEST,
|
|
409: ErrorCode.STATE_CONFLICT,
|
|
422: ErrorCode.VALIDATION_FAILED,
|
|
429: ErrorCode.RATE_LIMITED,
|
|
501: ErrorCode.NOT_IMPLEMENTED,
|
|
502: ErrorCode.UPSTREAM_ERROR,
|
|
503: ErrorCode.UPSTREAM_ERROR,
|
|
}
|
|
|
|
|
|
def _json(status: int, body: dict[str, Any]) -> JSONResponse:
|
|
return JSONResponse(status_code=status, content=body)
|
|
|
|
|
|
def install_exception_handlers(app: FastAPI) -> None:
|
|
@app.exception_handler(AppError)
|
|
async def _app_error(_request: Request, exc: AppError) -> JSONResponse:
|
|
logger.warning("app_error", code=int(exc.code), error_message=exc.message)
|
|
return _json(
|
|
exc.http_status,
|
|
fail(exc.code, message=exc.message, hint=exc.hint, data=exc.data or None),
|
|
)
|
|
|
|
@app.exception_handler(RequestValidationError)
|
|
async def _validation_error(_request: Request, exc: RequestValidationError) -> JSONResponse:
|
|
details = [
|
|
{"field": ".".join(str(p) for p in err.get("loc", ())), "reason": err.get("msg", "")}
|
|
for err in exc.errors()
|
|
]
|
|
return _json(
|
|
422,
|
|
fail(ErrorCode.VALIDATION_FAILED, data={"errors": details}),
|
|
)
|
|
|
|
@app.exception_handler(StarletteHTTPException)
|
|
async def _http_error(_request: Request, exc: StarletteHTTPException) -> JSONResponse:
|
|
code = _HTTP_STATUS_TO_CODE.get(exc.status_code, ErrorCode.INTERNAL_ERROR)
|
|
message = exc.detail if isinstance(exc.detail, str) else ERROR_META[code].message
|
|
return _json(exc.status_code, fail(code, message=message))
|
|
|
|
@app.exception_handler(Exception)
|
|
async def _unhandled(_request: Request, exc: Exception) -> JSONResponse:
|
|
logger.exception("unhandled_error", error_type=type(exc).__name__)
|
|
return _json(500, fail(ErrorCode.INTERNAL_ERROR))
|