复审 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 未动。
292 lines
12 KiB
Go
292 lines
12 KiB
Go
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;
|
||
// - 其余**一律丢弃**:响应体原文、请求参数、请求头、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(字符串或数字;数字按十进制渲染)。
|
||
Code string
|
||
// Type 是上游 JSON 里 error.type。
|
||
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)。
|
||
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 {
|
||
info.Code = parsed.Code
|
||
info.Type = parsed.Type
|
||
if cfg.keepMessage {
|
||
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 —— 正文一个字节都不留。
|
||
//
|
||
// 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)
|
||
}
|