Coverage for agentos/errors/handler.py: 44%

102 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-02 09:59 +0800

1"""v0.80 — 用户友好错误处理:分类 + 格式化 + 建议。""" 

2 

3from __future__ import annotations 

4 

5import traceback 

6import sys 

7from dataclasses import dataclass, field 

8from enum import Enum, auto 

9from typing import Any, Optional 

10 

11 

12class ErrorCategory(Enum): 

13 """错误分类枚举。""" 

14 NETWORK = auto() # 网络/API 调用失败 

15 AUTH = auto() # 认证/API Key 问题 

16 CONFIG = auto() # 配置错误 

17 RATE_LIMIT = auto() # 限流/配额 

18 VALIDATION = auto() # 输入验证 

19 TIMEOUT = auto() # 超时 

20 RESOURCE = auto() # 资源不足(内存/磁盘) 

21 MODEL = auto() # 模型相关 

22 PLUGIN = auto() # 插件错误 

23 INTERNAL = auto() # 内部错误 

24 UNKNOWN = auto() # 未分类 

25 

26 

27CATEGORY_HINTS = { 

28 ErrorCategory.NETWORK: "请检查网络连接或 API 端点地址。", 

29 ErrorCategory.AUTH: "请确认 API Key 是否正确设置(环境变量或配置文件)。", 

30 ErrorCategory.CONFIG: "请检查 agentos.yaml 配置文件,确保字段拼写正确。", 

31 ErrorCategory.RATE_LIMIT: "请求频率过高,请稍后重试。可调整 RateLimitCfg.max_rps。", 

32 ErrorCategory.VALIDATION: "输入参数不符合预期格式,请参考文档修正。", 

33 ErrorCategory.TIMEOUT: "操作超时。可增大 LoopCfg.step_timeout 或 ModelConfig.timeout。", 

34 ErrorCategory.RESOURCE: "系统资源不足,请检查内存/磁盘或降低并发。", 

35 ErrorCategory.MODEL: "模型返回异常或调用失败,可尝试切换备用 Provider。", 

36 ErrorCategory.PLUGIN: "插件加载失败,请检查插件路径和依赖。", 

37 ErrorCategory.INTERNAL: "内部错误,请联系开发者并提供 trace_id。", 

38 ErrorCategory.UNKNOWN: "未知错误,请查看详细日志。", 

39} 

40 

41 

42@dataclass 

43class ErrorContext: 

44 """错误上下文信息。""" 

45 trace_id: str = "" 

46 category: ErrorCategory = ErrorCategory.UNKNOWN 

47 message: str = "" 

48 suggestion: str = "" 

49 detail: str = "" 

50 recovery_actions: list[str] = field(default_factory=list) 

51 

52 

53class HumanError(Exception): 

54 """包装原始异常,附带用户友好的上下文。""" 

55 

56 def __init__(self, original: Exception, context: ErrorContext): 

57 super().__init__(str(original)) 

58 self.original = original 

59 self.context = context 

60 

61 def __str__(self) -> str: 

62 return self.context.message or super().__str__() 

63 

64 

65class ErrorFormatter: 

66 """将 Python 异常转换为用户友好的格式化输出。""" 

67 

68 @staticmethod 

69 def categorize(exc: Exception) -> ErrorCategory: 

70 """根据异常类型和消息自动分类。""" 

71 msg = str(exc).lower() 

72 type_name = type(exc).__name__.lower() 

73 

74 if any(kw in msg for kw in ["timeout", "timed out", "connect timeout"]): 

75 return ErrorCategory.TIMEOUT 

76 if any(kw in msg for kw in ["rate limit", "too many requests", "429"]): 

77 return ErrorCategory.RATE_LIMIT 

78 if any(kw in msg for kw in ["unauthorized", "forbidden", "401", "403", "api key", "invalid key"]): 

79 return ErrorCategory.AUTH 

80 if any(kw in msg for kw in ["connection", "network", "dns", "refused", "unreachable"]): 

81 return ErrorCategory.NETWORK 

82 if any(kw in msg for kw in ["validation", "invalid", "expected", "type error", "value error"]): 

83 return ErrorCategory.VALIDATION 

