feat: 新增全链路追踪、优雅关停支持与统一异常处理,优化渠道查询接口

1. 新增TraceId中间件实现全链路请求追踪
2. 新增在途请求计数器与中间件支持优雅关停
3. 重构全局异常处理器,统一三模块异常响应格式
4. 优化渠道查询接口,新增多维度过滤与模糊匹配
5. 重构channels模块数据库会话管理,避免事务冲突
6. 新增目录搜索建议与目录导出功能
This commit is contained in:
Kris 2026-07-07 16:25:47 +08:00
parent e6e24a91de
commit 12ec5af0de
10 changed files with 595 additions and 85 deletions

View File

@ -16,17 +16,26 @@ from collections import defaultdict, deque
import uvicorn
from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from yuxi.external_systems.exceptions import ExternalSystemError
from yuxi.scheduler.exceptions import SchedulerError
from yuxi.channels.contract.errors import Error as ChannelError
from server.routers import router
from server.utils.error_handlers import (
unified_error_handler,
unhandled_exception_handler,
request_validation_error_handler,
)
from server.utils.lifespan import lifespan
from server.utils.auth_middleware import is_public_path
from server.utils.common_utils import setup_logging
from server.utils.access_log_middleware import AccessLogMiddleware
from server.utils.trace_middleware import TraceIdMiddleware
from server.utils.inflight_tracker import InflightTrackingMiddleware
# 设置日志配置
setup_logging()
@ -44,47 +53,12 @@ app = FastAPI(lifespan=lifespan)
app.include_router(router, prefix="/api")
@app.exception_handler(ExternalSystemError)
async def external_system_error_handler(_request: Request, exc: ExternalSystemError) -> JSONResponse:
"""外部系统限界上下文异常统一映射为 HTTP 响应。
- ``status_code`` 来自异常类
- ``details`` 透传到响应体供前端结构化处理
- 429/503 ``details`` ``retry_after`` 自动设置 ``Retry-After`` 响应头
"""
headers: dict[str, str] = {}
if exc.status_code in (429, 503):
retry_after = exc.details.get("retry_after")
if retry_after is not None:
headers["Retry-After"] = str(retry_after)
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.message, "details": exc.details},
headers=headers,
)
@app.exception_handler(SchedulerError)
async def scheduler_error_handler(_request: Request, exc: SchedulerError) -> JSONResponse:
"""定时任务调度限界上下文异常统一映射为 HTTP 响应。
- ``status_code`` 来自异常类400 / 404 / 409 / 413 / 500
- ``details`` 透传到响应体供前端结构化处理
"""
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.message, "details": exc.details},
)
# CORS 设置
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 三模块异常统一注册到同一个 handler
app.add_exception_handler(ChannelError, unified_error_handler)
app.add_exception_handler(ExternalSystemError, unified_error_handler)
app.add_exception_handler(SchedulerError, unified_error_handler)
app.add_exception_handler(RequestValidationError, request_validation_error_handler)
app.add_exception_handler(Exception, unhandled_exception_handler)
def _extract_client_ip(request: Request) -> str:
@ -115,7 +89,15 @@ class LoginRateLimitMiddleware(BaseHTTPMiddleware):
retry_after = int(max(1, RATE_LIMIT_WINDOW_SECONDS - (now - attempt_history[0])))
return JSONResponse(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
content={"detail": "登录尝试过于频繁,请稍后再试"},
content={
"success": False,
"error": {
"code": "RATE_LIMIT",
"message": "登录尝试过于频繁,请稍后再试",
"trace_id": None,
"details": {"retry_after": retry_after},
},
},
headers={"Retry-After": str(retry_after)},
)
@ -165,12 +147,20 @@ class AuthMiddleware(BaseHTTPMiddleware):
return await call_next(request)
# 添加访问日志中间件(记录请求处理时间)
app.add_middleware(AccessLogMiddleware)
# 添加鉴权中间件
# 中间件注册顺序为 LIFO后注册先执行
# CORSMiddleware 最内层InflightTrackingMiddleware 最外层最先执行
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(LoginRateLimitMiddleware)
app.add_middleware(AuthMiddleware)
app.add_middleware(AccessLogMiddleware)
app.add_middleware(TraceIdMiddleware)
app.add_middleware(InflightTrackingMiddleware)
if __name__ == "__main__":
# uvicorn.run(app, host="0.0.0.0", port=5050, threads=10, workers=10, reload=True)

