refactor(exceptions): 统一异常体系并替换零散异常抛出

1. 新增FrameworkError作为框架技术异常基类,统一处理非业务校验类错误
2. 为所有异常类添加error_code和trace_id支持,完善异常序列化能力
3. 将多处原生异常、RuntimeError替换为标准领域/框架异常
4. 优化WSDL解析逻辑,添加详细的警告日志便于排查问题
This commit is contained in:
Kris 2026-06-24 21:51:44 +08:00
parent e9a1ea1050
commit 1003b842e1
12 changed files with 146 additions and 25 deletions

View File

@ -46,22 +46,48 @@ class ExternalSystemError(Exception):
所有本上下文抛出的异常都应继承此类最外层统一捕获后映射为 HTTP 响应 所有本上下文抛出的异常都应继承此类最外层统一捕获后映射为 HTTP 响应
``status_code`` 作为异常到 HTTP 状态码的映射依据子类按需覆盖 ``status_code`` 作为异常到 HTTP 状态码的映射依据子类按需覆盖
``error_code`` 作为稳定错误码子类以类变量覆盖用于驱动协议响应与日志关联
``trace_id`` 用于跨链路追踪关联
""" """
status_code: int = 500 status_code: int = 500
error_code: str = "EXTERNAL_SYSTEM_ERROR"
def __init__( def __init__(
self, self,
message: str, message: str,
*, *,
details: dict[str, Any] | None = None, details: dict[str, Any] | None = None,
trace_id: str | None = None,
) -> None: ) -> None:
"""初始化异常实例。
参数
message: 人类可读错误信息
details: 业务字段字典默认空字典
trace_id: 调用链路追踪 ID用于跨链路关联
"""
super().__init__(message) super().__init__(message)
self.message = message self.message = message
self.details = details or {} self.details = details or {}
self.trace_id = trace_id
def to_dict(self) -> dict[str, Any]:
"""序列化为字典,便于跨层传递、日志记录与 API 响应。
返回 ``error_code`` / ``message`` / ``trace_id`` 三个基础字段
并展开 ``details`` 中的业务字段
"""
return {
"error_code": self.error_code,
"message": self.message,
"trace_id": self.trace_id,
**self.details,
}
def __str__(self) -> str: def __str__(self) -> str:
return self.message """返回 ``[error_code] message`` 格式的字符串表示。"""
return f"[{self.error_code}] {self.message}"
# ─── 业务规则异常 ────────────────────────────────────────────────────────── # ─── 业务规则异常 ──────────────────────────────────────────────────────────
@ -75,24 +101,32 @@ class DomainValidationError(ExternalSystemError, ValueError):
""" """
status_code = 400 status_code = 400
error_code = "VALIDATION_ERROR"
class InvalidSlugError(DomainValidationError): class InvalidSlugError(DomainValidationError):
"""slug 非法或命中保留字。""" """slug 非法或命中保留字。"""
error_code = "INVALID_SLUG"
class EnvironmentDisabledError(DomainValidationError): class EnvironmentDisabledError(DomainValidationError):
"""禁用环境不能设为默认环境。""" """禁用环境不能设为默认环境。"""
error_code = "ENVIRONMENT_DISABLED"
class ImportConflictError(DomainValidationError): class ImportConflictError(DomainValidationError):
"""导入时发现 slug 冲突。""" """导入时发现 slug 冲突。"""
error_code = "IMPORT_CONFLICT"
class SsrfViolationError(DomainValidationError): class SsrfViolationError(DomainValidationError):
"""SSRF 安全规则违反。""" """SSRF 安全规则违反。"""
status_code = 403 status_code = 403
error_code = "SSRF_VIOLATION"
class CircuitBreakerStateError(DomainValidationError): class CircuitBreakerStateError(DomainValidationError):
@ -102,6 +136,8 @@ class CircuitBreakerStateError(DomainValidationError):
表示熔断器未处于打开或半开状态无需也无法重置 表示熔断器未处于打开或半开状态无需也无法重置
""" """
error_code = "CIRCUIT_BREAKER_STATE_INVALID"
class EntityNotFoundError(ExternalSystemError): class EntityNotFoundError(ExternalSystemError):
"""领域实体不存在。 """领域实体不存在。
@ -112,6 +148,7 @@ class EntityNotFoundError(ExternalSystemError):
""" """
status_code = 404 status_code = 404
error_code = "NOT_FOUND"
class ReferencedError(ExternalSystemError): class ReferencedError(ExternalSystemError):
@ -127,12 +164,25 @@ class ReferencedError(ExternalSystemError):
""" """
status_code = 409 status_code = 409
error_code = "REFERENCED"
def __init__(self, references: dict[str, dict[str, list[str]]]) -> None: def __init__(
self,
references: dict[str, dict[str, list[str]]],
*,
trace_id: str | None = None,
) -> None:
"""初始化被引用无法删除异常。
参数
references: 被引用关系字典结构示例见类 docstring
trace_id: 调用链路追踪 ID用于跨链路关联
"""
self.references = references self.references = references
super().__init__( super().__init__(
f"以下外部系统仍被引用,无法删除: {references}", f"以下外部系统仍被引用,无法删除: {references}",
details={"references": references}, details={"references": references},
trace_id=trace_id,
) )
@ -145,6 +195,7 @@ class ConflictError(ExternalSystemError):
""" """
status_code = 409 status_code = 409
error_code = "CONFLICT"
# ─── 框架技术异常 ────────────────────────────────────────────────────────── # ─── 框架技术异常 ──────────────────────────────────────────────────────────
@ -158,6 +209,8 @@ class FrameworkError(ExternalSystemError):
而非"业务规则不允许" 而非"业务规则不允许"
""" """
error_code = "FRAMEWORK_ERROR"
class AdapterNotFoundError(FrameworkError): class AdapterNotFoundError(FrameworkError):
"""找不到对应 ``adapter_type`` 的适配器。 """找不到对应 ``adapter_type`` 的适配器。
@ -166,12 +219,14 @@ class AdapterNotFoundError(FrameworkError):
""" """
status_code = 404 status_code = 404
error_code = "ADAPTER_NOT_FOUND"
class AuthPluginNotFoundError(FrameworkError): class AuthPluginNotFoundError(FrameworkError):
"""找不到对应 ``auth_type`` 的认证插件。""" """找不到对应 ``auth_type`` 的认证插件。"""
status_code = 404 status_code = 404
error_code = "AUTH_PLUGIN_NOT_FOUND"
class IntegrationOperationNotRegisteredError(FrameworkError): class IntegrationOperationNotRegisteredError(FrameworkError):
@ -186,6 +241,7 @@ class IntegrationOperationNotRegisteredError(FrameworkError):
""" """
status_code = 400 status_code = 400
error_code = "OPERATION_NOT_REGISTERED"
class ExecutionError(FrameworkError): class ExecutionError(FrameworkError):
@ -196,6 +252,7 @@ class ExecutionError(FrameworkError):
""" """
status_code = 502 status_code = 502
error_code = "EXECUTION_ERROR"
class RateLimitExceededError(ExecutionError): class RateLimitExceededError(ExecutionError):
@ -210,6 +267,7 @@ class RateLimitExceededError(ExecutionError):
""" """
status_code = 429 status_code = 429
error_code = "RATE_LIMIT_EXCEEDED"
def __init__( def __init__(
self, self,
@ -220,7 +278,19 @@ class RateLimitExceededError(ExecutionError):
system_id: int | str | None = None, system_id: int | str | None = None,
env_key: str | None = None, env_key: str | None = None,
details: dict[str, Any] | None = None, details: dict[str, Any] | None = None,
trace_id: str | None = None,
) -> None: ) -> None:
"""初始化限流超限异常。
参数
message: 人类可读错误信息
retry_after: 建议客户端等待秒数令牌恢复时间
limit_type: 限流类型``qps`` ``concurrency``
system_id: 限流维度系统 ID
env_key: 限流维度环境键
details: 业务字段字典与上述参数合并
trace_id: 调用链路追踪 ID用于跨链路关联
"""
merged: dict[str, Any] = dict(details or {}) merged: dict[str, Any] = dict(details or {})
if retry_after is not None: if retry_after is not None:
merged["retry_after"] = retry_after merged["retry_after"] = retry_after
@ -230,7 +300,7 @@ class RateLimitExceededError(ExecutionError):
merged["system_id"] = system_id merged["system_id"] = system_id
if env_key is not None: if env_key is not None:
merged["env_key"] = env_key merged["env_key"] = env_key
super().__init__(message, details=merged) super().__init__(message, details=merged, trace_id=trace_id)
class CircuitOpenError(ExecutionError): class CircuitOpenError(ExecutionError):
@ -244,6 +314,7 @@ class CircuitOpenError(ExecutionError):
""" """
status_code = 503 status_code = 503
error_code = "CIRCUIT_OPEN"
def __init__( def __init__(
self, self,
@ -253,7 +324,18 @@ class CircuitOpenError(ExecutionError):
system_id: int | str | None = None, system_id: int | str | None = None,
env_key: str | None = None, env_key: str | None = None,
details: dict[str, Any] | None = None, details: dict[str, Any] | None = None,
trace_id: str | None = None,
) -> None: ) -> None:
"""初始化熔断器打开异常。
参数
message: 人类可读错误信息
retry_after: 剩余冷却秒数熔断器进入 half_open 的等待时间
system_id: 熔断维度系统 ID
env_key: 熔断维度环境键
details: 业务字段字典与上述参数合并
trace_id: 调用链路追踪 ID用于跨链路关联
"""
merged: dict[str, Any] = dict(details or {}) merged: dict[str, Any] = dict(details or {})
if retry_after is not None: if retry_after is not None:
merged["retry_after"] = retry_after merged["retry_after"] = retry_after
@ -261,7 +343,7 @@ class CircuitOpenError(ExecutionError):
merged["system_id"] = system_id merged["system_id"] = system_id
if env_key is not None: if env_key is not None:
merged["env_key"] = env_key merged["env_key"] = env_key
super().__init__(message, details=merged) super().__init__(message, details=merged, trace_id=trace_id)
class SecretResolutionError(FrameworkError): class SecretResolutionError(FrameworkError):
@ -272,6 +354,7 @@ class SecretResolutionError(FrameworkError):
""" """
status_code = 400 status_code = 400
error_code = "SECRET_RESOLUTION_FAILED"
class AuthError(FrameworkError): class AuthError(FrameworkError):
@ -282,6 +365,7 @@ class AuthError(FrameworkError):
""" """
status_code = 403 status_code = 403
error_code = "AUTH_ERROR"
class QuotaExceededError(ExecutionError): class QuotaExceededError(ExecutionError):
@ -300,6 +384,7 @@ class QuotaExceededError(ExecutionError):
""" """
status_code = 429 status_code = 429
error_code = "QUOTA_EXCEEDED"
def __init__( def __init__(
self, self,
@ -312,7 +397,21 @@ class QuotaExceededError(ExecutionError):
system_id: int | str | None = None, system_id: int | str | None = None,
env_key: str | None = None, env_key: str | None = None,
details: dict[str, Any] | None = None, details: dict[str, Any] | None = None,
trace_id: str | None = None,
) -> None: ) -> None:
"""初始化配额耗尽异常。
参数
message: 人类可读错误信息
quota_key: 配额键
limit_value: 配额上限
used_value: 已用配额
remaining: 剩余配额通常为 0
system_id: 配额维度系统 ID
env_key: 配额维度环境键
details: 业务字段字典与上述参数合并
trace_id: 调用链路追踪 ID用于跨链路关联
"""
merged: dict[str, Any] = dict(details or {}) merged: dict[str, Any] = dict(details or {})
if quota_key is not None: if quota_key is not None:
merged["quota_key"] = quota_key merged["quota_key"] = quota_key
@ -326,7 +425,7 @@ class QuotaExceededError(ExecutionError):
merged["system_id"] = system_id merged["system_id"] = system_id
if env_key is not None: if env_key is not None:
merged["env_key"] = env_key merged["env_key"] = env_key
super().__init__(message, details=merged) super().__init__(message, details=merged, trace_id=trace_id)
class AccessDeniedError(FrameworkError): class AccessDeniedError(FrameworkError):
@ -338,6 +437,7 @@ class AccessDeniedError(FrameworkError):
""" """
status_code = 403 status_code = 403
error_code = "ACCESS_DENIED"
class WebhookError(FrameworkError): class WebhookError(FrameworkError):
@ -347,3 +447,4 @@ class WebhookError(FrameworkError):
""" """
status_code = 400 status_code = 400
error_code = "WEBHOOK_ERROR"

