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

View File

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

View File

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

View File

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

View File

@ -28,6 +28,10 @@ from yuxi.external_systems.core.contracts import (
SecretResolver,
)
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
@ -74,9 +78,9 @@ class SecretRotationSchedulerImpl:
"""
policy = await self._repo.get_by_id(policy_id)
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:
raise ValueError("manual 模式需要手动提供 new_secret")
raise DomainValidationError("manual 模式需要手动提供 new_secret")
return {"success": True, "policy_id": policy_id, "new_secret": new_secret}
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 yuxi.external_systems.core.ports import ToolTestCaseRepository
from yuxi.external_systems.exceptions import (
EntityNotFoundError,
FrameworkError,
)
from yuxi.utils.datetime_utils import utc_now_naive
# 执行回调类型tool_slug + parameters + env_key → 执行结果
@ -241,11 +245,11 @@ class TestCaseRunnerImpl:
"""
callback = execute_callback or self._execute_callback
if callback is None:
raise ValueError("未提供执行回调,无法运行测试用例")
raise FrameworkError("未提供执行回调,无法运行测试用例")
test_case = await self._repo.get_by_id(test_case_id)
if test_case is None:
raise ValueError(f"测试用例不存在: {test_case_id}")
raise EntityNotFoundError("测试用例不存在", details={"identifier": test_case_id})
started_at = utc_now_naive()
try:

View File

@ -28,6 +28,7 @@ from yuxi.external_systems.exceptions import (
AuthError,
DomainValidationError,
ExecutionError,
FrameworkError,
RateLimitExceededError,
)
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":
return
if product_line == "finance_operations":
raise NotImplementedError("F&O 产品线将在 P1 实现")
raise FrameworkError("F&O 产品线将在 P1 实现")
raise DomainValidationError(f"未知的 product_line: {product_line}")

View File

@ -35,6 +35,7 @@ from yuxi.external_systems.exceptions import (
AuthError,
DomainValidationError,
ExecutionError,
FrameworkError,
RateLimitExceededError,
)
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":
return deployment
if deployment == "btp":
raise NotImplementedError("BTP 部署模式将在 P2 实现")
raise FrameworkError("BTP 部署模式将在 P2 实现")
raise DomainValidationError(f"未知的 deployment: {deployment}")

View File

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

View File

@ -783,8 +783,8 @@ class ImportService(ImportServicePort):
return result
if isinstance(raw, list):
return [d if isinstance(d, dict) else d.model_dump() for d in raw]
raise TypeError(
f"适配器 generate_from_asset 返回了不支持的类型 {type(raw).__name__},预期 SystemImportDraft 或 list"
raise DomainValidationError(
f"适配器 generate_from_asset 返回了不支持的类型 {type(raw).__name__}"
)
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 (
DomainValidationError,
EntityNotFoundError,
FrameworkError,
)
from yuxi.external_systems.use_cases.dto.test_case import (
AlertConfigOutput,
@ -256,7 +257,7 @@ class TestCaseService(TestCaseServicePort):
"""
runner = self._runtime.test_case_runner
if runner is None:
raise RuntimeError("TestCaseRunner 未注入执行回调,无法执行测试用例")
raise FrameworkError("TestCaseRunner 未注入执行回调,无法执行测试用例")
# 预检验证用例存在run 内部也会检查,但提前抛 404 更友好)
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
if runner is None:
raise RuntimeError("TestCaseRunner 未注入执行回调,无法执行测试用例")
raise FrameworkError("TestCaseRunner 未注入执行回调,无法执行测试用例")
succeeded = 0
failed = 0

View File

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