feat(proxy,failover,transport): Hyper 上游客户端精细计时、连续失败退避与连接泄漏修复

Proxy:
- 将上游 HTTP 客户端从 reqwest 替换为 hyper,新增 InstrumentedConnector
  实现 TCP 连接/TLS 握手级别的独立计时,上报 connection_reused 等指标
- 前端展示细粒度代理计时(连接复用、等待响应头等)

Failover:
- 引入连续失败退避机制,每 10 次失败递增退避间隔
- 检测 H2 max outbound streams 错误并触发上游客户端重建
- 新增 HTTPClientPool.reset_upstream_client 支持按需重建缓存客户端

连接泄漏修复:
- Handler 异常路径确保 response_ctx 被正确关闭
- HubResponseStream 迭代结束后在 finally 块中清理 stream_id
- HubTunnelTransport.handle_request 捕获所有异常并清理流状态
This commit is contained in:
fawney19
2026-03-06 17:55:14 +08:00
parent 2269617a9f
commit 050cba9563
15 changed files with 902 additions and 72 deletions

View File

@@ -458,6 +458,67 @@ class HTTPClientPool:
return httpx.AsyncClient(**client_config) # type: ignore[arg-type]
@classmethod
async def reset_upstream_client(
cls,
delegate_cfg: dict[str, Any] | None,
proxy_config: dict[str, Any] | None = None,
tls_profile: str | None = None,
) -> bool:
"""Reset cached upstream client for the given proxy/tunnel route.
Returns True when a cached client was closed and removed.
For the shared no-proxy default client this is a no-op to avoid
disrupting unrelated in-flight requests.
"""
if delegate_cfg and delegate_cfg.get("tunnel"):
node_id = str(delegate_cfg.get("node_id") or "")
if not node_id:
return False
lock = cls._get_proxy_clients_lock()
async with lock:
client = cls._tunnel_clients.pop(node_id, None)
if client is None:
return False
try:
await client.aclose()
except Exception as exc:
logger.warning("关闭 Tunnel 客户端失败(node_id={}): {}", node_id, exc)
return True
if not proxy_config:
proxy_config = get_system_proxy_config()
base_cache_key = compute_proxy_cache_key(proxy_config)
if base_cache_key == "__no_proxy__":
return False
cache_key_prefixes = [base_cache_key]
tls_profile_key = str(tls_profile or "").strip().lower()
if tls_profile_key:
cache_key_prefixes = [f"{base_cache_key}::tls:{tls_profile_key}"]
lock = cls._get_proxy_clients_lock()
async with lock:
keys_to_remove = [
key
for key in list(cls._proxy_clients.keys())
if any(
key == prefix
or key.startswith(f"{prefix}::")
or key.startswith(f"{prefix}::tls:")
for prefix in cache_key_prefixes
)
]
clients = [cls._proxy_clients.pop(key)[0] for key in keys_to_remove]
for client in clients:
try:
await client.aclose()
except Exception as exc:
logger.warning("关闭上游代理客户端失败: {}", exc)
return bool(clients)
async def get_upstream_client(
cls,
delegate_cfg: dict[str, Any] | None,