refactor: 移除 Python 后端源码,全面迁移至 Rust gateway 架构

- 删除全部 Python 源码 (src/) 及 Alembic 迁移脚本,归档至 _deprecated_py_src/
- 重构 Rust gateway ai_pipeline: 拆分 planner/finalize 模块,新增 contracts/adaptation 层
- 重组 handlers 模块为 admin/public/proxy/internal/shared 子模块结构
- 新增 executor 模块,引入 Rust 原生数据库迁移 (aether-data/migrations)
- 简化 CI/Docker 构建流程,移除 base image 二级构建,统一为单一 app image
- 移除 Python 相关基础设施文件 (entrypoint.sh, gunicorn_conf.py, Dockerfile.base)
This commit is contained in:
fawney19
2026-04-03 16:26:16 +08:00
parent 8f26e1a31f
commit 1d9c77522a
868 changed files with 1735 additions and 2433 deletions

View File

@@ -0,0 +1,11 @@
from .http_client import HTTPClientPool, close_http_clients, get_http_client
from .redis_client import close_redis_client, get_redis_client, get_redis_client_sync
__all__ = [
"HTTPClientPool",
"get_http_client",
"close_http_clients",
"get_redis_client",
"get_redis_client_sync",
"close_redis_client",
]

View File

@@ -0,0 +1,258 @@
"""curl_cffi-based httpx AsyncTransport for TLS fingerprint impersonation.
When ``curl_cffi`` is installed, this transport can replace the default httpx
transport to send upstream requests with a browser-grade TLS fingerprint
(JA3/JA4), making the traffic indistinguishable from a real browser or
Node.js client.
The transport is used exclusively when ``tls_profile == "claude_code_nodejs"``
and ``curl_cffi`` is available. Otherwise, the system falls back to the
default httpx SSL context (best-effort cipher ordering only).
Design notes:
- curl_cffi AsyncSession instances are **reused** per (impersonate, proxy) pair
to avoid rebuilding the TLS session on every request.
- Streaming is supported via ``aiter_content()`` on the curl_cffi response.
- The transport implements ``httpx.AsyncBaseTransport`` so it plugs into
the existing ``HTTPClientPool`` without changing callers.
"""
from __future__ import annotations
import asyncio
import os
from collections import OrderedDict
from typing import Any
import httpx
from src.core.logger import logger
# ---------------------------------------------------------------------------
# Availability check
# ---------------------------------------------------------------------------
try:
from curl_cffi.requests import AsyncSession # type: ignore[import-untyped]
CURL_CFFI_AVAILABLE = True
except ImportError:
CURL_CFFI_AVAILABLE = False
# ---------------------------------------------------------------------------
# Default impersonate profile
# ---------------------------------------------------------------------------
# "chrome120" closely matches the TLS fingerprint of Node.js 20.x on Linux
# (which Claude Code CLI uses). If the upstream introduces fingerprint
# rotation, this can be made configurable per-profile.
DEFAULT_IMPERSONATE = "chrome120"
def _get_max_sessions() -> int:
raw = os.getenv("CURL_CFFI_MAX_SESSIONS", "20")
try:
value = int(raw)
except ValueError:
logger.warning("环境变量 CURL_CFFI_MAX_SESSIONS 非法: {}, 使用默认值 20", raw)
return 20
return max(1, value)
# ---------------------------------------------------------------------------
# Session pool (module-level, async-safe)
# ---------------------------------------------------------------------------
_MAX_SESSIONS = _get_max_sessions()
_session_pool: OrderedDict[str, AsyncSession] = OrderedDict()
_pool_lock = asyncio.Lock()
def _session_key(impersonate: str, proxy: str | None) -> str:
return f"{impersonate}::{proxy or '__direct__'}"
async def _get_or_create_session(
impersonate: str = DEFAULT_IMPERSONATE,
proxy: str | None = None,
) -> AsyncSession:
"""Get or create a cached curl_cffi AsyncSession."""
key = _session_key(impersonate, proxy)
evicted_sessions: list[tuple[str, AsyncSession]] = []
async with _pool_lock:
session = _session_pool.get(key)
if session is not None:
_session_pool.move_to_end(key)
return session
kwargs: dict[str, Any] = {
"impersonate": impersonate,
"verify": True,
}
if proxy:
kwargs["proxy"] = proxy
session = AsyncSession(**kwargs)
_session_pool[key] = session
_session_pool.move_to_end(key)
while len(_session_pool) > _MAX_SESSIONS:
old_key, old_session = _session_pool.popitem(last=False)
evicted_sessions.append((old_key, old_session))
logger.info(
"curl_cffi session created: impersonate={}, proxy={}",
impersonate,
proxy or "direct",
)
for old_key, old_session in evicted_sessions:
try:
await old_session.close()
logger.debug("curl_cffi session evicted: {}", old_key)
except Exception as exc:
logger.warning("curl_cffi session close failed during eviction ({}): {}", old_key, exc)
return session
async def close_all_sessions() -> None:
"""Close all cached curl_cffi sessions (called at shutdown)."""
async with _pool_lock:
sessions = list(_session_pool.values())
_session_pool.clear()
for s in sessions:
try:
await s.close()
except Exception:
pass
# ---------------------------------------------------------------------------
# Exception mapping (curl_cffi -> httpx)
# ---------------------------------------------------------------------------
def _map_curl_exception(exc: Exception) -> httpx.HTTPError:
"""Map curl_cffi exceptions to the closest httpx equivalents.
This lets the upstream failover / error_classifier distinguish between
transient timeouts (retryable) and hard connection failures.
"""
if CURL_CFFI_AVAILABLE:
from curl_cffi.requests.exceptions import ConnectionError as CurlConnectionError
from curl_cffi.requests.exceptions import ProxyError as CurlProxyError
from curl_cffi.requests.exceptions import Timeout as CurlTimeout
if isinstance(exc, CurlTimeout):
return httpx.ReadTimeout(f"curl_cffi timeout: {exc}")
if isinstance(exc, CurlProxyError):
return httpx.ProxyError(f"curl_cffi proxy error: {exc}")
if isinstance(exc, CurlConnectionError):
return httpx.ConnectError(f"curl_cffi connection error: {exc}")
return httpx.ConnectError(f"curl_cffi request failed: {exc}")
# ---------------------------------------------------------------------------
# httpx AsyncTransport implementation
# ---------------------------------------------------------------------------
class CurlCffiStream(httpx.AsyncByteStream):
"""Async byte stream backed by curl_cffi response content iterator."""
def __init__(self, curl_response: Any) -> None:
self._response = curl_response
self._consumed = False
async def __aiter__(self) -> Any: # type: ignore[override]
if self._consumed:
return
try:
async for chunk in self._response.aiter_content():
yield chunk
finally:
self._consumed = True
async def aclose(self) -> None:
self._consumed = True
close_fn = getattr(self._response, "aclose", None)
if close_fn and callable(close_fn):
try:
await close_fn()
except Exception:
pass
class CurlCffiTransport(httpx.AsyncBaseTransport):
"""httpx-compatible async transport using curl_cffi for TLS impersonation.
Usage::
transport = CurlCffiTransport(proxy="http://proxy:8080")
client = httpx.AsyncClient(transport=transport)
resp = await client.post(url, json=payload, headers=headers)
"""
def __init__(
self,
impersonate: str = DEFAULT_IMPERSONATE,
proxy: str | None = None,
) -> None:
self._impersonate = impersonate
self._proxy = proxy
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
session = await _get_or_create_session(self._impersonate, self._proxy)
# Build headers dict (skip host header, curl_cffi handles it).
headers: dict[str, str] = {}
for key, value in request.headers.raw:
k = key.decode("latin-1").lower()
if k in ("host", "content-length", "transfer-encoding"):
continue
headers[key.decode("latin-1")] = value.decode("latin-1")
body = request.content if request.content else None
method = request.method.upper()
url = str(request.url)
# Determine timeout from request extensions.
timeout = 60.0
if hasattr(request, "extensions") and isinstance(request.extensions, dict):
raw_timeout = request.extensions.get("timeout")
if isinstance(raw_timeout, dict):
# httpx timeout pool format: {"connect": ..., "read": ..., "write": ..., "pool": ...}
read_timeout = raw_timeout.get("read")
if isinstance(read_timeout, (int, float)) and read_timeout > 0:
timeout = float(read_timeout)
elif isinstance(raw_timeout, (int, float)) and raw_timeout > 0:
timeout = float(raw_timeout)
try:
# Use stream=True for all requests so we can support streaming responses.
curl_resp = await session.request(
method,
url,
headers=headers,
data=body,
timeout=timeout,
stream=True,
)
except Exception as exc:
raise _map_curl_exception(exc) from exc
# Build response headers.
resp_headers_list: list[tuple[bytes, bytes]] = []
if hasattr(curl_resp, "headers") and curl_resp.headers:
for k, v in curl_resp.headers.multi_items():
resp_headers_list.append((k.encode("latin-1"), v.encode("latin-1")))
return httpx.Response(
status_code=curl_resp.status_code,
headers=resp_headers_list,
stream=CurlCffiStream(curl_resp),
request=request,
)
__all__ = [
"CURL_CFFI_AVAILABLE",
"CurlCffiTransport",
"close_all_sessions",
"DEFAULT_IMPERSONATE",
]

