feat(network): migrate async HTTP client to HTTPX2 (#6394)

This commit is contained in:
InfinityPacer
2026-08-22 16:48:23 +08:00
committed by GitHub
parent dfc047b880
commit d131f8d571
9 changed files with 198 additions and 65 deletions
+2 -2
View File
@@ -20,7 +20,7 @@ from urllib.parse import parse_qs, quote, unquote, urlparse, urlsplit
import aiofiles
import aioshutil
import httpx
import httpx2
from anyio import Path as AsyncPath
from packaging.markers import default_environment
from packaging.requirements import Requirement
@@ -2152,7 +2152,7 @@ class PluginHelper(metaclass=WeakSingleton):
async def __async_request_with_fallback(url: str,
headers: Optional[dict] = None,
timeout: Optional[int] = 60,
is_api: bool = False) -> Optional[httpx.Response]:
is_api: bool = False) -> Optional[httpx2.Response]:
"""
使用自动降级策略,异步请求资源,优先级依次为镜像站、代理、直连
:param url: 目标URL
+55 -52
View File
@@ -10,7 +10,7 @@ from pathlib import Path
from typing import Any, Dict, Optional, Tuple, Union
import chardet
import httpx
import httpx2
import requests
import urllib3
from requests import Response, Session
@@ -31,7 +31,14 @@ def configure_default_user_agent(user_agent: str) -> None:
_default_user_agent = user_agent
class _NonClosingTransportProxy(httpx.AsyncBaseTransport):
_ASYNC_STALE_CONNECTION_ERRORS = (
httpx2.RemoteProtocolError,
httpx2.ReadError,
httpx2.WriteError,
)
class _NonClosingTransportProxy(httpx2.AsyncBaseTransport):
"""
包装共享底层 transport,转发请求但吞掉 __aexit__/aclose 调用。
防止 per-call AsyncClient 在 async with 退出时把底层连接池一并清空。
@@ -40,7 +47,7 @@ class _NonClosingTransportProxy(httpx.AsyncBaseTransport):
__slots__ = ("_wrapped",)
def __init__(self, wrapped: httpx.AsyncBaseTransport):
def __init__(self, wrapped: httpx2.AsyncBaseTransport):
"""保存由进程统一管理生命周期的底层传输对象。"""
self._wrapped = wrapped
@@ -58,7 +65,7 @@ class _NonClosingTransportProxy(httpx.AsyncBaseTransport):
# 故意 no-op:调用方显式 aclose 也不影响共享池
return None
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response:
"""将异步请求转发给共享底层传输。"""
return await self._wrapped.handle_async_request(request)
@@ -73,7 +80,7 @@ _SharedTransportKey = Tuple[
]
# 共享底层 transport 桶,按事件循环和配置区分,支持 LRU 淘汰
_shared_async_transports: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, collections.OrderedDict[_SharedTransportKey, httpx.AsyncHTTPTransport]] = weakref.WeakKeyDictionary()
_shared_async_transports: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, collections.OrderedDict[_SharedTransportKey, httpx2.AsyncHTTPTransport]] = weakref.WeakKeyDictionary()
# 不同线程各自驱动的事件循环并发首次写入外层弱字典时,需要互斥保护
_shared_async_transports_lock = threading.Lock()
# 每个事件循环允许的最大共享 transport 桶数;超出后按 LRU 淘汰最久未用桶。
@@ -87,7 +94,7 @@ _DEFAULT_KEEPALIVE_EXPIRY = 30
# 同步 requests.Session 复用连接时,遇到对端或代理关闭 keep-alive 后允许重试的方法
_REQUESTS_RETRY_IDEMPOTENT_METHODS = ("GET", "HEAD", "OPTIONS")
# 代理走 CONNECT 隧道时,httpx 默认开启的 HTTP/2 多路复用会把并发请求叠加到极少数隧道上;
# 代理走 CONNECT 隧道时,HTTP/2 多路复用会把并发请求叠加到极少数隧道上;
# 隧道被代理节点切换或空闲回收打断后,复用其上的所有请求会同时失败。按 (proxy, host) 熔断:
# 命中一次连接层失败就记录下次允许再尝试 h2 的时间戳(time.monotonic 基准),冷却期内该
# (proxy, host) 的请求直接退化为 http1.1;冷却期结束后自动恢复尝试 h2。
@@ -98,17 +105,17 @@ _h2_proxy_retry_at: Dict[Tuple[str, str], float] = {}
# EndOfStream 等);超时、连接失败、代理不可达等错误换 h1 一样会发生,
# 不应触发熔断,也不值得付出一次注定同样失败的 h1 重试
_H2_TUNNEL_BREAK_ERRORS = (
httpx.RemoteProtocolError,
httpx.LocalProtocolError,
httpx.ReadError,
httpx.WriteError,
httpx.CloseError,
httpx2.RemoteProtocolError,
httpx2.LocalProtocolError,
httpx2.ReadError,
httpx2.WriteError,
httpx2.CloseError,
)
def _h2_proxy_breaker_key(proxy: str, url: str) -> Tuple[str, str]:
try:
host = httpx.URL(url).host or ""
host = httpx2.URL(url).host or ""
except Exception:
host = url
return proxy, host
@@ -152,7 +159,7 @@ def _get_shared_async_transport(
max_keepalive_connections: int,
max_connections: int,
keepalive_expiry: int,
) -> Optional[httpx.AsyncHTTPTransport]:
) -> Optional[httpx2.AsyncHTTPTransport]:
"""
返回与当前事件循环绑定的共享 AsyncHTTPTransport(底层连接池);首次按需创建。
没有运行中的事件循环或循环已关闭时返回 None,由调用方走临时客户端兜底。
@@ -161,7 +168,7 @@ def _get_shared_async_transport(
会话级状态由调用方在外层 AsyncClient(transport=...) 实例化时单独配置,
每次调用用完即销毁,因此天然无 jar 累积串扰。
"""
# 规范化代理:拒绝空字符串等非法值,防止 httpx 抛出 Unknown scheme for proxy URL
# 规范化代理:拒绝空字符串等非法值,防止客户端解析空代理地址失败
if proxy is not None and (not proxy or not proxy.strip()):
proxy = None
try:
@@ -191,11 +198,11 @@ def _get_shared_async_transport(
return transport
# 首次见到这个配置,创建新的共享 transport 桶
transport = httpx.AsyncHTTPTransport(
transport = httpx2.AsyncHTTPTransport(
http2=http2,
proxy=proxy,
verify=verify,
limits=httpx.Limits(
limits=httpx2.Limits(
max_keepalive_connections=max_keepalive_connections,
max_connections=max_connections,
keepalive_expiry=keepalive_expiry,
@@ -978,7 +985,7 @@ class AsyncRequestUtils:
ua: str = None,
cookies: Union[str, dict] = None,
proxies: dict = None,
client: httpx.AsyncClient = None,
client: httpx2.AsyncClient = None,
timeout: int = None,
referer: str = None,
content_type: str = None,
@@ -995,7 +1002,7 @@ class AsyncRequestUtils:
:param ua: User-Agent字符串
:param cookies: Cookie字符串或字典
:param proxies: 代理设置
:param client: httpx.AsyncClient实例,如果为None则创建新的客户端
:param client: 调用方自管的 HTTPX2 AsyncClient;为空时使用宿主客户端
:param timeout: 请求超时时间,默认为20秒
:param referer: Referer头部信息
:param content_type: 请求的Content-Type,默认为 "application/x-www-form-urlencoded; charset=UTF-8"
@@ -1096,7 +1103,7 @@ class AsyncRequestUtils:
async def request(
self, method: str, url: str, raise_exception: bool = False, **kwargs
) -> Optional[httpx.Response]:
) -> Optional[httpx2.Response]:
"""
发起异步HTTP请求
:param method: HTTP方法,如 get, post, put 等
@@ -1104,7 +1111,7 @@ class AsyncRequestUtils:
:param raise_exception: 是否在发生异常时抛出异常,否则默认拦截异常返回None
:param kwargs: 其他请求参数,如headers, cookies, proxies等
:return: HTTP响应对象
:raises: httpx.RequestError 仅raise_exceptionTrue时抛出
:raises: HTTP 客户端请求异常仅在 raise_exception=True 时抛出
"""
# 运行时 self._cookies 只能是 dict | Nonecookie_parse 默认 array=False 返回 dict
cookies_dict: Optional[dict] = self._cookies if isinstance(self._cookies, dict) else None
@@ -1137,7 +1144,7 @@ class AsyncRequestUtils:
return await self._dispatch_request(
False, cookies_dict, method, url, raise_exception, **kwargs
)
except httpx.RequestError:
except httpx2.RequestError:
# 与 h2 隧道无关的失败(超时、连接失败等):不熔断也不重试,
# 恢复调用方原本的 raise_exception 语义
if raise_exception:
@@ -1147,7 +1154,7 @@ class AsyncRequestUtils:
async def _dispatch_request(
self, http2: bool, cookies_dict: Optional[dict], method: str, url: str,
raise_exception: bool, **kwargs
) -> Optional[httpx.Response]:
) -> Optional[httpx2.Response]:
"""
按给定 http2 开关构建/复用底层连接并发起请求,供 request() 的 h2/h1 熔断切换复用
"""
@@ -1165,9 +1172,9 @@ class AsyncRequestUtils:
if transport is not None:
# 用 _NonClosingTransportProxy 包装共享 transport,吞掉 AsyncClient.__aexit__
# 传播下来的 transport.__aexit__,避免每次 async with 退出都把共享连接池清空。
async with httpx.AsyncClient(
async with httpx2.AsyncClient(
transport=_NonClosingTransportProxy(transport),
timeout=httpx.Timeout(self._timeout),
timeout=httpx2.Timeout(self._timeout),
follow_redirects=self._follow_redirects,
cookies=cookies_dict,
) as client:
@@ -1176,7 +1183,7 @@ class AsyncRequestUtils:
)
# 兜底:没有运行中的事件循环时,临时客户端走完即关
async with httpx.AsyncClient(
async with httpx2.AsyncClient(
http2=http2,
proxy=self._proxies,
timeout=self._timeout,
@@ -1190,12 +1197,12 @@ class AsyncRequestUtils:
async def _make_request(
self,
client: httpx.AsyncClient,
client: httpx2.AsyncClient,
method: str,
url: str,
raise_exception: bool = False,
**kwargs,
) -> Optional[httpx.Response]:
) -> Optional[httpx2.Response]:
"""
执行实际的异步请求
"""
@@ -1213,15 +1220,13 @@ class AsyncRequestUtils:
# 仅对幂等方法做 stale-pool 竞态重试:复用了刚被对端 FIN 的 keep-alive 连接时,
# 实际请求通常未到服务端,httpx 自身不重试,这里兜底一次。
is_idempotent = method_upper in ("GET", "HEAD", "OPTIONS")
stale_conn_errs = (httpx.RemoteProtocolError, httpx.ReadError, httpx.WriteError)
try:
return await client.request(method, url, **kwargs)
except stale_conn_errs:
except _ASYNC_STALE_CONNECTION_ERRORS:
if is_idempotent:
try:
return await client.request(method, url, **kwargs)
except httpx.RequestError:
except httpx2.RequestError:
if raise_exception:
raise
return None
@@ -1230,7 +1235,7 @@ class AsyncRequestUtils:
if raise_exception:
raise
return None
except httpx.RequestError:
except httpx2.RequestError:
if raise_exception:
raise
return None
@@ -1258,7 +1263,7 @@ class AsyncRequestUtils:
async def post(
self, url: str, data: Any = None, json: dict = None, **kwargs
) -> Optional[httpx.Response]:
) -> Optional[httpx2.Response]:
"""
发送异步POST请求
:param url: 请求的URL
@@ -1273,7 +1278,7 @@ class AsyncRequestUtils:
async def put(
self, url: str, data: Any = None, **kwargs
) -> Optional[httpx.Response]:
) -> Optional[httpx2.Response]:
"""
发送异步PUT请求
:param url: 请求的URL
@@ -1292,7 +1297,7 @@ class AsyncRequestUtils:
allow_redirects: bool = True,
raise_exception: bool = False,
**kwargs,
) -> Optional[httpx.Response]:
) -> Optional[httpx2.Response]:
"""
发送异步GET请求并返回响应对象
:param url: 请求的URL
@@ -1303,7 +1308,7 @@ class AsyncRequestUtils:
:param raise_exception: 是否在发生异常时抛出异常,否则默认拦截异常返回None
:param kwargs: 其他请求参数,如headers, cookies, proxies等
:return: HTTP响应对象,若发生RequestError则返回None
:raises: httpx.RequestError 仅raise_exceptionTrue时抛出
:raises: HTTP 客户端请求异常仅在 raise_exception=True 时抛出
"""
return await self.request(
method="get",
@@ -1326,13 +1331,13 @@ class AsyncRequestUtils:
):
"""
获取异步流式响应的上下文管理器,适用于大文件下载。
使用 httpx.AsyncClient.stream() 标准流式 API,避免把响应体一次性读入内存。
使用 AsyncClient.stream() 标准流式 API,避免把响应体一次性读入内存。
:param url: 请求的URL
:param params: 请求的参数
:param raise_exception: 是否在发生异常时抛出,否则吞掉并 yield None
:param kwargs: 其他请求参数(headers, cookies 等)
:return: 上下文管理器,进入后 yield httpx.Response(出错时 yield None
:return: 上下文管理器,进入后返回响应对象(出错时返回 None
"""
cookies_dict: Optional[dict] = self._cookies if isinstance(self._cookies, dict) else None
kwargs["headers"] = with_correlation_header(
@@ -1341,8 +1346,6 @@ class AsyncRequestUtils:
# 与 _make_request 保持一致:复用 keep-alive 时偶遇对端 FIN 的连接,
# 流式 GET 是幂等的,单次重试即可
stale_conn_errs = (httpx.RemoteProtocolError, httpx.ReadError, httpx.WriteError)
async with AsyncExitStack() as stack:
# 选 client:复用与 request() 相同的三条 path 逻辑
if self._client is not None:
@@ -1360,16 +1363,16 @@ class AsyncRequestUtils:
)
if transport is not None:
client = await stack.enter_async_context(
httpx.AsyncClient(
httpx2.AsyncClient(
transport=_NonClosingTransportProxy(transport),
timeout=httpx.Timeout(self._timeout),
timeout=httpx2.Timeout(self._timeout),
follow_redirects=self._follow_redirects,
cookies=cookies_dict,
)
)
else:
client = await stack.enter_async_context(
httpx.AsyncClient(
httpx2.AsyncClient(
http2=self._http2,
proxy=self._proxies,
timeout=self._timeout,
@@ -1383,17 +1386,17 @@ class AsyncRequestUtils:
response = await stack.enter_async_context(
client.stream("GET", url, params=params, **kwargs)
)
except stale_conn_errs:
except _ASYNC_STALE_CONNECTION_ERRORS:
try:
response = await stack.enter_async_context(
client.stream("GET", url, params=params, **kwargs)
)
except httpx.RequestError:
except httpx2.RequestError:
if raise_exception:
raise
yield None
return
except httpx.RequestError:
except httpx2.RequestError:
if raise_exception:
raise
yield None
@@ -1413,7 +1416,7 @@ class AsyncRequestUtils:
json: dict = None,
raise_exception: bool = False,
**kwargs,
) -> Optional[httpx.Response]:
) -> Optional[httpx2.Response]:
"""
发送异步POST请求并返回响应对象
:param url: 请求的URL
@@ -1425,7 +1428,7 @@ class AsyncRequestUtils:
:param raise_exception: 是否在发生异常时抛出异常,否则默认拦截异常返回None
:param kwargs: 其他请求参数,如headers, cookies, proxies等
:return: HTTP响应对象,若发生RequestError则返回None
:raises: httpx.RequestError 仅raise_exceptionTrue时抛出
:raises: HTTP 客户端请求异常仅在 raise_exception=True 时抛出
"""
return await self.request(
method="post",
@@ -1449,7 +1452,7 @@ class AsyncRequestUtils:
json: dict = None,
raise_exception: bool = False,
**kwargs,
) -> Optional[httpx.Response]:
) -> Optional[httpx2.Response]:
"""
发送异步PUT请求并返回响应对象
:param url: 请求的URL
@@ -1461,7 +1464,7 @@ class AsyncRequestUtils:
:param raise_exception: 是否在发生异常时抛出异常,否则默认拦截异常返回None
:param kwargs: 其他请求参数,如headers, cookies, proxies等
:return: HTTP响应对象,若发生RequestError则返回None
:raises: httpx.RequestError 仅raise_exceptionTrue时抛出
:raises: HTTP 客户端请求异常仅在 raise_exception=True 时抛出
"""
return await self.request(
method="put",
@@ -1483,7 +1486,7 @@ class AsyncRequestUtils:
allow_redirects: bool = True,
raise_exception: bool = False,
**kwargs,
) -> Optional[httpx.Response]:
) -> Optional[httpx2.Response]:
"""
发送异步DELETE请求并返回响应对象
:param url: 请求的URL
@@ -1493,7 +1496,7 @@ class AsyncRequestUtils:
:param raise_exception: 是否在发生异常时抛出异常,否则默认拦截异常返回None
:param kwargs: 其他请求参数,如headers, cookies, proxies等
:return: HTTP响应对象,若发生RequestError则返回None
:raises: httpx.RequestError 仅raise_exceptionTrue时抛出
:raises: HTTP 客户端请求异常仅在 raise_exception=True 时抛出
"""
return await self.request(
method="delete",