mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
feat(network): migrate async HTTP client to HTTPX2 (#6394)
This commit is contained in:
Vendored
+2
-2
@@ -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
|
||||
|
||||
@@ -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_exception为True时会抛出
|
||||
:raises: HTTP 客户端请求异常仅在 raise_exception=True 时抛出
|
||||
"""
|
||||
# 运行时 self._cookies 只能是 dict | None(cookie_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_exception为True时会抛出
|
||||
: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_exception为True时会抛出
|
||||
: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_exception为True时会抛出
|
||||
: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_exception为True时会抛出
|
||||
:raises: HTTP 客户端请求异常仅在 raise_exception=True 时抛出
|
||||
"""
|
||||
return await self.request(
|
||||
method="delete",
|
||||
|
||||
Reference in New Issue
Block a user