View File

@@ -0,0 +1,758 @@
"""
全局HTTP客户端池管理
避免每次请求都创建新的AsyncClient,提高性能
性能优化说明:
1. 默认客户端:无代理场景,全局复用单一客户端
2. 代理客户端缓存:相同代理配置复用同一客户端,避免重复创建
3. 连接池复用Keep-alive 连接减少 TCP 握手开销
"""
from __future__ import annotations
import asyncio
import os
import time
from contextlib import asynccontextmanager
from typing import Any
import httpx
from src.config import config
from src.core.logger import logger
from src.services.proxy_node.resolver import (
build_proxy_url_async,
compute_proxy_cache_key,
get_system_proxy_config_async,
make_proxy_param,
resolve_delegate_config_async,
)
from src.utils.ssl_utils import get_ssl_context, get_ssl_context_for_profile
# 模块级锁,避免类属性延迟初始化的竞态条件
_proxy_clients_lock = asyncio.Lock()
_default_client_lock = asyncio.Lock()
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)
class HTTPClientPool:
"""
全局HTTP客户端池单例
管理可重用的httpx.AsyncClient实例,避免频繁创建/销毁连接
性能优化:
1. 默认客户端:无代理场景复用
2. 代理客户端缓存:相同代理配置复用同一客户端
3. LRU 淘汰:代理客户端超过上限时淘汰最久未使用的
"""
_instance: HTTPClientPool | None = None
_default_client: httpx.AsyncClient | None = None
_clients: dict[str, httpx.AsyncClient] = {}
_max_named_clients: int = 20
# 代理客户端缓存:{cache_key: (client, last_used_time)}
_proxy_clients: dict[str, tuple[httpx.AsyncClient, float]] = {}
# 代理客户端缓存上限(避免内存泄漏)
_max_proxy_clients: int = 50
# Tunnel 客户端缓存:{node_id: (client, last_used_time)}
_tunnel_clients: dict[str, tuple[httpx.AsyncClient, float]] = {}
_max_tunnel_clients: int = 30
# 后台清理任务引用集合(防止被 GC 回收)
_background_tasks: set[asyncio.Task[None]] = set()
def __new__(cls) -> "HTTPClientPool":
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
@classmethod
async def get_default_client_async(cls) -> httpx.AsyncClient:
"""
获取默认的HTTP客户端异步线程安全版本
用于大多数HTTP请求,具有合理的默认配置
"""
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(
http2=config.enable_http2,
verify=get_ssl_context(), # 使用 certifi 证书
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(
"全局HTTP客户端池已初始化: max_connections={}, keepalive={}, keepalive_expiry={}s",
config.http_max_connections,
config.http_keepalive_connections,
config.http_keepalive_expiry,
)
return cls._default_client
@classmethod
def get_default_client(cls) -> httpx.AsyncClient:
"""
获取默认的HTTP客户端同步版本向后兼容
⚠️ 注意:此方法在高并发首次调用时可能存在竞态条件,
推荐使用 get_default_client_async() 异步版本。
"""
if cls._default_client is None:
cls._default_client = httpx.AsyncClient(
http2=config.enable_http2,
verify=get_ssl_context(), # 使用 certifi 证书
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(
"全局HTTP客户端池已初始化: max_connections={}, keepalive={}, keepalive_expiry={}s",
config.http_max_connections,
config.http_keepalive_connections,
config.http_keepalive_expiry,
)
return cls._default_client
@classmethod
def get_client(cls, name: str, **kwargs: Any) -> httpx.AsyncClient:
"""
获取或创建命名的HTTP客户端
用于需要特定配置的场景(如不同的超时设置、代理等)
Args:
name: 客户端标识符
**kwargs: httpx.AsyncClient的配置参数
"""
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)
cls._clients[name] = httpx.AsyncClient(**default_config) # type: ignore[arg-type]
logger.debug("创建命名HTTP客户端: {}", name)
return cls._clients[name]
@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()
logger.debug("淘汰代理客户端: {}", oldest_key)
except Exception as e:
logger.warning("关闭代理客户端失败: {}", e)
@classmethod
async def get_proxy_client(
cls,
proxy_config: dict[str, Any] | None = None,
tls_profile: str | None = None,
) -> httpx.AsyncClient:
"""
获取代理客户端(带缓存复用)
相同代理配置会复用同一个客户端,大幅减少连接建立开销。
当 proxy_config 为 None 时,自动回退到系统默认代理节点。
注意:返回的客户端使用默认超时配置,如需自定义超时请在请求时传递 timeout 参数。
Args:
proxy_config: 代理配置字典,为 None 时使用系统默认代理
Returns:
可复用的 httpx.AsyncClient 实例
"""
# 无特定代理时,回退到系统默认代理
if not proxy_config:
proxy_config = await get_system_proxy_config_async()
delegate_cfg = await resolve_delegate_config_async(proxy_config)
if delegate_cfg and delegate_cfg.get("tunnel"):
return await cls._get_tunnel_client(delegate_cfg["node_id"])
cache_key = compute_proxy_cache_key(proxy_config)
tls_profile_key = str(tls_profile or "").strip().lower()
if tls_profile_key:
cache_key = f"{cache_key}::tls:{tls_profile_key}"
# 无代理时返回默认客户端
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]
logger.debug("代理客户端已关闭,将重新创建: {}", cache_key)
else:
# 更新最后使用时间
cls._proxy_clients[cache_key] = (client, time.time())
if tls_profile_key:
logger.debug(
"复用代理客户端 TLS profile={} key={}", tls_profile_key, cache_key
)
return client
# 淘汰旧客户端(如果超过上限)
await cls._evict_lru_proxy_client()
# 添加代理配置
proxy_url = await build_proxy_url_async(proxy_config) if proxy_config else None
# curl_cffi Transport: real TLS fingerprint impersonation.
# 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:
from src.services.provider.fingerprint import KNOWN_IMPERSONATE_PROFILES
use_curl_cffi_tls = (
tls_profile_key == "claude_code_nodejs"
or tls_profile_key in KNOWN_IMPERSONATE_PROFILES
)
if use_curl_cffi_tls:
from src.clients.curl_cffi_transport import (
CURL_CFFI_AVAILABLE,
CurlCffiTransport,
)
if CURL_CFFI_AVAILABLE:
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)
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,
)
# 创建新客户端(使用默认超时,请求时可覆盖)
client_config: dict[str, Any] = {
"http2": config.enable_http2,
"verify": get_ssl_context_for_profile(tls_profile),
"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,
),
}
proxy_param = make_proxy_param(proxy_url)
if proxy_param:
client_config["proxy"] = proxy_param
client = httpx.AsyncClient(**client_config) # type: ignore[arg-type]
cls._proxy_clients[cache_key] = (client, time.time())
proxy_label = "none"
if proxy_config:
proxy_label = str(
proxy_config.get("node_id") or proxy_config.get("url") or "unknown"
)
logger.debug(
"创建代理客户端(缓存): {}, 缓存数量: {}", proxy_label, len(cls._proxy_clients)
)
if tls_profile_key:
logger.debug("创建代理客户端 TLS profile={} key={}", tls_profile_key, cache_key)
return client
@classmethod
async def close_all(cls) -> None:
"""关闭所有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()
logger.debug("命名HTTP客户端已关闭: {}", name)
cls._clients.clear()
# 关闭代理客户端缓存
for cache_key, (client, _) in cls._proxy_clients.items():
try:
await client.aclose()
logger.debug("代理客户端已关闭: {}", cache_key)
except Exception as e:
logger.warning("关闭代理客户端失败: {}", e)
cls._proxy_clients.clear()
# 关闭 tunnel 客户端缓存
for nid, (client, _) in cls._tunnel_clients.items():
try:
await client.aclose()
logger.debug("tunnel 客户端已关闭: {}", nid)
except Exception as e:
logger.warning("关闭 tunnel 客户端失败: {}", e)
cls._tunnel_clients.clear()
# 关闭 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)
logger.info("所有HTTP客户端已关闭")
@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,
}
@classmethod
@asynccontextmanager
async def get_temp_client(cls, **kwargs: Any) -> Any:
"""
获取临时HTTP客户端(上下文管理器)
用于一次性请求,使用后自动关闭
用法:
async with HTTPClientPool.get_temp_client() as client:
response = await client.get('https://example.com')
"""
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,
),
}
default_config.update(kwargs)
client = httpx.AsyncClient(**default_config) # type: ignore[arg-type]
try:
yield client
finally:
await client.aclose()
@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
@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, 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.
"""
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:
entry = cls._tunnel_clients.pop(node_id, None)
if entry is None:
return False
client, _ = entry
try:
await client.aclose()
except Exception as exc:
logger.warning("关闭 Tunnel 客户端失败(node_id={}): {}", node_id, exc)
return True
if not proxy_config:
proxy_config = await get_system_proxy_config_async()
base_cache_key = compute_proxy_cache_key(proxy_config)
if base_cache_key == "__no_proxy__":
return await cls._reset_default_client()
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)
@classmethod
async def get_upstream_client(
cls,
delegate_cfg: dict[str, Any] | None,
proxy_config: dict[str, Any] | None = None,
tls_profile: str | None = None,
) -> httpx.AsyncClient:
"""
获取可复用的上游请求客户端(自动选择 tunnel/代理模式)
tunnel 模式(delegate_cfg.tunnel=True):返回 TunnelTransport 客户端
直连/代理模式:返回代理客户端(含系统默认代理回退)
"""
if delegate_cfg and delegate_cfg.get("tunnel"):
return await cls._get_tunnel_client(delegate_cfg["node_id"])
return await cls.get_proxy_client(proxy_config=proxy_config, tls_profile=tls_profile)
@classmethod
async def create_upstream_stream_client(
cls,
delegate_cfg: dict[str, Any] | None,
proxy_config: dict[str, Any] | None = None,
timeout: httpx.Timeout | None = None,
tls_profile: str | None = None,
) -> httpx.AsyncClient:
"""
创建上游流式请求客户端(自动选择 tunnel/代理模式)
调用者需负责关闭返回的客户端。
"""
if delegate_cfg and delegate_cfg.get("tunnel"):
return await cls._get_tunnel_client(delegate_cfg["node_id"], timeout=timeout)
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
)
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)
@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由调用方负责关闭。
"""
from src.services.proxy_node.tunnel_transport import create_tunnel_transport
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:
transport = create_tunnel_transport(node_id, timeout=timeout_secs or 60.0)
return httpx.AsyncClient(transport=transport, timeout=t)
# 非流式请求:复用缓存的 client加锁与 proxy_clients 保持一致)
lock = cls._get_proxy_clients_lock()
async with lock:
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)
transport = create_tunnel_transport(node_id, timeout=timeout_secs or 60.0)
client = httpx.AsyncClient(transport=transport, timeout=t)
cls._tunnel_clients[node_id] = (client, time.time())
return client
@classmethod
def get_pool_stats(cls) -> dict[str, Any]:
"""获取连接池统计信息"""
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,
"tunnel_clients_count": len(cls._tunnel_clients),
}
# 便捷访问函数
def get_http_client() -> httpx.AsyncClient:
"""获取默认HTTP客户端的便捷函数"""
return HTTPClientPool.get_default_client()
async def close_http_clients() -> None:
"""关闭所有HTTP客户端的便捷函数"""
await HTTPClientPool.close_all()

View File

@@ -0,0 +1,447 @@
"""
全局Redis客户端管理
提供统一的Redis客户端访问确保所有服务使用同一个连接池
熔断器说明:
- 连续失败达到阈值后开启熔断
- 熔断期间返回明确的状态而非静默失败
- 调用方可以根据状态决定降级策略
"""
from __future__ import annotations
import asyncio
import os
import time
from enum import Enum
import redis.asyncio as aioredis
from redis.asyncio import sentinel as redis_sentinel
from src.core.logger import logger
class RedisState(Enum):
"""Redis 连接状态"""
NOT_INITIALIZED = "not_initialized" # 未初始化
CONNECTED = "connected" # 已连接
CIRCUIT_OPEN = "circuit_open" # 熔断中
DISCONNECTED = "disconnected" # 断开连接
class RedisClientManager:
"""
Redis 客户端管理器
提供 Redis 连接管理、熔断器保护和状态监控。
"""
def __init__(
self,
*,
client_name: str,
encoding_errors: str = "strict",
degraded_warning: str | None = None,
) -> None:
self._redis: aioredis.Redis | None = None
self._redis_by_loop: dict[int, aioredis.Redis] = {}
self._primary_loop_key: int | None = None
self._client_name = client_name
self._encoding_errors = encoding_errors
self._degraded_warning = degraded_warning
self._circuit_open_until: float | None = None
self._consecutive_failures: int = 0
self._circuit_threshold = int(os.getenv("REDIS_CIRCUIT_BREAKER_THRESHOLD", "3"))
self._circuit_reset_seconds = int(os.getenv("REDIS_CIRCUIT_BREAKER_RESET_SECONDS", "60"))
self._last_error: str | None = None # 记录最后一次错误
@staticmethod
def _current_loop_key() -> int | None:
try:
return id(asyncio.get_running_loop())
except RuntimeError:
return None
def get_state(self) -> RedisState:
"""
获取 Redis 连接状态
Returns:
当前连接状态枚举值
"""
if self._redis_by_loop or self._redis is not None:
return RedisState.CONNECTED
if self._circuit_open_until and time.time() < self._circuit_open_until:
return RedisState.CIRCUIT_OPEN
if self._last_error:
return RedisState.DISCONNECTED
return RedisState.NOT_INITIALIZED
def get_circuit_info(self) -> dict:
"""
获取熔断器详细信息
Returns:
包含熔断器状态的字典
"""
state = self.get_state()
info = {
"state": state.value,
"consecutive_failures": self._consecutive_failures,
"circuit_threshold": self._circuit_threshold,
"last_error": self._last_error,
}
if state == RedisState.CIRCUIT_OPEN and self._circuit_open_until:
info["circuit_remaining_seconds"] = max(0, self._circuit_open_until - time.time())
return info
def reset_circuit_breaker(self) -> None:
"""
手动重置熔断器(用于管理后台紧急恢复)
"""
logger.info("{} 熔断器手动重置", self._client_name)
self._circuit_open_until = None
self._consecutive_failures = 0
self._last_error = None
async def initialize(self, require_redis: bool = False) -> aioredis.Redis | None:
"""
初始化Redis连接
Args:
require_redis: 是否强制要求Redis连接成功如果为True则连接失败时抛出异常
Returns:
Redis客户端实例如果连接失败返回None当require_redis=False时
Raises:
RuntimeError: 当require_redis=True且连接失败时
"""
loop_key = self._current_loop_key()
if loop_key is not None:
existing_client = self._redis_by_loop.get(loop_key)
if existing_client is not None:
return existing_client
# 检查熔断状态
if self._circuit_open_until and time.time() < self._circuit_open_until:
remaining = self._circuit_open_until - time.time()
logger.warning(
"{} 处于熔断状态,跳过初始化,剩余 {:.1f} 秒 (last_error: {})",
self._client_name,
remaining,
self._last_error,
)
if require_redis:
raise RuntimeError(
f"Redis 处于熔断状态,剩余 {remaining:.1f} 秒。"
f"最后错误: {self._last_error}"
"使用管理 API 重置熔断器或等待自动恢复。"
)
return None
# 优先使用 REDIS_URL如果没有则根据密码构建 URL
redis_url = os.getenv("REDIS_URL")
redis_max_conn = int(os.getenv("REDIS_MAX_CONNECTIONS", "50"))
sentinel_hosts = os.getenv("REDIS_SENTINEL_HOSTS")
sentinel_service = os.getenv("REDIS_SENTINEL_SERVICE_NAME", "mymaster")
redis_password = os.getenv("REDIS_PASSWORD")
if not redis_url and not sentinel_hosts:
# 本地开发模式:从 REDIS_PASSWORD 构建 URL
if redis_password:
redis_url = f"redis://:{redis_password}@localhost:6379/0"
else:
redis_url = "redis://localhost:6379/0"
try:
if sentinel_hosts:
sentinel_list = []
for host in sentinel_hosts.split(","):
host = host.strip()
if not host:
continue
if ":" in host:
hostname, port = host.split(":", 1)
sentinel_list.append((hostname, int(port)))
else:
sentinel_list.append((host, 26379))
sentinel_kwargs = {
"password": redis_password,
"socket_timeout": 5.0,
}
sentinel = redis_sentinel.Sentinel(
sentinel_list,
**sentinel_kwargs,
)
client = sentinel.master_for(
service_name=sentinel_service,
max_connections=redis_max_conn,
decode_responses=True,
encoding_errors=self._encoding_errors,
socket_connect_timeout=5.0,
health_check_interval=30, # 每 30 秒检查连接健康状态
)
safe_url = f"sentinel://{sentinel_service}"
else:
client = await aioredis.from_url(
redis_url,
encoding="utf-8",
decode_responses=True,
encoding_errors=self._encoding_errors,
socket_timeout=5.0,
socket_connect_timeout=5.0,
max_connections=redis_max_conn,
health_check_interval=30, # 每 30 秒检查连接健康状态
)
safe_url = redis_url.split("@")[-1] if "@" in redis_url else redis_url
# 测试连接
await client.ping()
if loop_key is not None:
self._redis_by_loop[loop_key] = client
if self._primary_loop_key is None:
self._primary_loop_key = loop_key
self._redis = client
elif self._redis is None:
self._redis = client
logger.info("[OK] {} 初始化成功: {}", self._client_name, safe_url)
self._consecutive_failures = 0
self._circuit_open_until = None
return client
except Exception as e:
error_msg = str(e)
self._last_error = error_msg
logger.error("[ERROR] {} 连接失败: {}", self._client_name, error_msg)
self._consecutive_failures += 1
if self._consecutive_failures >= self._circuit_threshold:
self._circuit_open_until = time.time() + self._circuit_reset_seconds
logger.warning(
"{} 初始化连续失败 {} 次,开启熔断 {} 秒。"
"可通过管理 API /api/admin/system/redis/reset-circuit 手动重置。",
self._client_name,
self._consecutive_failures,
self._circuit_reset_seconds,
)
if require_redis:
# 强制要求Redis时抛出异常拒绝启动
raise RuntimeError(
f"Redis连接失败: {error_msg}\n"
"缓存亲和性功能需要Redis支持请确保Redis服务正常运行。\n"
"检查事项:\n"
"1. Redis服务是否已启动docker compose up -d redis\n"
"2. 环境变量 REDIS_URL 或 REDIS_PASSWORD 是否配置正确\n"
"3. Redis端口默认6379是否可访问"
) from e
if self._degraded_warning:
logger.warning(self._degraded_warning)
return None
async def close(self) -> None:
"""关闭Redis连接"""
clients = list(self._redis_by_loop.values())
if self._redis is not None and not clients:
clients = [self._redis]
seen_client_ids: set[int] = set()
for client in clients:
client_id = id(client)
if client_id in seen_client_ids:
continue
seen_client_ids.add(client_id)
await client.close()
if clients:
logger.info("{} 已关闭", self._client_name)
self._redis = None
self._redis_by_loop.clear()
self._primary_loop_key = None
def get_current_loop_client(self) -> aioredis.Redis | None:
"""获取当前事件循环绑定的 Redis 客户端。"""
loop_key = self._current_loop_key()
if loop_key is None:
return self.get_client()
return self._redis_by_loop.get(loop_key)
def get_client(self) -> aioredis.Redis | None:
"""
获取Redis客户端非异步
注意必须先调用initialize()初始化
Returns:
Redis客户端实例或None
"""
if self._redis is not None:
return self._redis
if self._primary_loop_key is not None:
primary = self._redis_by_loop.get(self._primary_loop_key)
if primary is not None:
self._redis = primary
return primary
if self._redis_by_loop:
return next(iter(self._redis_by_loop.values()))
return None
_GLOBAL_REDIS_DEGRADED_WARNING = (
"[WARN] Redis 不可用,以下功能将降级运行(仅在单实例环境下安全):\n"
" - 缓存亲和性: 禁用(每次请求随机选择 Endpoint\n"
" - 分布式并发控制: 降级为本地计数\n"
" - RPM 限流: 降级为本地限流"
)
_USAGE_QUEUE_REDIS_DEGRADED_WARNING = (
"[WARN] Usage Queue Redis 不可用usage queue 写入与消费将暂时不可用"
)
_redis_manager: RedisClientManager | None = None
_usage_queue_redis_manager: RedisClientManager | None = None
def _get_global_redis_manager() -> RedisClientManager:
global _redis_manager
if _redis_manager is None:
_redis_manager = RedisClientManager(
client_name="全局Redis客户端",
encoding_errors="strict",
degraded_warning=_GLOBAL_REDIS_DEGRADED_WARNING,
)
return _redis_manager
def _get_usage_queue_redis_manager() -> RedisClientManager:
global _usage_queue_redis_manager
if _usage_queue_redis_manager is None:
_usage_queue_redis_manager = RedisClientManager(
client_name="Usage Queue Redis客户端",
encoding_errors="surrogateescape",
degraded_warning=_USAGE_QUEUE_REDIS_DEGRADED_WARNING,
)
return _usage_queue_redis_manager
async def get_redis_client(require_redis: bool = False) -> aioredis.Redis | None:
"""
获取全局Redis客户端
Args:
require_redis: 是否强制要求Redis连接成功如果为True则连接失败时抛出异常
Returns:
Redis客户端实例如果未初始化或连接失败返回None当require_redis=False时
Raises:
RuntimeError: 当require_redis=True且连接失败时
"""
manager = _get_global_redis_manager()
# 如果尚未连接(例如启动时降级、或 close() 后),尝试重新初始化。
# initialize() 内部包含熔断器逻辑,避免频繁重试导致抖动。
if manager.get_current_loop_client() is None:
await manager.initialize(require_redis=require_redis)
return manager.get_current_loop_client()
async def get_usage_queue_redis_client(require_redis: bool = False) -> aioredis.Redis | None:
"""
获取 Usage Queue 专用 Redis 客户端。
与全局 Redis 客户端隔离,专门用于 usage queue 的 msgpack/surrogateescape 编解码链路。
"""
manager = _get_usage_queue_redis_manager()
if manager.get_current_loop_client() is None:
await manager.initialize(require_redis=require_redis)
return manager.get_current_loop_client()
def get_redis_client_sync() -> aioredis.Redis | None:
"""
同步获取Redis客户端不会初始化
Returns:
Redis客户端实例或None
"""
global _redis_manager
if _redis_manager is None:
return None
return _redis_manager.get_client()
async def close_redis_client() -> None:
"""关闭 Redis 客户端(包含全局客户端和 Usage Queue 专用客户端)"""
if _redis_manager:
await _redis_manager.close()
if _usage_queue_redis_manager:
await _usage_queue_redis_manager.close()
def get_redis_state() -> RedisState:
"""
获取 Redis 连接状态(同步方法)
Returns:
Redis 连接状态枚举
"""
global _redis_manager
if _redis_manager is None:
return RedisState.NOT_INITIALIZED
return _redis_manager.get_state()
def get_redis_circuit_info() -> dict:
"""
获取 Redis 熔断器详细信息(同步方法)
Returns:
熔断器状态字典
"""
global _redis_manager
if _redis_manager is None:
return {
"state": RedisState.NOT_INITIALIZED.value,
"consecutive_failures": 0,
"circuit_threshold": 3,
"last_error": None,
}
return _redis_manager.get_circuit_info()
def reset_redis_circuit_breaker() -> bool:
"""
手动重置 Redis 熔断器(同步方法)
同时重置全局 Redis 客户端与 Usage Queue 专用客户端,
避免其中一方仍停留在熔断状态导致功能未恢复。
Returns:
是否至少重置了一个客户端
"""
reset_any = False
if _redis_manager is not None:
_redis_manager.reset_circuit_breaker()
reset_any = True
if _usage_queue_redis_manager is not None:
_usage_queue_redis_manager.reset_circuit_breaker()
reset_any = True
return reset_any