mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
refactor: 统一任务框架 Phase 3 - 用 TaskService/FailoverEngine 替代 FallbackOrchestrator
核心重构:
- 移除 FallbackOrchestrator,用 TaskService + FailoverEngine 替代
- TaskService 作为统一入口,支持 SYNC/ASYNC 两种任务模式
- FailoverEngine 实现候选遍历、重试、故障转移逻辑
- 新增 AttemptFunc/AttemptResult 协议,统一尝试结果表示
功能改进:
- 流式响应首字节探测(30s 超时,空流触发故障转移)
- 流式取消归因优化(区分客户端断连 vs 服务端中断)
- 新增 OpenAI Sora 视频取消路由 POST /v1/videos/{task_id}/cancel
- OpenAI 流式请求自动添加 stream_options.include_usage
代码规范:
- 修复 loguru 日志格式(%s → {})
- 新增 FORMAT_CONVERSION_ENABLED 环境变量说明
测试覆盖:
- test_failover_engine.py: FailoverEngine 单元测试
- test_task_service_async_execute.py: TaskService ASYNC 模式测试
- test_video_cancel_e2e.py: 视频取消端到端测试
This commit is contained in:
233
tests/e2e/test_video_cancel_e2e.py
Normal file
233
tests/e2e/test_video_cancel_e2e.py
Normal file
@@ -0,0 +1,233 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src.api.base.pipeline import ApiRequestPipeline
|
||||
from src.api.handlers.gemini.video_adapter import GeminiVeoAdapter
|
||||
from src.api.handlers.openai.video_adapter import OpenAIVideoAdapter
|
||||
from src.core.api_format.conversion.internal_video import VideoStatus
|
||||
|
||||
|
||||
def _make_request(
|
||||
*,
|
||||
method: str,
|
||||
path: str,
|
||||
headers: dict[str, str],
|
||||
body: bytes,
|
||||
) -> MagicMock:
|
||||
req = MagicMock()
|
||||
req.method = method
|
||||
req.url = SimpleNamespace(path=path)
|
||||
req.headers = headers
|
||||
req.query_params = {}
|
||||
req.client = None
|
||||
req.state = SimpleNamespace()
|
||||
req.body = AsyncMock(return_value=body)
|
||||
return req
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_video_cancel_openai_route_end_to_end(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""
|
||||
End-to-end-ish test:
|
||||
ApiRequestPipeline -> OpenAIVideoAdapter -> OpenAIVideoHandler -> TaskService.cancel
|
||||
"""
|
||||
pipeline = ApiRequestPipeline()
|
||||
|
||||
# Pipeline auth/quota/audit shortcuts
|
||||
user = SimpleNamespace(id="u1", username="u1", role="user", quota_usd=None, used_usd=0.0)
|
||||
api_key = SimpleNamespace(id="ak1", user_id="u1", is_standalone=False)
|
||||
monkeypatch.setattr(pipeline.auth_service, "authenticate_api_key", lambda _db, _k: (user, api_key))
|
||||
monkeypatch.setattr(pipeline.usage_service, "check_user_quota", lambda *_args, **_kwargs: (True, "ok"))
|
||||
monkeypatch.setattr(pipeline.audit_service, "log_event", MagicMock())
|
||||
|
||||
# DB stubs used by TaskService.cancel
|
||||
task = SimpleNamespace(
|
||||
id="t1",
|
||||
short_id="s1",
|
||||
user_id="u1",
|
||||
request_id="r1",
|
||||
external_task_id="ext-1",
|
||||
endpoint_id="e1",
|
||||
key_id="k1",
|
||||
status=VideoStatus.SUBMITTED.value,
|
||||
updated_at=None,
|
||||
request_metadata={},
|
||||
)
|
||||
endpoint = SimpleNamespace(
|
||||
id="e1",
|
||||
base_url="https://upstream.example.com",
|
||||
api_format="openai:video",
|
||||
api_family="openai",
|
||||
endpoint_kind="video",
|
||||
header_rules=None,
|
||||
)
|
||||
provider_key = SimpleNamespace(
|
||||
id="k1",
|
||||
api_key="encrypted",
|
||||
auth_type="api_key",
|
||||
)
|
||||
|
||||
q_task = MagicMock()
|
||||
q_task.filter.return_value.first.return_value = task
|
||||
q_endpoint = MagicMock()
|
||||
q_endpoint.filter.return_value.first.return_value = endpoint
|
||||
q_key = MagicMock()
|
||||
q_key.filter.return_value.first.return_value = provider_key
|
||||
|
||||
db = MagicMock()
|
||||
|
||||
def _query(model): # noqa: ANN001
|
||||
name = getattr(model, "__name__", "")
|
||||
if name == "VideoTask":
|
||||
return q_task
|
||||
if name == "ProviderEndpoint":
|
||||
return q_endpoint
|
||||
if name == "ProviderAPIKey":
|
||||
return q_key
|
||||
return MagicMock()
|
||||
|
||||
db.query.side_effect = _query
|
||||
|
||||
# Upstream call stubs
|
||||
upstream = SimpleNamespace(
|
||||
delete=AsyncMock(return_value=httpx.Response(200, json={"ok": True})),
|
||||
post=AsyncMock(), # not used for openai cancel
|
||||
)
|
||||
|
||||
with (
|
||||
patch("src.clients.http_client.HTTPClientPool.get_default_client_async", AsyncMock(return_value=upstream)),
|
||||
patch("src.core.crypto.crypto_service.decrypt", lambda _v: "upstream-key"),
|
||||
patch(
|
||||
"src.services.provider.transport.build_provider_url",
|
||||
lambda _endpoint, **_kwargs: "https://upstream.example.com/v1/videos",
|
||||
),
|
||||
patch("src.services.usage.service.UsageService.finalize_void", MagicMock(return_value=True)),
|
||||
patch("src.services.usage.service.UsageService.void_settled", MagicMock()),
|
||||
):
|
||||
request = _make_request(
|
||||
method="POST",
|
||||
path="/v1/videos/t1/cancel",
|
||||
headers={"authorization": "Bearer sk-test", "x-real-ip": "127.0.0.1", "user-agent": "pytest"},
|
||||
body=b"",
|
||||
)
|
||||
adapter = OpenAIVideoAdapter()
|
||||
resp = await pipeline.run(
|
||||
adapter=adapter,
|
||||
http_request=request,
|
||||
db=db,
|
||||
mode=adapter.mode,
|
||||
api_format_hint=adapter.allowed_api_formats[0],
|
||||
path_params={"task_id": "t1"},
|
||||
)
|
||||
|
||||
assert getattr(resp, "status_code", None) == 200
|
||||
assert upstream.delete.await_count == 1
|
||||
assert upstream.delete.call_args.args[0] == "https://upstream.example.com/v1/videos/ext-1"
|
||||
assert task.status == VideoStatus.CANCELLED.value
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_video_cancel_gemini_route_end_to_end(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""
|
||||
End-to-end-ish test:
|
||||
ApiRequestPipeline -> GeminiVeoAdapter -> GeminiVeoHandler -> TaskService.cancel
|
||||
"""
|
||||
pipeline = ApiRequestPipeline()
|
||||
|
||||
# Pipeline auth/quota/audit shortcuts
|
||||
user = SimpleNamespace(id="u1", username="u1", role="user", quota_usd=None, used_usd=0.0)
|
||||
api_key = SimpleNamespace(id="ak1", user_id="u1", is_standalone=False)
|
||||
monkeypatch.setattr(pipeline.auth_service, "authenticate_api_key", lambda _db, _k: (user, api_key))
|
||||
monkeypatch.setattr(pipeline.usage_service, "check_user_quota", lambda *_args, **_kwargs: (True, "ok"))
|
||||
monkeypatch.setattr(pipeline.audit_service, "log_event", MagicMock())
|
||||
|
||||
# DB stubs used by TaskService.cancel
|
||||
task = SimpleNamespace(
|
||||
id="t1",
|
||||
short_id="op123",
|
||||
user_id="u1",
|
||||
request_id="r1",
|
||||
external_task_id="op123",
|
||||
endpoint_id="e1",
|
||||
key_id="k1",
|
||||
status=VideoStatus.SUBMITTED.value,
|
||||
updated_at=None,
|
||||
request_metadata={},
|
||||
)
|
||||
endpoint = SimpleNamespace(
|
||||
id="e1",
|
||||
base_url="https://generativelanguage.googleapis.com",
|
||||
api_format="gemini:video",
|
||||
api_family="gemini",
|
||||
endpoint_kind="video",
|
||||
header_rules=None,
|
||||
)
|
||||
provider_key = SimpleNamespace(
|
||||
id="k1",
|
||||
api_key="encrypted",
|
||||
auth_type="api_key",
|
||||
)
|
||||
|
||||
q_task_id = MagicMock()
|
||||
q_task_id.filter.return_value.first.return_value = None
|
||||
q_task_short = MagicMock()
|
||||
q_task_short.filter.return_value.first.return_value = task
|
||||
q_endpoint = MagicMock()
|
||||
q_endpoint.filter.return_value.first.return_value = endpoint
|
||||
q_key = MagicMock()
|
||||
q_key.filter.return_value.first.return_value = provider_key
|
||||
|
||||
db = MagicMock()
|
||||
_video_query_count = {"n": 0}
|
||||
|
||||
def _query(model): # noqa: ANN001
|
||||
name = getattr(model, "__name__", "")
|
||||
if name == "VideoTask":
|
||||
_video_query_count["n"] += 1
|
||||
return q_task_id if _video_query_count["n"] == 1 else q_task_short
|
||||
if name == "ProviderEndpoint":
|
||||
return q_endpoint
|
||||
if name == "ProviderAPIKey":
|
||||
return q_key
|
||||
return MagicMock()
|
||||
|
||||
db.query.side_effect = _query
|
||||
|
||||
upstream = SimpleNamespace(
|
||||
post=AsyncMock(return_value=httpx.Response(200, json={"done": True})),
|
||||
delete=AsyncMock(), # not used for gemini cancel
|
||||
)
|
||||
|
||||
with (
|
||||
patch("src.clients.http_client.HTTPClientPool.get_default_client_async", AsyncMock(return_value=upstream)),
|
||||
patch("src.core.crypto.crypto_service.decrypt", lambda _v: "upstream-key"),
|
||||
patch("src.api.handlers.base.request_builder.get_provider_auth", AsyncMock(return_value=None)),
|
||||
patch("src.services.usage.service.UsageService.finalize_void", MagicMock(return_value=True)),
|
||||
patch("src.services.usage.service.UsageService.void_settled", MagicMock()),
|
||||
):
|
||||
request = _make_request(
|
||||
method="POST",
|
||||
path="/v1beta/operations/op123:cancel",
|
||||
headers={"x-goog-api-key": "sk-test", "x-real-ip": "127.0.0.1", "user-agent": "pytest"},
|
||||
body=b"",
|
||||
)
|
||||
adapter = GeminiVeoAdapter()
|
||||
resp = await pipeline.run(
|
||||
adapter=adapter,
|
||||
http_request=request,
|
||||
db=db,
|
||||
mode=adapter.mode,
|
||||
api_format_hint=adapter.allowed_api_formats[0],
|
||||
path_params={"task_id": "op123", "action": "cancel"},
|
||||
)
|
||||
|
||||
assert getattr(resp, "status_code", None) == 200
|
||||
assert upstream.post.await_count == 1
|
||||
assert upstream.post.call_args.args[0].endswith("/v1beta/operations/op123:cancel")
|
||||
assert task.status == VideoStatus.CANCELLED.value
|
||||
|
||||
Reference in New Issue
Block a user