Files
xsl 12583e9739 feat(shared): cross-cutting plumbing and pure domain layer
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.
2026-08-14 00:26:39 +08:00

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