2025-12-10 20:52:44 +08:00
|
|
|
|
"""
|
|
|
|
|
|
全局HTTP客户端池管理
|
|
|
|
|
|
避免每次请求都创建新的AsyncClient,提高性能
|
2026-01-08 13:34:59 +08:00
|
|
|
|
|
|
|
|
|
|
性能优化说明:
|
|
|
|
|
|
1. 默认客户端:无代理场景,全局复用单一客户端
|
|
|
|
|
|
2. 代理客户端缓存:相同代理配置复用同一客户端,避免重复创建
|
|
|
|
|
|
3. 连接池复用:Keep-alive 连接减少 TCP 握手开销
|
2025-12-10 20:52:44 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
2026-01-30 03:10:21 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-01-08 13:34:59 +08:00
|
|
|
|
import asyncio
|
2026-03-14 11:59:07 +08:00
|
|
|
|
import os
|
2026-01-08 13:34:59 +08:00
|
|
|
|
import time
|
2025-12-10 20:52:44 +08:00
|
|
|
|
from contextlib import asynccontextmanager
|
2026-01-30 03:10:21 +08:00
|
|
|
|
from typing import Any
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
|
2025-12-30 15:06:55 +08:00
|
|
|
|
from src.config import config
|
2025-12-10 20:52:44 +08:00
|
|
|
|
from src.core.logger import logger
|
2026-02-08 13:33:08 +08:00
|
|
|
|
from src.services.proxy_node.resolver import (
|
2026-03-12 13:59:21 +08:00
|
|
|
|
build_proxy_url_async,
|
2026-02-08 13:33:08 +08:00
|
|
|
|
compute_proxy_cache_key,
|
2026-03-12 13:59:21 +08:00
|
|
|
|
get_system_proxy_config_async,
|
2026-02-08 13:33:08 +08:00
|
|
|
|
make_proxy_param,
|
2026-03-12 13:59:21 +08:00
|
|
|
|
resolve_delegate_config_async,
|
2026-02-08 13:33:08 +08:00
|
|
|
|
)
|
2026-02-27 13:54:46 +08:00
|
|
|
|
from src.utils.ssl_utils import get_ssl_context, get_ssl_context_for_profile
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-01-08 13:34:59 +08:00
|
|
|
|
# 模块级锁,避免类属性延迟初始化的竞态条件
|
|
|
|
|
|
_proxy_clients_lock = asyncio.Lock()
|
|
|
|
|
|
_default_client_lock = asyncio.Lock()
|
|
|
|
|
|
|
2026-02-07 23:18:58 +08:00
|
|
|
|
|
2026-03-14 11:59:07 +08:00
|
|
|
|
def _get_int_env(name: str, default: int, minimum: int) -> int:
|
|
|
|
|
|
"""Read positive integer env value with bounds and fallback."""
|
|
|
|
|
|
raw = os.getenv(name)
|
|
|
|
|
|
if raw is None:
|
|
|
|
|
|
return default
|
|
|
|
|
|
try:
|
|
|
|
|
|
value = int(raw)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
logger.warning("环境变量 {} 不是有效整数: {}, 使用默认值 {}", name, raw, default)
|
|
|
|
|
|
return default
|
|
|
|
|
|
return max(minimum, value)
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
class HTTPClientPool:
|
|
|
|
|
|
"""
|
|
|
|
|
|
全局HTTP客户端池单例
|
|
|
|
|
|
|
|
|
|
|
|
管理可重用的httpx.AsyncClient实例,避免频繁创建/销毁连接
|
2026-01-08 13:34:59 +08:00
|
|
|
|
|
|
|
|
|
|
性能优化:
|
|
|
|
|
|
1. 默认客户端:无代理场景复用
|
|
|
|
|
|
2. 代理客户端缓存:相同代理配置复用同一客户端
|
|
|
|
|
|
3. LRU 淘汰:代理客户端超过上限时淘汰最久未使用的
|
2025-12-10 20:52:44 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
2026-01-30 03:10:21 +08:00
|
|
|
|
_instance: HTTPClientPool | None = None
|
|
|
|
|
|
_default_client: httpx.AsyncClient | None = None
|
|
|
|
|
|
_clients: dict[str, httpx.AsyncClient] = {}
|
2026-03-10 09:52:21 +08:00
|
|
|
|
_max_named_clients: int = 20
|
2026-01-08 13:34:59 +08:00
|
|
|
|
# 代理客户端缓存:{cache_key: (client, last_used_time)}
|
2026-01-30 03:10:21 +08:00
|
|
|
|
_proxy_clients: dict[str, tuple[httpx.AsyncClient, float]] = {}
|
2026-01-08 13:34:59 +08:00
|
|
|
|
# 代理客户端缓存上限(避免内存泄漏)
|
|
|
|
|
|
_max_proxy_clients: int = 50
|
2026-03-10 09:52:21 +08:00
|
|
|
|
# Tunnel 客户端缓存:{node_id: (client, last_used_time)}
|
|
|
|
|
|
_tunnel_clients: dict[str, tuple[httpx.AsyncClient, float]] = {}
|
|
|
|
|
|
_max_tunnel_clients: int = 30
|
2026-03-08 01:27:50 +08:00
|
|
|
|
# 后台清理任务引用集合(防止被 GC 回收)
|
|
|
|
|
|
_background_tasks: set[asyncio.Task[None]] = set()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
def __new__(cls) -> "HTTPClientPool":
|
2025-12-10 20:52:44 +08:00
|
|
|
|
if cls._instance is None:
|
|
|
|
|
|
cls._instance = super().__new__(cls)
|
|
|
|
|
|
return cls._instance
|
|
|
|
|
|
|
|
|
|
|
|
@classmethod
|
2026-01-08 13:34:59 +08:00
|
|
|
|
async def get_default_client_async(cls) -> httpx.AsyncClient:
|
2025-12-10 20:52:44 +08:00
|
|
|
|
"""
|
2026-01-08 13:34:59 +08:00
|
|
|
|
获取默认的HTTP客户端(异步线程安全版本)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
用于大多数HTTP请求,具有合理的默认配置
|
|
|
|
|
|
"""
|
2026-01-08 13:34:59 +08:00
|
|
|
|
if cls._default_client is not None:
|
|
|
|
|
|
return cls._default_client
|
|
|
|
|
|
|
|
|
|
|
|
async with _default_client_lock:
|
|
|
|
|
|
# 双重检查,避免重复创建
|
|
|
|
|
|
if cls._default_client is None:
|
|
|
|
|
|
cls._default_client = httpx.AsyncClient(
|
2026-02-28 20:53:11 +08:00
|
|
|
|
http2=config.enable_http2,
|
2026-01-19 17:56:02 +08:00
|
|
|
|
verify=get_ssl_context(), # 使用 certifi 证书
|
2026-01-08 13:34:59 +08:00
|
|
|
|
timeout=httpx.Timeout(
|
|
|
|
|
|
connect=config.http_connect_timeout,
|
|
|
|
|
|
read=config.http_read_timeout,
|
|
|
|
|
|
write=config.http_write_timeout,
|
|
|
|
|
|
pool=config.http_pool_timeout,
|
|
|
|
|
|
),
|
|
|
|
|
|
limits=httpx.Limits(
|
|
|
|
|
|
max_connections=config.http_max_connections,
|
|
|
|
|
|
max_keepalive_connections=config.http_keepalive_connections,
|
|
|
|
|
|
keepalive_expiry=config.http_keepalive_expiry,
|
|
|
|
|
|
),
|
|
|
|
|
|
follow_redirects=True, # 跟随重定向
|
|
|
|
|
|
)
|
|
|
|
|
|
logger.info(
|
2026-02-08 13:33:08 +08:00
|
|
|
|
"全局HTTP客户端池已初始化: max_connections={}, keepalive={}, keepalive_expiry={}s",
|
|
|
|
|
|
config.http_max_connections,
|
|
|
|
|
|
config.http_keepalive_connections,
|
|
|
|
|
|
config.http_keepalive_expiry,
|
2026-01-08 13:34:59 +08:00
|
|
|
|
)
|
|
|
|
|
|
return cls._default_client
|
|
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def get_default_client(cls) -> httpx.AsyncClient:
|
|
|
|
|
|
"""
|
|
|
|
|
|
获取默认的HTTP客户端(同步版本,向后兼容)
|
|
|
|
|
|
|
|
|
|
|
|
⚠️ 注意:此方法在高并发首次调用时可能存在竞态条件,
|
|
|
|
|
|
推荐使用 get_default_client_async() 异步版本。
|
|
|
|
|
|
"""
|
2025-12-10 20:52:44 +08:00
|
|
|
|
if cls._default_client is None:
|
|
|
|
|
|
cls._default_client = httpx.AsyncClient(
|
2026-02-28 20:53:11 +08:00
|
|
|
|
http2=config.enable_http2,
|
2026-01-19 17:56:02 +08:00
|
|
|
|
verify=get_ssl_context(), # 使用 certifi 证书
|
2025-12-10 20:52:44 +08:00
|
|
|
|
timeout=httpx.Timeout(
|
2025-12-30 15:06:55 +08:00
|
|
|
|
connect=config.http_connect_timeout,
|
|
|
|
|
|
read=config.http_read_timeout,
|
|
|
|
|
|
write=config.http_write_timeout,
|
|
|
|
|
|
pool=config.http_pool_timeout,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
),
|
|
|
|
|
|
limits=httpx.Limits(
|
2026-01-08 02:34:59 +08:00
|
|
|
|
max_connections=config.http_max_connections,
|
|
|
|
|
|
max_keepalive_connections=config.http_keepalive_connections,
|
|
|
|
|
|
keepalive_expiry=config.http_keepalive_expiry,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
),
|
|
|
|
|
|
follow_redirects=True, # 跟随重定向
|
|
|
|
|
|
)
|
2026-01-08 02:34:59 +08:00
|
|
|
|
logger.info(
|
2026-02-08 13:33:08 +08:00
|
|
|
|
"全局HTTP客户端池已初始化: max_connections={}, keepalive={}, keepalive_expiry={}s",
|
|
|
|
|
|
config.http_max_connections,
|
|
|
|
|
|
config.http_keepalive_connections,
|
|
|
|
|
|
config.http_keepalive_expiry,
|
2026-01-08 02:34:59 +08:00
|
|
|
|
)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
return cls._default_client
|
|
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def get_client(cls, name: str, **kwargs: Any) -> httpx.AsyncClient:
|
|
|
|
|
|
"""
|
|
|
|
|
|
获取或创建命名的HTTP客户端
|
|
|
|
|
|
|
|
|
|
|
|
用于需要特定配置的场景(如不同的超时设置、代理等)
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
name: 客户端标识符
|
|
|
|
|
|
**kwargs: httpx.AsyncClient的配置参数
|
|
|
|
|
|
"""
|
2026-03-10 09:52:21 +08:00
|
|
|
|
if name in cls._clients:
|
|
|
|
|
|
# 命中缓存:移到末尾以维护 LRU 顺序
|
|
|
|
|
|
cls._clients[name] = cls._clients.pop(name)
|
|
|
|
|
|
return cls._clients[name]
|
|
|
|
|
|
|
|
|
|
|
|
# 淘汰最久未使用的客户端(dict 头部即 LRU)
|
|
|
|
|
|
if len(cls._clients) >= cls._max_named_clients:
|
|
|
|
|
|
oldest_name = next(iter(cls._clients))
|
|
|
|
|
|
old_client = cls._clients.pop(oldest_name)
|
|
|
|
|
|
try:
|
|
|
|
|
|
asyncio.get_running_loop().create_task(old_client.aclose())
|
|
|
|
|
|
except RuntimeError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
logger.debug("淘汰命名HTTP客户端: {}", oldest_name)
|
|
|
|
|
|
|
|
|
|
|
|
# 合并默认配置和自定义配置
|
|
|
|
|
|
default_config = {
|
|
|
|
|
|
"http2": config.enable_http2,
|
|
|
|
|
|
"verify": get_ssl_context(),
|
|
|
|
|
|
"timeout": httpx.Timeout(
|
|
|
|
|
|
connect=config.http_connect_timeout,
|
|
|
|
|
|
read=config.http_read_timeout,
|
|
|
|
|
|
write=config.http_write_timeout,
|
|
|
|
|
|
pool=config.http_pool_timeout,
|
|
|
|
|
|
),
|
|
|
|
|
|
"follow_redirects": True,
|
|
|
|
|
|
}
|
|
|
|
|
|
default_config.update(kwargs)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-03-10 09:52:21 +08:00
|
|
|
|
cls._clients[name] = httpx.AsyncClient(**default_config) # type: ignore[arg-type]
|
|
|
|
|
|
logger.debug("创建命名HTTP客户端: {}", name)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
return cls._clients[name]
|
|
|
|
|
|
|
2026-01-08 13:34:59 +08:00
|
|
|
|
@classmethod
|
|
|
|
|
|
def _get_proxy_clients_lock(cls) -> asyncio.Lock:
|
|
|
|
|
|
"""获取代理客户端缓存锁(模块级单例,避免竞态条件)"""
|
|
|
|
|
|
return _proxy_clients_lock
|
|
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
async def _evict_lru_proxy_client(cls) -> None:
|
|
|
|
|
|
"""淘汰最久未使用的代理客户端"""
|
|
|
|
|
|
if len(cls._proxy_clients) < cls._max_proxy_clients:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
# 找到最久未使用的客户端
|
|
|
|
|
|
oldest_key = min(cls._proxy_clients.keys(), key=lambda k: cls._proxy_clients[k][1])
|
|
|
|
|
|
old_client, _ = cls._proxy_clients.pop(oldest_key)
|
|
|
|
|
|
|
|
|
|
|
|
# 异步关闭旧客户端
|
|
|
|
|
|
try:
|
|
|
|
|
|
await old_client.aclose()
|
2026-02-08 13:33:08 +08:00
|
|
|
|
logger.debug("淘汰代理客户端: {}", oldest_key)
|
2026-01-08 13:34:59 +08:00
|
|
|
|
except Exception as e:
|
2026-02-08 13:33:08 +08:00
|
|
|
|
logger.warning("关闭代理客户端失败: {}", e)
|
2026-01-08 13:34:59 +08:00
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
async def get_proxy_client(
|
|
|
|
|
|
cls,
|
2026-01-30 03:10:21 +08:00
|
|
|
|
proxy_config: dict[str, Any] | None = None,
|
2026-02-27 13:54:46 +08:00
|
|
|
|
tls_profile: str | None = None,
|
2026-01-08 13:34:59 +08:00
|
|
|
|
) -> httpx.AsyncClient:
|
|
|
|
|
|
"""
|
|
|
|
|
|
获取代理客户端(带缓存复用)
|
|
|
|
|
|
|
|
|
|
|
|
相同代理配置会复用同一个客户端,大幅减少连接建立开销。
|
2026-02-07 14:30:46 +08:00
|
|
|
|
当 proxy_config 为 None 时,自动回退到系统默认代理节点。
|
2026-01-08 13:34:59 +08:00
|
|
|
|
注意:返回的客户端使用默认超时配置,如需自定义超时请在请求时传递 timeout 参数。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
2026-02-07 14:30:46 +08:00
|
|
|
|
proxy_config: 代理配置字典,为 None 时使用系统默认代理
|
2026-01-08 13:34:59 +08:00
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
可复用的 httpx.AsyncClient 实例
|
|
|
|
|
|
"""
|
2026-02-07 14:30:46 +08:00
|
|
|
|
# 无特定代理时,回退到系统默认代理
|
|
|
|
|
|
if not proxy_config:
|
2026-03-12 13:59:21 +08:00
|
|
|
|
proxy_config = await get_system_proxy_config_async()
|
2026-02-07 14:30:46 +08:00
|
|
|
|
|
2026-03-12 13:59:21 +08:00
|
|
|
|
delegate_cfg = await resolve_delegate_config_async(proxy_config)
|
2026-02-26 12:25:23 +08:00
|
|
|
|
if delegate_cfg and delegate_cfg.get("tunnel"):
|
|
|
|
|
|
return await cls._get_tunnel_client(delegate_cfg["node_id"])
|
|
|
|
|
|
|
2026-02-08 13:33:08 +08:00
|
|
|
|
cache_key = compute_proxy_cache_key(proxy_config)
|
2026-02-27 13:54:46 +08:00
|
|
|
|
tls_profile_key = str(tls_profile or "").strip().lower()
|
|
|
|
|
|
if tls_profile_key:
|
|
|
|
|
|
cache_key = f"{cache_key}::tls:{tls_profile_key}"
|
2026-01-08 13:34:59 +08:00
|
|
|
|
|
|
|
|
|
|
# 无代理时返回默认客户端
|
|
|
|
|
|
if cache_key == "__no_proxy__":
|
|
|
|
|
|
return await cls.get_default_client_async()
|
|
|
|
|
|
|
|
|
|
|
|
lock = cls._get_proxy_clients_lock()
|
|
|
|
|
|
async with lock:
|
|
|
|
|
|
# 检查缓存
|
|
|
|
|
|
if cache_key in cls._proxy_clients:
|
|
|
|
|
|
client, _ = cls._proxy_clients[cache_key]
|
|
|
|
|
|
# 健康检查:如果客户端已关闭,移除并重新创建
|
|
|
|
|
|
if client.is_closed:
|
|
|
|
|
|
del cls._proxy_clients[cache_key]
|
2026-02-08 13:33:08 +08:00
|
|
|
|
logger.debug("代理客户端已关闭,将重新创建: {}", cache_key)
|
2026-01-08 13:34:59 +08:00
|
|
|
|
else:
|
|
|
|
|
|
# 更新最后使用时间
|
|
|
|
|
|
cls._proxy_clients[cache_key] = (client, time.time())
|
2026-02-27 13:54:46 +08:00
|
|
|
|
if tls_profile_key:
|
|
|
|
|
|
logger.debug(
|
|
|
|
|
|
"复用代理客户端 TLS profile={} key={}", tls_profile_key, cache_key
|
|
|
|
|
|
)
|
2026-01-08 13:34:59 +08:00
|
|
|
|
return client
|
|
|
|
|
|
|
|
|
|
|
|
# 淘汰旧客户端(如果超过上限)
|
|
|
|
|
|
await cls._evict_lru_proxy_client()
|
|
|
|
|
|
|
2026-02-27 18:10:27 +08:00
|
|
|
|
# 添加代理配置
|
2026-03-12 13:59:21 +08:00
|
|
|
|
proxy_url = await build_proxy_url_async(proxy_config) if proxy_config else None
|
2026-02-27 18:10:27 +08:00
|
|
|
|
|
|
|
|
|
|
# curl_cffi Transport: real TLS fingerprint impersonation.
|
2026-03-05 02:07:34 +08:00
|
|
|
|
# Supports:
|
|
|
|
|
|
# - "claude_code_nodejs" (legacy alias, uses default chrome120 impersonate)
|
|
|
|
|
|
# - direct chrome impersonate profile names (e.g. "chrome124")
|
|
|
|
|
|
use_curl_cffi_tls = False
|
|
|
|
|
|
if tls_profile_key:
|
2026-03-14 11:59:07 +08:00
|
|
|
|
from src.services.provider.fingerprint import KNOWN_IMPERSONATE_PROFILES
|
|
|
|
|
|
|
2026-03-05 02:07:34 +08:00
|
|
|
|
use_curl_cffi_tls = (
|
|
|
|
|
|
tls_profile_key == "claude_code_nodejs"
|
|
|
|
|
|
or tls_profile_key in KNOWN_IMPERSONATE_PROFILES
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if use_curl_cffi_tls:
|
2026-02-27 18:10:27 +08:00
|
|
|
|
from src.clients.curl_cffi_transport import (
|
|
|
|
|
|
CURL_CFFI_AVAILABLE,
|
|
|
|
|
|
CurlCffiTransport,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if CURL_CFFI_AVAILABLE:
|
2026-03-05 02:07:34 +08:00
|
|
|
|
transport_kwargs: dict[str, Any] = {"proxy": proxy_url}
|
|
|
|
|
|
if tls_profile_key != "claude_code_nodejs":
|
|
|
|
|
|
transport_kwargs["impersonate"] = tls_profile_key
|
|
|
|
|
|
|
|
|
|
|
|
transport = CurlCffiTransport(**transport_kwargs)
|
2026-02-27 18:10:27 +08:00
|
|
|
|
client = httpx.AsyncClient(
|
|
|
|
|
|
transport=transport,
|
|
|
|
|
|
follow_redirects=True,
|
|
|
|
|
|
timeout=httpx.Timeout(
|
|
|
|
|
|
connect=config.http_connect_timeout,
|
|
|
|
|
|
read=config.http_read_timeout,
|
|
|
|
|
|
write=config.http_write_timeout,
|
|
|
|
|
|
pool=config.http_pool_timeout,
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
cls._proxy_clients[cache_key] = (client, time.time())
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"创建 curl_cffi TLS 指纹客户端: profile={}, proxy={}",
|
|
|
|
|
|
tls_profile_key,
|
|
|
|
|
|
proxy_url or "direct",
|
|
|
|
|
|
)
|
|
|
|
|
|
return client
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"curl_cffi 不可用,回退到 best-effort TLS 配置 (profile={})",
|
|
|
|
|
|
tls_profile_key,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-08 13:34:59 +08:00
|
|
|
|
# 创建新客户端(使用默认超时,请求时可覆盖)
|
2026-01-30 03:10:21 +08:00
|
|
|
|
client_config: dict[str, Any] = {
|
2026-02-28 20:53:11 +08:00
|
|
|
|
"http2": config.enable_http2,
|
2026-02-27 13:54:46 +08:00
|
|
|
|
"verify": get_ssl_context_for_profile(tls_profile),
|
2026-01-08 13:34:59 +08:00
|
|
|
|
"follow_redirects": True,
|
|
|
|
|
|
"limits": httpx.Limits(
|
|
|
|
|
|
max_connections=config.http_max_connections,
|
|
|
|
|
|
max_keepalive_connections=config.http_keepalive_connections,
|
|
|
|
|
|
keepalive_expiry=config.http_keepalive_expiry,
|
|
|
|
|
|
),
|
|
|
|
|
|
"timeout": httpx.Timeout(
|
|
|
|
|
|
connect=config.http_connect_timeout,
|
|
|
|
|
|
read=config.http_read_timeout,
|
|
|
|
|
|
write=config.http_write_timeout,
|
|
|
|
|
|
pool=config.http_pool_timeout,
|
|
|
|
|
|
),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 13:33:08 +08:00
|
|
|
|
proxy_param = make_proxy_param(proxy_url)
|
2026-02-07 23:18:58 +08:00
|
|
|
|
if proxy_param:
|
|
|
|
|
|
client_config["proxy"] = proxy_param
|
2026-01-08 13:34:59 +08:00
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
client = httpx.AsyncClient(**client_config) # type: ignore[arg-type]
|
2026-01-08 13:34:59 +08:00
|
|
|
|
cls._proxy_clients[cache_key] = (client, time.time())
|
|
|
|
|
|
|
2026-02-07 12:24:42 +08:00
|
|
|
|
proxy_label = "none"
|
|
|
|
|
|
if proxy_config:
|
|
|
|
|
|
proxy_label = str(
|
|
|
|
|
|
proxy_config.get("node_id") or proxy_config.get("url") or "unknown"
|
|
|
|
|
|
)
|
2026-01-08 13:34:59 +08:00
|
|
|
|
logger.debug(
|
2026-02-08 13:33:08 +08:00
|
|
|
|
"创建代理客户端(缓存): {}, 缓存数量: {}", proxy_label, len(cls._proxy_clients)
|
2026-01-08 13:34:59 +08:00
|
|
|
|
)
|
2026-02-27 13:54:46 +08:00
|
|
|
|
if tls_profile_key:
|
|
|
|
|
|
logger.debug("创建代理客户端 TLS profile={} key={}", tls_profile_key, cache_key)
|
2026-01-08 13:34:59 +08:00
|
|
|
|
|
|
|
|
|
|
return client
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
@classmethod
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def close_all(cls) -> None:
|
2025-12-10 20:52:44 +08:00
|
|
|
|
"""关闭所有HTTP客户端"""
|
|
|
|
|
|
if cls._default_client is not None:
|
|
|
|
|
|
await cls._default_client.aclose()
|
|
|
|
|
|
cls._default_client = None
|
|
|
|
|
|
logger.info("默认HTTP客户端已关闭")
|
|
|
|
|
|
|
|
|
|
|
|
for name, client in cls._clients.items():
|
|
|
|
|
|
await client.aclose()
|
2026-02-08 13:33:08 +08:00
|
|
|
|
logger.debug("命名HTTP客户端已关闭: {}", name)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
cls._clients.clear()
|
2026-01-08 13:34:59 +08:00
|
|
|
|
|
|
|
|
|
|
# 关闭代理客户端缓存
|
|
|
|
|
|
for cache_key, (client, _) in cls._proxy_clients.items():
|
|
|
|
|
|
try:
|
|
|
|
|
|
await client.aclose()
|
2026-02-08 13:33:08 +08:00
|
|
|
|
logger.debug("代理客户端已关闭: {}", cache_key)
|
2026-01-08 13:34:59 +08:00
|
|
|
|
except Exception as e:
|
2026-02-08 13:33:08 +08:00
|
|
|
|
logger.warning("关闭代理客户端失败: {}", e)
|
2026-01-08 13:34:59 +08:00
|
|
|
|
|
|
|
|
|
|
cls._proxy_clients.clear()
|
2026-02-08 13:33:08 +08:00
|
|
|
|
|
2026-02-25 21:59:29 +08:00
|
|
|
|
# 关闭 tunnel 客户端缓存
|
2026-03-10 09:52:21 +08:00
|
|
|
|
for nid, (client, _) in cls._tunnel_clients.items():
|
2026-02-08 13:33:08 +08:00
|
|
|
|
try:
|
|
|
|
|
|
await client.aclose()
|
2026-02-25 21:59:29 +08:00
|
|
|
|
logger.debug("tunnel 客户端已关闭: {}", nid)
|
2026-02-08 13:33:08 +08:00
|
|
|
|
except Exception as e:
|
2026-02-25 21:59:29 +08:00
|
|
|
|
logger.warning("关闭 tunnel 客户端失败: {}", e)
|
2026-02-08 13:33:08 +08:00
|
|
|
|
|
2026-02-25 21:59:29 +08:00
|
|
|
|
cls._tunnel_clients.clear()
|
2026-02-27 18:10:27 +08:00
|
|
|
|
|
|
|
|
|
|
# 关闭 curl_cffi session 缓存
|
|
|
|
|
|
try:
|
|
|
|
|
|
from src.clients.curl_cffi_transport import CURL_CFFI_AVAILABLE, close_all_sessions
|
|
|
|
|
|
|
|
|
|
|
|
if CURL_CFFI_AVAILABLE:
|
|
|
|
|
|
await close_all_sessions()
|
|
|
|
|
|
logger.debug("curl_cffi sessions 已关闭")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.debug("关闭 curl_cffi sessions 失败: {}", e)
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
logger.info("所有HTTP客户端已关闭")
|
|
|
|
|
|
|
2026-03-14 11:59:07 +08:00
|
|
|
|
@classmethod
|
|
|
|
|
|
async def cleanup_idle_clients(
|
|
|
|
|
|
cls,
|
|
|
|
|
|
max_idle_seconds: int | None = None,
|
|
|
|
|
|
) -> dict[str, int]:
|
|
|
|
|
|
"""清理空闲的代理/Tunnel 客户端并关闭连接池资源。"""
|
|
|
|
|
|
idle_seconds = max_idle_seconds
|
|
|
|
|
|
if idle_seconds is None:
|
|
|
|
|
|
idle_seconds = _get_int_env("HTTP_CLIENT_IDLE_CLEANUP_MAX_SECONDS", 600, minimum=60)
|
|
|
|
|
|
|
|
|
|
|
|
now = time.time()
|
|
|
|
|
|
stale_proxy_clients: list[tuple[str, httpx.AsyncClient]] = []
|
|
|
|
|
|
stale_tunnel_clients: list[tuple[str, httpx.AsyncClient]] = []
|
|
|
|
|
|
removed_closed_proxy = 0
|
|
|
|
|
|
removed_closed_tunnel = 0
|
|
|
|
|
|
|
|
|
|
|
|
lock = cls._get_proxy_clients_lock()
|
|
|
|
|
|
async with lock:
|
|
|
|
|
|
for cache_key, (client, last_used) in list(cls._proxy_clients.items()):
|
|
|
|
|
|
if client.is_closed:
|
|
|
|
|
|
cls._proxy_clients.pop(cache_key, None)
|
|
|
|
|
|
removed_closed_proxy += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
if now - last_used > idle_seconds:
|
|
|
|
|
|
entry = cls._proxy_clients.pop(cache_key, None)
|
|
|
|
|
|
if entry is not None:
|
|
|
|
|
|
stale_proxy_clients.append((cache_key, entry[0]))
|
|
|
|
|
|
|
|
|
|
|
|
for node_id, (client, last_used) in list(cls._tunnel_clients.items()):
|
|
|
|
|
|
if client.is_closed:
|
|
|
|
|
|
cls._tunnel_clients.pop(node_id, None)
|
|
|
|
|
|
removed_closed_tunnel += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
if now - last_used > idle_seconds:
|
|
|
|
|
|
entry = cls._tunnel_clients.pop(node_id, None)
|
|
|
|
|
|
if entry is not None:
|
|
|
|
|
|
stale_tunnel_clients.append((node_id, entry[0]))
|
|
|
|
|
|
|
|
|
|
|
|
proxy_closed = 0
|
|
|
|
|
|
tunnel_closed = 0
|
|
|
|
|
|
for cache_key, client in stale_proxy_clients:
|
|
|
|
|
|
try:
|
|
|
|
|
|
await client.aclose()
|
|
|
|
|
|
proxy_closed += 1
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning("关闭空闲代理客户端失败(key={}): {}", cache_key, e)
|
|
|
|
|
|
|
|
|
|
|
|
for node_id, client in stale_tunnel_clients:
|
|
|
|
|
|
try:
|
|
|
|
|
|
await client.aclose()
|
|
|
|
|
|
tunnel_closed += 1
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning("关闭空闲 Tunnel 客户端失败(node_id={}): {}", node_id, e)
|
|
|
|
|
|
|
|
|
|
|
|
if proxy_closed or tunnel_closed or removed_closed_proxy or removed_closed_tunnel:
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"HTTP 客户端空闲清理完成: proxy_closed={}, tunnel_closed={}, "
|
|
|
|
|
|
"proxy_already_closed={}, tunnel_already_closed={}, idle_seconds={}",
|
|
|
|
|
|
proxy_closed,
|
|
|
|
|
|
tunnel_closed,
|
|
|
|
|
|
removed_closed_proxy,
|
|
|
|
|
|
removed_closed_tunnel,
|
|
|
|
|
|
idle_seconds,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"proxy_closed": proxy_closed,
|
|
|
|
|
|
"tunnel_closed": tunnel_closed,
|
|
|
|
|
|
"proxy_already_closed": removed_closed_proxy,
|
|
|
|
|
|
"tunnel_already_closed": removed_closed_tunnel,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
@classmethod
|
|
|
|
|
|
@asynccontextmanager
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def get_temp_client(cls, **kwargs: Any) -> Any:
|
2025-12-10 20:52:44 +08:00
|
|
|
|
"""
|
|
|
|
|
|
获取临时HTTP客户端(上下文管理器)
|
|
|
|
|
|
|
|
|
|
|
|
用于一次性请求,使用后自动关闭
|
|
|
|
|
|
|
|
|
|
|
|
用法:
|
|
|
|
|
|
async with HTTPClientPool.get_temp_client() as client:
|
|
|
|
|
|
response = await client.get('https://example.com')
|
|
|
|
|
|
"""
|
2025-12-30 15:06:55 +08:00
|
|
|
|
default_config = {
|
2026-02-28 20:53:11 +08:00
|
|
|
|
"http2": config.enable_http2,
|
2026-01-19 17:56:02 +08:00
|
|
|
|
"verify": get_ssl_context(),
|
2025-12-30 15:06:55 +08:00
|
|
|
|
"timeout": httpx.Timeout(
|
|
|
|
|
|
connect=config.http_connect_timeout,
|
|
|
|
|
|
read=config.http_read_timeout,
|
|
|
|
|
|
write=config.http_write_timeout,
|
|
|
|
|
|
pool=config.http_pool_timeout,
|
|
|
|
|
|
),
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
2025-12-30 15:06:55 +08:00
|
|
|
|
default_config.update(kwargs)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
client = httpx.AsyncClient(**default_config) # type: ignore[arg-type]
|
2025-12-10 20:52:44 +08:00
|
|
|
|
try:
|
|
|
|
|
|
yield client
|
|
|
|
|
|
finally:
|
|
|
|
|
|
await client.aclose()
|
|
|
|
|
|
|
2026-03-08 01:27:50 +08:00
|
|
|
|
@classmethod
|
|
|
|
|
|
async def _reset_default_client(cls) -> bool:
|
|
|
|
|
|
"""Atomically replace the shared default client with a fresh instance.
|
|
|
|
|
|
|
|
|
|
|
|
The old client is kept open briefly so that in-flight requests can
|
|
|
|
|
|
finish on their existing HTTP/2 streams; it is closed asynchronously
|
|
|
|
|
|
after a short grace period.
|
|
|
|
|
|
"""
|
|
|
|
|
|
async with _default_client_lock:
|
|
|
|
|
|
old_client = cls._default_client
|
|
|
|
|
|
if old_client is None:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
# Create a new client before discarding the old one
|
|
|
|
|
|
cls._default_client = httpx.AsyncClient(
|
|
|
|
|
|
http2=config.enable_http2,
|
|
|
|
|
|
verify=get_ssl_context(),
|
|
|
|
|
|
timeout=httpx.Timeout(
|
|
|
|
|
|
connect=config.http_connect_timeout,
|
|
|
|
|
|
read=config.http_read_timeout,
|
|
|
|
|
|
write=config.http_write_timeout,
|
|
|
|
|
|
pool=config.http_pool_timeout,
|
|
|
|
|
|
),
|
|
|
|
|
|
limits=httpx.Limits(
|
|
|
|
|
|
max_connections=config.http_max_connections,
|
|
|
|
|
|
max_keepalive_connections=config.http_keepalive_connections,
|
|
|
|
|
|
keepalive_expiry=config.http_keepalive_expiry,
|
|
|
|
|
|
),
|
|
|
|
|
|
follow_redirects=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Close old client after a grace period so in-flight requests can drain
|
|
|
|
|
|
async def _close_old() -> None:
|
|
|
|
|
|
await asyncio.sleep(5)
|
|
|
|
|
|
try:
|
|
|
|
|
|
await old_client.aclose()
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.warning("关闭旧默认客户端失败: {}", exc)
|
|
|
|
|
|
|
|
|
|
|
|
task = asyncio.create_task(_close_old())
|
|
|
|
|
|
cls._background_tasks.add(task)
|
|
|
|
|
|
task.add_done_callback(cls._background_tasks.discard)
|
|
|
|
|
|
logger.warning("默认HTTP客户端已重建(HTTP/2 流容量恢复)")
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
2026-02-08 13:33:08 +08:00
|
|
|
|
@classmethod
|
2026-03-06 17:55:14 +08:00
|
|
|
|
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.
|
2026-03-08 01:27:50 +08:00
|
|
|
|
For the shared no-proxy default client, atomically replaces it with a
|
|
|
|
|
|
new instance so that subsequent requests get a fresh HTTP/2 connection
|
|
|
|
|
|
while in-flight requests on the old client can finish naturally.
|
2026-03-06 17:55:14 +08:00
|
|
|
|
"""
|
|
|
|
|
|
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:
|
2026-03-10 09:52:21 +08:00
|
|
|
|
entry = cls._tunnel_clients.pop(node_id, None)
|
|
|
|
|
|
if entry is None:
|
2026-03-06 17:55:14 +08:00
|
|
|
|
return False
|
2026-03-10 09:52:21 +08:00
|
|
|
|
client, _ = entry
|
2026-03-06 17:55:14 +08:00
|
|
|
|
try:
|
|
|
|
|
|
await client.aclose()
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.warning("关闭 Tunnel 客户端失败(node_id={}): {}", node_id, exc)
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
if not proxy_config:
|
2026-03-12 13:59:21 +08:00
|
|
|
|
proxy_config = await get_system_proxy_config_async()
|
2026-03-06 17:55:14 +08:00
|
|
|
|
|
|
|
|
|
|
base_cache_key = compute_proxy_cache_key(proxy_config)
|
|
|
|
|
|
if base_cache_key == "__no_proxy__":
|
2026-03-08 01:27:50 +08:00
|
|
|
|
return await cls._reset_default_client()
|
2026-03-06 17:55:14 +08:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
2026-03-06 18:01:28 +08:00
|
|
|
|
@classmethod
|
2026-02-08 13:33:08 +08:00
|
|
|
|
async def get_upstream_client(
|
|
|
|
|
|
cls,
|
|
|
|
|
|
delegate_cfg: dict[str, Any] | None,
|
|
|
|
|
|
proxy_config: dict[str, Any] | None = None,
|
2026-02-27 13:54:46 +08:00
|
|
|
|
tls_profile: str | None = None,
|
2026-02-08 13:33:08 +08:00
|
|
|
|
) -> httpx.AsyncClient:
|
|
|
|
|
|
"""
|
2026-02-25 21:59:29 +08:00
|
|
|
|
获取可复用的上游请求客户端(自动选择 tunnel/代理模式)
|
2026-02-08 13:33:08 +08:00
|
|
|
|
|
2026-02-25 21:59:29 +08:00
|
|
|
|
tunnel 模式(delegate_cfg.tunnel=True):返回 TunnelTransport 客户端
|
2026-02-08 13:33:08 +08:00
|
|
|
|
直连/代理模式:返回代理客户端(含系统默认代理回退)
|
|
|
|
|
|
"""
|
2026-02-25 21:59:29 +08:00
|
|
|
|
if delegate_cfg and delegate_cfg.get("tunnel"):
|
|
|
|
|
|
return await cls._get_tunnel_client(delegate_cfg["node_id"])
|
2026-02-27 13:54:46 +08:00
|
|
|
|
return await cls.get_proxy_client(proxy_config=proxy_config, tls_profile=tls_profile)
|
2026-02-08 13:33:08 +08:00
|
|
|
|
|
|
|
|
|
|
@classmethod
|
2026-02-25 21:59:29 +08:00
|
|
|
|
async def create_upstream_stream_client(
|
2026-02-08 13:33:08 +08:00
|
|
|
|
cls,
|
|
|
|
|
|
delegate_cfg: dict[str, Any] | None,
|
|
|
|
|
|
proxy_config: dict[str, Any] | None = None,
|
|
|
|
|
|
timeout: httpx.Timeout | None = None,
|
2026-02-27 13:54:46 +08:00
|
|
|
|
tls_profile: str | None = None,
|
2026-02-08 13:33:08 +08:00
|
|
|
|
) -> httpx.AsyncClient:
|
|
|
|
|
|
"""
|
2026-02-25 21:59:29 +08:00
|
|
|
|
创建上游流式请求客户端(自动选择 tunnel/代理模式)
|
2026-02-08 13:33:08 +08:00
|
|
|
|
|
|
|
|
|
|
调用者需负责关闭返回的客户端。
|
|
|
|
|
|
"""
|
2026-02-25 21:59:29 +08:00
|
|
|
|
if delegate_cfg and delegate_cfg.get("tunnel"):
|
|
|
|
|
|
return await cls._get_tunnel_client(delegate_cfg["node_id"], timeout=timeout)
|
2026-03-12 13:59:21 +08:00
|
|
|
|
client_config: dict[str, Any] = {
|
|
|
|
|
|
"http2": config.enable_http2,
|
|
|
|
|
|
"verify": get_ssl_context_for_profile(tls_profile),
|
|
|
|
|
|
"follow_redirects": True,
|
|
|
|
|
|
}
|
|
|
|
|
|
if timeout:
|
|
|
|
|
|
client_config["timeout"] = timeout
|
|
|
|
|
|
else:
|
|
|
|
|
|
client_config["timeout"] = httpx.Timeout(
|
|
|
|
|
|
connect=config.http_connect_timeout,
|
|
|
|
|
|
read=config.http_read_timeout,
|
|
|
|
|
|
write=config.http_write_timeout,
|
|
|
|
|
|
pool=config.http_pool_timeout,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
resolved_proxy_config = proxy_config
|
|
|
|
|
|
if resolved_proxy_config is None:
|
|
|
|
|
|
resolved_proxy_config = await get_system_proxy_config_async()
|
|
|
|
|
|
|
|
|
|
|
|
proxy_url = (
|
|
|
|
|
|
await build_proxy_url_async(resolved_proxy_config) if resolved_proxy_config else None
|
2026-02-27 13:54:46 +08:00
|
|
|
|
)
|
2026-03-12 13:59:21 +08:00
|
|
|
|
proxy_param = make_proxy_param(proxy_url)
|
|
|
|
|
|
if proxy_param:
|
|
|
|
|
|
client_config["proxy"] = proxy_param
|
|
|
|
|
|
logger.debug(
|
|
|
|
|
|
"创建带代理的HTTP客户端(一次性): {}",
|
|
|
|
|
|
resolved_proxy_config.get("url", "unknown") if resolved_proxy_config else "unknown",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return httpx.AsyncClient(**client_config)
|
2026-02-08 13:33:08 +08:00
|
|
|
|
|
2026-02-25 21:59:29 +08:00
|
|
|
|
@classmethod
|
|
|
|
|
|
async def _get_tunnel_client(
|
|
|
|
|
|
cls,
|
|
|
|
|
|
node_id: str,
|
|
|
|
|
|
timeout: httpx.Timeout | None = None,
|
|
|
|
|
|
) -> httpx.AsyncClient:
|
|
|
|
|
|
"""获取使用 TunnelTransport 的 httpx 客户端
|
|
|
|
|
|
|
|
|
|
|
|
当 timeout 为 None 时(非流式请求),返回按 node_id 缓存的 client,
|
|
|
|
|
|
调用方不应关闭此 client,其生命周期由 HTTPClientPool 管理。
|
|
|
|
|
|
当 timeout 非 None 时(流式请求),每次创建新 client,由调用方负责关闭。
|
|
|
|
|
|
"""
|
2026-03-02 02:43:14 +08:00
|
|
|
|
from src.services.proxy_node.tunnel_transport import create_tunnel_transport
|
2026-02-25 21:59:29 +08:00
|
|
|
|
|
|
|
|
|
|
t = timeout or httpx.Timeout(
|
|
|
|
|
|
connect=config.http_connect_timeout,
|
|
|
|
|
|
read=config.http_read_timeout,
|
|
|
|
|
|
write=config.http_write_timeout,
|
|
|
|
|
|
pool=config.http_pool_timeout,
|
|
|
|
|
|
)
|
|
|
|
|
|
timeout_secs = t.read if isinstance(t, httpx.Timeout) else 60.0
|
|
|
|
|
|
|
|
|
|
|
|
# 流式请求:每次创建新 client(调用方负责关闭)
|
|
|
|
|
|
if timeout is not None:
|
2026-03-02 02:43:14 +08:00
|
|
|
|
transport = create_tunnel_transport(node_id, timeout=timeout_secs or 60.0)
|
2026-02-25 21:59:29 +08:00
|
|
|
|
return httpx.AsyncClient(transport=transport, timeout=t)
|
|
|
|
|
|
|
|
|
|
|
|
# 非流式请求:复用缓存的 client(加锁与 proxy_clients 保持一致)
|
|
|
|
|
|
lock = cls._get_proxy_clients_lock()
|
|
|
|
|
|
async with lock:
|
2026-03-10 09:52:21 +08:00
|
|
|
|
entry = cls._tunnel_clients.get(node_id)
|
|
|
|
|
|
if entry is not None:
|
|
|
|
|
|
existing, _ = entry
|
|
|
|
|
|
if not existing.is_closed:
|
|
|
|
|
|
cls._tunnel_clients[node_id] = (existing, time.time())
|
|
|
|
|
|
return existing
|
|
|
|
|
|
del cls._tunnel_clients[node_id]
|
|
|
|
|
|
|
|
|
|
|
|
# 淘汰最久未使用的 tunnel 客户端
|
|
|
|
|
|
if len(cls._tunnel_clients) >= cls._max_tunnel_clients:
|
|
|
|
|
|
oldest_nid = min(cls._tunnel_clients, key=lambda k: cls._tunnel_clients[k][1])
|
|
|
|
|
|
old_client, _ = cls._tunnel_clients.pop(oldest_nid)
|
|
|
|
|
|
try:
|
|
|
|
|
|
await old_client.aclose()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
logger.debug("淘汰 tunnel 客户端: {}", oldest_nid)
|
2026-02-25 21:59:29 +08:00
|
|
|
|
|
2026-03-02 02:43:14 +08:00
|
|
|
|
transport = create_tunnel_transport(node_id, timeout=timeout_secs or 60.0)
|
2026-02-25 21:59:29 +08:00
|
|
|
|
client = httpx.AsyncClient(transport=transport, timeout=t)
|
2026-03-10 09:52:21 +08:00
|
|
|
|
cls._tunnel_clients[node_id] = (client, time.time())
|
2026-02-25 21:59:29 +08:00
|
|
|
|
return client
|
|
|
|
|
|
|
2026-01-08 13:34:59 +08:00
|
|
|
|
@classmethod
|
2026-01-30 03:10:21 +08:00
|
|
|
|
def get_pool_stats(cls) -> dict[str, Any]:
|
2026-01-08 13:34:59 +08:00
|
|
|
|
"""获取连接池统计信息"""
|
|
|
|
|
|
return {
|
|
|
|
|
|
"default_client_active": cls._default_client is not None,
|
|
|
|
|
|
"named_clients_count": len(cls._clients),
|
|
|
|
|
|
"proxy_clients_count": len(cls._proxy_clients),
|
|
|
|
|
|
"max_proxy_clients": cls._max_proxy_clients,
|
2026-02-25 21:59:29 +08:00
|
|
|
|
"tunnel_clients_count": len(cls._tunnel_clients),
|
2026-01-08 13:34:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
# 便捷访问函数
|
|
|
|
|
|
def get_http_client() -> httpx.AsyncClient:
|
|
|
|
|
|
"""获取默认HTTP客户端的便捷函数"""
|
|
|
|
|
|
return HTTPClientPool.get_default_client()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def close_http_clients() -> None:
|
2025-12-10 20:52:44 +08:00
|
|
|
|
"""关闭所有HTTP客户端的便捷函数"""
|
|
|
|
|
|
await HTTPClientPool.close_all()
|