feat: cmhub shared Session + connection pool + proxy handling (T-546)
- app/ai.py: 模块级共享 requests.Session + HTTPAdapter 连接池 (CMHUB_HTTP_POOL_SIZE=32);_apply_cmhub_proxy() 切换 trust_env; 生成/下载/models/balance 统一复用该 Session。 - app/appconfig.py: ai.cmhub.use_system_proxy 默认 false(绕过系统代理)。 - tests/test_ai.py: 补 Session 复用/代理/连接池测试;原 18 处 cmhub mock 从模块级 requests 改到共享 Session。 - docs: troubleshooting 加「图片下载很慢」排障节;06-tasks 标 T-546 DONE。 附带:把 app/ai.py 行尾从 CRLF 归一为 LF;并一并纳入工作区中此前未提交、 已在运行构建里的 cmhub 下载诊断代码(调试图片 URL 日志、下载重试事件), 无法与 T-546 在同一文件内拆分单独提交。243 单测全绿。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b257fb5c83
commit
5a09c6662e
+226
-14
@@ -311,7 +311,7 @@ class AITests(TempDirMixin, unittest.TestCase):
|
||||
}
|
||||
)
|
||||
|
||||
with mock.patch("app.ai.requests.request", side_effect=fake_request):
|
||||
with mock.patch.object(ai._cmhub_session(), "request", side_effect=fake_request):
|
||||
title = ai.gen_title(
|
||||
"优化标题",
|
||||
"旧标题",
|
||||
@@ -398,8 +398,8 @@ class AITests(TempDirMixin, unittest.TestCase):
|
||||
downloads.append((url, kwargs))
|
||||
return _RequestsResponse(content=generated.getvalue())
|
||||
|
||||
with mock.patch("app.ai.requests.request", side_effect=fake_request), \
|
||||
mock.patch("app.ai.requests.get", side_effect=fake_get), \
|
||||
with mock.patch.object(ai._cmhub_session(), "request", side_effect=fake_request), \
|
||||
mock.patch.object(ai._cmhub_session(), "get", side_effect=fake_get), \
|
||||
mock.patch(
|
||||
"app.ai.socket.getaddrinfo",
|
||||
return_value=[
|
||||
@@ -454,6 +454,176 @@ class AITests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_cmhub_gen_cover_emits_debug_image_url_when_enabled(self):
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
self.skipTest("Pillow not installed")
|
||||
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg, key_path = self._cmhub_config(temp_dir)
|
||||
old_cover = os.path.join(temp_dir, "old.jpg")
|
||||
output = os.path.join(temp_dir, "new.jpg")
|
||||
Image.new("RGB", (16, 16), (20, 30, 40)).save(old_cover, "JPEG")
|
||||
generated_png = self._png_bytes()
|
||||
steps = []
|
||||
|
||||
def fake_request(method, url, **kwargs):
|
||||
return _RequestsResponse(
|
||||
{"image_url": "https://cdn.example.com/generated.png?token=secret"}
|
||||
)
|
||||
|
||||
def fake_get(url, **kwargs):
|
||||
return _RequestsResponse(content=generated_png)
|
||||
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{"CMSHOPEE_DEBUG_CMHUB_IMAGE_URL": "1"},
|
||||
), mock.patch.object(ai._cmhub_session(), "request", side_effect=fake_request), \
|
||||
mock.patch.object(ai._cmhub_session(), "get", side_effect=fake_get), \
|
||||
mock.patch(
|
||||
"app.ai.socket.getaddrinfo",
|
||||
return_value=[
|
||||
(
|
||||
socket.AF_INET,
|
||||
socket.SOCK_STREAM,
|
||||
6,
|
||||
"",
|
||||
("93.184.216.34", 443),
|
||||
)
|
||||
],
|
||||
):
|
||||
ai.gen_cover(
|
||||
"生成封面",
|
||||
old_cover,
|
||||
output,
|
||||
config=cfg,
|
||||
cmhub_config_path=key_path,
|
||||
on_step=steps.append,
|
||||
)
|
||||
|
||||
debug_events = [
|
||||
event for event in steps
|
||||
if isinstance(event, dict) and event.get("step") == "cover_image_url"
|
||||
]
|
||||
self.assertEqual(1, len(debug_events))
|
||||
self.assertEqual("debug", debug_events[0]["result"])
|
||||
self.assertTrue(debug_events[0]["debug_only"])
|
||||
self.assertIn("https://cdn.example.com/generated.png", debug_events[0]["detail"])
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_cmhub_gen_cover_retries_image_download_without_new_generation(self):
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
self.skipTest("Pillow not installed")
|
||||
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg, key_path = self._cmhub_config(temp_dir)
|
||||
old_cover = os.path.join(temp_dir, "old.jpg")
|
||||
output = os.path.join(temp_dir, "new.jpg")
|
||||
Image.new("RGB", (16, 16), (20, 30, 40)).save(old_cover, "JPEG")
|
||||
generated_png = self._png_bytes()
|
||||
request_calls = []
|
||||
download_calls = []
|
||||
steps = []
|
||||
|
||||
def fake_request(method, url, **kwargs):
|
||||
request_calls.append((method, url, kwargs))
|
||||
return _RequestsResponse(
|
||||
{"image_url": "https://cdn.example.com/generated.png"}
|
||||
)
|
||||
|
||||
def fake_get(url, **kwargs):
|
||||
download_calls.append((url, kwargs))
|
||||
if len(download_calls) == 1:
|
||||
raise ai.requests.exceptions.ConnectionError("temporary")
|
||||
return _RequestsResponse(content=generated_png)
|
||||
|
||||
with mock.patch.object(ai._cmhub_session(), "request", side_effect=fake_request), \
|
||||
mock.patch.object(ai._cmhub_session(), "get", side_effect=fake_get), \
|
||||
mock.patch(
|
||||
"app.ai.socket.getaddrinfo",
|
||||
return_value=[
|
||||
(
|
||||
socket.AF_INET,
|
||||
socket.SOCK_STREAM,
|
||||
6,
|
||||
"",
|
||||
("93.184.216.34", 443),
|
||||
)
|
||||
],
|
||||
), mock.patch("app.ai.time.sleep"):
|
||||
result = ai.gen_cover(
|
||||
"生成封面",
|
||||
old_cover,
|
||||
output,
|
||||
config=cfg,
|
||||
cmhub_config_path=key_path,
|
||||
on_step=steps.append,
|
||||
)
|
||||
|
||||
self.assertEqual(os.path.abspath(output), result)
|
||||
self.assertEqual(1, len(request_calls))
|
||||
self.assertEqual(2, len(download_calls))
|
||||
retry_events = [
|
||||
event for event in steps
|
||||
if isinstance(event, dict)
|
||||
and event.get("step") == "cover_download"
|
||||
and event.get("result") == "retry"
|
||||
]
|
||||
self.assertEqual(1, len(retry_events))
|
||||
self.assertEqual(1, retry_events[0]["attempt"])
|
||||
self.assertEqual(ai.CMHUB_IMAGE_DOWNLOAD_ATTEMPTS, retry_events[0]["attempts"])
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_cmhub_download_slow_warning_event(self):
|
||||
try:
|
||||
from PIL import Image # noqa: F401
|
||||
except ImportError:
|
||||
self.skipTest("Pillow not installed")
|
||||
|
||||
generated_png = self._png_bytes()
|
||||
steps = []
|
||||
|
||||
def fake_get(url, **kwargs):
|
||||
return _RequestsResponse(content=generated_png)
|
||||
|
||||
with mock.patch.object(ai._cmhub_session(), "get", side_effect=fake_get), \
|
||||
mock.patch(
|
||||
"app.ai.socket.getaddrinfo",
|
||||
return_value=[
|
||||
(
|
||||
socket.AF_INET,
|
||||
socket.SOCK_STREAM,
|
||||
6,
|
||||
"",
|
||||
("93.184.216.34", 443),
|
||||
)
|
||||
],
|
||||
):
|
||||
image_bytes, elapsed = ai._download_cmhub_image_with_retry(
|
||||
"https://cdn.example.com/generated.png",
|
||||
connect_timeout=3,
|
||||
read_timeout=650,
|
||||
on_step=steps.append,
|
||||
slow_threshold=0,
|
||||
)
|
||||
|
||||
self.assertEqual(generated_png, image_bytes)
|
||||
self.assertGreaterEqual(elapsed, 0)
|
||||
self.assertTrue(
|
||||
any(
|
||||
isinstance(event, dict)
|
||||
and event.get("step") == "cover_download"
|
||||
and event.get("result") == "warning"
|
||||
and "图片下载较慢" in event.get("detail", "")
|
||||
for event in steps
|
||||
)
|
||||
)
|
||||
|
||||
def test_cmhub_image_url_rejects_private_and_private_dns(self):
|
||||
with self.assertRaises(ai.AIError):
|
||||
ai._download_cmhub_image("http://127.0.0.1/a.png", 1, 1)
|
||||
@@ -481,7 +651,7 @@ class AITests(TempDirMixin, unittest.TestCase):
|
||||
)
|
||||
return _RequestsResponse({"titles": ["新标题"], "points_balance": 10})
|
||||
|
||||
with mock.patch("app.ai.requests.request", side_effect=fake_request), \
|
||||
with mock.patch.object(ai._cmhub_session(), "request", side_effect=fake_request), \
|
||||
mock.patch("app.ai.time.sleep"):
|
||||
title = ai.gen_title(
|
||||
"prompt",
|
||||
@@ -512,7 +682,7 @@ class AITests(TempDirMixin, unittest.TestCase):
|
||||
calls.append((method, url, kwargs))
|
||||
raise ai.requests.exceptions.ReadTimeout("slow")
|
||||
|
||||
with mock.patch("app.ai.requests.request", side_effect=fake_request):
|
||||
with mock.patch.object(ai._cmhub_session(), "request", side_effect=fake_request):
|
||||
with self.assertRaises(ai.CMHubError) as raised:
|
||||
ai.gen_cover(
|
||||
"prompt",
|
||||
@@ -548,7 +718,7 @@ class AITests(TempDirMixin, unittest.TestCase):
|
||||
}
|
||||
)
|
||||
|
||||
with mock.patch("app.ai.requests.request", side_effect=fake_request):
|
||||
with mock.patch.object(ai._cmhub_session(), "request", side_effect=fake_request):
|
||||
models = ai.fetch_cmhub_models("https://cmhub.example.com", "sk-cmhub-secret")
|
||||
|
||||
self.assertEqual("GET", calls[0][0])
|
||||
@@ -562,7 +732,7 @@ class AITests(TempDirMixin, unittest.TestCase):
|
||||
calls.append((method, url, kwargs))
|
||||
return _RequestsResponse({"detail": "notfound"}, status_code=404)
|
||||
|
||||
with mock.patch("app.ai.requests.request", side_effect=fake_request):
|
||||
with mock.patch.object(ai._cmhub_session(), "request", side_effect=fake_request):
|
||||
with self.assertRaises(ai.CMHubError) as raised:
|
||||
ai.fetch_cmhub_models(
|
||||
"https://cmhub.example.com/api/v1/",
|
||||
@@ -584,7 +754,7 @@ class AITests(TempDirMixin, unittest.TestCase):
|
||||
calls.append((method, url, kwargs))
|
||||
return _RequestsResponse({"user": {"id": "u1"}, "points_balance": 42})
|
||||
|
||||
with mock.patch("app.ai.requests.request", side_effect=fake_request):
|
||||
with mock.patch.object(ai._cmhub_session(), "request", side_effect=fake_request):
|
||||
balance = ai.fetch_cmhub_balance("https://cmhub.example.com", "sk-cmhub-secret")
|
||||
|
||||
self.assertEqual("GET", calls[0][0])
|
||||
@@ -657,8 +827,8 @@ class AITests(TempDirMixin, unittest.TestCase):
|
||||
downloads.append((url, kwargs))
|
||||
return _RequestsResponse(content=generated_png)
|
||||
|
||||
with mock.patch("app.ai.requests.request", side_effect=fake_request), \
|
||||
mock.patch("app.ai.requests.get", side_effect=fake_get), \
|
||||
with mock.patch.object(ai._cmhub_session(), "request", side_effect=fake_request), \
|
||||
mock.patch.object(ai._cmhub_session(), "get", side_effect=fake_get), \
|
||||
mock.patch("app.ai.socket.getaddrinfo", return_value=public_dns):
|
||||
summary = ai.generate_batch(
|
||||
tasks,
|
||||
@@ -713,8 +883,8 @@ class AITests(TempDirMixin, unittest.TestCase):
|
||||
downloads_seen.append((url, kwargs))
|
||||
raise ai.requests.exceptions.ConnectionError("download failed")
|
||||
|
||||
with mock.patch("app.ai.requests.request", side_effect=fake_request), \
|
||||
mock.patch("app.ai.requests.get", side_effect=fake_get), \
|
||||
with mock.patch.object(ai._cmhub_session(), "request", side_effect=fake_request), \
|
||||
mock.patch.object(ai._cmhub_session(), "get", side_effect=fake_get), \
|
||||
mock.patch("app.ai.socket.getaddrinfo", return_value=public_dns):
|
||||
summary = ai.generate_batch(
|
||||
tasks,
|
||||
@@ -730,7 +900,7 @@ class AITests(TempDirMixin, unittest.TestCase):
|
||||
self.assertEqual(0, summary["cover_done"])
|
||||
self.assertEqual(1, summary["failed"])
|
||||
self.assertEqual(1, len(requests_seen))
|
||||
self.assertEqual(1, len(downloads_seen))
|
||||
self.assertEqual(ai.CMHUB_IMAGE_DOWNLOAD_ATTEMPTS, len(downloads_seen))
|
||||
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||||
self.assertEqual("failed", updated.status)
|
||||
self.assertIn("下载 cmhub 图片失败", updated.last_error)
|
||||
@@ -756,7 +926,7 @@ class AITests(TempDirMixin, unittest.TestCase):
|
||||
}
|
||||
)
|
||||
|
||||
with mock.patch("app.ai.requests.request", side_effect=fake_request):
|
||||
with mock.patch.object(ai._cmhub_session(), "request", side_effect=fake_request):
|
||||
summary = ai.generate_batch(
|
||||
tasks,
|
||||
{"title": "标题提示", "cover": "封面"},
|
||||
@@ -1190,6 +1360,48 @@ class AITests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_cmhub_session_is_shared_singleton_with_pool(self):
|
||||
session_a = ai._cmhub_session()
|
||||
session_b = ai._cmhub_session()
|
||||
self.assertIs(session_a, session_b)
|
||||
# 连接池要覆盖生图并发 + 下载并发(各上限 5)
|
||||
self.assertGreaterEqual(
|
||||
ai.CMHUB_HTTP_POOL_SIZE, ai.CMHUB_IMAGE_CONCURRENCY_LIMIT * 2
|
||||
)
|
||||
adapter = session_a.get_adapter("https://cmhub.example.com")
|
||||
self.assertEqual(ai.CMHUB_HTTP_POOL_SIZE, adapter._pool_maxsize)
|
||||
|
||||
def test_apply_cmhub_proxy_toggles_trust_env(self):
|
||||
try:
|
||||
session = ai._apply_cmhub_proxy(False)
|
||||
self.assertIs(session, ai._cmhub_session())
|
||||
self.assertFalse(session.trust_env)
|
||||
ai._apply_cmhub_proxy(True)
|
||||
self.assertTrue(ai._cmhub_session().trust_env)
|
||||
finally:
|
||||
# 复位为默认(绕过系统代理),避免影响其它测试
|
||||
ai._apply_cmhub_proxy(False)
|
||||
|
||||
def test_cmhub_runtime_applies_and_returns_use_system_proxy(self):
|
||||
with mock.patch(
|
||||
"app.ai.appconfig.cmhub_config",
|
||||
return_value={
|
||||
"base_url": "https://cmhub.example.com",
|
||||
"title_alias": "title-standard",
|
||||
"image_alias": "image-hd",
|
||||
"connect_timeout": 10,
|
||||
"use_system_proxy": True,
|
||||
},
|
||||
), mock.patch(
|
||||
"app.ai.appconfig.get_cmhub_api_key", return_value="sk_cmhub_test"
|
||||
):
|
||||
try:
|
||||
runtime = ai._cmhub_runtime({}, "image", cmhub_config_path=None)
|
||||
self.assertTrue(runtime["use_system_proxy"])
|
||||
self.assertTrue(ai._cmhub_session().trust_env)
|
||||
finally:
|
||||
ai._apply_cmhub_proxy(False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user