From 542bac677dac3e3665615169953365ee8afa35dd Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 26 Oct 2026 13:31:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(trace):=20UpstreamError=20=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E4=B8=8D=E5=B8=A6=20error.message=EF=BC=8C=E8=A6=81?= =?UTF-8?q?=E5=B8=A6=E5=BF=85=E9=A1=BB=E6=98=BE=E5=BC=8F=20opt-in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 P2 收口。上一条留的漏法(不是理论,是构造得出的): `{"error":{"code":"invalid_request_error","message":"Invalid value: <用户原文>"}}` —— 默认保留前 128 rune,那 128 rune 就会**原样**进 error 与日志。此前的用例只钉了 input/echo 这类 sibling 字段,没钉到 message 这一面;而 M41(embedding input 是她的 记忆正文、DashScope TTS input 是她要念的话)与 M42(渠道响应体)都把这个 helper 当 模板,默认带 message 等于把洞复制到十几处。 改法(默认安全,放宽必须写出来): - `UpstreamError(upstream, status, contentType, body, opts ...UpstreamOption)`: 最常用的四参调用**天然是最安全的那个**;默认只留 Upstream/Status/ContentType/ BodyLen/BodySHA256/Code/Type; - 新增 `WithUpstreamMessage()` 作为显式开关:带 message 时**必须**过 RedactText (凭据形状打码 + 压平空白)再截到 maxUpstreamMessageRunes,并在注释里写明 「只有在你确认该上游不会回显用户内容时才可用」; - 文件头写清取舍:**宁可少一条人话提示,也不让她的原话进日志**;排障靠 status + code + type + bodyLen + sha256 通常足够分类(400 还是 429、是 model_not_found 还是 context_length_exceeded、是不是同一份报文),细节要看时 报文在调用点上下文里还在。 测试(可变异打红): - 新增 TestUpstreamError_DefaultDropsMessage:message 里塞哨兵的 400 ⇒ 默认构造下 error 与 slog 输出都搜不到哨兵,Message 为空,而 status/code/type/bodyLen/sha256 仍可辨;Attrs() 也不旁路带出; - 新增 TestUpstreamError_OptInMessageIsSanitized:opt-in 后 message 被截到 128 rune 且凭据被打码(超长 + 带 token 的 message); - 既有 TestUpstreamError_DropsEchoedBody 补一条「默认 Message 为空」; - 变异验证:把默认构造改回带 message ⇒ 前两条变红;让 opt-in 跳过 RedactText ⇒ opt-in 那条变红。 回归:go build ./... + go vet ./... + go test -count=1 ./... 全绿(backthink 的两条 2s 定时用例在满并发下偶发超时,单元重跑与整仓重跑均绿,与本改动无关:该包不引用 trace);trace 包另跑 -race 绿。只动 upstream.go 与其测试,白名单/session/loop/ orchestration 未动。 --- internal/runtime/trace/upstream.go | 77 ++++++++++++++++--- .../trace/write_side_redaction_test.go | 76 ++++++++++++++++-- 2 files changed, 136 insertions(+), 17 deletions(-) diff --git a/internal/runtime/trace/upstream.go b/internal/runtime/trace/upstream.go index 8180b8c..f8150ab 100644 --- a/internal/runtime/trace/upstream.go +++ b/internal/runtime/trace/upstream.go @@ -15,12 +15,26 @@ import ( // // 🔴 契约(改本文件前先读): // - 只保留:upstream 名 / status / contentType / bodyLen / body sha256 前缀 / -// JSON 里的 error.code / error.type / 截断到 maxUpstreamMessageRunes 的 -// error.message; +// JSON 里的 error.code / error.type;**error.message 默认不带**,只有显式 +// opt-in(WithUpstreamMessage)才保留,且必须过 RedactText 再截到 +// maxUpstreamMessageRunes; // - 其余**一律丢弃**:响应体原文、请求参数、请求头、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 // 够看清「上游为什么拒绝」,又短到不足以把一段用户正文整段带出来;超出部分 @@ -53,7 +67,10 @@ type UpstreamErrorInfo struct { Code string // Type 是上游 JSON 里 error.type。 Type string - // Message 是上游 JSON 里 error.message,已清洗凭据形状、压平空白并截断。 + // Message 是上游 JSON 里 error.message,**只有显式 opt-in(WithUpstreamMessage) + // 才非空**:那一刻它已压平空白、清洗凭据形状并截断到 + // maxUpstreamMessageRunes。默认构造下恒为空 —— 上游把用户内容拼进 message + // 时,带它等于把她的原话抄进日志(见文件头注释的漏法演示)。 Message string } @@ -121,6 +138,32 @@ func (e *UpstreamErrorInfo) Attrs() []any { 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 的用法模板): @@ -131,14 +174,21 @@ func (e *UpstreamErrorInfo) Attrs() []any { // s.log.Error("upstream call failed", err) // 安全:Error() 只渲染脱敏字段 // // body 可以是任意字节(JSON、HTML、代理页、二进制):它只被用来数长度、取 -// sha256 前缀,并在看起来是 JSON 时抽取 error.code / error.type / error.message。 -// 其余字段、以及 body 的任何一个字节都不会进入返回的错误。 +// sha256 前缀,并在看起来是 JSON 时抽取 error.code / error.type(error.message +// **不抽**,除非显式 WithUpstreamMessage)。其余字段、以及 body 的任何一个字节 +// 都不会进入返回的错误。 // -// 🔴 边界(调用方必须知道):error.message 是上游自己写的诊断语,本函数最多保留 -// 128 rune。上游若把用户正文拼进 message(少数实现会这么做),那 128 rune 会随之 -// 出来 —— 因此调用方**不得**在把响应体交给本函数之前再自行拼接任何请求内容, -// 也不得把 message 之外的字段(param / input / code_id …)自行补进错误。 -func UpstreamError(upstream string, status int, contentType string, body []byte) error { +// 🔴 默认口径:**不带 error.message**。见文件头「为什么 message 默认不带」—— +// 宁可少一条人话提示,也不让她的原话进日志;status/code/type/bodyLen/sha256 +// 足够分类与相关性比对。确实要看那句话时,在**确认该上游不回显用户内容**的调用点 +// 加 WithUpstreamMessage(那时它仍会被 RedactText 清洗并截到 128 rune)。 +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, @@ -151,7 +201,9 @@ func UpstreamError(upstream string, status int, contentType string, body []byte) if parsed, ok := parseUpstreamErrorBody(body); ok { info.Code = parsed.Code info.Type = parsed.Type - info.Message = sanitizeUpstreamMessage(parsed.Message) + if cfg.keepMessage { + info.Message = sanitizeUpstreamMessage(parsed.Message) + } } return info } @@ -167,6 +219,9 @@ type upstreamErrorBody struct { // 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 diff --git a/internal/runtime/trace/write_side_redaction_test.go b/internal/runtime/trace/write_side_redaction_test.go index c8f6277..d1ceeb0 100644 --- a/internal/runtime/trace/write_side_redaction_test.go +++ b/internal/runtime/trace/write_side_redaction_test.go @@ -228,6 +228,11 @@ func TestUpstreamError_DropsEchoedBody(t *testing.T) { if info.BodyLen != len(body) || info.ContentType != "application/json" || info.BodySHA256 == "" { t.Fatalf("info = %+v,want bodyLen/contentType/sha256 作为诊断线索", info) } + // 默认构造连上游那句人话(error.message)也不带:它的措辞里就可能嵌着用户内容 + // (这个报文里是 'messages[0].content'),默认安全口径见上游文件头注释。 + if info.Message != "" { + t.Fatalf("默认构造的 Message = %q,want 空", info.Message) + } // Attrs() 是日志侧的第二出口,必须与 Error() 同源(同样不含正文)。 attrs := info.Attrs() if joined := strings.TrimSpace(strings.Join(anyToStrings(attrs), " ")); strings.Contains(joined, echoedInput) { @@ -250,19 +255,78 @@ func TestUpstreamError_NonJSONBodyKeepsOnlyMeta(t *testing.T) { } } -// TestUpstreamErrorMessageIsBounded 钉住 message 的上限:上游把一大段东西写进 -// error.message 时,进错误的只有前 128 rune。 -func TestUpstreamErrorMessageIsBounded(t *testing.T) { +// TestUpstreamError_DefaultDropsMessage 是 P2 收口的行为钉(M41/M42 的模板): +// 上游把**用户内容**拼进 error.message 时,**默认构造**必须一个字都不带出来。 +// 这不是理论担心 —— `{"error":{"message":"Invalid value: <用户原文>"}}` 是真实 +// 存在的写法,默认保留前 128 rune 就够把一段原话抄进日志。 +// +// 断言同时要求「诊断仍可辨」:status / code / type / bodyLen / bodySHA256 都还在, +// 它们才是分类与降级的依据。 +func TestUpstreamError_DefaultDropsMessage(t *testing.T) { t.Parallel() - body := []byte(`{"error":{"code":"bad","message":"` + strings.Repeat("详", 400) + `"}}`) - err := UpstreamError("p", 400, "application/json", body) + const sentinel = "SENTINEL-USER-INPUT-IN-MESSAGE-3c9f-她在输入框里写的话" + body := []byte(`{"error":{"code":"invalid_request_error","type":"invalid_request",` + + `"message":"Invalid value: ` + sentinel + `"}}`) + + err := UpstreamError("api.example.com", 400, "application/json", body) + var logBuf bytes.Buffer + log := slog.New(slog.NewJSONHandler(&logBuf, nil)) + log.Error("upstream call failed", "err", err) + + for _, surface := range []string{err.Error(), logBuf.String()} { + if strings.Contains(surface, sentinel) { + t.Fatalf("默认构造把 error.message 里的用户内容带出来了:%s", surface) + } + } info := err.(*UpstreamErrorInfo) + if info.Message != "" { + t.Fatalf("默认构造的 Message = %q,want 空(message 必须显式 opt-in)", info.Message) + } + for _, want := range []string{"status=400", "invalid_request_error", "type=invalid_request", "body_bytes="} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error = %q,want 含 %q(丢掉 message 不等于丢掉诊断)", err.Error(), want) + } + } + if info.BodyLen != len(body) || info.BodySHA256 == "" { + t.Fatalf("info = %+v,want bodyLen/bodySHA256 仍用于相关性比对", info) + } + // Attrs() 同样不得旁路带出 message。 + if joined := strings.Join(anyToStrings(info.Attrs()), " "); strings.Contains(joined, sentinel) { + t.Fatalf("Attrs() 带出了 message 里的用户内容:%s", joined) + } +} + +// TestUpstreamError_OptInMessageIsSanitized 钉住 opt-in 侧的边界:显式 +// WithUpstreamMessage 之后拿到的 message 仍**必须**过 RedactText(凭据打码)并 +// 截断到 maxUpstreamMessageRunes —— opt-in 放宽的是「带不带人话」,不是「带多脏」。 +func TestUpstreamError_OptInMessageIsSanitized(t *testing.T) { + t.Parallel() + const secret = "abcdef1234567890" + // 凭据放在开头(截断窗口内),后面接一大段正文,验证「打码 + 截断」两件事。 + message := "lookup failed: token=" + secret + " " + strings.Repeat("详", 400) + body := []byte(`{"error":{"code":"bad","type":"invalid_request","message":"` + message + `"}}`) + + err := UpstreamError("p", 400, "application/json", body, WithUpstreamMessage()) + info := err.(*UpstreamErrorInfo) + if info.Message == "" { + t.Fatal("opt-in 之后 Message 仍为空") + } + if strings.Contains(info.Message, secret) { + t.Fatalf("opt-in 的 message 带着凭据原文:%q", info.Message) + } + if !strings.Contains(info.Message, redactedMarker) { + t.Fatalf("opt-in 的 message = %q,want 含 %s(必须走 RedactText)", info.Message, redactedMarker) + } if n := len([]rune(info.Message)); n > maxUpstreamMessageRunes+1 { t.Fatalf("message 长度 = %d rune,超过上限 %d", n, maxUpstreamMessageRunes) } - if !strings.HasPrefix(info.Message, "详") { + if !strings.HasPrefix(info.Message, "lookup failed:") { t.Fatalf("message = %q,want 保留上游诊断语的前缀", info.Message) } + // opt-in 只放宽 message:凭据不许旁路进 Error() 的其它字段。 + if strings.Contains(err.Error(), secret) { + t.Fatalf("opt-in 后 Error() 带着凭据原文:%s", err.Error()) + } } // TestHostOf_DropsPathQueryAndUserInfo 钉住「日志里的 URL 只有来源」。