// HTTP error structuring -- the APIError type plus response-body and header // parsers. These live in the neutral apierror package (rather than the // transport/api package) so that both the wire path and the transport path // can construct and consume one structured error type without a circular // dependency. // // HTTP 错误结构化 -- APIError 类型加响应体 / 响应头解析器. 这些放在中立的 // apierror 包 (而非 transport/api 包), 使 wire 路径和 transport 路径都能 // 构造并消费同一套结构化错误类型, 不产生循环依赖. package apierror import ( "bytes" "encoding/json" "fmt" "net/http" "regexp" "strconv" "strings" "time" ) // ============================================================ // 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.提取