View File

@ -16,7 +16,6 @@ from enum import Enum
from typing import Any, Literal
from fastapi import APIRouter, Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.channels.contract.dtos.common import Operator, OperatorRole
from yuxi.channels.contract.dtos.control import ControlResult
from yuxi.channels.contract.errors import (
@ -51,13 +50,12 @@ from yuxi.channels.infrastructure.channel_use_cases import (
ChannelUseCases,
create_channel_use_cases,
)
from yuxi.storage.postgres.manager import pg_manager
from yuxi.storage.postgres.models_business import User
from yuxi.utils.crypto import SENSITIVE_HTTP_HEADERS
from yuxi.utils.datetime_utils import coerce_any_to_utc_datetime
from yuxi.utils.trace_context import get_trace_id
from server.utils.auth_middleware import get_db
# 时间粒度查询参数类型analytics / dashboard / pairing / content_review / outbox 共享)
Granularity = Literal["hour", "day", "week"]
@ -86,23 +84,29 @@ async def get_channel_di_container(request: Request) -> DependencyInjectionConta
async def get_channel_use_cases(
db: AsyncSession = Depends(get_db),
di_container: DependencyInjectionContainer = Depends(get_channel_di_container),
) -> ChannelUseCases:
"""装配 channels 控制面 UseCase 集合
"""装配 channels 控制面 UseCase 集合(请求级独立 session
将请求级 DB 会话与 DI 容器传入 ``create_channel_use_cases``构造本次
请求可用的 ``ChannelUseCases``UseCase 内部端口方法在控制面管道事务中
执行
channels 模块 **不复用** 鉴权依赖``get_db``持有的 ``AsyncSession``
而是通过 ``pg_manager.get_async_session_context()`` 开启独立的请求级
会话原因鉴权阶段执行的 ``SELECT`` 会触发 SQLAlchemy 2.0 autobegin
语义开启隐式事务 channels 复用同一 session控制面
``TransactionPort.begin()`` 会在已存在的事务上再次 ``begin()``
抛出 ``InvalidRequestError: A transaction is already begun``
独立 session 使事务边界由 channels 应用层独占控制与鉴权会话隔离
避免事务状态互相污染会话生命周期限制在本次请求内请求结束自动
commit无异常 rollback有异常后关闭
Args:
db: 请求级异步 DB 会话
di_container: channels 模块 DI 容器
Returns:
Yields:
已装配的 ``ChannelUseCases`` ``session_management`` 等端口字段
"""
return create_channel_use_cases(db, di_container)
async with pg_manager.get_async_session_context() as db:
yield create_channel_use_cases(db, di_container)
def build_operator(user: User, request: Request) -> Operator:

View File

@ -1,38 +1,51 @@
"""目录查询域 RouterFR-14DIR-QUERY-01~04 + DIR-01 + DIR-CACHE-CLEAR)。
"""目录查询域 RouterFR-14DIR-QUERY-01~04 + DIR-01 + DIR-CACHE-CLEAR + Task 5)。
router 实现渠道通讯录目录查询的全部 HTTP 端点覆盖用户搜索 / 群组搜索 /
用户资料 / 群组详情 / 群组成员 / 缓存清理共 6 个操作所有端点统一采用模板 A
控制面端口路由通过 ``get_channel_use_cases`` 装配 ``ChannelUseCases``
``directory`` 端口调用类型化方法 ``ChannelControlService._executeControl``
内部走控制面管道auth permission rate_limit dispatch audit
用户资料 / 群组详情 / 群组成员 / 缓存清理 / 目录导出共 7 个操作除导出端点
返回 ``StreamingResponse`` 文件流外其余端点统一采用模板 A控制面端口路由
通过 ``get_channel_use_cases`` 装配 ``ChannelUseCases`` ``directory`` 端口
调用类型化方法 ``ChannelControlService._executeControl`` 内部走控制面管道
auth permission rate_limit dispatch audit导出端点同样经控制面
管道执行handler 返回内容字符串router 包装为文件流以保持审计链路
鉴权策略全部端点使用 ``get_admin_user`` 依赖要求管理员或超级管理员角色
角色校验由依赖函数完成router 内不做角色判断规范 §4
路径设计静态后缀路径``/search``先于动态单段路径``/{peer_id}`` /
``/{group_id}``声明动态单段先于多段子资源路径``/{group_id}/members``
声明规范 §6.5避免静态路径被动态参数捕获 router 不自行设置 prefix
根前缀 ``/channels`` ``channels_router`` 聚合 router 统一追加
路径设计静态后缀路径``/search`` / ``/export``先于动态单段路径
``/{peer_id}`` / ``/{group_id}``声明动态单段先于多段子资源路径
``/{group_id}/members``声明规范 §6.5避免静态路径被动态参数捕获
router 不自行设置 prefix根前缀 ``/channels`` ``channels_router`` 聚合
router 统一追加
端点清单对应05-目录查询域设计方案 v1.1§2.3
- GET /{channel_type}/{account_id}/directory/users/search DIR-QUERY-01 search_directory_users
- GET /{channel_type}/{account_id}/directory/groups/search DIR-QUERY-02 search_directory_groups
- GET /{channel_type}/{account_id}/directory/users/{peer_id} DIR-QUERY-03 get_directory_user_profile
- GET /{channel_type}/{account_id}/directory/groups/{group_id} DIR-01 get_directory_group_detail
- GET /{channel_type}/{account_id}/directory/groups/{group_id}/members DIR-QUERY-04 get_directory_group_members
- DELETE /{channel_type}/{account_id}/directory/cache DIR-CACHE-CLEAR clear_directory_cache
端点清单对应05-目录查询域设计方案 v1.1§2.3 + Task 5
- GET /{channel_type}/{account_id}/directory/users/search
DIR-QUERY-01 search_directory_users
- GET /{channel_type}/{account_id}/directory/groups/search
DIR-QUERY-02 search_directory_groups
- GET /{channel_type}/{account_id}/directory/users/{peer_id}
DIR-QUERY-03 get_directory_user_profile
- GET /{channel_type}/{account_id}/directory/groups/{group_id}
DIR-01 get_directory_group_detail
- GET /{channel_type}/{account_id}/directory/groups/{group_id}/members
DIR-QUERY-04 get_directory_group_members
- DELETE /{channel_type}/{account_id}/directory/cache
DIR-CACHE-CLEAR clear_directory_cache
- POST /{channel_type}/{account_id}/directory/export
TASK-5 export_directory
"""
from __future__ import annotations
from typing import Any
from typing import Any, Literal
from fastapi import APIRouter, Body, Depends, Query, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, ConfigDict, Field
from yuxi.channels.contract.dtos.channel import ChannelType
from yuxi.channels.contract.dtos.directory import (
BatchDirectoryProfilesQuery,
ClearDirectoryCacheCmd,
DirectoryEntryType,
DirectoryQuery,
)
from yuxi.storage.postgres.models_business import User
@ -83,6 +96,38 @@ async def search_directory_users(
return {"success": True, "data": serialize_control_data(result.data)}
@directory_router.get(
"/{channel_type}/{account_id}/directory/suggestions",
response_model=dict,
)
async def search_directory_suggestions(
channel_type: ChannelType,
account_id: str,
request: Request,
keyword: str = Query(..., min_length=1, description="搜索关键词"),
limit: int = Query(default=10, ge=1, le=20, description="建议数量"),
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""搜索渠道目录建议FR-14
根据关键词搜索渠道侧目录建议用于输入联想5min TTL 缓存对应控制
面操作 ``directory/search_suggestions``
``ChannelControlService.searchDirectorySuggestions`` 内部构造
``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道
"""
operator = build_operator(current_user, request)
query = DirectoryQuery(keyword=keyword, limit=limit)
result = await use_cases.directory.searchDirectorySuggestions(
channel_type=channel_type,
account_id=account_id,
query=query,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@directory_router.get(
"/{channel_type}/{account_id}/directory/groups/search",
response_model=dict,
@ -157,6 +202,35 @@ class BatchProfilesRequest(BaseModel):
min_length=1,
description="待查询的对端 ID 列表(可能是用户 ID 或群组 ID",
)
types: list[Literal["user", "group"]] | None = Field(
default=None,
description="按条目类型过滤user / group不传入则查询全部类型",
)
class ExportDirectoryRequest(BaseModel):
"""目录导出请求Task 5"""
model_config = ConfigDict(frozen=True)
scope: Literal["users", "groups"] = Field(
default="users",
description="导出范围users 或 groups",
)
keyword: str | None = Field(
default=None,
description="搜索关键词",
)
format: Literal["csv", "json"] = Field(
default="csv",
description="导出格式csv 或 json",
)
limit: int = Field(
default=1000,
ge=1,
le=1000,
description="导出数量限制1~1000",
)
@directory_router.post(
@ -182,7 +256,11 @@ async def batch_directory_profiles(
``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道
"""
operator = build_operator(current_user, request)
query = BatchDirectoryProfilesQuery(peer_ids=tuple(payload.peer_ids))
types = tuple(DirectoryEntryType(t) for t in payload.types) if payload.types is not None else None
query = BatchDirectoryProfilesQuery(
peer_ids=tuple(payload.peer_ids),
types=types,
)
result = await use_cases.directory.batchGetDirectoryProfiles(
channel_type=channel_type,
account_id=account_id,
@ -193,6 +271,53 @@ async def batch_directory_profiles(
return {"success": True, "data": serialize_control_data(result.data)}
@directory_router.post(
"/{channel_type}/{account_id}/directory/export",
)
async def export_directory(
channel_type: ChannelType,
account_id: str,
request: Request,
payload: ExportDirectoryRequest = Body(...),
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> StreamingResponse:
"""导出目录数据FR-14Task 5
搜索渠道侧用户或群组目录将结果格式化为 CSV JSON 文件流返回
经控制面管道执行 ``directory/export`` 操作保持审计链路handler 返回
``{"content": str, "format": str, "filename": str}``router 包装为
``StreamingResponse`` 触发浏览器下载
"""
operator = build_operator(current_user, request)
result = await use_cases.directory.exportDirectory(
channel_type=channel_type,
account_id=account_id,
scope=payload.scope,
keyword=payload.keyword,
format=payload.format,
limit=payload.limit,
operator=operator,
)
raiseOnControlFailure(result)
data = serialize_control_data(result.data)
content: str = data["content"]
fmt: str = data["format"]
filename: str = data["filename"]
media_type = "text/csv; charset=utf-8" if fmt == "csv" else "application/json; charset=utf-8"
async def _stream():
yield content.encode("utf-8")
return StreamingResponse(
_stream(),
media_type=media_type,
headers={
"Content-Disposition": f"attachment; filename={filename}",
},
)
@directory_router.get(
"/{channel_type}/{account_id}/directory/groups/{group_id}",
response_model=dict,

View File

@ -145,10 +145,19 @@ async def list_admin_messages(
channel_type: ChannelType | None = Query(default=None, description="按渠道过滤"),
start_time: str | None = Query(default=None, description="起始时间 ISO 8601"),
end_time: str | None = Query(default=None, description="截止时间 ISO 8601"),
channel_status: Literal["sent", "delivered", "read", "recalled", "edited"] | None = Query(
channel_status: str | None = Query(
default=None,
description="按渠道侧状态过滤sent/delivered/read/recalled/edited",
description="按渠道侧状态过滤,支持逗号分隔多值(如 sent,delivered",
),
role: str | None = Query(
default=None,
description="按角色过滤,支持逗号分隔多值(如 admin,assistant",
),
account_id: str | None = Query(default=None, description="按渠道账户业务 ID 模糊匹配"),
peer_id: str | None = Query(default=None, description="按对端 ID 模糊匹配"),
message_id: str | None = Query(default=None, description="按消息业务 ID 模糊匹配"),
channel_msg_id: str | None = Query(default=None, description="按渠道消息 ID 模糊匹配"),
channel_session_id: str | None = Query(default=None, description="按渠道会话 ID 模糊匹配"),
limit: int = Query(default=50, ge=1, le=200, description="分页大小"),
offset: int = Query(default=0, ge=0, description="分页偏移"),
use_cases=Depends(get_channel_use_cases),
@ -169,6 +178,12 @@ async def list_admin_messages(
start_time=start_time_dt,
end_time=end_time_dt,
channel_status=channel_status,
role=role,
account_id=account_id,
peer_id=peer_id,
message_id=message_id,
channel_msg_id=channel_msg_id,
channel_session_id=channel_session_id,
limit=limit,
offset=offset,
operator=operator,
@ -182,8 +197,18 @@ async def search_messages(
request: Request,
keyword: str = Query(..., min_length=2, description="搜索关键词"),
channel_type: ChannelType | None = Query(default=None, description="按渠道过滤"),
channel_status: str | None = Query(
default=None,
description="按渠道侧状态过滤,支持逗号分隔多值(如 sent,delivered",
),
role: str | None = Query(
default=None,
description="按角色过滤,支持逗号分隔多值(如 admin,assistant",
),
channel_session_id: str | None = Query(default=None, description="按会话 ID 过滤"),
peer_id: str | None = Query(default=None, description="按对端 ID 过滤"),
message_id: str | None = Query(default=None, description="按消息业务 ID 模糊匹配"),
channel_msg_id: str | None = Query(default=None, description="按渠道消息 ID 模糊匹配"),
start_time: str | None = Query(default=None, description="起始时间ISO 8601"),
end_time: str | None = Query(default=None, description="截止时间ISO 8601"),
limit: int = Query(default=20, ge=1, le=100, description="分页大小"),
@ -193,8 +218,8 @@ async def search_messages(
) -> dict[str, Any]:
"""全文搜索消息MSG-SEARCH-01
按关键词搜索消息内容支持渠道会话对端 ID时间范围过滤
对应控制面操作 ``message/search``
按关键词搜索消息内容支持渠道状态角色会话对端 ID消息 ID
渠道消息 ID时间范围过滤对应控制面操作 ``message/search``
"""
operator = build_operator(current_user, request)
start_time_dt = parse_datetime("start_time", start_time)
@ -205,8 +230,12 @@ async def search_messages(
offset=offset,
operator=operator,
channel_type=channel_type,
channel_status=channel_status,
role=role,
channel_session_id=channel_session_id,
peer_id=peer_id,
message_id=message_id,
channel_msg_id=channel_msg_id,
start_time=start_time_dt,
end_time=end_time_dt,
)

View File

@ -82,11 +82,12 @@ class UpdateRetryPolicyRequest(BaseModel):
async def list_outbox_messages(
request: Request,
channel_type: ChannelType | None = Query(default=None, description="按渠道类型过滤"),
channel_account_id: str | None = Query(default=None, description="按渠道账户 ID 过滤"),
channel_account_id: str | None = Query(default=None, description="按渠道账户 ID 过滤,支持模糊搜索"),
status: OutboxStatus | None = Query(default=None, description="按状态过滤"),
message_id: str | None = Query(default=None, description="按消息 ID 过滤"),
channel_msg_id: str | None = Query(default=None, description="按渠道侧消息 ID 过滤"),
message_id: str | None = Query(default=None, description="按消息 ID 模糊搜索"),
channel_msg_id: str | None = Query(default=None, description="按渠道侧消息 ID 模糊搜索"),
channel_session_id: str | None = Query(default=None, description="按渠道会话 ID 过滤"),
last_error: str | None = Query(default=None, description="按错误关键词筛选"),
created_after: str | None = Query(default=None, description="创建时间下界ISO 8601"),
created_before: str | None = Query(default=None, description="创建时间上界ISO 8601"),
retry_count_min: int | None = Query(
@ -103,14 +104,15 @@ async def list_outbox_messages(
created_before_dt = parse_datetime("created_before", created_before)
query_filter = OutboxQueryFilter(
channel_type=channel_type,
channel_account_id=channel_account_id,
status=status,
message_id=message_id,
channel_msg_id=channel_msg_id,
channel_session_id=channel_session_id,
created_after=created_after_dt,
created_before=created_before_dt,
retry_count_min=retry_count_min,
channel_account_id_like=channel_account_id,
message_id_like=message_id,
channel_msg_id_like=channel_msg_id,
last_error_like=last_error,
)
result = await use_cases.outbox_management.listOutboxEntries(
query_filter,
@ -147,6 +149,7 @@ async def get_outbox_trend(
start_time: str = Query(..., description="起始时间ISO 8601"),
end_time: str = Query(..., description="结束时间ISO 8601"),
channel_type: ChannelType | None = Query(default=None, description="按渠道类型过滤"),
channel_account_id: str | None = Query(default=None, description="按渠道账户 ID 过滤"),
granularity: Literal["minute", "hour", "day"] = Query(default="hour", description="时间粒度"),
metric: Literal["queue_depth", "retry_count", "dead_count"] = Query(default="queue_depth", description="度量指标"),
use_cases=Depends(get_channel_use_cases),
@ -164,6 +167,7 @@ async def get_outbox_trend(
start_time=start_time_dt,
end_time=end_time_dt,
channel_type=channel_type,
channel_account_id=channel_account_id,
granularity=granularity,
metric=metric,
)
@ -175,6 +179,17 @@ async def get_outbox_trend(
@outbox_router.get("/dead-letter", response_model=dict)
async def list_dead_letter_outbox(
request: Request,
channel_type: ChannelType | None = Query(default=None, description="按渠道类型过滤"),
channel_account_id: str | None = Query(default=None, description="按渠道账户 ID 过滤,支持模糊搜索"),
message_id: str | None = Query(default=None, description="按消息 ID 模糊搜索"),
channel_msg_id: str | None = Query(default=None, description="按渠道侧消息 ID 模糊搜索"),
channel_session_id: str | None = Query(default=None, description="按渠道会话 ID 过滤"),
last_error: str | None = Query(default=None, description="按错误关键词筛选"),
created_after: str | None = Query(default=None, description="创建时间下界ISO 8601"),
created_before: str | None = Query(default=None, description="创建时间上界ISO 8601"),
retry_count_min: int | None = Query(
default=None, ge=0, description="最小重试次数下界(含),用于筛选已发生重试的条目"
),
limit: int = Query(default=100, ge=1, le=200, description="分页大小"),
offset: int = Query(default=0, ge=0, description="分页偏移"),
use_cases=Depends(get_channel_use_cases),
@ -182,7 +197,21 @@ async def list_dead_letter_outbox(
) -> dict[str, Any]:
"""查询死信队列OBX-04。对应控制面操作 outbox/dead_letter_list。"""
operator = build_operator(current_user, request)
created_after_dt = parse_datetime("created_after", created_after)
created_before_dt = parse_datetime("created_before", created_before)
query_filter = OutboxQueryFilter(
channel_type=channel_type,
channel_session_id=channel_session_id,
created_after=created_after_dt,
created_before=created_before_dt,
retry_count_min=retry_count_min,
channel_account_id_like=channel_account_id,
message_id_like=message_id,
channel_msg_id_like=channel_msg_id,
last_error_like=last_error,
)
result = await use_cases.outbox_management.listDeadLetterOutboxEntries(
query_filter,
operator=operator,
limit=limit,
offset=offset,

View File

@ -0,0 +1,92 @@
"""全局异常处理器集中实现。
3 handler
- unified_error_handler处理三模块异常满足 UnifiedError 协议
- request_validation_error_handler处理 Pydantic 请求体校验失败
- unhandled_exception_handler兜底原生 Exception
"""
from __future__ import annotations
import logging
from fastapi import Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from yuxi.channels.contract.errors import Error as ChannelError
from yuxi.external_systems.exceptions import ExternalSystemError
from yuxi.scheduler.exceptions import SchedulerError
from server.utils.error_response import build_error_response, build_error_response_from_exc
async def unified_error_handler(
_request: Request,
exc: ChannelError | ExternalSystemError | SchedulerError,
) -> JSONResponse:
"""三模块异常统一处理器。
三模块异常基类都满足 UnifiedError 协议status_code/error_code/message/
details/trace_id通过 build_error_response_from_exc() 统一构建响应
不再有 isinstance 特判不再有查表/类属性分支逻辑完全相同
Args:
_request: FastAPI 请求对象未使用
exc: 三模块异常之一
Returns:
统一格式的错误响应
"""
return build_error_response_from_exc(exc)
async def unhandled_exception_handler(_request: Request, exc: Exception) -> JSONResponse:
"""原生异常兜底处理器。
所有未被特定异常处理器捕获的 Exception 在此统一兜底
返回结构化 500 响应trace_id 自动从 ContextVar 获取
Args:
_request: FastAPI 请求对象未使用
exc: 未被捕获的原生异常
Returns:
500 内部服务器错误响应
"""
logging.getLogger("uvicorn.error").exception("unhandled exception", exc_info=exc)
return build_error_response(
status_code=500,
code="INTERNAL",
message="Internal server error",
)
async def request_validation_error_handler(
_request: Request,
exc: RequestValidationError,
) -> JSONResponse:
"""Pydantic 请求体校验失败统一处理器。
FastAPI 默认的 422 + ``{"detail": [...]}`` 转换为项目统一格式
422 + ``{"success": false, "error": {...}}``确保响应格式一致
Pydantic v2 ``model_validator`` / ``field_validator`` 中抛出的
``ValueError`` 子类含项目的 ``ValidationError``会被包装为
``pydantic.ValidationError``再由 FastAPI 转为 ``RequestValidationError``
本处理器统一捕获并转换为项目格式状态码保持 422FastAPI 标准
仅统一响应结构
"""
errors = exc.errors()
first = errors[0] if errors else {}
loc = first.get("loc", [])
field = ".".join(str(part) for part in loc) if loc else ""
message = first.get("msg", "request validation failed")
# Pydantic 对 ValueError 子类的包装会加 "Value error, " 前缀,去除以还原原始 message
if message.startswith("Value error, "):
message = message[len("Value error, ") :]
return build_error_response(
status_code=422,
code="VALIDATION_ERROR",
message=message,
details={"field": field, "errors": errors},
)

View File

@ -0,0 +1,122 @@
"""统一错误响应构建器。
定义 ``UnifiedError`` 协议三模块异常基类都实现此协议
提供 ``build_error_response()`` ``build_error_response_from_exc()``
统一构建错误响应确保所有全局异常处理器输出格式一致
"""
from __future__ import annotations
from typing import Any, Protocol
from fastapi.responses import JSONResponse
from yuxi.utils.trace_context import get_trace_id
class UnifiedError(Protocol):
"""统一异常协议。
三模块异常基类ChannelError / ExternalSystemError / SchedulerError
都实现此协议允许统一 handler 处理
字段
status_code: HTTP 状态码
error_code: 稳定错误码字符串 "VALIDATION_ERROR"
message: 人类可读错误信息
details: 业务字段字典 references, retry_after
trace_id: 链路追踪 ID
"""
status_code: int
error_code: str
message: str
details: dict[str, Any]
trace_id: str | None
def _extract_retry_after(exc: UnifiedError) -> dict[str, str]:
"""统一提取 Retry-After 响应头。
429/503 details retry_after 时设置
503 且无 retry_after 时设置默认 60 ChannelDegradedError 等降级场景
channels RateLimitError 已将 retry_after 写入 details通过 to_dict()
external_systems RateLimitExceededError 已将 retry_after 写入 details
Args:
exc: 满足 UnifiedError 协议的异常对象
Returns:
Retry-After 头的字典无需要时为空字典
"""
headers: dict[str, str] = {}
if exc.status_code in (429, 503):
retry_after = exc.details.get("retry_after")
if retry_after is not None:
headers["Retry-After"] = str(retry_after)
elif exc.status_code == 503:
# 503 降级场景无明确 retry_after 时设置默认 60 秒
headers["Retry-After"] = "60"
return headers
def build_error_response(
status_code: int,
code: str,
message: str,
details: dict[str, Any] | None = None,
trace_id: str | None = None,
headers: dict[str, str] | None = None,
) -> JSONResponse:
"""构建统一错误响应。
所有全局异常处理器调用此函数确保响应格式一致
trace_id 优先使用传入值其次从 ContextVar 获取
Args:
status_code: HTTP 状态码
code: 稳定错误码
message: 人类可读错误信息
details: 业务字段字典
trace_id: 链路追踪 ID None 时从 ContextVar 获取
headers: 额外响应头 Retry-After
Returns:
统一格式的 JSONResponse
"""
resolved_trace_id = trace_id or get_trace_id()
return JSONResponse(
status_code=status_code,
content={
"success": False,
"error": {
"code": code,
"message": message,
"trace_id": resolved_trace_id,
"details": details or {},
},
},
headers=headers or {},
)
def build_error_response_from_exc(exc: UnifiedError) -> JSONResponse:
"""从统一异常对象构建错误响应(统一入口)。
所有满足 UnifiedError 协议的异常都通过此函数构建响应
确保 handler 逻辑完全一致不再有 isinstance 特判
Args:
exc: 满足 UnifiedError 协议的异常对象
Returns:
统一格式的 JSONResponse
"""
headers = _extract_retry_after(exc)
return build_error_response(
status_code=exc.status_code,
code=exc.error_code,
message=exc.message,
details=exc.details,
trace_id=exc.trace_id,
headers=headers,
)

View File

@ -0,0 +1,78 @@
"""在途请求计数器与中间件,用于优雅关停时排空在途请求。
``InflightRequestTracker`` 通过 ``increment`` / ``decrement`` 维护在途请求
计数计数归零时 ``wait_drained`` 立即返回超时未归零则抛 ``TimeoutError``
``HostShutdown._waitForInflightRequests`` 捕获并记录告警后强制进入下一步
§11.9
``InflightTrackingMiddleware`` 在每个 HTTP 请求进入时 ``increment``结束时
``decrement````finally`` 保证异常路径也执行确保计数准确
"""
from __future__ import annotations
import asyncio
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
__all__ = ["InflightRequestTracker", "InflightTrackingMiddleware"]
class InflightRequestTracker:
"""在途请求计数器,用于优雅关停时排空在途请求。
关键不变量
- ``_count`` 归零时 ``_zero_event`` set``wait_drained`` 立即返回
- ``decrement`` 防御性归零避免计数变为负数
"""
def __init__(self) -> None:
self._count = 0
self._zero_event = asyncio.Event()
self._zero_event.set() # 初始为 0已触发
@property
def count(self) -> int:
"""当前在途请求数。"""
return self._count
def increment(self) -> None:
"""在途请求计数 +1清除归零事件。"""
self._count += 1
self._zero_event.clear()
def decrement(self) -> None:
"""在途请求计数 -1归零时触发归零事件。"""
self._count -= 1
if self._count <= 0:
self._count = 0
self._zero_event.set()
async def wait_drained(self, timeout: float) -> None:
"""等待在途请求排空。
参数
timeout: 等待超时超时后抛 ``TimeoutError``
Raises:
TimeoutError: 超时未排空
"""
await asyncio.wait_for(self._zero_event.wait(), timeout=timeout)
class InflightTrackingMiddleware(BaseHTTPMiddleware):
"""在途请求计数中间件。
每个请求进入时 ``increment``结束时 ``decrement````finally`` 保证
异常路径也执行注册时应最后注册LIFO 顺序中最先执行确保覆盖
所有后续中间件与路由处理
"""
async def dispatch(self, request: Request, call_next):
tracker: InflightRequestTracker = request.app.state.inflight_tracker
tracker.increment()
try:
return await call_next(request)
finally:
tracker.decrement()

View File

@ -15,11 +15,15 @@ from yuxi.knowledge import knowledge_base
from yuxi.utils import logger
from yuxi.agents.backends.sandbox import init_sandbox_provider, shutdown_sandbox_provider
from yuxi import get_version
from server.utils.inflight_tracker import InflightRequestTracker
@asynccontextmanager
async def lifespan(app: FastAPI):
# 初始化在途请求计数器InflightTrackingMiddleware 依赖)
app.state.inflight_tracker = InflightRequestTracker()
# 注册外部系统适配器(避免模块导入副作用)
try:
register_adapters()

View File

@ -0,0 +1,37 @@
"""TraceId 中间件:为每个请求生成/注入 trace_id。
优先从 X-Request-Id header 读取支持上游链路传递
否则生成 uuid4写入 ContextVar 供下游使用
"""
from __future__ import annotations
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from yuxi.utils.trace_context import (
generate_trace_id,
reset_trace_id,
set_trace_id,
)
_TRACE_ID_HEADER = "X-Request-Id"
class TraceIdMiddleware(BaseHTTPMiddleware):
"""为每个请求注入 trace_id 的中间件。
注册时应最后注册LIFO 顺序中最先执行确保所有后续中间件
异常处理器都能从 ContextVar 读取 trace_id
"""
async def dispatch(self, request: Request, call_next):
trace_id = request.headers.get(_TRACE_ID_HEADER) or generate_trace_id()
set_trace_id(trace_id)
try:
response: Response = await call_next(request)
# 回写 trace_id 到响应头,前端可关联
response.headers[_TRACE_ID_HEADER] = trace_id
return response
finally:
reset_trace_id()