fix(BIZ-42): 综合评审优化 — 12项修复
四轮评审反馈全部处理: 🔴 Critical (5): - _stats data race: 新增 _stats_lock (asyncio.Lock) + _increment_stat() helper - Admin API 无认证: 新增 SIDECAR_ADMIN_TOKEN Bearer Token 认证 - API Key 明文暴露: GET config 返回 masked api_key (前4位+****) - queue_max_size hot-reload 假生效: PriorityQueue.set_max_size() + 收缩保护 - SIDECAR_TIMEOUT 6000→60s + 上限截断 300s 🟠 Major (3): - upstream_api_key 启动检查: lifespan 阶段 warning 日志 - Dashboard HTML 无缓存: 300s TTL 内存缓存 - queue_stats 异常日志: logger.warning(queue_stats_unavailable) 🟡 Medium (3): - CORS middleware 配置 - httpx 连接池限制 (max_connections=100, keepalive=20) - SSE retry: 3000 字段 🟢 Minor (1): - _extract_model 类型注解 body: dict→Any - passthrough 硬编码 30s→_config.request_timeout mypy strict: 5 files, zero errors Reviewed-by: 梁思筑, 严维序, 陆怀瑾, 沈路明 Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -8,13 +8,15 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from pydantic import BaseModel
|
||||
|
||||
webui_router: APIRouter = APIRouter(prefix="/api", tags=["webui"])
|
||||
@@ -22,6 +24,14 @@ logger: structlog.stdlib.BoundLogger = structlog.get_logger("nvidia_sidecar.webu
|
||||
|
||||
STATIC_DIR: Path = Path(__file__).parent / "static"
|
||||
|
||||
# dashboard.html 缓存(严维序评审 #6 / 梁思筑评审 #8:避免每次请求读磁盘)
|
||||
_dashboard_html_cache: tuple[str, float] | None = None
|
||||
_DASHBOARD_CACHE_TTL: float = 300.0 # 5 分钟
|
||||
|
||||
# Admin API 认证(严维序评审 #1)
|
||||
_ADMIN_TOKEN: str | None = os.environ.get("SIDECAR_ADMIN_TOKEN")
|
||||
_admin_auth_scheme: HTTPBearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 配置热重载模型
|
||||
@@ -44,12 +54,18 @@ async def _dashboard_stream(request: Request) -> StreamingResponse:
|
||||
供 dashboard.html 的 EventSource 消费。
|
||||
"""
|
||||
async def event_generator() -> AsyncGenerator[str, None]:
|
||||
# 首帧发送 retry 字段(严维序评审 minor):指示客户端断连后等待 3s 重试
|
||||
first_frame = True
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
try:
|
||||
snapshot: dict[str, Any] = await _build_snapshot()
|
||||
yield f"data: {json.dumps(snapshot, ensure_ascii=False)}\n\n"
|
||||
payload_sse = f"data: {json.dumps(snapshot, ensure_ascii=False)}\n\n"
|
||||
if first_frame:
|
||||
payload_sse = f"retry: 3000\n{payload_sse}"
|
||||
first_frame = False
|
||||
yield payload_sse
|
||||
except Exception:
|
||||
logger.exception("dashboard_sse_error")
|
||||
yield f"data: {json.dumps({'error': 'internal'})}\n\n"
|
||||
@@ -65,6 +81,12 @@ async def _dashboard_stream(request: Request) -> StreamingResponse:
|
||||
)
|
||||
|
||||
|
||||
# SSE 首帧写入 retry 字段(严维序评审 minor),在 event_generator 首次 yield 前注入
|
||||
# 通过在 StreamingResponse 返回前手动发送 retry header 实现
|
||||
# (SSE 协议支持 retry 字段作为重建连接间隔)
|
||||
# 注:在 event_generator 的首个 yield 中加入 retry 声明
|
||||
|
||||
|
||||
async def _build_snapshot() -> dict[str, Any]:
|
||||
"""构建当前状态快照(从全局状态读取,含队列深度)。"""
|
||||
# 延迟导入避免循环依赖
|
||||
@@ -90,7 +112,7 @@ async def _build_snapshot() -> dict[str, Any]:
|
||||
"total_dropped": queue_stats.get("total_dropped", 0),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning("queue_stats_unavailable", message="队列统计获取失败,仪表盘队列深度可能不准确")
|
||||
|
||||
return {
|
||||
"timestamp": now,
|
||||
@@ -133,6 +155,7 @@ async def get_config() -> dict[str, Any]:
|
||||
"listen_port": cfg.listen_port,
|
||||
"metrics_port": cfg.metrics_port,
|
||||
"upstream_url": cfg.upstream_url,
|
||||
"upstream_api_key": _mask_api_key(cfg.upstream_api_key),
|
||||
"rate_rpm": _get_current_rate(server),
|
||||
"bucket_capacity": cfg.bucket_capacity,
|
||||
"request_timeout": cfg.request_timeout,
|
||||
@@ -160,8 +183,12 @@ async def update_config(body: ConfigPatch) -> JSONResponse:
|
||||
if body.queue_max_size is not None:
|
||||
if body.queue_max_size <= 0:
|
||||
raise HTTPException(status_code=400, detail="queue_max_size must be > 0")
|
||||
ok, msg = server._priority_queue.set_max_size(body.queue_max_size)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=msg)
|
||||
cfg.queue_max_size = body.queue_max_size
|
||||
changed.append("queue_max_size")
|
||||
logger.info("queue_max_size_updated", detail=msg)
|
||||
|
||||
if body.fallback_enabled_passthrough is not None:
|
||||
cfg.fallback_enabled_passthrough = body.fallback_enabled_passthrough
|
||||
@@ -173,6 +200,18 @@ async def update_config(body: ConfigPatch) -> JSONResponse:
|
||||
)
|
||||
|
||||
|
||||
def _mask_api_key(key: str) -> str:
|
||||
"""对 API Key 进行脱敏处理,仅保留前 4 位以供识别。
|
||||
|
||||
严维序评审 #2 / 沈路明评审 #3:防止 API Key 明文泄露。
|
||||
"""
|
||||
if not key:
|
||||
return ""
|
||||
if len(key) <= 4:
|
||||
return key[:2] + "****"
|
||||
return key[:4] + "****"
|
||||
|
||||
|
||||
def _get_current_rate(server_module: Any) -> float:
|
||||
"""获取当前实际速率(避退调整后),兼容 AdaptiveTokenBucket。"""
|
||||
tb = server_module._token_bucket
|
||||
@@ -191,15 +230,36 @@ async def dashboard_stream(request: Request) -> StreamingResponse:
|
||||
return await _dashboard_stream(request)
|
||||
|
||||
|
||||
async def _verify_admin_auth(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(_admin_auth_scheme),
|
||||
) -> None:
|
||||
"""Admin API Bearer Token 认证(严维序评审 #1)。
|
||||
|
||||
若设置了 SIDECAR_ADMIN_TOKEN 环境变量,则要求请求携带匹配的 Bearer Token。
|
||||
未设置时跳过认证(开发/测试环境)。
|
||||
"""
|
||||
if _ADMIN_TOKEN is None:
|
||||
return # 未配置认证 token,允许无认证访问
|
||||
if credentials is None:
|
||||
raise HTTPException(status_code=401, detail="需要 Bearer Token 认证(Admin API)")
|
||||
if credentials.credentials != _ADMIN_TOKEN:
|
||||
raise HTTPException(status_code=403, detail="Admin Token 无效")
|
||||
|
||||
|
||||
@webui_router.get("/admin/config")
|
||||
async def admin_get_config() -> JSONResponse:
|
||||
"""获取当前配置。"""
|
||||
async def admin_get_config(
|
||||
_auth: None = Depends(_verify_admin_auth),
|
||||
) -> JSONResponse:
|
||||
"""获取当前配置(需要 Admin 认证)。"""
|
||||
return JSONResponse(content=await get_config())
|
||||
|
||||
|
||||
@webui_router.post("/admin/config")
|
||||
async def admin_update_config(body: ConfigPatch) -> JSONResponse:
|
||||
"""在线修改配置(热重载)。"""
|
||||
async def admin_update_config(
|
||||
body: ConfigPatch,
|
||||
_auth: None = Depends(_verify_admin_auth),
|
||||
) -> JSONResponse:
|
||||
"""在线修改配置(热重载,需要 Admin 认证)。"""
|
||||
return await update_config(body)
|
||||
|
||||
|
||||
@@ -207,10 +267,27 @@ async def admin_update_config(body: ConfigPatch) -> JSONResponse:
|
||||
# 仪表盘静态页面
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@webui_router.get("/dashboard", include_in_schema=False)
|
||||
async def dashboard_page() -> HTMLResponse:
|
||||
"""仪表盘 HTML 页面。"""
|
||||
def _get_dashboard_html() -> str:
|
||||
"""获取仪表盘 HTML(带缓存,严维序评审 #6 / 梁思筑评审 #8)。
|
||||
|
||||
首次加载后缓存 5 分钟,避免每次请求读磁盘。
|
||||
"""
|
||||
global _dashboard_html_cache
|
||||
now = time.monotonic()
|
||||
if _dashboard_html_cache is not None:
|
||||
cached_content, cached_at = _dashboard_html_cache
|
||||
if now - cached_at < _DASHBOARD_CACHE_TTL:
|
||||
return cached_content
|
||||
|
||||
dashboard_path = STATIC_DIR / "dashboard.html"
|
||||
if dashboard_path.is_file():
|
||||
return HTMLResponse(content=dashboard_path.read_text(encoding="utf-8"))
|
||||
return HTMLResponse(content="<h1>dashboard.html not found</h1>", status_code=404)
|
||||
content = dashboard_path.read_text(encoding="utf-8")
|
||||
_dashboard_html_cache = (content, now)
|
||||
return content
|
||||
return "<h1>dashboard.html not found</h1>"
|
||||
|
||||
|
||||
@webui_router.get("/dashboard", include_in_schema=False)
|
||||
async def dashboard_page() -> HTMLResponse:
|
||||
"""仪表盘 HTML 页面(含缓存策略)。"""
|
||||
return HTMLResponse(content=_get_dashboard_html())
|
||||
Reference in New Issue
Block a user