// API 错误分类系统 -- 一次分类,多处消费. // // 升华改进(ELEVATED): 早期方案有三套平行的 if-else 链(classifyAPIError / getAssistantMessageFromError / // categorizeRetryableAPIError)各自独立做字符串匹配,改一个忘另一个就是 bug. // 早期方案注释甚至承认 "Patterns MUST stay in sync"--需要注释来保证同步说明结构就是错的. // // 本设计:解析 HTTP 响应时一次性创建结构化 APIError(含 Category + RetryInfo + TokenGap + Hint), // 所有消费者(分析,用户消息,重试决策)直接读字段,不再各自做 string matching. // // 替代方案:<原方案三套独立分类器 + 字符串匹配> package api import ( "bytes" "encoding/json" "fmt" "net/http" "regexp" "strconv" "strings" "time" "git.flytoex.net/yuanwei/flyto-agent/internal/apierror" ) // ============================================================ // ErrorCategory - 错误的语义分类枚举 // ============================================================ // ErrorCategory 是 API 错误的语义分类(使用 apierror 包的定义). type ErrorCategory = apierror.ErrorCategory // 以下常量与 apierror.Err* 对应,保持向后兼容. const ( ErrUnknown ErrorCategory = apierror.ErrUnknown ErrAborted ErrorCategory = apierror.ErrAborted ErrTimeout ErrorCategory = apierror.ErrTimeout ErrRateLimit ErrorCategory = apierror.ErrRateLimit ErrOverloaded ErrorCategory = apierror.ErrOverloaded ErrPromptTooLong ErrorCategory = apierror.ErrPromptTooLong ErrMediaTooLarge ErrorCategory = apierror.ErrMediaTooLarge ErrRequestTooLarge ErrorCategory = apierror.ErrRequestTooLarge ErrInvalidRequest ErrorCategory = apierror.ErrInvalidRequest ErrAuthentication ErrorCategory = apierror.ErrAuthentication ErrModelNotFound ErrorCategory = apierror.ErrModelNotFound ErrBilling ErrorCategory = apierror.ErrBilling ErrServerError ErrorCategory = apierror.ErrServerError ErrConnection ErrorCategory = apierror.ErrConnection ErrSSL ErrorCategory = apierror.ErrSSL ErrToolMismatch ErrorCategory = apierror.ErrToolMismatch ErrUnexpectedTool ErrorCategory = apierror.ErrUnexpectedTool ErrDuplicateToolID ErrorCategory = apierror.ErrDuplicateToolID ErrInvalidModel ErrorCategory = apierror.ErrInvalidModel ErrContentPolicy ErrorCategory = apierror.ErrContentPolicy ) // String 和 IsRetryableByDefault 方法继承自 apierror.ErrorCategory // ============================================================ // RetryInfo - 重试元信息 // ============================================================ // RetryInfo 封装重试建议(使用 apierror 包的定义). type RetryInfo = apierror.RetryInfo // ============================================================ // APIError - 结构化 API 错误 // ============================================================ // APIError 是结构化的 API 错误,实现 error 接口. // // 升华改进(ELEVATED): 所有错误信息在创建时一次性解析填充-- // Category(分类),RetryInfo(重试),TokenGap(压缩跳步),Hint(用户提示). // 消费者只读字段,不做二次解析. // // 替代方案:<原方案每个消费者各自 instanceof + string includes 做分类> type APIError struct { // ErrCategory 是错误的语义分类 ErrCategory ErrorCategory // StatusCode 是 HTTP 状态码(连接错误时为 0) StatusCode int // Msg 是原始错误消息(可能来自 API JSON body 或网络错误) Msg string // Hint 是用户可操作的诊断提示(如 "检查企业代理的 SSL 证书") Hint string // Retry 是重试建议(nil 表示使用 ErrCategory 默认行为) Retry *RetryInfo // TokenGap 是 prompt_too_long 时溢出的 token 数(用于响应式压缩跳步). // 精妙之处(CLEVER): 从 "137500 tokens > 135000 maximum" 中提取差值 2500, // 让压缩模块一步跳过 2500 token 的消息组,而非逐个尝试. // 仅在 ErrCategory == ErrPromptTooLong 时有意义. TokenGap int // RespHeaders 是保留的响应头(用于下游消费者提取供应商特定信息) RespHeaders http.Header // Body 是原始响应体(用于调试和日志) Body string // Cause 是底层错误(网络错误时保留原始 error 链) Cause error } // Error implements the error interface. The output line includes every // structured field populated at construction time -- Hint, TokenGap, and // a truncated Body preview. Previous revisions read only StatusCode / // ErrCategory / Msg; the other three were filled by the classifier but // silently dropped, so each log line showed half the diagnostic that the // struct carried. Body is truncated to 256 bytes to keep lines // log-friendly; the full body stays on the APIError and can be read // directly by debug tooling when needed. // // Previous implementation retained for reference: // // if e.StatusCode > 0 { // return fmt.Sprintf("api: HTTP %d [%s]: %s", e.StatusCode, e.ErrCategory, e.Msg) // } // return fmt.Sprintf("api: [%s]: %s", e.ErrCategory, e.Msg) // // Error 实现 error 接口. 输出行包含构造时填充的所有结构字段 -- Hint / // TokenGap / Body 截断预览. 旧版本只读 StatusCode / ErrCategory / Msg, // 其他三个字段由 classifier 填了但被静默丢弃, 每条日志只能看到结构体 // 携带一半的诊断信息. Body 截到 256 字节保持日志可读; 完整 body 仍挂 // 在 APIError 上, debug 工具需要时直接读字段. // // 原实现保留供参照, 见上方 godoc. func (e *APIError) Error() string { var b strings.Builder if e.StatusCode > 0 { fmt.Fprintf(&b, "api: HTTP %d [%s]: %s", e.StatusCode, e.ErrCategory, e.Msg) } else { fmt.Fprintf(&b, "api: [%s]: %s", e.ErrCategory, e.Msg) } if e.TokenGap > 0 { fmt.Fprintf(&b, " (token_gap=%d)", e.TokenGap) } if e.Hint != "" { fmt.Fprintf(&b, " hint: %s", e.Hint) } if e.Body != "" { preview := e.Body if len(preview) > 256 { preview = preview[:256] + "..." } fmt.Fprintf(&b, " body[%d]: %s", len(e.Body), preview) } return b.String() } // Unwrap 支持 errors.Is / errors.As 链式解包. func (e *APIError) Unwrap() error { return e.Cause } // IsRetryable 返回是否建议重试. // 优先使用 Retry 的精确判断,fallback 到 ErrCategory 默认行为. func (e *APIError) IsRetryable() bool { if e.Retry != nil { return e.Retry.Retryable } return e.ErrCategory.IsRetryableByDefault() } // RetryDelay 返回建议的重试等待时间. func (e *APIError) RetryDelay() time.Duration { if e.Retry != nil { return e.Retry.After } return 0 } // AnalyticsTag 返回用于分析系统的标准化标签字符串. func (e *APIError) AnalyticsTag() string { return e.ErrCategory.String() } // Category 返回错误的字符串分类(实现 retry.RetryError 接口). func (e *APIError) Category() string { return e.ErrCategory.String() } // Message 返回错误消息字符串(实现 retry.RetryError 接口). func (e *APIError) Message() string { return e.Msg } // Headers 返回响应头(实现 retry.RetryError 接口). func (e *APIError) Headers() http.Header { return e.RespHeaders } // RetryInfo 返回重试信息(实现 retry.RetryError 接口). func (e *APIError) RetryInfo() *RetryInfo { return e.Retry } // ============================================================ // Token 差值解析 - 从 prompt_too_long 消息中提取溢出量 // ============================================================ // promptTooLongRe 匹配 "prompt is too long: 137500 tokens > 135000 maximum" 格式. // 精妙之处(CLEVER): 大小写不敏感 + 宽松匹配-- // Anthropic 直连返回小写,Vertex 返回首字母大写,其他网关可能有不同格式. // 只要包含 "数字 tokens > 数字" 就能提取. var promptTooLongRe = regexp.MustCompile(`(?i)prompt is too long[^0-9]*(\d+)\s*tokens?\s*>\s*(\d+)`) // ParseTokenGap 从错误消息中提取 prompt_too_long 的 token 溢出量. // 返回 0 表示无法解析. func ParseTokenGap(message string) int { matches := promptTooLongRe.FindStringSubmatch(message) if len(matches) < 3 { return 0 } actual, err1 := strconv.Atoi(matches[1]) limit, err2 := strconv.Atoi(matches[2]) if err1 != nil || err2 != nil { return 0 } gap := actual - limit if gap > 0 { return gap } return 0 } // ============================================================ // HTML 消毒 - 清理 CloudFlare 等网关返回的 HTML 错误页 // ============================================================ // SanitizeErrorHTML 清理可能包含 HTML 的错误消息. // // 精妙之处(CLEVER): CloudFlare,AWS ALB 等网关在故障时返回 HTML 错误页(而非 JSON). // 这些 HTML 如果原样传给 LLM 会浪费几千 token.提取 标签文本作为简洁消息. // 如果没有 HTML 则原样返回. func SanitizeErrorHTML(message string) string { if !strings.Contains(message, "<!DOCTYPE html") && !strings.Contains(message, "<html") { return message } // 提取 <title> 标签内容 titleRe := regexp.MustCompile(`<title>([^<]+)`) matches := titleRe.FindStringSubmatch(message) if len(matches) >= 2 { return strings.TrimSpace(matches[1]) } return "" } // ============================================================ // API 错误体解析 - 从 JSON 响应体中提取错误信息 // ============================================================ // ParseAPIErrorBody 从 API JSON 响应体中提取错误类型和消息. // // API 错误响应格式: // // {"type":"error","error":{"type":"invalid_request_error","message":"..."}} // // 精妙之处(CLEVER): 不做完整 JSON 反序列化--只提取两个字段, // 避免为错误路径引入复杂的类型定义.手动 JSON 解析虽然"丑",但错误路径 // 需要最大的容错能力,半格式化的 JSON 也要尽量提取信息. func ParseAPIErrorBody(body []byte) (errorType, message string) { // 快速路径:空 body if len(body) == 0 { return "", "" } // 尝试标准 API 错误格式 var apiErr struct { Error struct { Type string `json:"type"` Message string `json:"message"` } `json:"error"` } if err := jsonUnmarshalSafe(body, &apiErr); err == nil && apiErr.Error.Type != "" { return apiErr.Error.Type, apiErr.Error.Message } // 尝试扁平格式(某些网关/代理可能返回) var flatErr struct { Type string `json:"type"` Message string `json:"message"` } if err := jsonUnmarshalSafe(body, &flatErr); err == nil && flatErr.Message != "" { return flatErr.Type, flatErr.Message } // 回退:把整个 body 当 message(可能是 HTML 或纯文本) return "", SanitizeErrorHTML(string(body)) } // jsonUnmarshalSafe 是安全的 JSON 解析,不 panic. // 先检查是否像 JSON,再尝试解析,避免在 HTML/纯文本上浪费时间. func jsonUnmarshalSafe(data []byte, v any) error { trimmed := bytes.TrimSpace(data) if len(trimmed) == 0 || (trimmed[0] != '{' && trimmed[0] != '[') { return fmt.Errorf("not JSON") } return json.Unmarshal(trimmed, v) } // ============================================================ // 辅助函数 // ============================================================ // ParseRetryAfter 从 retry-after header 值解析等待时间. // 支持秒数格式("30"). func ParseRetryAfter(value string) time.Duration { if value == "" { return 0 } seconds, err := strconv.Atoi(strings.TrimSpace(value)) if err != nil || seconds <= 0 { return 0 } return time.Duration(seconds) * time.Second } // ParseShouldRetry 从 x-should-retry header 解析服务端重试建议. // 返回 nil 表示服务端未表态. func ParseShouldRetry(value string) *bool { switch strings.TrimSpace(strings.ToLower(value)) { case "true": v := true return &v case "false": v := false return &v default: return nil } }