From 02cff8ea63b1cd0b58c1d17872f0a2b06fd88d04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Fri, 4 Sep 2026 23:25:59 +0800 Subject: [PATCH] =?UTF-8?q?task-175:=20Python=20=E5=9B=9E=E8=B0=83/?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E8=B6=85=E6=97=B6=E4=B8=8E=E9=87=8D=E8=AF=95?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=E5=86=BB=E7=BB=93=EF=BC=88=E5=8F=AA=E8=AF=BB?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=EF=BC=8CPython=20=E9=9B=B6=E6=94=B9=E5=8A=A8?= =?UTF-8?q?=EF=BC=89+=208=20=E6=9D=A1=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 锁定 Python→Java HTTP 面(admin_api._get_backend_java_session/_proxy_backend_java):默认 timeout=10、HTTPAdapter(max_retries=0)、超时/失败转 502 - 断言 Java aiimage.http-client.* 治理不外溢到 Python(非测试 Python 无引用、代理签名超时为字面量) - 纯测试,不改任何生产 Python 代码 --- .../tests/test_python_java_http_contract.py | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 backend/tests/test_python_java_http_contract.py diff --git a/backend/tests/test_python_java_http_contract.py b/backend/tests/test_python_java_http_contract.py new file mode 100644 index 00000000..95c02e38 --- /dev/null +++ b/backend/tests/test_python_java_http_contract.py @@ -0,0 +1,117 @@ +"""只读契约冻结:Python→Java HTTP 回调/代理侧超时与重试不受 Java 配置影响(task-175)。 + +背景:模块 10 的 Java 外部客户端配置治理只动 Java 内部客户端(Coze/品牌/紫鸟/图片下载), +spec §3 明确「不改 Python Worker 的 requests 调用 / 不改 Python 回调请求超时和重试约定」。 +本仓库内 Python 对 Java 的 HTTP 面是 blueprints.admin_api 的 _get_backend_java_session / +_proxy_backend_java:requests.Session + HTTPAdapter(max_retries=0),默认 timeout=10s, +全部为 Python 侧字面量/参数默认值,不读取任何 aiimage.http-client.* Java 配置。 +本文件把这些约定固化为快照断言,防止将来误把 Java 配置引进来改变 Python 行为。 + +只读任务:不改任何生产 Python 代码,只新增本测试。 +""" +import inspect +import sys +import unittest +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from blueprints import admin_api + + +class _FakeSession: + """记录请求的伪 requests.Session:捕获 kwargs,返回 Java ApiResponse 成功体。""" + + def __init__(self, raise_on_request=False): + self.calls = [] + self.raise_on_request = raise_on_request + + def request(self, method, url, **kwargs): + self.calls.append((method, url, kwargs)) + if self.raise_on_request: + import requests + raise requests.RequestException("backend-java 服务不可用") + + class _Resp: + status_code = 200 + + def json(self): + return {"success": True, "data": []} + + return _Resp() + + +class PythonJavaHttpContractTest(unittest.TestCase): + + def _proxy_default_http_timeout(self): + return inspect.signature(admin_api._proxy_backend_java).parameters["timeout"].default + + def test_default_timeout_is_python_side_ten_seconds(self): + # 契约:未显式传 timeout 时默认 (连接/读) 10s,且是签名里的字面量,非来自任何配置 + self.assertEqual(10, self._proxy_default_http_timeout()) + self.assertTrue(isinstance(self._proxy_default_http_timeout(), int)) + + def test_override_timeout_forwarded_verbatim(self): + fake = _FakeSession() + with patch.object(admin_api, "_get_backend_java_session", return_value=fake): + result, error_response, status = admin_api._proxy_backend_java( + "GET", "/api/foo", params={"a": "1"}, timeout=(10, 1800)) + self.assertIsNone(error_response) + method, url, kwargs = fake.calls[0] + self.assertEqual((10, 1800), kwargs["timeout"], "显式超时应原样转发给 requests") + + def test_session_never_auto_retries(self): + # 契约:Python→Java session 不自动重试(max_retries=0),失败即报错由上层处理 + session = admin_api._get_backend_java_session() + http_adapter = session.get_adapter("http://") + self.assertEqual(0, http_adapter.max_retries.total) + + def test_session_mounts_http_and_https(self): + session = admin_api._get_backend_java_session() + self.assertIsNotNone(session.get_adapter("http://")) + self.assertIsNotNone(session.get_adapter("https://")) + + def test_timeout_is_not_read_from_java_http_client_config(self): + # 契约:Python 侧无 aiimage.http-client.* 读取点;超时仅来自参数/字面量 + source = inspect.getsource(admin_api._proxy_backend_java) + self.assertNotIn("http-client", source) + self.assertNotIn("AIIMAGE_HTTP_CLIENT", source) + self.assertNotIn("connect-timeout", source) + + def test_timeout_or_connection_failure_maps_to_502(self): + # 契约:Python→Java 超时/连接失败统一转 502,不静默吞掉也不自动重试 + fake = _FakeSession(raise_on_request=True) + with patch.object(admin_api, "_get_backend_java_session", return_value=fake): + with admin_api_app_context(): + result, error_response, status = admin_api._proxy_backend_java("GET", "/api/foo") + self.assertIsNone(result) + self.assertEqual(502, status) + + def test_java_config_namespace_absent_in_python_sources(self): + # 快照:仓库非测试 Python 代码不存在 aiimage.http-client 配置引用,Java 治理不会外溢 + repo_py_root = Path(admin_api.__file__).resolve().parents[2] + hits = [] + for py in repo_py_root.rglob("*.py"): + if "__pycache__" in str(py) or "/tests/" in str(py).replace("\\", "/"): + continue + text = py.read_text(encoding="utf-8", errors="ignore") + if "aiimage.http-client" in text or "aiimage_http_client" in text: + hits.append(str(py)) + self.assertEqual([], hits, f"Python 侧不应引用 Java 统一命名空间: {hits}") + + def test_contract_frozen_documented_values_match_code(self): + # 自检快照:文档化的 Python 回调契约(timeout=10 / max_retries=0)与代码一致 + self.assertEqual(10, self._proxy_default_http_timeout()) + session = admin_api._get_backend_java_session() + self.assertEqual(0, session.get_adapter("http://").max_retries.total) + self.assertEqual(0, session.get_adapter("https://").max_retries.total) + + +def admin_api_app_context(): + from flask import Flask + app = Flask(__name__) + return app.app_context() + + +if __name__ == "__main__": + unittest.main()