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:
ranzhigg
2026-09-22 19:53:23 +08:00
committed by GitHub
parent aeaf473ebd
commit b134840102
6 changed files with 431 additions and 61 deletions

View File

@@ -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

View File

@@ -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"
]

View File

@@ -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"}
]

View File

@@ -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",

View File

@@ -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 降级重试,避免服务端可能已收到数据时重复产生副作用。

View File

@@ -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()