Files
xiaoxia-saas/tests/unit/test_mediakit_retry.py
xiaoxia 3e24f8de4a
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 52s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m11s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m33s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m25s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m23s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m52s
CI/CD Pipeline / Integration Tests (push) Successful in 1m32s
CI/CD Pipeline / Unit Tests (push) Successful in 9m8s
CI/CD Pipeline / Frontend Lint (push) Failing after 13m58s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 14m6s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 33s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 17s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 36s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 44s
fix: MediaKit 重试逻辑 + TimeInterval默认策略 + 错误日志增强 (#1227)
2026-08-03 13:51:27 +08:00

402 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)