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.
This commit is contained in:
Executable
+69
@@ -0,0 +1,69 @@
|
||||
"""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))
|
||||
Reference in New Issue
Block a user