View File

@ -4,6 +4,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from yuxi.external_systems.exceptions import DomainValidationError
from yuxi.external_systems.framework.adapters.http.models import ( from yuxi.external_systems.framework.adapters.http.models import (
RequestBuildState, RequestBuildState,
) )
@ -34,7 +35,7 @@ class FormRequestBuilder(RequestBuilder):
state.body = body_params or None state.body = body_params or None
if state.body is not None: if state.body is not None:
if not isinstance(state.body, dict): if not isinstance(state.body, dict):
raise ValueError("form 请求体必须是字典") raise DomainValidationError("form 请求体必须是字典")
# 注意httpx 发送 files 时会自动设置带 boundary 的 Content-Type # 注意httpx 发送 files 时会自动设置带 boundary 的 Content-Type
# 此处不手动设置,避免 boundary 缺失。 # 此处不手动设置,避免 boundary 缺失。
state.body = {k: (None, str(v)) for k, v in state.body.items()} state.body = {k: (None, str(v)) for k, v in state.body.items()}

View File

@ -246,7 +246,8 @@ def _extract_parameters(
try: try:
input_message = operation.input input_message = operation.input
except Exception: except Exception as exc:
logger.warning(f"WSDL 解析失败,返回空列表: {exc}")
return [] return []
# 尝试从 body type 的 elements 提取 # 尝试从 body type 的 elements 提取
@ -267,14 +268,15 @@ def _extract_parameters(
if param is not None: if param is not None:
parameters.append(param) parameters.append(param)
return parameters return parameters
except Exception: except Exception as exc:
pass logger.warning(f"WSDL 解析失败,回退到签名解析: {exc}")
# 兜底:从 signature 字符串提取顶层参数名 # 兜底:从 signature 字符串提取顶层参数名
try: try:
signature = str(input_message.signature()) signature = str(input_message.signature())
return _parse_signature_parameters(signature) return _parse_signature_parameters(signature)
except Exception: except Exception as exc:
logger.warning(f"WSDL 解析失败,返回空列表: {exc}")
return [] return []

View File

@ -39,6 +39,7 @@ from yuxi.external_systems.exceptions import (
AuthError, AuthError,
CircuitOpenError, CircuitOpenError,
ExecutionError, ExecutionError,
FrameworkError,
QuotaExceededError, QuotaExceededError,
RateLimitExceededError, RateLimitExceededError,
SecretResolutionError, SecretResolutionError,
@ -275,7 +276,7 @@ class SystemExecutor:
context_builder 未注入时抛 NotImplementedError过渡方案见设计方案 §10.2 context_builder 未注入时抛 NotImplementedError过渡方案见设计方案 §10.2
""" """
if self._context_builder is None: if self._context_builder is None:
raise NotImplementedError("context_builder 未注入,系统级路径暂不可用") raise FrameworkError("context_builder 未注入,系统级路径暂不可用")
return await self._context_builder.build(tool, env) return await self._context_builder.build(tool, env)
def _build_hooks( def _build_hooks(

View File

@ -28,6 +28,10 @@ from yuxi.external_systems.core.contracts import (
SecretResolver, SecretResolver,
) )
from yuxi.external_systems.core.ports import SecretRotationPolicyRepository from yuxi.external_systems.core.ports import SecretRotationPolicyRepository
from yuxi.external_systems.exceptions import (
DomainValidationError,
EntityNotFoundError,
)
from yuxi.utils.datetime_utils import utc_now_naive from yuxi.utils.datetime_utils import utc_now_naive
@ -74,9 +78,9 @@ class SecretRotationSchedulerImpl:
""" """
policy = await self._repo.get_by_id(policy_id) policy = await self._repo.get_by_id(policy_id)
if policy is None: if policy is None:
raise ValueError(f"轮换策略不存在: {policy_id}") raise EntityNotFoundError("轮换策略不存在", details={"identifier": policy_id})
if policy.rotation_mode == "manual" and new_secret is None: if policy.rotation_mode == "manual" and new_secret is None:
raise ValueError("manual 模式需要手动提供 new_secret") raise DomainValidationError("manual 模式需要手动提供 new_secret")
return {"success": True, "policy_id": policy_id, "new_secret": new_secret} return {"success": True, "policy_id": policy_id, "new_secret": new_secret}
async def get_overdue_policies(self) -> list[Any]: async def get_overdue_policies(self) -> list[Any]:

View File

@ -46,6 +46,10 @@ from collections.abc import Awaitable, Callable
from typing import Any from typing import Any
from yuxi.external_systems.core.ports import ToolTestCaseRepository from yuxi.external_systems.core.ports import ToolTestCaseRepository
from yuxi.external_systems.exceptions import (
EntityNotFoundError,
FrameworkError,
)
from yuxi.utils.datetime_utils import utc_now_naive from yuxi.utils.datetime_utils import utc_now_naive
# 执行回调类型tool_slug + parameters + env_key → 执行结果 # 执行回调类型tool_slug + parameters + env_key → 执行结果
@ -241,11 +245,11 @@ class TestCaseRunnerImpl:
""" """
callback = execute_callback or self._execute_callback callback = execute_callback or self._execute_callback
if callback is None: if callback is None:
raise ValueError("未提供执行回调,无法运行测试用例") raise FrameworkError("未提供执行回调,无法运行测试用例")
test_case = await self._repo.get_by_id(test_case_id) test_case = await self._repo.get_by_id(test_case_id)
if test_case is None: if test_case is None:
raise ValueError(f"测试用例不存在: {test_case_id}") raise EntityNotFoundError("测试用例不存在", details={"identifier": test_case_id})
started_at = utc_now_naive() started_at = utc_now_naive()
try: try:

View File

@ -28,6 +28,7 @@ from yuxi.external_systems.exceptions import (
AuthError, AuthError,
DomainValidationError, DomainValidationError,
ExecutionError, ExecutionError,
FrameworkError,
RateLimitExceededError, RateLimitExceededError,
) )
from yuxi.external_systems.integrations.dynamics365.error_extractor import ( from yuxi.external_systems.integrations.dynamics365.error_extractor import (
@ -238,7 +239,7 @@ def _ensure_dataverse_product_line(system_config: dict[str, Any]) -> None:
if product_line == "dataverse": if product_line == "dataverse":
return return
if product_line == "finance_operations": if product_line == "finance_operations":
raise NotImplementedError("F&O 产品线将在 P1 实现") raise FrameworkError("F&O 产品线将在 P1 实现")
raise DomainValidationError(f"未知的 product_line: {product_line}") raise DomainValidationError(f"未知的 product_line: {product_line}")

View File

@ -35,6 +35,7 @@ from yuxi.external_systems.exceptions import (
AuthError, AuthError,
DomainValidationError, DomainValidationError,
ExecutionError, ExecutionError,
FrameworkError,
RateLimitExceededError, RateLimitExceededError,
) )
from yuxi.external_systems.integrations.sap_s4hana.error_extractor import ( from yuxi.external_systems.integrations.sap_s4hana.error_extractor import (
@ -288,7 +289,7 @@ def _ensure_supported_deployment(system_config: dict[str, Any]) -> str:
if deployment == "cloud": if deployment == "cloud":
return deployment return deployment
if deployment == "btp": if deployment == "btp":
raise NotImplementedError("BTP 部署模式将在 P2 实现") raise FrameworkError("BTP 部署模式将在 P2 实现")
raise DomainValidationError(f"未知的 deployment: {deployment}") raise DomainValidationError(f"未知的 deployment: {deployment}")

View File

@ -19,6 +19,10 @@ import re
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import Any from typing import Any
from yuxi.external_systems.exceptions import (
DomainValidationError,
EntityNotFoundError,
)
from yuxi.external_systems.integrations.slack.constants import ( from yuxi.external_systems.integrations.slack.constants import (
PAGE_SIZE, PAGE_SIZE,
) )
@ -69,7 +73,7 @@ class SlackChannelResolver:
return await self._resolve_channel_by_name(channel_input[1:]) return await self._resolve_channel_by_name(channel_input[1:])
if channel_input.startswith("@"): if channel_input.startswith("@"):
return await self._resolve_user_by_name(channel_input[1:]) return await self._resolve_user_by_name(channel_input[1:])
raise ValueError(f"无法解析的 channel 输入: {channel_input}(需以 # / @ 开头或为编码 ID") raise DomainValidationError(f"无法解析的 channel 输入: {channel_input}(需以 # / @ 开头或为编码 ID")
async def _resolve_channel_by_name(self, name: str) -> str: async def _resolve_channel_by_name(self, name: str) -> str:
"""通过 ``conversations.list`` cursor 分页查找 name 匹配的频道。""" """通过 ``conversations.list`` cursor 分页查找 name 匹配的频道。"""
@ -92,7 +96,7 @@ class SlackChannelResolver:
cursor = (resp.get("response_metadata") or {}).get("next_cursor", "") cursor = (resp.get("response_metadata") or {}).get("next_cursor", "")
if not cursor: if not cursor:
break break
raise ValueError(f"未找到频道: #{name}") raise EntityNotFoundError("频道不存在", details={"identifier": name})
async def _resolve_user_by_name(self, name: str) -> str: async def _resolve_user_by_name(self, name: str) -> str:
"""通过 ``users.list`` cursor 分页查找 name 匹配的用户。""" """通过 ``users.list`` cursor 分页查找 name 匹配的用户。"""
@ -112,4 +116,4 @@ class SlackChannelResolver:
cursor = (resp.get("response_metadata") or {}).get("next_cursor", "") cursor = (resp.get("response_metadata") or {}).get("next_cursor", "")
if not cursor: if not cursor:
break break
raise ValueError(f"未找到用户: @{name}") raise EntityNotFoundError("用户不存在", details={"identifier": name})

View File

@ -783,8 +783,8 @@ class ImportService(ImportServicePort):
return result return result
if isinstance(raw, list): if isinstance(raw, list):
return [d if isinstance(d, dict) else d.model_dump() for d in raw] return [d if isinstance(d, dict) else d.model_dump() for d in raw]
raise TypeError( raise DomainValidationError(
f"适配器 generate_from_asset 返回了不支持的类型 {type(raw).__name__},预期 SystemImportDraft 或 list" f"适配器 generate_from_asset 返回了不支持的类型 {type(raw).__name__}"
) )
def _drafts_from_dicts(self, drafts: list[dict[str, Any]]) -> list[dict[str, Any]]: def _drafts_from_dicts(self, drafts: list[dict[str, Any]]) -> list[dict[str, Any]]:

View File

@ -25,6 +25,7 @@ from yuxi.external_systems.core.contracts import (
from yuxi.external_systems.exceptions import ( from yuxi.external_systems.exceptions import (
DomainValidationError, DomainValidationError,
EntityNotFoundError, EntityNotFoundError,
FrameworkError,
) )
from yuxi.external_systems.use_cases.dto.test_case import ( from yuxi.external_systems.use_cases.dto.test_case import (
AlertConfigOutput, AlertConfigOutput,
@ -256,7 +257,7 @@ class TestCaseService(TestCaseServicePort):
""" """
runner = self._runtime.test_case_runner runner = self._runtime.test_case_runner
if runner is None: if runner is None:
raise RuntimeError("TestCaseRunner 未注入执行回调,无法执行测试用例") raise FrameworkError("TestCaseRunner 未注入执行回调,无法执行测试用例")
# 预检验证用例存在run 内部也会检查,但提前抛 404 更友好) # 预检验证用例存在run 内部也会检查,但提前抛 404 更友好)
test_case = await self._repos.tool_test_case.get_by_id(input_dto.case_id) test_case = await self._repos.tool_test_case.get_by_id(input_dto.case_id)
@ -317,7 +318,7 @@ class TestCaseService(TestCaseServicePort):
runner = self._runtime.test_case_runner runner = self._runtime.test_case_runner
if runner is None: if runner is None:
raise RuntimeError("TestCaseRunner 未注入执行回调,无法执行测试用例") raise FrameworkError("TestCaseRunner 未注入执行回调,无法执行测试用例")
succeeded = 0 succeeded = 0
failed = 0 failed = 0

View File

@ -27,6 +27,7 @@ from yuxi.external_systems.core.contracts import (
EventPublisher, EventPublisher,
UnitOfWork, UnitOfWork,
) )
from yuxi.external_systems.exceptions import FrameworkError
from yuxi.external_systems.use_cases.dto.test_case import ( from yuxi.external_systems.use_cases.dto.test_case import (
ScheduledRegressionOutput, ScheduledRegressionOutput,
) )
@ -79,7 +80,7 @@ class TestRegressionService(TestRegressionServicePort):
""" """
runner = self._runtime.test_case_runner runner = self._runtime.test_case_runner
if runner is None: if runner is None:
raise RuntimeError("TestCaseRunner 未注入执行回调,无法执行测试回归") raise FrameworkError("TestCaseRunner 未注入执行回调,无法执行测试回归")
due_cases = await self._repos.tool_test_case.list_scheduled() due_cases = await self._repos.tool_test_case.list_scheduled()