package trace import ( "encoding/json" "errors" "fmt" "strconv" "strings" ) // 本文件是**上游错误体的唯一收敛点**(M40 地基)。推理侧(M41/M42 的 provider // 适配、模型路由降级、工具/渠道的上游调用)不许各写一份「截断一下响应体再 // 拼进 error」的实现 —— 那样的实现会漂移,而漂移的方向总是「某一家 provider // 开始把响应体原文吐进错误与日志」。 // // 🔴 契约(改本文件前先读): // - 只保留:upstream 名 / status / contentType / bodyLen / body sha256 前缀 / // JSON 里的 error.code / error.type;**error.message 默认不带**,只有显式 // opt-in(WithUpstreamMessage)才保留,且必须过 RedactText 再截到 // maxUpstreamMessageRunes; // - 🔴 **code / type 构造时就过令牌守卫**(ErrorToken):只有像协议令牌的短串才 // 出镜,散文一律变空串。守卫**在本构造函数里**,不在调用点 —— 因此任何新调用点 // **默认就是安全的**。M46 曾把它加在调用方(model / voice 各一份),M48 复审的 // 中危 finding 证明那正是漂移形态:调用方逐点补,四条出口(httpclient / vision / // embedding / weixin)就一直漏。M49 把守卫上移到这里,并把「构造后再注入」这一 // 条窄路收进 SetCode / SetType(同样守卫)。 // - 其余**一律丢弃**:响应体原文、请求参数、请求头、provider 自带的 // param/input/code_id 之流,绝不进入 error 的文本,也绝不进入日志; // - Error() 与 Attrs() 是**唯二**的出口,两者都只渲染上面那些字段, // 因此把 err 直接丢进 slog(或任何日志)是安全的。 // // 🔴 为什么 message 默认不带(这条取舍是**构造得出的**,不是理论担心): // 上游把用户内容拼进 message 是真实存在的写法 —— // `{"error":{"code":"invalid_request_error","message":"Invalid value: <用户原文>"}}` // 若默认保留前 128 rune,那 128 rune 就会**原样**出现在 error 与日志里。M41 的 // embedding input 是**她的记忆正文**、DashScope TTS 的 input 是**她要念的话**, // M42 的渠道响应体同样可能回显消息内容 —— 一个「默认带 message」的模板会被复制 // 到十几处调用点,等于把同一个洞铺开。 // // 因此本文件的默认口径是:**宁可少一条人话提示,也不让她的原话进日志**。 // 排障通常不需要那句话:`status` + `code` + `type` + `body_len` + `body_sha256` // 足够分类(400 还是 429、是 model_not_found 还是 context_length_exceeded、 // 是不是同一份报文),而真正要看细节时,报文在**调用点**的上下文里还在。 // maxUpstreamMessageRunes 是上游 error.message 允许保留的 rune 上限。128 rune // 够看清「上游为什么拒绝」,又短到不足以把一段用户正文整段带出来;超出部分 // 截断并标省略号(宁可丢信息,不可丢边界)。 const maxUpstreamMessageRunes = 128 // maxUpstreamLabelRunes 是 upstream 名保留的 rune 上限(它是代码里写死的名字 // 或主机名,本来就很短;上界只是防御)。 const maxUpstreamLabelRunes = 80 // ErrUpstream 是「上游返回了错误」这一类错误的哨兵,供调用方 // errors.Is(err, trace.ErrUpstream) 分类降级(主 → 备 → 本地)。 var ErrUpstream = errors.New("trace: upstream error") // UpstreamErrorInfo 是一次上游失败**可安全出镜**的全部信息。它不含响应体原文, // 也不含任何请求侧内容 —— 字段本身就是脱敏后的口径。 type UpstreamErrorInfo struct { // Upstream 是上游标识(provider 名或主机,见 HostOf)。 Upstream string // Status 是 HTTP 状态码(0 表示没有拿到状态码,例如连接中途断开)。 Status int // ContentType 是响应的媒体类型(已去掉 charset 等参数)。 ContentType string // BodyLen 是响应体的字节数(体积可诊断,内容不留)。 BodyLen int // BodySHA256 是响应体 sha256 的十六进制前缀,用于相关性比对(同一份报文能 // 认出来,正文拿不到)。 BodySHA256 string // Code 是上游 JSON 里 error.code(字符串或数字;数字按十进制渲染),**构造时已过 // 令牌守卫**(与 SetCode 同一道):不像协议令牌的值在这里恒为空串。空串是预期 // 结果 —— 宁可不显示一条分类提示,也不让正文借道字段出镜;调用方不得按「是否为空」 // 推断「上游没给码」(两者在构造后不可区分)。 Code string // Type 是上游 JSON 里 error.type,**构造时已过令牌守卫**,口径与 Code 逐字相同。 Type string // Message 是上游 JSON 里 error.message,**只有显式 opt-in(WithUpstreamMessage) // 才非空**:那一刻它已压平空白、清洗凭据形状并截断到 // maxUpstreamMessageRunes。默认构造下恒为空 —— 上游把用户内容拼进 message // 时,带它等于把她的原话抄进日志(见文件头注释的漏法演示)。 Message string } // Error 渲染一句**只含上述字段**的单行诊断。可诊断性来自 status/code/type/ // bodyLen/bodySHA256,绝不来自响应体原文。 func (e *UpstreamErrorInfo) Error() string { var b strings.Builder b.WriteString("upstream error") if e.Upstream != "" { b.WriteString(" from ") b.WriteString(e.Upstream) } if e.Status != 0 { fmt.Fprintf(&b, ": status=%d", e.Status) } else { b.WriteString(": status=unknown") } if e.ContentType != "" { b.WriteString(" content_type=") b.WriteString(e.ContentType) } fmt.Fprintf(&b, " body_bytes=%d", e.BodyLen) if e.BodySHA256 != "" { b.WriteString(" body_sha256=") b.WriteString(e.BodySHA256) } if e.Code != "" { b.WriteString(" code=") b.WriteString(e.Code) } if e.Type != "" { b.WriteString(" type=") b.WriteString(e.Type) } if e.Message != "" { b.WriteString(" message=") b.WriteString(strconv.Quote(e.Message)) } return b.String() } // Unwrap 让 errors.Is(err, ErrUpstream) 成立,调用方据此走降级链而不必解析文本。 func (e *UpstreamErrorInfo) Unwrap() error { return ErrUpstream } // Attrs 把同一份脱敏信息渲染成 slog 的键值对,供结构化日志直接展开 // (s.log.Error("provider call failed", info.Attrs()...))。它与 Error() 同源, // 因此不存在「文本里干净、日志里脏」的第二条路径。 func (e *UpstreamErrorInfo) Attrs() []any { attrs := []any{ "upstream", e.Upstream, "status", e.Status, "content_type", e.ContentType, "body_bytes", e.BodyLen, "body_sha256", e.BodySHA256, } if e.Code != "" { attrs = append(attrs, "code", e.Code) } if e.Type != "" { attrs = append(attrs, "type", e.Type) } if e.Message != "" { attrs = append(attrs, "message", e.Message) } return attrs } // upstreamOptions 是 UpstreamError 的可选开关集合。零值 = **全部关闭**, // 也就是「默认安全」:不带 message。 type upstreamOptions struct { keepMessage bool } // UpstreamOption 是 UpstreamError 的可选开关。函数式选项的形状让 // `UpstreamError(a, b, c, d)` 这个最常用的调用**天然是最安全的那个**—— // 想放宽任何一项,都必须在这里显式写出来,代码评审时一眼可见。 type UpstreamOption func(*upstreamOptions) // WithUpstreamMessage 显式要求保留上游 JSON 的 error.message(过 RedactText // 清洗凭据形状、压平空白,再截断到 maxUpstreamMessageRunes)。 // // 🔴 只有在你**确认该上游不会把用户内容拼进 message** 时才可用。默认不带它是 // 刻意的:`{"error":{"message":"Invalid value: <用户原文>"}}` 这种写法真实存在, // 128 rune 会原样进 error 与日志;M41(embedding input = 她的记忆正文、 // DashScope TTS input = 她要念的话)与 M42(渠道响应体)尤其不能默认打开。 // // 用法: // // err := trace.UpstreamError(host, status, ct, body, trace.WithUpstreamMessage()) func WithUpstreamMessage() UpstreamOption { return func(o *upstreamOptions) { o.keepMessage = true } } // UpstreamError 把一次上游失败收敛成一个**绝不携带响应体原文**的错误。 // // 用法(M41/M42 的用法模板): // // err := trace.UpstreamError(trace.HostOf(reqURL), resp.StatusCode, // resp.Header.Get("Content-Type"), body) // if errors.Is(err, trace.ErrUpstream) { ...降级到备用 provider... } // s.log.Error("upstream call failed", err) // 安全:Error() 只渲染脱敏字段 // // body 可以是任意字节(JSON、HTML、代理页、二进制):它只被用来数长度、取 // sha256 前缀,并在看起来是 JSON 时抽取 error.code / error.type(error.message // **不抽**,除非显式 WithUpstreamMessage)。其余字段、以及 body 的任何一个字节 // 都不会进入返回的错误。 // // 🔴 默认口径:**不带 error.message**。见文件头「为什么 message 默认不带」—— // 宁可少一条人话提示,也不让她的原话进日志;status/code/type/bodyLen/sha256 // 足够分类与相关性比对。确实要看那句话时,在**确认该上游不回显用户内容**的调用点 // 加 WithUpstreamMessage(那时它仍会被 RedactText 清洗并截到 128 rune)。 // // 🔴 同样默认的还有 **code / type 的令牌守卫**:它们不是「可选加固」,而是本构造函数 // 的固定行为 —— 报文里的 error.code / error.type 只有像协议令牌(≤64 字节、仅 // [A-Za-z0-9_.-])才出镜,其余变空串。调用方**不需要**、也**不要**再守一遍:守卫 // 加在调用点意味着新调用点默认不安全(M46 → M49 的教训)。构造后要注入这两个字段 // 时走 SetCode / SetType,别直接赋值。 func UpstreamError(upstream string, status int, contentType string, body []byte, opts ...UpstreamOption) error { var cfg upstreamOptions for _, opt := range opts { if opt != nil { opt(&cfg) } } info := &UpstreamErrorInfo{ Upstream: sanitizeUpstreamLabel(upstream), Status: status, ContentType: sanitizeContentType(contentType), BodyLen: len(body), } if len(body) > 0 { info.BodySHA256 = DigestOf(string(body)).SHA256 } if parsed, ok := parseUpstreamErrorBody(body); ok { // 🔴 构造即安全(M49):code / type 在这里过令牌守卫,调用点不需要也不应该 // 再守一遍 —— 守卫留在调用方的形态必然漏点(新调用点默认不安全)。 info.Code = ErrorToken(parsed.Code) info.Type = ErrorToken(parsed.Type) if cfg.keepMessage { info.Message = sanitizeUpstreamMessage(parsed.Message) } } return info } // SetCode 是**构造之后**改写 Code 的唯一合法通道:它过的是与构造函数同一道令牌守卫 // (ErrorToken),所以「写入即安全」。 // // 为什么还需要这条窄路:有些上游把错误码放在**顶层** code(DashScope 的 4xx 报文)、 // 或者把失败放进一个已经 200 的**流内事件**里(Anthropic SSE 的 error.type)—— // 这两种报文都不在 UpstreamError 参数的视野里,只能构造后注入。注入点也是守卫点, // 但守卫代码留在本包:调用点只写 `info.SetCode(raw)`,不再各抄一份 ErrorToken // (M46 的两份实现就是这么漂移出来的)。 // // 语义是**覆盖**:注入的值不像令牌时 Code 变空串,而不是保留构造函数解出来的旧值。 func (e *UpstreamErrorInfo) SetCode(code string) { e.Code = ErrorToken(code) } // SetType 是 Type 版的 SetCode,口径逐字相同。 func (e *UpstreamErrorInfo) SetType(typ string) { e.Type = ErrorToken(typ) } // upstreamErrorBody 是**唯一**从响应体里读出的结构:只有 error 的三个字段, // 其余字段(param / input / code_id / 请求回显 …)根本不会被解码到任何地方。 type upstreamErrorBody struct { Code string Type string Message string } // parseUpstreamErrorBody 尽力从 JSON 响应体里抽出 error.code / error.type / // error.message。不是 JSON(HTML 错误页、纯文本、被截断的报文)⇒ ok=false, // 此时错误里只有 status/contentType/bodyLen/bodySHA256 —— 正文一个字节都不留。 // // message 虽然在这里被解析出来,但**只有 opt-in(WithUpstreamMessage)才会往 // 外走**;默认构造把它原样丢掉(见 UpstreamError 的默认口径)。 func parseUpstreamErrorBody(body []byte) (upstreamErrorBody, bool) { if len(body) == 0 { return upstreamErrorBody{}, false } var envelope struct { Error struct { Code json.RawMessage `json:"code"` Type string `json:"type"` Message string `json:"message"` } `json:"error"` } if err := json.Unmarshal(body, &envelope); err != nil { return upstreamErrorBody{}, false } parsed := upstreamErrorBody{ Code: normalizeUpstreamCode(envelope.Error.Code), Type: sanitizeUpstreamMessage(envelope.Error.Type), Message: envelope.Error.Message, } if parsed.Code == "" && parsed.Type == "" && parsed.Message == "" { return upstreamErrorBody{}, false } return parsed, true } // normalizeUpstreamCode 把 error.code 归一成短字符串:JSON 字符串取原值, // JSON 数字取十进制文本(Google 风格用 int code),其它形态一律丢弃。 func normalizeUpstreamCode(raw json.RawMessage) string { if len(raw) == 0 { return "" } var s string if err := json.Unmarshal(raw, &s); err == nil { return sanitizeUpstreamMessage(s) } var n json.Number if err := json.Unmarshal(raw, &n); err == nil { return sanitizeUpstreamMessage(n.String()) } return "" } // sanitizeUpstreamMessage 是上游**自由文本**的唯一清洗路径:压平空白(一条诊断 // 只占一行,也顺带挡掉换行注入),清洗凭据形状,再截断到 // maxUpstreamMessageRunes。 func sanitizeUpstreamMessage(s string) string { s = strings.TrimSpace(s) if s == "" { return "" } s = strings.Join(strings.Fields(s), " ") return truncate(redactSecrets(s), maxUpstreamMessageRunes) } // sanitizeUpstreamLabel 清洗 upstream 名/主机:不允许它成为第二正文通道。 func sanitizeUpstreamLabel(s string) string { return truncate(redactSecrets(strings.TrimSpace(s)), maxUpstreamLabelRunes) } // sanitizeContentType 只保留媒体类型本体(丢掉 charset/boundary 等参数), // 并清洗凭据形状 —— Content-Type 的参数偶尔会带 provider 自造的 token。 func sanitizeContentType(s string) string { if i := strings.IndexByte(s, ';'); i >= 0 { s = s[:i] } return truncate(redactSecrets(strings.TrimSpace(s)), maxUpstreamLabelRunes) }