From b1348401026adbb63e164533616cdcc2e7648279 Mon Sep 17 00:00:00 2001 From: ranzhigg <126594517+ranzhigg@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:53:23 +0800 Subject: [PATCH] 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> --- app/adapters/network/http.py | 342 +++++++++++++++--- .../architecture/dependency-baseline.json | 21 +- .../architecture/dependency-policy.json | 4 +- tests/test_architecture_egress.py | 4 +- tests/test_async_request_utils.py | 66 ++++ tests/test_request_utils.py | 55 +++ 6 files changed, 431 insertions(+), 61 deletions(-) diff --git a/app/adapters/network/http.py b/app/adapters/network/http.py index 4a87b6389..852a0cf8d 100644 --- a/app/adapters/network/http.py +++ b/app/adapters/network/http.py @@ -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 diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index bef93f86d..c8c168677 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -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" ] diff --git a/tests/fixtures/architecture/dependency-policy.json b/tests/fixtures/architecture/dependency-policy.json index 1a3bee064..a34fa9e08 100644 --- a/tests/fixtures/architecture/dependency-policy.json +++ b/tests/fixtures/architecture/dependency-policy.json @@ -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"} ] diff --git a/tests/test_architecture_egress.py b/tests/test_architecture_egress.py index 905cd15a0..d020d0ee6 100644 --- a/tests/test_architecture_egress.py +++ b/tests/test_architecture_egress.py @@ -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", diff --git a/tests/test_async_request_utils.py b/tests/test_async_request_utils.py index 1d3a03c15..b7e95bcaa 100644 --- a/tests/test_async_request_utils.py +++ b/tests/test_async_request_utils.py @@ -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 降级重试,避免服务端可能已收到数据时重复产生副作用。 diff --git a/tests/test_request_utils.py b/tests/test_request_utils.py index 8a485f16e..495ee3e23 100644 --- a/tests/test_request_utils.py +++ b/tests/test_request_utils.py @@ -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()