mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-06 15:22:05 +08:00
fix(notification): show module execution error details
This commit is contained in:
@@ -170,14 +170,14 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met
|
||||
|
||||
def __handle_plugin_error(self, err: Exception, plugin_id: str, plugin_name: str, method: str, **kwargs):
|
||||
"""
|
||||
处理插件模块执行错误
|
||||
处理插件模块执行错误,通知保留调用位置和异常原因,完整堆栈写入日志与事件。
|
||||
"""
|
||||
if kwargs.get("raise_exception"):
|
||||
raise err
|
||||
logger.error(f"运行插件 {plugin_id} 模块 {method} 出错:{str(err)}\n{traceback.format_exc()}")
|
||||
self.messagehelper.put(
|
||||
title=f"{plugin_name} 运行失败",
|
||||
message="插件运行失败,请稍后重试",
|
||||
message=f"{plugin_id}.{method}:{str(err).strip() or type(err).__name__}",
|
||||
role="plugin",
|
||||
)
|
||||
self.eventmanager.send_event(
|
||||
@@ -194,14 +194,14 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met
|
||||
|
||||
def __handle_system_error(self, err: Exception, module_id: str, module_name: str, method: str, **kwargs):
|
||||
"""
|
||||
处理系统模块执行错误
|
||||
处理系统模块执行错误,通知保留调用位置和异常原因,完整堆栈写入日志与事件。
|
||||
"""
|
||||
if kwargs.get("raise_exception"):
|
||||
raise err
|
||||
logger.error(f"运行模块 {module_id}.{method} 出错:{str(err)}\n{traceback.format_exc()}")
|
||||
self.messagehelper.put(
|
||||
title=f"{module_name}运行失败",
|
||||
message="系统模块运行失败,请稍后重试",
|
||||
message=f"{module_id}.{method}:{str(err).strip() or type(err).__name__}",
|
||||
role="system",
|
||||
)
|
||||
self.eventmanager.send_event(
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Chain 运行上下文注入和无参兼容 provider 测试。"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from dataclasses import replace
|
||||
from types import ModuleType
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from unittest.mock import Mock, call
|
||||
|
||||
import pytest
|
||||
@@ -12,6 +13,7 @@ from app.application.chain.context import ChainRuntimeContext
|
||||
from app.application.configuration import ChainRuntimeConfig
|
||||
from app.chain.base import ChainBase
|
||||
from app.runtime.extensions.module.dispatcher import ModuleInvocationDispatcher
|
||||
from app.schemas.types import EventType
|
||||
from app.startup.composition.runtime import RuntimeDependencies
|
||||
|
||||
|
||||
@@ -66,6 +68,85 @@ def test_chain_accepts_explicit_runtime_context() -> None:
|
||||
context.message_queue.bind.assert_called_once_with(chain.run_module)
|
||||
|
||||
|
||||
def _failing_chain(error: Exception, source: str) -> ChainBase:
|
||||
"""注入会抛出指定异常的同步、异步模块,覆盖真实调度到通知的链路。"""
|
||||
def fail(**_kwargs):
|
||||
"""模拟同步模块执行失败。"""
|
||||
raise error
|
||||
|
||||
async def async_fail(**_kwargs):
|
||||
"""模拟异步模块执行失败。"""
|
||||
raise error
|
||||
|
||||
context = _context()
|
||||
context.plugin_manager.get_plugin_modules.return_value = {}
|
||||
context.module_manager.get_running_modules.return_value = []
|
||||
if source == "plugin":
|
||||
context.plugin_manager.get_plugin_modules.return_value = {
|
||||
("TestPlugin", "测试插件"): {"fail": fail, "async_fail": async_fail},
|
||||
}
|
||||
else:
|
||||
context.module_manager.get_running_modules.return_value = [
|
||||
SimpleNamespace(
|
||||
get_name=Mock(return_value="测试模块"),
|
||||
get_priority=Mock(return_value=1),
|
||||
fail=fail,
|
||||
async_fail=async_fail,
|
||||
),
|
||||
]
|
||||
return ChainBase(context)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["system", "plugin"])
|
||||
@pytest.mark.parametrize("asynchronous", [False, True])
|
||||
@pytest.mark.parametrize(
|
||||
("error", "expected_reason"),
|
||||
[
|
||||
(RuntimeError("无法连接媒体服务器\nConnection refused"), "无法连接媒体服务器\nConnection refused"),
|
||||
(TimeoutError(), "TimeoutError"),
|
||||
(ValueError(" \n "), "ValueError"),
|
||||
],
|
||||
)
|
||||
def test_module_error_notification_preserves_reason(source, asynchronous, error, expected_reason) -> None:
|
||||
"""系统与插件的同步、异步失败通知应携带调用位置和错误原因,空异常使用类型兜底。"""
|
||||
chain = _failing_chain(error, source)
|
||||
method = "async_fail" if asynchronous else "fail"
|
||||
|
||||
result = asyncio.run(chain.async_run_module(method)) if asynchronous else chain.run_module(method)
|
||||
|
||||
assert result is None
|
||||
source_id = "TestPlugin" if source == "plugin" else "SimpleNamespace"
|
||||
chain.messagehelper.put.assert_called_once_with(
|
||||
title="测试插件 运行失败" if source == "plugin" else "测试模块运行失败",
|
||||
message=f"{source_id}.{method}:{expected_reason}",
|
||||
role=source,
|
||||
)
|
||||
chain.eventmanager.send_event.assert_called_once()
|
||||
event_type, payload = chain.eventmanager.send_event.call_args.args
|
||||
assert event_type == EventType.SystemError
|
||||
assert payload["error"] == str(error)
|
||||
assert "Traceback (most recent call last)" in payload["traceback"]
|
||||
assert type(error).__name__ in payload["traceback"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["system", "plugin"])
|
||||
@pytest.mark.parametrize("asynchronous", [False, True])
|
||||
def test_module_explicit_error_propagation_does_not_notify(source, asynchronous) -> None:
|
||||
"""显式要求传播异常时,保持原异常对象且不重复生成通知或错误事件。"""
|
||||
error = RuntimeError("连接被拒绝")
|
||||
chain = _failing_chain(error, source)
|
||||
|
||||
with pytest.raises(RuntimeError) as caught:
|
||||
if asynchronous:
|
||||
asyncio.run(chain.async_run_module("async_fail", raise_exception=True))
|
||||
else:
|
||||
chain.run_module("fail", raise_exception=True)
|
||||
|
||||
assert caught.value is error
|
||||
chain.messagehelper.put.assert_not_called()
|
||||
chain.eventmanager.send_event.assert_not_called()
|
||||
|
||||
|
||||
def test_chains_bind_distinct_callbacks_without_starting_queue() -> None:
|
||||
"""多个 Chain 只创建轻量客户端,不得启动或覆盖共享队列 owner。"""
|
||||
context = _context()
|
||||
|
||||
Reference in New Issue
Block a user