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 / 截断到 maxUpstreamMessageRunes 的 // error.message; // - 其余**一律丢弃**:响应体原文、请求参数、请求头、provider 自带的 // param/input/code_id 之流,绝不进入 error 的文本,也绝不进入日志; // - Error() 与 Attrs() 是**唯二**的出口,两者都只渲染上面那些字段, // 因此把 err 直接丢进 slog(或任何日志)是安全的。 // 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(字符串或数字;数字按十进制渲染)。 Code string // Type 是上游 JSON 里 error.type。 Type string // Message 是上游 JSON 里 error.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 } // 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。 // 其余字段、以及 body 的任何一个字节都不会进入返回的错误。 // // 🔴 边界(调用方必须知道):error.message 是上游自己写的诊断语,本函数最多保留 // 128 rune。上游若把用户正文拼进 message(少数实现会这么做),那 128 rune 会随之 // 出来 —— 因此调用方**不得**在把响应体交给本函数之前再自行拼接任何请求内容, // 也不得把 message 之外的字段(param / input / code_id …)自行补进错误。 func UpstreamError(upstream string, status int, contentType string, body []byte) error { 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 { info.Code = parsed.Code info.Type = parsed.Type info.Message = sanitizeUpstreamMessage(parsed.Message) } return info } // 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 —— 正文一个字节都不留。 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) }