feat: add test_mediakit_retry.py
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 37s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 49s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 54s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m21s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m37s
AI Code Review / AI Code Review (pull_request) Failing after 2m17s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m52s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m52s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m0s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m32s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m36s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 7m38s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 39s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 54s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 37s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 49s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 54s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m21s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m37s
AI Code Review / AI Code Review (pull_request) Failing after 2m17s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m52s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m52s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m0s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m32s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m36s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 7m38s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 39s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 54s
This commit is contained in:
@@ -0,0 +1,401 @@
|
||||
"""MediaKit 重试逻辑 + 错误日志增强测试.
|
||||
|
||||
覆盖范围:
|
||||
1. _is_retryable_error — 可重试错误判断
|
||||
2. extract_frames 重试 — signal: killed / InternalError 自动重试一次
|
||||
3. analyze_videos 重试 — 同上
|
||||
4. 不可重试错误不触发重试
|
||||
5. 默认抽帧策略为 TimeInterval
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.shared.mediakit_client import (
|
||||
MediaKitClient,
|
||||
_is_retryable_error,
|
||||
)
|
||||
|
||||
# ── _is_retryable_error ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestIsRetryableError:
|
||||
"""可重试错误判断."""
|
||||
|
||||
def test_signal_killed(self):
|
||||
"""signal: killed 可重试."""
|
||||
assert _is_retryable_error("signal: killed") is True
|
||||
|
||||
def test_internal_error(self):
|
||||
"""InternalError 可重试."""
|
||||
assert _is_retryable_error("InternalError: something went wrong") is True
|
||||
|
||||
def test_oom(self):
|
||||
"""OOM 可重试."""
|
||||
assert _is_retryable_error("ExtractFrames OOM") is True
|
||||
|
||||
def test_out_of_memory(self):
|
||||
"""out of memory 可重试."""
|
||||
assert _is_retryable_error("process out of memory") is True
|
||||
|
||||
def test_model_timeout_not_retryable(self):
|
||||
"""model timeout 不可重试."""
|
||||
assert _is_retryable_error("model timeout") is False
|
||||
|
||||
def test_network_error_not_retryable(self):
|
||||
"""普通网络错误不可重试."""
|
||||
assert _is_retryable_error("Connection refused") is False
|
||||
|
||||
def test_empty_string(self):
|
||||
"""空字符串不可重试."""
|
||||
assert _is_retryable_error("") is False
|
||||
|
||||
def test_case_insensitive(self):
|
||||
"""大小写不敏感."""
|
||||
assert _is_retryable_error("SIGNAL: KILLED") is True
|
||||
assert _is_retryable_error("internalerror") is True
|
||||
|
||||
|
||||
# ── extract_frames 重试逻辑 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestExtractFramesRetry:
|
||||
"""extract_frames 重试逻辑测试."""
|
||||
|
||||
def _make_client(self, api_key: str = "test-key") -> MediaKitClient:
|
||||
with patch("packages.shared.mediakit_client.get_shared_settings") as mock_settings:
|
||||
mock_settings.return_value = MagicMock(
|
||||
mediakit_api_key=api_key,
|
||||
mediakit_base_url="https://mock.mediakit.com/api/v1",
|
||||
mediakit_timeout=30,
|
||||
)
|
||||
return MediaKitClient()
|
||||
|
||||
@patch("packages.shared.mediakit_client.time.sleep")
|
||||
@patch("packages.shared.mediakit_client.httpx.get")
|
||||
@patch("packages.shared.mediakit_client.httpx.post")
|
||||
def test_retry_on_signal_killed_then_success(self, mock_post, mock_get, mock_sleep):
|
||||
"""signal: killed 后重试成功."""
|
||||
# 第一次提交成功
|
||||
mock_post.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {"task_id": "task-retry-1"},
|
||||
raise_for_status=MagicMock(),
|
||||
)
|
||||
|
||||
# 第一次轮询返回 failed(signal: killed),第二次返回 success
|
||||
call_count = [0]
|
||||
|
||||
def get_side_effect(*args, **kwargs):
|
||||
call_count[0] += 1
|
||||
if call_count[0] == 1:
|
||||
# 第一次轮询:失败
|
||||
return MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {"status": "failed", "error": "signal: killed"},
|
||||
raise_for_status=MagicMock(),
|
||||
)
|
||||
else:
|
||||
# 第二次轮询(重试后):成功
|
||||
return MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {
|
||||
"status": "success",
|
||||
"result": {"snapshots": [{"image_url": "https://example.com/frame.jpg", "timestamp": 1.0}]},
|
||||
},
|
||||
raise_for_status=MagicMock(),
|
||||
)
|
||||
|
||||
mock_get.side_effect = get_side_effect
|
||||
|
||||
client = self._make_client()
|
||||
frames = client.extract_frames(
|
||||
video_url="https://example.com/video.mp4",
|
||||
strategy="TimeInterval",
|
||||
max_frames=5,
|
||||
poll_interval=0.01,
|
||||
max_retries=1,
|
||||
)
|
||||
|
||||
assert frames is not None
|
||||
assert len(frames) == 1
|
||||
assert frames[0]["image_url"] == "https://example.com/frame.jpg"
|
||||
|
||||
# 验证 sleep 被调用(重试间隔)
|
||||
mock_sleep.assert_called()
|
||||
|
||||
@patch("packages.shared.mediakit_client.time.sleep")
|
||||
@patch("packages.shared.mediakit_client.httpx.get")
|
||||
@patch("packages.shared.mediakit_client.httpx.post")
|
||||
def test_no_retry_on_non_retryable_error(self, mock_post, mock_get, mock_sleep):
|
||||
"""不可重试错误直接返回 None,不重试."""
|
||||
mock_post.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {"task_id": "task-fail"},
|
||||
raise_for_status=MagicMock(),
|
||||
)
|
||||
|
||||
mock_get.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {"status": "failed", "error": "model timeout"},
|
||||
raise_for_status=MagicMock(),
|
||||
)
|
||||
|
||||
client = self._make_client()
|
||||
frames = client.extract_frames(
|
||||
video_url="https://example.com/video.mp4",
|
||||
strategy="TimeInterval",
|
||||
max_frames=5,
|
||||
poll_interval=0.01,
|
||||
max_retries=1,
|
||||
)
|
||||
|
||||
assert frames is None
|
||||
# 不可重试错误不应该调用 sleep(不重试)
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
@patch("packages.shared.mediakit_client.time.sleep")
|
||||
@patch("packages.shared.mediakit_client.httpx.get")
|
||||
@patch("packages.shared.mediakit_client.httpx.post")
|
||||
def test_retry_exhausted_returns_none(self, mock_post, mock_get, mock_sleep):
|
||||
"""重试次数用尽后返回 None."""
|
||||
mock_post.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {"task_id": "task-fail-retry"},
|
||||
raise_for_status=MagicMock(),
|
||||
)
|
||||
|
||||
# 每次都返回 signal: killed
|
||||
mock_get.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {"status": "failed", "error": "signal: killed"},
|
||||
raise_for_status=MagicMock(),
|
||||
)
|
||||
|
||||
client = self._make_client()
|
||||
frames = client.extract_frames(
|
||||
video_url="https://example.com/video.mp4",
|
||||
strategy="TimeInterval",
|
||||
max_frames=5,
|
||||
poll_interval=0.01,
|
||||
max_retries=1, # 最多重试 1 次
|
||||
)
|
||||
|
||||
assert frames is None
|
||||
# post 被调用 2 次(原始 + 1 次重试)
|
||||
assert mock_post.call_count == 2
|
||||
|
||||
def test_default_strategy_is_time_interval(self):
|
||||
"""默认抽帧策略为 TimeInterval(不是 SceneChange)."""
|
||||
with patch("packages.shared.mediakit_client.httpx.post") as mock_post:
|
||||
with patch("packages.shared.mediakit_client.httpx.get") as mock_get:
|
||||
mock_post.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {"task_id": "task-default"},
|
||||
raise_for_status=MagicMock(),
|
||||
)
|
||||
mock_get.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {
|
||||
"status": "success",
|
||||
"result": {"snapshots": [{"image_url": "url", "timestamp": 0.0}]},
|
||||
},
|
||||
raise_for_status=MagicMock(),
|
||||
)
|
||||
|
||||
client = self._make_client()
|
||||
# 不传 strategy,使用默认值
|
||||
client.extract_frames(
|
||||
video_url="https://example.com/video.mp4",
|
||||
poll_interval=0.01,
|
||||
)
|
||||
|
||||
# 验证提交时使用了 TimeInterval
|
||||
call_args = mock_post.call_args
|
||||
payload = call_args.kwargs["json"]
|
||||
assert payload["strategy"] == "TimeInterval"
|
||||
|
||||
|
||||
# ── analyze_videos 重试逻辑 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAnalyzeVideosRetry:
|
||||
"""analyze_videos 重试逻辑测试."""
|
||||
|
||||
def _make_client(self, api_key: str = "test-key") -> MediaKitClient:
|
||||
with patch("packages.shared.mediakit_client.get_shared_settings") as mock_settings:
|
||||
mock_settings.return_value = MagicMock(
|
||||
mediakit_api_key=api_key,
|
||||
mediakit_base_url="https://mock.mediakit.com/api/v1",
|
||||
mediakit_timeout=30,
|
||||
)
|
||||
return MediaKitClient()
|
||||
|
||||
@patch("packages.shared.mediakit_client.time.sleep")
|
||||
@patch("packages.shared.mediakit_client.httpx.get")
|
||||
@patch("packages.shared.mediakit_client.httpx.post")
|
||||
def test_retry_on_internal_error_then_success(self, mock_post, mock_get, mock_sleep):
|
||||
"""InternalError 后重试成功."""
|
||||
mock_post.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {"task_id": "task-retry-vu"},
|
||||
raise_for_status=MagicMock(),
|
||||
)
|
||||
|
||||
call_count = [0]
|
||||
|
||||
def get_side_effect(*args, **kwargs):
|
||||
call_count[0] += 1
|
||||
if call_count[0] == 1:
|
||||
return MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {"status": "failed", "error": "InternalError: service busy"},
|
||||
raise_for_status=MagicMock(),
|
||||
)
|
||||
else:
|
||||
return MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {
|
||||
"status": "completed",
|
||||
"result": {"contents": ["视频展示了一只猫在沙发上睡觉"]},
|
||||
},
|
||||
raise_for_status=MagicMock(),
|
||||
)
|
||||
|
||||
mock_get.side_effect = get_side_effect
|
||||
|
||||
client = self._make_client()
|
||||
result = client.analyze_videos(
|
||||
video_urls=["https://example.com/cat.mp4"],
|
||||
prompt="描述视频内容",
|
||||
poll_interval=0.01,
|
||||
max_retries=1,
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert len(result) == 1
|
||||
assert "猫" in result[0]
|
||||
mock_sleep.assert_called()
|
||||
|
||||
@patch("packages.shared.mediakit_client.time.sleep")
|
||||
@patch("packages.shared.mediakit_client.httpx.get")
|
||||
@patch("packages.shared.mediakit_client.httpx.post")
|
||||
def test_no_retry_on_unknown_error(self, mock_post, mock_get, mock_sleep):
|
||||
"""未知错误不重试."""
|
||||
mock_post.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {"task_id": "task-unknown-fail"},
|
||||
raise_for_status=MagicMock(),
|
||||
)
|
||||
|
||||
mock_get.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {"status": "failed", "error": "unknown error"},
|
||||
raise_for_status=MagicMock(),
|
||||
)
|
||||
|
||||
client = self._make_client()
|
||||
result = client.analyze_videos(
|
||||
video_urls=["https://example.com/v.mp4"],
|
||||
prompt="describe",
|
||||
poll_interval=0.01,
|
||||
max_retries=1,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
mock_sleep.assert_not_called()
|
||||
# post 只调用 1 次(不重试)
|
||||
assert mock_post.call_count == 1
|
||||
|
||||
|
||||
# ── 错误日志增强 ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestErrorLogging:
|
||||
"""错误日志包含完整信息."""
|
||||
|
||||
def _make_client(self, api_key: str = "test-key") -> MediaKitClient:
|
||||
with patch("packages.shared.mediakit_client.get_shared_settings") as mock_settings:
|
||||
mock_settings.return_value = MagicMock(
|
||||
mediakit_api_key=api_key,
|
||||
mediakit_base_url="https://mock.mediakit.com/api/v1",
|
||||
mediakit_timeout=30,
|
||||
)
|
||||
return MediaKitClient()
|
||||
|
||||
@patch("packages.shared.mediakit_client.httpx.get")
|
||||
@patch("packages.shared.mediakit_client.httpx.post")
|
||||
def test_extract_frames_error_includes_task_id(self, mock_post, mock_get, caplog):
|
||||
"""抽帧失败日志包含 task_id 和完整 error."""
|
||||
import logging
|
||||
|
||||
mock_post.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {"task_id": "task-log-test"},
|
||||
raise_for_status=MagicMock(),
|
||||
)
|
||||
|
||||
mock_get.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {
|
||||
"status": "failed",
|
||||
"error": "signal: killed",
|
||||
"error_node": "ExtractFrames",
|
||||
},
|
||||
raise_for_status=MagicMock(),
|
||||
)
|
||||
|
||||
client = self._make_client()
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
client.extract_frames(
|
||||
video_url="https://example.com/video.mp4",
|
||||
strategy="TimeInterval",
|
||||
max_frames=5,
|
||||
poll_interval=0.01,
|
||||
max_retries=0, # 不重试,直接失败
|
||||
)
|
||||
|
||||
# 验证日志中包含 task_id 和 error 信息
|
||||
error_logs = [r.message for r in caplog.records if r.levelno >= logging.ERROR]
|
||||
assert any("task-log-test" in msg for msg in error_logs)
|
||||
assert any("signal: killed" in msg for msg in error_logs)
|
||||
|
||||
@patch("packages.shared.mediakit_client.httpx.get")
|
||||
@patch("packages.shared.mediakit_client.httpx.post")
|
||||
def test_analyze_videos_error_includes_full_error(self, mock_post, mock_get, caplog):
|
||||
"""视频理解失败日志包含完整 error 和 error_node."""
|
||||
import logging
|
||||
|
||||
mock_post.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {"task_id": "task-vu-log"},
|
||||
raise_for_status=MagicMock(),
|
||||
)
|
||||
|
||||
mock_get.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {
|
||||
"status": "failed",
|
||||
"error": "signal: killed in ExtractFrames",
|
||||
"error_node": "ExtractFrames",
|
||||
},
|
||||
raise_for_status=MagicMock(),
|
||||
)
|
||||
|
||||
client = self._make_client()
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
client.analyze_videos(
|
||||
video_urls=["https://example.com/v.mp4"],
|
||||
prompt="describe",
|
||||
poll_interval=0.01,
|
||||
max_retries=0,
|
||||
)
|
||||
|
||||
error_logs = [r.message for r in caplog.records if r.levelno >= logging.ERROR]
|
||||
assert any("task-vu-log" in msg for msg in error_logs)
|
||||
assert any("ExtractFrames" in msg for msg in error_logs)
|
||||
Reference in New Issue
Block a user