- config.py rag_legacy_base_url=7910(默认)/ upstream.py legacy 键入 probe_all - routers/legacy.py:13 族前缀白名单透明代理(线程池阻塞调用) - 浸泡:sdk/.env RAG_LEGACY_BASE_URL=7911(agent/legacy-rag 编译树) - 验证:经 BFF 代理 kg/stats+kg/families 200,非白名单 404 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
203 lines
6.5 KiB
Python
203 lines
6.5 KiB
Python
"""upstream.py —— 上游健康探测(httpx 单例 + 5s TTL 缓存)与统一错误信封。
|
||
|
||
- `probe_all()`:并发探测 qa(/health 或 /info)、rag(/health)、ragAlt(/health)、
|
||
Neo4j(延迟导入 `app.services.neo4j.probe_neo4j()`,A3 提供;若模块缺失则置
|
||
`{ok:false}`)。探测超时 1s(settings.timeout_health),结果缓存 5s。
|
||
- `envelope(code, message, degraded, status)`:API.md §1 错误信封构造器,供所有 router 用。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
import time
|
||
from typing import Any, Awaitable, Callable
|
||
|
||
import httpx
|
||
|
||
from .config import get_settings
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_client: httpx.AsyncClient | None = None
|
||
_httpx_lock = asyncio.Lock()
|
||
|
||
|
||
async def get_client() -> httpx.AsyncClient:
|
||
"""httpx.AsyncClient 单例(供 qa/kg/extraction 各代理层共用)。"""
|
||
global _client
|
||
if _client is None or _client.is_closed:
|
||
async with _httpx_lock:
|
||
if _client is None or _client.is_closed:
|
||
settings = get_settings()
|
||
_client = httpx.AsyncClient(
|
||
timeout=httpx.Timeout(
|
||
connect=settings.timeout_health,
|
||
read=settings.timeout_qa_chat,
|
||
write=10.0,
|
||
pool=5.0,
|
||
),
|
||
follow_redirects=True,
|
||
)
|
||
return _client
|
||
|
||
|
||
async def close_client() -> None:
|
||
"""app shutdown 钩子。"""
|
||
global _client
|
||
if _client is not None and not _client.is_closed:
|
||
await _client.aclose()
|
||
_client = None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 错误信封(API.md §1)——供所有 router 用
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_ERROR_MESSAGES: dict[str, str] = {
|
||
"UPSTREAM_DOWN": "知识问答服务当前不可用,请稍后重试",
|
||
"UPSTREAM_ERROR": "上游服务返回错误",
|
||
"TIMEOUT": "检索超时",
|
||
"INVALID_CITATION": "引用校验失败",
|
||
"NOT_FOUND": "资源不存在",
|
||
"VALIDATION": "请求参数不合法",
|
||
"INTERNAL": "服务内部错误",
|
||
}
|
||
|
||
|
||
def envelope(
|
||
code: str,
|
||
message: str | None = None,
|
||
degraded: bool = False,
|
||
status: int | None = None,
|
||
) -> tuple[dict[str, Any], int]:
|
||
"""构造 `{"ok": false, "error": {code, message, degraded}}` 信封 + HTTP 状态。
|
||
|
||
status 缺省时按 code 推导(API.md §1 表):
|
||
UPSTREAM_DOWN=503, UPSTREAM_ERROR=502, TIMEOUT=504, INVALID_CITATION=502,
|
||
NOT_FOUND=404, VALIDATION=422, INTERNAL=500。
|
||
"""
|
||
if status is None:
|
||
status = {
|
||
"UPSTREAM_DOWN": 503,
|
||
"UPSTREAM_ERROR": 502,
|
||
"TIMEOUT": 504,
|
||
"INVALID_CITATION": 502,
|
||
"NOT_FOUND": 404,
|
||
"VALIDATION": 422,
|
||
"INTERNAL": 500,
|
||
}.get(code, 500)
|
||
if message is None:
|
||
message = _ERROR_MESSAGES.get(code, "未知错误")
|
||
return {
|
||
"ok": False,
|
||
"error": {"code": code, "message": message, "degraded": degraded},
|
||
}, status
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 健康探测
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_probe_lock = asyncio.Lock()
|
||
_probe_at: float = 0.0
|
||
_probe_result: dict[str, Any] | None = None
|
||
|
||
|
||
async def _get_json(url: str) -> tuple[bool, Any]:
|
||
"""1s 超时 GET + JSON 解析;任何失败返回 (False, None),不抛错。"""
|
||
try:
|
||
client = await get_client()
|
||
resp = await client.get(url, timeout=httpx.Timeout(get_settings().timeout_health))
|
||
if resp.status_code >= 400:
|
||
return False, None
|
||
return True, resp.json()
|
||
except Exception:
|
||
return False, None
|
||
|
||
|
||
async def _probe_qa() -> dict[str, Any]:
|
||
settings = get_settings()
|
||
base = settings.qa_base_url.rstrip("/")
|
||
# 按 DISCOVERY.md:health 为可靠探测端点;其不可用时回落 info
|
||
ok, data = await _get_json(f"{base}{settings.qa_api_path}/health")
|
||
if not ok:
|
||
ok, data = await _get_json(f"{base}{settings.qa_api_path}/info")
|
||
if not ok or not isinstance(data, dict):
|
||
return {"ok": False}
|
||
version = data.get("version") or data.get("app_version")
|
||
return {"ok": True, "version": version}
|
||
|
||
|
||
async def _probe_rag(base_url: str) -> dict[str, Any]:
|
||
ok, data = await _get_json(f"{base_url.rstrip('/')}/health")
|
||
if not ok:
|
||
return {"ok": False}
|
||
return {"ok": True}
|
||
|
||
|
||
async def _probe_neo4j() -> dict[str, Any]:
|
||
"""Neo4j 状态由 A3 提供(app.services.neo4j.probe_neo4j)。
|
||
延迟导入:A3 未实现时 catch ImportError,置 {ok:false}。
|
||
"""
|
||
try:
|
||
from .services.neo4j import probe_neo4j
|
||
except ImportError:
|
||
return {"ok": False}
|
||
try:
|
||
result = await probe_neo4j()
|
||
except Exception:
|
||
return {"ok": False}
|
||
if not isinstance(result, dict):
|
||
return {"ok": False}
|
||
out: dict[str, Any] = {"ok": bool(result.get("ok"))}
|
||
if result.get("active"):
|
||
out["active"] = result["active"]
|
||
if result.get("detail"):
|
||
out["detail"] = result["detail"]
|
||
return out
|
||
|
||
|
||
async def _probe_all_uncached() -> dict[str, Any]:
|
||
settings = get_settings()
|
||
qa_t, rag_t, alt_t, leg_t, neo_t = await asyncio.gather(
|
||
_probe_qa(),
|
||
_probe_rag(settings.rag_base_url),
|
||
_probe_rag(settings.rag_alt_base_url),
|
||
_probe_rag(settings.rag_legacy_base_url),
|
||
_probe_neo4j(),
|
||
)
|
||
return {
|
||
"qa": qa_t,
|
||
"rag": rag_t,
|
||
"ragAlt": alt_t,
|
||
"legacy": leg_t,
|
||
"neo4j": neo_t,
|
||
}
|
||
|
||
|
||
async def probe_all(force: bool = False) -> dict[str, Any]:
|
||
"""并发探测全部上游;结果缓存 settings.health_cache_ttl 秒(默认 5)。"""
|
||
global _probe_at, _probe_result
|
||
now = time.monotonic()
|
||
if not force and _probe_result is not None:
|
||
if now - _probe_at < get_settings().health_cache_ttl:
|
||
return _probe_result
|
||
async with _probe_lock:
|
||
now = time.monotonic()
|
||
if not force and _probe_result is not None:
|
||
if now - _probe_at < get_settings().health_cache_ttl:
|
||
return _probe_result
|
||
result = await _probe_all_uncached()
|
||
_probe_at = time.monotonic()
|
||
_probe_result = result
|
||
return result
|
||
|
||
|
||
async def probe_all_refresh() -> dict[str, Any]:
|
||
"""强制刷新(无缓存)。"""
|
||
return await probe_all(force=True)
|
||
|
||
|
||
# 供 A2/A3 复用的类型别名
|
||
UpstreamProbe = Callable[..., Awaitable[dict[str, Any]]]
|