84 if any(kw in msg for kw in ["memory", "disk", "quota", "out of"]): 

85 return ErrorCategory.RESOURCE 

86 if any(kw in type_name for kw in ["plugin", "load"]): 

87 return ErrorCategory.PLUGIN 

88 if any(kw in msg for kw in ["config", "cfg", "yaml"]): 

89 return ErrorCategory.CONFIG 

90 if any(kw in type_name for kw in ["model", "llm", "provider"]): 

91 return ErrorCategory.MODEL 

92 return ErrorCategory.UNKNOWN 

93 

94 @staticmethod 

95 def extract_recovery(original: Exception, category: ErrorCategory) -> list[str]: 

96 """根据异常给出可操作的恢复建议。""" 

97 actions = [CATEGORY_HINTS.get(category, "")] 

98 msg = str(original) 

99 

100 if ErrorFormatter._has_retry(category): 

101 actions.append("框架已自动重试,若持续失败请检查上游服务状态。") 

102 if "api key" in msg.lower() or "key" in msg.lower(): 

103 actions.append("运行 `agentos config set api_key <your-key>` 或设置环境变量。") 

104 if "model" in msg.lower() and "not found" in msg.lower(): 

105 actions.append("请确认 ModelConfig.model_name 拼写正确,或使用 RECOMMENDED_CONFIG。") 

106 return [a for a in actions if a] 

107 

108 @staticmethod 

109 def _has_retry(category: ErrorCategory) -> bool: 

110 return category in (ErrorCategory.NETWORK, ErrorCategory.TIMEOUT, ErrorCategory.RATE_LIMIT) 

111 

112 @classmethod 

113 def format(cls, exc: Exception, trace_id: str = "") -> ErrorContext: 

114 """将异常格式化为 ErrorContext。""" 

115 category = cls.categorize(exc) 

116 return ErrorContext( 

117 trace_id=trace_id, 

118 category=category, 

119 message=cls._friendly_message(exc, category), 

120 suggestion=CATEGORY_HINTS.get(category, ""), 

121 detail=cls._extract_key_detail(exc), 

122 recovery_actions=cls.extract_recovery(exc, category), 

123 ) 

124 

125 @staticmethod 

126 def _friendly_message(exc: Exception, category: ErrorCategory) -> str: 

127 type_msg = str(exc) 

128 prefix = { 

129 ErrorCategory.NETWORK: "网络连接失败", 

130 ErrorCategory.AUTH: "认证失败", 

131 ErrorCategory.CONFIG: "配置错误", 

132 ErrorCategory.RATE_LIMIT: "请求被限流", 

133 ErrorCategory.VALIDATION: "输入校验失败", 

134 ErrorCategory.TIMEOUT: "操作超时", 

135 ErrorCategory.RESOURCE: "资源不足", 

136 ErrorCategory.MODEL: "模型调用异常", 

137 ErrorCategory.PLUGIN: "插件错误", 

138 ErrorCategory.INTERNAL: "内部错误", 

139 ErrorCategory.UNKNOWN: "发生错误", 

140 }.get(category, "错误") 

141 return f"{prefix}: {type_msg[:120]}" 

142 

143 @staticmethod 

144 def _extract_key_detail(exc: Exception) -> str: 

145 lines = traceback.format_exception_only(type(exc), exc) 

146 return "".join(lines[-2:]).strip() 

147 

148 

149def format_error(exc: Exception, trace_id: str = "") -> str: 

150 """一行调用:输出用户友好的错误信息。""" 

151 ctx = ErrorFormatter.format(exc, trace_id) 

152 parts = [f"[{ctx.category.name}] {ctx.message}"] 

153 if ctx.suggestion: 

154 parts.append(f" 建议: {ctx.suggestion}") 

155 for action in ctx.recovery_actions: 

156 parts.append(f" -> {action}") 

157 return "\n".join(parts) 

158 

159 

160def friendly_error(func): 

161 """装饰器:自动捕获异常并输出友好信息。""" 

162 def wrapper(*args, **kwargs): 

163 try: 

164 return func(*args, **kwargs) 

165 except Exception as e: 

166 friendly_msg = format_error(e) 

167 print(friendly_msg, file=sys.stderr) 

168 raise 

169 return wrapper