mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
fix(network): retry proxied HTTPS with TLS 1.2 (#6758)
* fix(network): retry proxied HTTPS with TLS 1.2 * test: refresh egress architecture fingerprints * chore: refresh host architecture baseline * fix(network): satisfy mypy ratchet for TLS fallback --------- Co-authored-by: ranzhigg <51039935+ranzhigg@users.noreply.github.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import collections
|
||||
import re
|
||||
import ssl
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
@@ -15,6 +16,7 @@ import httpx2
|
||||
import requests
|
||||
import urllib3
|
||||
from requests import Response, Session
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.exceptions import InsecureRequestWarning
|
||||
|
||||
from app.runtime.correlation import with_correlation_header
|
||||
@@ -27,6 +29,8 @@ AsyncHttpRequestError = httpx2.RequestError
|
||||
|
||||
_default_user_agent: Optional[str] = None
|
||||
|
||||
_VerifySetting = Union[bool, str, ssl.SSLContext]
|
||||
|
||||
|
||||
def configure_default_user_agent(user_agent: str) -> None:
|
||||
"""注册宿主 User-Agent,用于识别并标记来自插件的请求。"""
|
||||
@@ -40,6 +44,74 @@ _ASYNC_STALE_CONNECTION_ERRORS = (
|
||||
httpx2.WriteError,
|
||||
)
|
||||
|
||||
# 代理 CONNECT 隧道在 TLS 协商阶段断开时,HTTPX 会把错误归类为这些连接层异常。
|
||||
# 超时类异常不在此列:重试同一条拥塞链路只会延长请求等待时间。
|
||||
_ASYNC_PROXY_TLS_FALLBACK_ERRORS = (
|
||||
httpx2.ConnectError,
|
||||
httpx2.RemoteProtocolError,
|
||||
httpx2.ReadError,
|
||||
httpx2.WriteError,
|
||||
)
|
||||
|
||||
|
||||
def _proxy_configured(proxies: Any) -> bool:
|
||||
"""判断是否存在非空代理配置。"""
|
||||
if isinstance(proxies, str):
|
||||
return bool(proxies.strip())
|
||||
if isinstance(proxies, dict):
|
||||
return any(isinstance(value, str) and value.strip() for value in proxies.values())
|
||||
return bool(proxies)
|
||||
|
||||
|
||||
def _can_retry_proxy_tls12(url: str, proxies: Any, method: str) -> bool:
|
||||
"""仅为代理下的 HTTPS 幂等请求启用 TLS 1.2 回退。"""
|
||||
return (
|
||||
_proxy_configured(proxies)
|
||||
and url.lower().startswith("https://")
|
||||
and method.upper() in _REQUESTS_RETRY_IDEMPOTENT_METHODS
|
||||
)
|
||||
|
||||
|
||||
def _build_tls12_context(verify: Union[bool, str]) -> ssl.SSLContext:
|
||||
"""复制证书校验策略并把单次回退请求限制为 TLS 1.2。"""
|
||||
if isinstance(verify, str):
|
||||
context = ssl.create_default_context(cafile=verify)
|
||||
else:
|
||||
context = ssl.create_default_context()
|
||||
if not verify:
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
context.maximum_version = ssl.TLSVersion.TLSv1_2
|
||||
return context
|
||||
|
||||
|
||||
class _TLS12HttpAdapter(HTTPAdapter): # type: ignore[misc]
|
||||
"""为 requests 的一次性回退会话注入 TLS 1.2 SSLContext。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ssl_context: ssl.SSLContext,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
self._ssl_context = ssl_context
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def init_poolmanager(
|
||||
self,
|
||||
connections: int,
|
||||
maxsize: int,
|
||||
block: bool = False,
|
||||
**pool_kwargs: Any,
|
||||
) -> None:
|
||||
pool_kwargs["ssl_context"] = self._ssl_context
|
||||
super().init_poolmanager(connections, maxsize, block, **pool_kwargs)
|
||||
|
||||
def proxy_manager_for(self, proxy: str, **proxy_kwargs: Any) -> Any:
|
||||
proxy_kwargs["ssl_context"] = self._ssl_context
|
||||
return super().proxy_manager_for(proxy, **proxy_kwargs)
|
||||
|
||||
|
||||
class _NonClosingTransportProxy(httpx2.AsyncBaseTransport):
|
||||
"""
|
||||
@@ -75,7 +147,7 @@ class _NonClosingTransportProxy(httpx2.AsyncBaseTransport):
|
||||
|
||||
_SharedTransportKey = Tuple[
|
||||
Optional[str], # proxy
|
||||
Union[bool, str], # verify
|
||||
_VerifySetting, # verify
|
||||
bool, # http2
|
||||
bool, # trust_env
|
||||
int, # max_keepalive_connections
|
||||
@@ -158,7 +230,7 @@ def _discard_pending_eviction_task(task: asyncio.Task) -> None:
|
||||
|
||||
def _get_shared_async_transport(
|
||||
proxy: Optional[str],
|
||||
verify: Union[bool, str],
|
||||
verify: _VerifySetting,
|
||||
http2: bool,
|
||||
max_keepalive_connections: int,
|
||||
max_connections: int,
|
||||
@@ -362,6 +434,7 @@ class RequestUtils:
|
||||
self._proxies = proxies
|
||||
self._session = session or (requests.Session() if use_session else None)
|
||||
self._owns_session = session is None and use_session
|
||||
self._fallback_session: Optional[Session] = None
|
||||
self._timeout = timeout or 20
|
||||
self._verify = verify
|
||||
if not content_type:
|
||||
@@ -416,6 +489,41 @@ class RequestUtils:
|
||||
self._session.close()
|
||||
self._session = None
|
||||
self._owns_session = False
|
||||
self._close_fallback_session()
|
||||
|
||||
def _close_fallback_session(self) -> None:
|
||||
"""关闭流式 TLS 回退会话,避免临时连接池泄漏。"""
|
||||
if self._fallback_session is not None:
|
||||
self._fallback_session.close()
|
||||
self._fallback_session = None
|
||||
|
||||
def _request_with_tls12(self, method: str, url: str, kwargs: dict[str, Any]) -> Response:
|
||||
"""使用一次性 TLS 1.2 会话重试代理下的幂等 HTTPS 请求。"""
|
||||
stream = bool(kwargs.get("stream"))
|
||||
if stream:
|
||||
self._close_fallback_session()
|
||||
fallback_session = requests.Session()
|
||||
self._fallback_session = fallback_session
|
||||
else:
|
||||
fallback_session = requests.Session()
|
||||
|
||||
fallback_session.mount(
|
||||
"https://",
|
||||
_TLS12HttpAdapter(_build_tls12_context(self._verify)),
|
||||
)
|
||||
try:
|
||||
response = fallback_session.request(method, url, **kwargs)
|
||||
except requests.exceptions.RequestException:
|
||||
if stream:
|
||||
self._close_fallback_session()
|
||||
else:
|
||||
fallback_session.close()
|
||||
raise
|
||||
|
||||
if not stream:
|
||||
# stream=False 已经把响应体读入 Response,连接池可以立即释放。
|
||||
fallback_session.close()
|
||||
return response
|
||||
|
||||
@contextmanager
|
||||
def response_manager(self, method: str, url: str, **kwargs):
|
||||
@@ -472,7 +580,18 @@ class RequestUtils:
|
||||
requests.exceptions.ConnectionError,
|
||||
requests.exceptions.ChunkedEncodingError,
|
||||
requests.exceptions.ReadTimeout,
|
||||
):
|
||||
) as error:
|
||||
if (
|
||||
self._session is None
|
||||
and isinstance(error, requests.exceptions.ConnectionError)
|
||||
and _can_retry_proxy_tls12(url, self._proxies, method_upper)
|
||||
):
|
||||
try:
|
||||
return self._request_with_tls12(method, url, kwargs)
|
||||
except requests.exceptions.RequestException:
|
||||
if raise_exception:
|
||||
raise
|
||||
return None
|
||||
if (
|
||||
self._session is not None
|
||||
and method_upper in _REQUESTS_RETRY_IDEMPOTENT_METHODS
|
||||
@@ -512,6 +631,7 @@ class RequestUtils:
|
||||
finally:
|
||||
if response is not None:
|
||||
response.close()
|
||||
self._close_fallback_session()
|
||||
|
||||
def post(
|
||||
self, url: str, data: Any = None, json: dict = None, **kwargs
|
||||
@@ -584,6 +704,7 @@ class RequestUtils:
|
||||
finally:
|
||||
if response is not None:
|
||||
response.close()
|
||||
self._close_fallback_session()
|
||||
|
||||
def post_res(
|
||||
self,
|
||||
@@ -1205,7 +1326,27 @@ class AsyncRequestUtils:
|
||||
# _h2_proxy_allowed 处注释);仅对幂等方法做"h2 失败就地降级 h1 重试",
|
||||
# 避免非幂等请求在服务端可能已收到数据的情况下重复产生副作用
|
||||
http2 = self._http2 and _h2_proxy_allowed(self._proxies, url)
|
||||
proxy_tls_fallback = _can_retry_proxy_tls12(url, self._proxies, method)
|
||||
if not (http2 and self._proxies and method.upper() in _REQUESTS_RETRY_IDEMPOTENT_METHODS):
|
||||
if proxy_tls_fallback:
|
||||
try:
|
||||
return await self._dispatch_request(
|
||||
http2,
|
||||
cookies_dict,
|
||||
method,
|
||||
url,
|
||||
True,
|
||||
**kwargs,
|
||||
)
|
||||
except _ASYNC_PROXY_TLS_FALLBACK_ERRORS:
|
||||
return await self._dispatch_with_tls12(
|
||||
http2,
|
||||
cookies_dict,
|
||||
method,
|
||||
url,
|
||||
raise_exception,
|
||||
**kwargs,
|
||||
)
|
||||
return await self._dispatch_request(
|
||||
http2, cookies_dict, method, url, raise_exception, **kwargs
|
||||
)
|
||||
@@ -1216,9 +1357,41 @@ class AsyncRequestUtils:
|
||||
)
|
||||
except _H2_TUNNEL_BREAK_ERRORS:
|
||||
_trip_h2_proxy_breaker(self._proxies, url)
|
||||
return await self._dispatch_request(
|
||||
False, cookies_dict, method, url, raise_exception, **kwargs
|
||||
)
|
||||
try:
|
||||
return await self._dispatch_request(
|
||||
False,
|
||||
cookies_dict,
|
||||
method,
|
||||
url,
|
||||
True if proxy_tls_fallback else raise_exception,
|
||||
**kwargs,
|
||||
)
|
||||
except httpx2.RequestError as error:
|
||||
if proxy_tls_fallback and isinstance(error, _ASYNC_PROXY_TLS_FALLBACK_ERRORS):
|
||||
return await self._dispatch_with_tls12(
|
||||
False,
|
||||
cookies_dict,
|
||||
method,
|
||||
url,
|
||||
raise_exception,
|
||||
**kwargs,
|
||||
)
|
||||
if raise_exception:
|
||||
raise
|
||||
return None
|
||||
except _ASYNC_PROXY_TLS_FALLBACK_ERRORS:
|
||||
if proxy_tls_fallback:
|
||||
return await self._dispatch_with_tls12(
|
||||
True,
|
||||
cookies_dict,
|
||||
method,
|
||||
url,
|
||||
raise_exception,
|
||||
**kwargs,
|
||||
)
|
||||
if raise_exception:
|
||||
raise
|
||||
return None
|
||||
except httpx2.RequestError:
|
||||
# 与 h2 隧道无关的失败(超时、连接失败等):不熔断也不重试,
|
||||
# 恢复调用方原本的 raise_exception 语义
|
||||
@@ -1228,7 +1401,7 @@ class AsyncRequestUtils:
|
||||
|
||||
async def _dispatch_request(
|
||||
self, http2: bool, cookies_dict: Optional[dict], method: str, url: str,
|
||||
raise_exception: bool, **kwargs
|
||||
raise_exception: bool, verify: Optional[_VerifySetting] = None, **kwargs
|
||||
) -> Optional[httpx2.Response]:
|
||||
"""
|
||||
按给定 http2 开关构建/复用底层连接并发起请求,供 request() 的 h2/h1 熔断切换复用
|
||||
@@ -1236,9 +1409,10 @@ class AsyncRequestUtils:
|
||||
# 共享底层 transport(连接池+TLS 复用),每次请求创建轻量 AsyncClient。
|
||||
# AsyncClient 持有的 cookie jar 仅存活于本次请求 lifecycle,
|
||||
# 既复用握手又彻底避免 jar 跨调用累积。
|
||||
request_verify = self._verify if verify is None else verify
|
||||
transport = _get_shared_async_transport(
|
||||
proxy=self._proxies,
|
||||
verify=self._verify,
|
||||
verify=request_verify,
|
||||
http2=http2,
|
||||
trust_env=self._trust_env,
|
||||
max_keepalive_connections=self._max_keepalive_connections,
|
||||
@@ -1263,7 +1437,7 @@ class AsyncRequestUtils:
|
||||
http2=http2,
|
||||
proxy=self._proxies,
|
||||
timeout=self._timeout,
|
||||
verify=self._verify,
|
||||
verify=request_verify,
|
||||
trust_env=self._trust_env,
|
||||
follow_redirects=self._follow_redirects,
|
||||
cookies=cookies_dict,
|
||||
@@ -1272,6 +1446,31 @@ class AsyncRequestUtils:
|
||||
client, method, url, raise_exception, **kwargs
|
||||
)
|
||||
|
||||
async def _dispatch_with_tls12(
|
||||
self,
|
||||
http2: bool,
|
||||
cookies_dict: Optional[dict[str, Any]],
|
||||
method: str,
|
||||
url: str,
|
||||
raise_exception: bool,
|
||||
**kwargs: Any,
|
||||
) -> Optional[httpx2.Response]:
|
||||
"""在代理握手失败后以 TLS 1.2 重试一次幂等请求。"""
|
||||
try:
|
||||
return await self._dispatch_request(
|
||||
http2,
|
||||
cookies_dict,
|
||||
method,
|
||||
url,
|
||||
True,
|
||||
verify=_build_tls12_context(self._verify),
|
||||
**kwargs,
|
||||
)
|
||||
except httpx2.RequestError:
|
||||
if raise_exception:
|
||||
raise
|
||||
return None
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
client: httpx2.AsyncClient,
|
||||
@@ -1451,57 +1650,100 @@ class AsyncRequestUtils:
|
||||
# 与 _make_request 保持一致:复用 keep-alive 时偶遇对端 FIN 的连接,
|
||||
# 流式 GET 是幂等的,单次重试即可
|
||||
async with AsyncExitStack() as stack:
|
||||
# 选 client:复用与 request() 相同的三条 path 逻辑
|
||||
if self._client is not None:
|
||||
client = self._client
|
||||
if cookies_dict is not None:
|
||||
kwargs.setdefault("cookies", cookies_dict)
|
||||
else:
|
||||
transport = _get_shared_async_transport(
|
||||
proxy=self._proxies,
|
||||
verify=self._verify,
|
||||
http2=self._http2,
|
||||
trust_env=self._trust_env,
|
||||
max_keepalive_connections=self._max_keepalive_connections,
|
||||
max_connections=self._max_connections,
|
||||
keepalive_expiry=self._keepalive_expiry,
|
||||
)
|
||||
if transport is not None:
|
||||
client = await stack.enter_async_context(
|
||||
httpx2.AsyncClient(
|
||||
transport=_NonClosingTransportProxy(transport),
|
||||
timeout=httpx2.Timeout(self._timeout),
|
||||
follow_redirects=self._follow_redirects,
|
||||
cookies=cookies_dict,
|
||||
)
|
||||
)
|
||||
else:
|
||||
client = await stack.enter_async_context(
|
||||
httpx2.AsyncClient(
|
||||
http2=self._http2,
|
||||
proxy=self._proxies,
|
||||
timeout=self._timeout,
|
||||
verify=self._verify,
|
||||
trust_env=self._trust_env,
|
||||
follow_redirects=self._follow_redirects,
|
||||
cookies=cookies_dict,
|
||||
)
|
||||
)
|
||||
proxy_tls_fallback = (
|
||||
self._client is None
|
||||
and _can_retry_proxy_tls12(url, self._proxies, "GET")
|
||||
)
|
||||
http2 = self._http2 and _h2_proxy_allowed(self._proxies, url)
|
||||
|
||||
try:
|
||||
response = await stack.enter_async_context(
|
||||
async def _open_stream(
|
||||
verify: _VerifySetting,
|
||||
use_http2: bool,
|
||||
) -> httpx2.Response:
|
||||
"""按给定 TLS/HTTP2 配置创建并托管一个流式响应。"""
|
||||
if self._client is not None:
|
||||
client = self._client
|
||||
if cookies_dict is not None:
|
||||
kwargs.setdefault("cookies", cookies_dict)
|
||||
else:
|
||||
transport = _get_shared_async_transport(
|
||||
proxy=self._proxies,
|
||||
verify=verify,
|
||||
http2=use_http2,
|
||||
trust_env=self._trust_env,
|
||||
max_keepalive_connections=self._max_keepalive_connections,
|
||||
max_connections=self._max_connections,
|
||||
keepalive_expiry=self._keepalive_expiry,
|
||||
)
|
||||
if transport is not None:
|
||||
client = await stack.enter_async_context(
|
||||
httpx2.AsyncClient(
|
||||
transport=_NonClosingTransportProxy(transport),
|
||||
timeout=httpx2.Timeout(self._timeout),
|
||||
follow_redirects=self._follow_redirects,
|
||||
cookies=cookies_dict,
|
||||
)
|
||||
)
|
||||
else:
|
||||
client = await stack.enter_async_context(
|
||||
httpx2.AsyncClient(
|
||||
http2=use_http2,
|
||||
proxy=self._proxies,
|
||||
timeout=self._timeout,
|
||||
verify=verify,
|
||||
trust_env=self._trust_env,
|
||||
follow_redirects=self._follow_redirects,
|
||||
cookies=cookies_dict,
|
||||
)
|
||||
)
|
||||
return await stack.enter_async_context(
|
||||
client.stream("GET", url, params=params, **kwargs)
|
||||
)
|
||||
|
||||
try:
|
||||
response = await _open_stream(self._verify, http2)
|
||||
except _ASYNC_STALE_CONNECTION_ERRORS:
|
||||
try:
|
||||
response = await stack.enter_async_context(
|
||||
client.stream("GET", url, params=params, **kwargs)
|
||||
)
|
||||
response = await _open_stream(self._verify, http2)
|
||||
except _ASYNC_PROXY_TLS_FALLBACK_ERRORS:
|
||||
if proxy_tls_fallback:
|
||||
try:
|
||||
response = await _open_stream(
|
||||
_build_tls12_context(self._verify),
|
||||
http2,
|
||||
)
|
||||
except httpx2.RequestError:
|
||||
if raise_exception:
|
||||
raise
|
||||
yield None
|
||||
return
|
||||
elif raise_exception:
|
||||
raise
|
||||
else:
|
||||
yield None
|
||||
return
|
||||
except httpx2.RequestError:
|
||||
if raise_exception:
|
||||
raise
|
||||
yield None
|
||||
return
|
||||
except _ASYNC_PROXY_TLS_FALLBACK_ERRORS:
|
||||
if proxy_tls_fallback:
|
||||
try:
|
||||
response = await _open_stream(
|
||||
_build_tls12_context(self._verify),
|
||||
http2,
|
||||
)
|
||||
except httpx2.RequestError:
|
||||
if raise_exception:
|
||||
raise
|
||||
yield None
|
||||
return
|
||||
elif raise_exception:
|
||||
raise
|
||||
else:
|
||||
yield None
|
||||
return
|
||||
except httpx2.RequestError:
|
||||
if raise_exception:
|
||||
raise
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
"bindings": [
|
||||
"import:httpx2 as httpx2"
|
||||
],
|
||||
"fingerprint": "a70799e9930ff79cd28ebed92836c8108cd2d18bc668e0081f68533d84f69d0d",
|
||||
"fingerprint": "73879afc31531cc27241f21e0edf3b8e493a7d9aef973d0c2e28165d006e631d",
|
||||
"kind": "raw_transport",
|
||||
"source": "app.adapters.network.http",
|
||||
"target": "httpx2",
|
||||
@@ -189,11 +189,10 @@
|
||||
"AsyncRequestUtils._dispatch_request|call:Timeout",
|
||||
"AsyncRequestUtils._make_request|call:request",
|
||||
"AsyncRequestUtils._make_request|call:request",
|
||||
"AsyncRequestUtils.get_stream|call:AsyncClient",
|
||||
"AsyncRequestUtils.get_stream|call:AsyncClient",
|
||||
"AsyncRequestUtils.get_stream|call:Timeout",
|
||||
"AsyncRequestUtils.get_stream|call:stream",
|
||||
"AsyncRequestUtils.get_stream|call:stream",
|
||||
"AsyncRequestUtils.get_stream._open_stream|call:AsyncClient",
|
||||
"AsyncRequestUtils.get_stream._open_stream|call:AsyncClient",
|
||||
"AsyncRequestUtils.get_stream._open_stream|call:Timeout",
|
||||
"AsyncRequestUtils.get_stream._open_stream|call:stream",
|
||||
"_get_shared_async_transport|call:AsyncHTTPTransport",
|
||||
"_get_shared_async_transport|call:Limits",
|
||||
"_h2_proxy_breaker_key|call:URL"
|
||||
@@ -203,14 +202,22 @@
|
||||
"bindings": [
|
||||
"from:requests.Response as Response",
|
||||
"from:requests.Session as Session",
|
||||
"from:requests.adapters.HTTPAdapter as HTTPAdapter",
|
||||
"import:requests as requests"
|
||||
],
|
||||
"fingerprint": "dffac27a2e24803dd7c50b238ba44b4f106b7b105e63a487edc12258f65c710d",
|
||||
"fingerprint": "c95df710a8536328f5ee005c0ce065b9528f2f4ebae1f077315102d8c0f4c01f",
|
||||
"kind": "raw_transport",
|
||||
"source": "app.adapters.network.http",
|
||||
"target": "requests",
|
||||
"uses": [
|
||||
"RequestUtils.__init__|call:Session",
|
||||
"RequestUtils._close_fallback_session|call:close",
|
||||
"RequestUtils._request_with_tls12|call:Session",
|
||||
"RequestUtils._request_with_tls12|call:Session",
|
||||
"RequestUtils._request_with_tls12|call:close",
|
||||
"RequestUtils._request_with_tls12|call:close",
|
||||
"RequestUtils._request_with_tls12|call:mount",
|
||||
"RequestUtils._request_with_tls12|call:request",
|
||||
"RequestUtils.request|call:request",
|
||||
"RequestUtils.request|call:request"
|
||||
]
|
||||
|
||||
@@ -105,8 +105,8 @@
|
||||
{"source": "app.adapters.network.doh", "target": "socket.getaddrinfo", "kind": "protocol_operation", "fingerprint": "4ff03419dfacc6bf582b7d4421dd5a0666a63f8ca79be2b1e625f4c8f4c96b71"},
|
||||
{"source": "app.adapters.network.doh", "target": "urllib.request", "kind": "raw_transport", "fingerprint": "6f5f5fd3da02a9e780ea5e7cc1e47bd962314a1a358f14b4ee698485f96ab52b"},
|
||||
{"source": "app.adapters.network.http", "target": "httpx", "kind": "raw_transport", "fingerprint": "c00458c868240f1adfe6278ba22bd5c4782e9bd2fcf896114c2288bf82981ee3"},
|
||||
{"source": "app.adapters.network.http", "target": "httpx2", "kind": "raw_transport", "fingerprint": "a70799e9930ff79cd28ebed92836c8108cd2d18bc668e0081f68533d84f69d0d"},
|
||||
{"source": "app.adapters.network.http", "target": "requests", "kind": "raw_transport", "fingerprint": "dffac27a2e24803dd7c50b238ba44b4f106b7b105e63a487edc12258f65c710d"},
|
||||
{"source": "app.adapters.network.http", "target": "httpx2", "kind": "raw_transport", "fingerprint": "73879afc31531cc27241f21e0edf3b8e493a7d9aef973d0c2e28165d006e631d"},
|
||||
{"source": "app.adapters.network.http", "target": "requests", "kind": "raw_transport", "fingerprint": "c95df710a8536328f5ee005c0ce065b9528f2f4ebae1f077315102d8c0f4c01f"},
|
||||
{"source": "app.adapters.network.ip", "target": "socket.gethostbyname", "kind": "protocol_operation", "fingerprint": "a43e8969e2f26546fcf925b258728b309f1b9b966ff3c7b48a64273b8c82d048"},
|
||||
{"source": "app.adapters.network.resolver", "target": "socket.getaddrinfo", "kind": "protocol_operation", "fingerprint": "1b1ce6763d730d7aa9c35d32a668560013c939467dcb3087357ffa53653ff306"}
|
||||
]
|
||||
|
||||
@@ -95,8 +95,8 @@ FROZEN_EGRESS_FINGERPRINT_BY_EDGE = {
|
||||
("app.adapters.network.doh", "socket.getaddrinfo"): "4ff03419dfacc6bf582b7d4421dd5a0666a63f8ca79be2b1e625f4c8f4c96b71",
|
||||
("app.adapters.network.doh", "urllib.request"): "6f5f5fd3da02a9e780ea5e7cc1e47bd962314a1a358f14b4ee698485f96ab52b",
|
||||
("app.adapters.network.http", "httpx"): "c00458c868240f1adfe6278ba22bd5c4782e9bd2fcf896114c2288bf82981ee3",
|
||||
("app.adapters.network.http", "httpx2"): "a70799e9930ff79cd28ebed92836c8108cd2d18bc668e0081f68533d84f69d0d",
|
||||
("app.adapters.network.http", "requests"): "dffac27a2e24803dd7c50b238ba44b4f106b7b105e63a487edc12258f65c710d",
|
||||
("app.adapters.network.http", "httpx2"): "73879afc31531cc27241f21e0edf3b8e493a7d9aef973d0c2e28165d006e631d",
|
||||
("app.adapters.network.http", "requests"): "c95df710a8536328f5ee005c0ce065b9528f2f4ebae1f077315102d8c0f4c01f",
|
||||
("app.adapters.network.http", "urllib3"): "66cbd8ec4e7552bd458db0baada30f1953e6d0493793822d23a2199567bca98a",
|
||||
("app.adapters.network.ip", "socket.gethostbyname"): "a43e8969e2f26546fcf925b258728b309f1b9b966ff3c7b48a64273b8c82d048",
|
||||
("app.adapters.network.resolver", "socket.getaddrinfo"): "1b1ce6763d730d7aa9c35d32a668560013c939467dcb3087357ffa53653ff306",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import ssl
|
||||
import time
|
||||
|
||||
import httpx2
|
||||
@@ -125,6 +126,71 @@ def test_timeout_still_raises_when_raise_exception_enabled(monkeypatch):
|
||||
assert http_module._h2_proxy_allowed(PROXY, URL) is True
|
||||
|
||||
|
||||
def test_proxy_connect_error_retries_with_tls12(monkeypatch):
|
||||
"""代理 TLS 握手连接错误时,幂等请求应只回退一次 TLS 1.2。"""
|
||||
calls = []
|
||||
|
||||
async def fake(
|
||||
_self,
|
||||
http2,
|
||||
_cookies_dict,
|
||||
_method,
|
||||
_url,
|
||||
_raise_exception,
|
||||
verify=None,
|
||||
**_kwargs,
|
||||
):
|
||||
calls.append((http2, verify))
|
||||
if verify is None:
|
||||
raise httpx2.ConnectError("TLS handshake failed")
|
||||
return "ok"
|
||||
|
||||
monkeypatch.setattr(AsyncRequestUtils, "_dispatch_request", fake)
|
||||
|
||||
result = asyncio.run(
|
||||
AsyncRequestUtils(proxies={"https": PROXY}).request("get", URL)
|
||||
)
|
||||
|
||||
assert result == "ok"
|
||||
assert calls[0] == (True, None)
|
||||
assert calls[1][0] is True
|
||||
assert isinstance(calls[1][1], ssl.SSLContext)
|
||||
assert calls[1][1].minimum_version is ssl.TLSVersion.TLSv1_2
|
||||
assert calls[1][1].maximum_version is ssl.TLSVersion.TLSv1_2
|
||||
|
||||
|
||||
def test_stream_proxy_connect_error_retries_with_tls12(monkeypatch):
|
||||
"""流式代理 HTTPS 请求也应在握手连接错误后回退 TLS 1.2。"""
|
||||
seen_verifies = []
|
||||
|
||||
def fake_transport(**kwargs):
|
||||
verify = kwargs["verify"]
|
||||
seen_verifies.append(verify)
|
||||
|
||||
async def respond(request):
|
||||
if isinstance(verify, ssl.SSLContext):
|
||||
return httpx2.Response(200, text="ok", request=request)
|
||||
raise httpx2.ConnectError("TLS handshake failed")
|
||||
|
||||
return httpx2.MockTransport(respond)
|
||||
|
||||
monkeypatch.setattr(http_module, "_get_shared_async_transport", fake_transport)
|
||||
|
||||
async def run_stream():
|
||||
async with AsyncRequestUtils(proxies={"https": PROXY}).get_stream(
|
||||
URL,
|
||||
raise_exception=True,
|
||||
) as response:
|
||||
assert response is not None
|
||||
assert await response.aread() == b"ok"
|
||||
|
||||
asyncio.run(run_stream())
|
||||
|
||||
assert len(seen_verifies) == 2
|
||||
assert isinstance(seen_verifies[1], ssl.SSLContext)
|
||||
assert seen_verifies[1].maximum_version is ssl.TLSVersion.TLSv1_2
|
||||
|
||||
|
||||
def test_post_does_not_downgrade_on_h2_failure(monkeypatch):
|
||||
"""
|
||||
非幂等方法 h2 失败时不做 h1 降级重试,避免服务端可能已收到数据时重复产生副作用。
|
||||
|
||||
@@ -3,6 +3,9 @@ import requests
|
||||
from app.adapters.network import http as http_module
|
||||
from app.adapters.network.http import AsyncRequestUtils, RequestUtils, cookie_parse
|
||||
|
||||
PROXY = "http://proxy.example:7890"
|
||||
URL = "https://raw.githubusercontent.com/demo/repo/main/package.json"
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""
|
||||
@@ -131,6 +134,58 @@ def test_request_utils_raises_retry_error_when_retry_still_fails():
|
||||
assert session.close_count == 1
|
||||
|
||||
|
||||
def test_request_utils_retries_proxy_connect_error_with_tls12(monkeypatch):
|
||||
"""同步代理 HTTPS 幂等请求连接失败时应进入 TLS 1.2 回退路径。"""
|
||||
response = _make_response()
|
||||
calls = []
|
||||
|
||||
def fake_request(method, url, **kwargs):
|
||||
calls.append((method, url, kwargs))
|
||||
raise requests.exceptions.ConnectionError("TLS handshake failed")
|
||||
|
||||
fallback_calls = []
|
||||
|
||||
def fake_tls12(self, method, url, kwargs):
|
||||
fallback_calls.append((method, url, kwargs))
|
||||
return response
|
||||
|
||||
monkeypatch.setattr(http_module.requests, "request", fake_request)
|
||||
monkeypatch.setattr(RequestUtils, "_request_with_tls12", fake_tls12)
|
||||
|
||||
result = RequestUtils(proxies={"https": PROXY}).get_res(
|
||||
URL,
|
||||
raise_exception=True,
|
||||
)
|
||||
|
||||
assert result is response
|
||||
assert len(calls) == 1
|
||||
assert fallback_calls[0][0:2] == ("get", URL)
|
||||
assert fallback_calls[0][2]["proxies"] == {"https": PROXY}
|
||||
|
||||
|
||||
def test_request_utils_does_not_retry_proxy_tls_for_post(monkeypatch):
|
||||
"""代理 TLS 回退不得重复非幂等请求。"""
|
||||
fallback_calls = []
|
||||
|
||||
def fake_request(_method, _url, **_kwargs):
|
||||
raise requests.exceptions.ConnectionError("TLS handshake failed")
|
||||
|
||||
def fake_tls12(self, method, url, kwargs):
|
||||
fallback_calls.append((method, url, kwargs))
|
||||
return _make_response()
|
||||
|
||||
monkeypatch.setattr(http_module.requests, "request", fake_request)
|
||||
monkeypatch.setattr(RequestUtils, "_request_with_tls12", fake_tls12)
|
||||
|
||||
result = RequestUtils(proxies={"https": PROXY}).post_res(
|
||||
URL,
|
||||
data={"name": "demo"},
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert fallback_calls == []
|
||||
|
||||
|
||||
def test_request_utils_owns_persistent_headers_cookies_and_close(monkeypatch):
|
||||
"""宿主持久客户端应封装 Session 状态,并只暴露规范状态操作。"""
|
||||
response = _make_response()
|
||||
|
||||
Reference in New Issue
Block a user