From 5edeef5a4d7d875b76a062eabead7484bd85c85d Mon Sep 17 00:00:00 2001 From: ga626 <135515176+ga626@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:53:49 +0800 Subject: [PATCH] fix(plugin-market): bound proxy failure handling --- app/adapters/external/plugin/client.py | 40 ++++++++++++------ tests/test_plugin_market_index_policy.py | 49 ++++++++++++++++++++++ tests/test_plugin_market_request_policy.py | 16 +++---- 3 files changed, 83 insertions(+), 22 deletions(-) diff --git a/app/adapters/external/plugin/client.py b/app/adapters/external/plugin/client.py index aba9f53ba..7c0a97e0f 100644 --- a/app/adapters/external/plugin/client.py +++ b/app/adapters/external/plugin/client.py @@ -50,6 +50,7 @@ PLUGIN_INDEX_MAX_ENTRIES = 4096 PLUGIN_INDEX_MAX_HISTORY_ENTRIES = 512 PLUGIN_INDEX_MAX_NESTING = 64 PLUGIN_INDEX_READ_CHUNK_SIZE = 64 * 1024 +PLUGIN_INDEX_REQUEST_TIMEOUT = 15 PLUGIN_INDEX_COMPATIBILITY_FLAG_PATTERN = re.compile(r"^v\d+t?$") PLUGIN_INDEX_TEXT_LIMITS = { "name": 256, @@ -68,6 +69,12 @@ class _PluginIndexTooLargeError(RuntimeError): """插件索引的声明长度或实际读取字节超过资源边界。""" +def _format_request_error(error: Exception) -> str: + """保留异常类型,避免无消息异常在运行日志中显示为空白。""" + detail = str(error).strip() + return f"{type(error).__name__}: {detail}" if detail else type(error).__name__ + + def build_local_repo_url( plugin_id: str, *, @@ -903,7 +910,7 @@ class PluginMarketTransport(metaclass=WeakSingleton): cls, url: str, headers: Optional[dict[str, str]] = None, - timeout: Optional[int] = 60, + timeout: Optional[int] = PLUGIN_INDEX_REQUEST_TIMEOUT, ) -> Optional[tuple[int, str]]: """按 GitHub 降级顺序流式读取同步插件索引,并限制解压后字节数。""" strategies = cls._build_github_request_strategies( @@ -929,7 +936,7 @@ class PluginMarketTransport(metaclass=WeakSingleton): except Exception as error: # noqa: BLE001 - 失败后尝试下一传输策略 logger.error( f"[GitHub] 插件索引请求失败,策略:{strategy_name}," - f"URL:{target_url},错误:{error}" + f"URL:{target_url},错误:{_format_request_error(error)}" ) logger.error(f"[GitHub] 所有策略均无法读取插件索引,URL:{url}") return None @@ -939,7 +946,7 @@ class PluginMarketTransport(metaclass=WeakSingleton): cls, url: str, headers: Optional[dict[str, str]] = None, - timeout: Optional[int] = 60, + timeout: Optional[int] = PLUGIN_INDEX_REQUEST_TIMEOUT, ) -> Optional[tuple[int, str]]: """按 GitHub 降级顺序流式读取异步插件索引,并限制解压后字节数。""" strategies = cls._build_github_request_strategies( @@ -965,7 +972,7 @@ class PluginMarketTransport(metaclass=WeakSingleton): except Exception as error: # noqa: BLE001 - 失败后尝试下一传输策略 logger.error( f"[GitHub] 插件索引请求失败,策略:{strategy_name}," - f"URL:{target_url},错误:{error}" + f"URL:{target_url},错误:{_format_request_error(error)}" ) logger.error(f"[GitHub] 所有策略均无法读取插件索引,URL:{url}") return None @@ -1251,7 +1258,7 @@ class PluginMarketTransport(metaclass=WeakSingleton): timeout: Optional[int] = 60, is_api: bool = False, ) -> list[tuple[str, str, PluginRequestOptions]]: - """构造同步与异步 GitHub 请求共用的镜像、代理和直连顺序。""" + """构造同步与异步 GitHub 请求共用的镜像和单一出口顺序。""" strategies: list[tuple[str, str, PluginRequestOptions]] = [] if not is_api and get_runtime_setting('GITHUB_PROXY'): proxy_url = ( @@ -1272,9 +1279,10 @@ class PluginMarketTransport(metaclass=WeakSingleton): }, ) ) - strategies.append( - ("直连", url, {"headers": headers, "timeout": timeout}) - ) + else: + strategies.append( + ("直连", url, {"headers": headers, "timeout": timeout}) + ) return strategies @staticmethod @@ -1283,7 +1291,8 @@ class PluginMarketTransport(metaclass=WeakSingleton): timeout: Optional[int] = 60, is_api: bool = False) -> Optional[Response]: """ - 使用自动降级策略,请求资源,优先级依次为镜像站、代理、直连 + 使用自动降级策略,请求资源:可选镜像站后只使用一个出口; + 显式配置代理时不再追加无效直连。 :param url: 目标URL :param headers: 请求头信息 :param timeout: 请求超时时间 @@ -1306,7 +1315,10 @@ class PluginMarketTransport(metaclass=WeakSingleton): logger.debug(f"[GitHub] 请求成功,策略:{strategy_name}, URL: {target_url}") return res except Exception as e: - logger.error(f"[GitHub] 请求失败,策略:{strategy_name}, URL: {target_url},错误:{str(e)}") + logger.error( + f"[GitHub] 请求失败,策略:{strategy_name}, URL: {target_url}," + f"错误:{_format_request_error(e)}" + ) logger.error(f"[GitHub] 所有策略均请求失败,URL: {url},请检查网络连接或 GitHub 配置") return None @@ -1404,7 +1416,8 @@ class PluginMarketTransport(metaclass=WeakSingleton): timeout: Optional[int] = 60, is_api: bool = False) -> Optional[httpx2.Response]: """ - 使用自动降级策略,异步请求资源,优先级依次为镜像站、代理、直连 + 使用自动降级策略,异步请求资源:可选镜像站后只使用一个出口; + 显式配置代理时不再追加无效直连。 :param url: 目标URL :param headers: 请求头信息 :param timeout: 请求超时时间 @@ -1427,7 +1440,10 @@ class PluginMarketTransport(metaclass=WeakSingleton): logger.debug(f"[GitHub] 请求成功,策略:{strategy_name}, URL: {target_url}") return res except Exception as e: - logger.error(f"[GitHub] 请求失败,策略:{strategy_name}, URL: {target_url},错误:{str(e)}") + logger.error( + f"[GitHub] 请求失败,策略:{strategy_name}, URL: {target_url}," + f"错误:{_format_request_error(e)}" + ) logger.error(f"[GitHub] 所有策略均请求失败,URL: {url},请检查网络连接或 GitHub 配置") return None diff --git a/tests/test_plugin_market_index_policy.py b/tests/test_plugin_market_index_policy.py index b1e7c8fb1..09498679c 100644 --- a/tests/test_plugin_market_index_policy.py +++ b/tests/test_plugin_market_index_policy.py @@ -6,16 +6,65 @@ from contextlib import asynccontextmanager, contextmanager import pytest from app.adapters.external.plugin.client import ( + PLUGIN_INDEX_REQUEST_TIMEOUT, PLUGIN_INDEX_MAX_BYTES, PLUGIN_INDEX_MAX_ENTRIES, PluginMarketClient, PluginMarketTransport, + _format_request_error, ) SYNC_INDEX_REQUEST = "_PluginMarketTransport__request_plugin_index_with_fallback" ASYNC_INDEX_REQUEST = "_PluginMarketTransport__async_request_plugin_index_with_fallback" +def test_plugin_index_request_timeout_is_bounded() -> None: + """插件索引故障应在有限时间内返回,不能沿用一分钟等待。""" + assert PLUGIN_INDEX_REQUEST_TIMEOUT == 15 + + +def test_github_request_strategies_do_not_retry_direct_when_proxy_is_configured( + monkeypatch, +) -> None: + """显式代理失败后不得绕过同一出口策略再等待一次直连超时。""" + settings = { + "GITHUB_PROXY": None, + "PROXY_HOST": "http://proxy:7891", + "PROXY": {"http": "http://proxy:7891", "https": "http://proxy:7891"}, + } + monkeypatch.setattr( + "app.adapters.external.plugin.client.get_runtime_setting", + lambda key: settings.get(key), + ) + + strategies = PluginMarketTransport._build_github_request_strategies( + url="https://raw.githubusercontent.com/example/repo/main/package.v3.json", + ) + + assert [name for name, _url, _params in strategies] == ["代理"] + + +def test_github_request_strategies_use_direct_without_configured_proxy( + monkeypatch, +) -> None: + """未配置代理时仍保留直连出口。""" + monkeypatch.setattr( + "app.adapters.external.plugin.client.get_runtime_setting", + lambda _key: None, + ) + + strategies = PluginMarketTransport._build_github_request_strategies( + url="https://raw.githubusercontent.com/example/repo/main/package.v3.json", + ) + + assert [name for name, _url, _params in strategies] == ["直连"] + + +def test_request_error_formatter_keeps_type_for_blank_exception() -> None: + """无消息异常也必须给运行日志留下可诊断类型。""" + assert _format_request_error(TimeoutError()) == "TimeoutError" + + class _SyncStreamResponse: """提供 requests 流式响应所需的最小测试契约。""" diff --git a/tests/test_plugin_market_request_policy.py b/tests/test_plugin_market_request_policy.py index e0750c138..b23b6ac53 100644 --- a/tests/test_plugin_market_request_policy.py +++ b/tests/test_plugin_market_request_policy.py @@ -12,7 +12,7 @@ from app.adapters.external.plugin.client import PluginMarketTransport async def test_sync_and_async_github_requests_share_fallback_policy( monkeypatch, ) -> None: - """同步与异步请求必须使用相同镜像、代理、直连顺序和参数。""" + """同步与异步请求必须使用相同镜像和单一出口顺序及参数。""" proxy = {"all": "http://proxy.example:7890"} runtime_settings = SimpleNamespace( GITHUB_PROXY="https://mirror.example", @@ -29,16 +29,16 @@ async def test_sync_and_async_github_requests_share_fallback_policy( response = object() class SyncRequest: - """记录同步请求,并让前两种策略失败以遍历完整顺序。""" + """记录同步请求,并让镜像策略失败以验证代理接管。""" def __init__(self, **kwargs) -> None: self._kwargs = kwargs def get_res(self, *, url: str, raise_exception: bool): - """记录请求目标,第三次返回固定响应。""" + """记录请求目标,第二次返回固定响应。""" assert raise_exception is True sync_requests.append((self._kwargs, url)) - if len(sync_requests) < 3: + if len(sync_requests) < 2: raise RuntimeError("next strategy") return response @@ -49,10 +49,10 @@ async def test_sync_and_async_github_requests_share_fallback_policy( self._kwargs = kwargs async def get_res(self, *, url: str, raise_exception: bool): - """记录请求目标,第三次返回固定响应。""" + """记录请求目标,第二次返回固定响应。""" assert raise_exception is True async_requests.append((self._kwargs, url)) - if len(async_requests) < 3: + if len(async_requests) < 2: raise RuntimeError("next strategy") return response @@ -80,10 +80,6 @@ async def test_sync_and_async_github_requests_share_fallback_policy( {"headers": {"X-Test": "1"}, "proxies": proxy, "timeout": 12}, "https://api.example/resource", ), - ( - {"headers": {"X-Test": "1"}, "timeout": 12}, - "https://api.example/resource", - ), ]