From d7b034c50ec9e9798ece78458cd50bf6c7da52fc Mon Sep 17 00:00:00 2001 From: qian Date: Sun, 2 Aug 2026 15:43:19 +0800 Subject: [PATCH] =?UTF-8?q?improve(log):=E4=BC=98=E5=8C=96=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E6=89=93=E5=8D=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- relay/handler/chat_handler.go | 7 +- relay/handler/claude_handler.go | 7 +- relay/handler/gemini_handler.go | 7 +- relay/handler/relay_handler.go | 5 +- relay/handler/task_handler.go | 11 ++- relay/helper/error_message.go | 96 +++++++++++++++++++ relay/helper/error_message_test.go | 148 +++++++++++++++++++++++++++++ 7 files changed, 271 insertions(+), 10 deletions(-) create mode 100644 relay/helper/error_message.go create mode 100644 relay/helper/error_message_test.go diff --git a/relay/handler/chat_handler.go b/relay/handler/chat_handler.go index d5130e9..43041cc 100644 --- a/relay/handler/chat_handler.go +++ b/relay/handler/chat_handler.go @@ -12,6 +12,7 @@ import ( "github.com/qianfree/team-api/relay/common" "github.com/qianfree/team-api/relay/constant" "github.com/qianfree/team-api/relay/dto" + "github.com/qianfree/team-api/relay/helper" ) // HandleChatCompletions 处理 /v1/chat/completions 请求 @@ -157,7 +158,7 @@ func WriteRelayError(w http.ResponseWriter, err error) { var relayErr *constant.RelayError var rateLimitErr *RelayErrorWithRateLimit statusCode := http.StatusInternalServerError - errMsg := err.Error() + errMsg := helper.SafeUpstreamErrorMessage(err) errType := "internal_error" if errors.As(err, &rateLimitErr) { @@ -171,7 +172,9 @@ func WriteRelayError(w http.ResponseWriter, err error) { statusCode = relayErr.StatusCode errMsg = relayErr.Message if relayErr.Cause != nil { - errMsg = relayErr.Message + ": " + relayErr.Cause.Error() + // 传输层错误(client.Do 的 *url.Error)的 Cause 含上游域名,必须脱敏后再暴露给用户; + // 日志侧仍用 originalError=%v 打印完整错误供运维定位。 + errMsg = relayErr.Message + ": " + helper.SafeUpstreamErrorMessage(relayErr.Cause) } errType = relayErr.Type } diff --git a/relay/handler/claude_handler.go b/relay/handler/claude_handler.go index 3ed41ff..8c57bef 100644 --- a/relay/handler/claude_handler.go +++ b/relay/handler/claude_handler.go @@ -10,6 +10,7 @@ import ( "github.com/qianfree/team-api/relay/common" "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/helper" ) // HandleClaudeMessages 处理 /v1/messages 请求(Claude 原生格式) @@ -37,7 +38,7 @@ func WriteClaudeRelayError(w http.ResponseWriter, err error) { var relayErr *constant.RelayError var rateLimitErr *RelayErrorWithRateLimit statusCode := http.StatusInternalServerError - errMsg := err.Error() + errMsg := helper.SafeUpstreamErrorMessage(err) errType := "api_error" if errors.As(err, &rateLimitErr) { @@ -48,7 +49,9 @@ func WriteClaudeRelayError(w http.ResponseWriter, err error) { statusCode = relayErr.StatusCode errMsg = relayErr.Message if relayErr.Cause != nil { - errMsg = relayErr.Message + ": " + relayErr.Cause.Error() + // 传输层错误(client.Do 的 *url.Error)的 Cause 含上游域名,必须脱敏后再暴露给用户; + // 日志侧仍用 originalError=%v 打印完整错误供运维定位。 + errMsg = relayErr.Message + ": " + helper.SafeUpstreamErrorMessage(relayErr.Cause) } errType = relayErr.Type } diff --git a/relay/handler/gemini_handler.go b/relay/handler/gemini_handler.go index a3fa637..ce80c79 100644 --- a/relay/handler/gemini_handler.go +++ b/relay/handler/gemini_handler.go @@ -12,6 +12,7 @@ import ( "github.com/qianfree/team-api/relay/common" "github.com/qianfree/team-api/relay/constant" "github.com/qianfree/team-api/relay/dto" + "github.com/qianfree/team-api/relay/helper" ) // HandleGeminiGenerateContent 处理 /v1beta/models/{model}:generateContent 请求(Gemini 原生格式) @@ -74,7 +75,7 @@ func WriteGeminiRelayError(w http.ResponseWriter, err error) { var relayErr *constant.RelayError var rateLimitErr *RelayErrorWithRateLimit statusCode := http.StatusInternalServerError - errMsg := err.Error() + errMsg := helper.SafeUpstreamErrorMessage(err) errStatus := "INTERNAL" if errors.As(err, &rateLimitErr) { @@ -85,7 +86,9 @@ func WriteGeminiRelayError(w http.ResponseWriter, err error) { statusCode = relayErr.StatusCode errMsg = relayErr.Message if relayErr.Cause != nil { - errMsg = relayErr.Message + ": " + relayErr.Cause.Error() + // 传输层错误(client.Do 的 *url.Error)的 Cause 含上游域名,必须脱敏后再暴露给用户; + // 日志侧仍用 originalError=%v 打印完整错误供运维定位。 + errMsg = relayErr.Message + ": " + helper.SafeUpstreamErrorMessage(relayErr.Cause) } switch statusCode { case 401: diff --git a/relay/handler/relay_handler.go b/relay/handler/relay_handler.go index 03eadda..46fe81a 100644 --- a/relay/handler/relay_handler.go +++ b/relay/handler/relay_handler.go @@ -639,7 +639,10 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He g.Log().Warningf(ctx, "[RelayHandler] DoResponse failed (will retry): adaptor=%s, inboundFormat=%s, channel=%d(%s) model=%s attempt=%d error=%v latency=%.0fms decision=%s", adaptor.GetChannelName(), info.InboundFormat, selection.ChannelID, selection.ChannelName, v.modelName, attempt, err, info.LatencyMs(), decision) } else { - g.Log().Errorf(ctx, "[RelayHandler] DoResponse failed (abort): adaptor=%s, inboundFormat=%s, channel=%d(%s) model=%s attempt=%d error=%v latency=%.0fms decision=%s", + // 上游响应错误属于预期内的运营事件(4xx/5xx/超时等),非代码 bug: + // 用 Warningf 避免触发 glog 默认对 ERROR+ 级别自动打印调用栈(StStatus=1), + // 堆栈只会复述对所有请求都一样的中间件链,污染日志且无诊断价值。 + g.Log().Warningf(ctx, "[RelayHandler] DoResponse failed (abort): adaptor=%s, inboundFormat=%s, channel=%d(%s) model=%s attempt=%d error=%v latency=%.0fms decision=%s", adaptor.GetChannelName(), info.InboundFormat, selection.ChannelID, selection.ChannelName, v.modelName, attempt, err, info.LatencyMs(), decision) } channelErrors = append(channelErrors, fmt.Sprintf("attempt=%d channel=%d(%s) model=%s doResponse_error=[%v] latency=%.0fms", diff --git a/relay/handler/task_handler.go b/relay/handler/task_handler.go index 2919d3f..95839bc 100644 --- a/relay/handler/task_handler.go +++ b/relay/handler/task_handler.go @@ -16,6 +16,7 @@ import ( "github.com/qianfree/team-api/relay/common" "github.com/qianfree/team-api/relay/constant" "github.com/qianfree/team-api/relay/dto" + "github.com/qianfree/team-api/relay/helper" "github.com/qianfree/team-api/relay/taskchannel" _ "github.com/qianfree/team-api/relay/taskchannel/ali" _ "github.com/qianfree/team-api/relay/taskchannel/gemini" @@ -211,8 +212,10 @@ func HandleTaskSubmit( if err := billingProvider.SettleTaskFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount); err != nil { g.Log().Errorf(ctx, "HandleTaskSubmit: SettleTaskFailed error, requestID=%s, amount=%.6f, err=%v", rc.RequestID, preDeductAmount.InexactFloat64(), err) } - g.Log().Errorf(ctx, "HandleTaskSubmit: upstream request failed, model=%s, err=%v", modelName, err) - writeTaskError(rc.Writer, http.StatusBadGateway, "upstream request failed: "+err.Error(), "") + // 上游请求失败(网络/超时/拒绝等)属预期内运营事件,非代码 bug: + // 用 Warningf 避免 glog 对 ERROR+ 自动打印调用栈污染日志(与 :229 upstream response error 一致)。 + g.Log().Warningf(ctx, "HandleTaskSubmit: upstream request failed, model=%s, err=%v", modelName, err) + writeTaskError(rc.Writer, http.StatusBadGateway, "upstream request failed: "+helper.SafeUpstreamErrorMessage(err), "") return } defer resp.Body.Close() @@ -379,10 +382,12 @@ func isSyncImageResultTask(t *common.AsyncTask) bool { // writeTaskError 写入错误响应 func writeTaskError(w http.ResponseWriter, statusCode int, message, code string) { + // 防御性兜底:抹除 message 中可能残留的上游 URL / IP(如 DoRequest 失败的 *url.Error、 + // MJ 图片代理的 http.Get 错误)。传输层域名已在主要泄露点(:215)经 SafeUpstreamErrorMessage 归一化。 resp := map[string]any{ "error": map[string]any{ "type": "invalid_request_error", - "message": message, + "message": helper.RedactMessage(message), }, } if code != "" { diff --git a/relay/helper/error_message.go b/relay/helper/error_message.go new file mode 100644 index 0000000..cbed572 --- /dev/null +++ b/relay/helper/error_message.go @@ -0,0 +1,96 @@ +package helper + +import ( + "context" + "errors" + "io" + "net" + "net/url" + "regexp" + "syscall" +) + +// 用户可见错误消息中的敏感信息替换占位符。 +const redactedPlaceholder = "[redacted]" + +var ( + // redactURLPattern 匹配 http(s):// 开头的 URL(含 host、path、query)。 + redactURLPattern = regexp.MustCompile(`(?i)https?://[^\s"'<>]+`) + // redactIPPattern 匹配 IPv4 地址。版本号偶有误伤,但在错误消息中无害。 + redactIPPattern = regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`) +) + +// SafeUpstreamErrorMessage 把可能包含上游渠道域名 / 内部传输细节的错误, +// 转换为对最终用户安全的消息文本。 +// +// 设计原则: +// - 传输层错误(client.Do 返回的 *url.Error)按类别归一化,保留 +// "超时 / 拒绝 / DNS / 重置" 等对用户有用的类别信息,但彻底抹掉 +// 上游域名与 URL(这是本修复的核心目标)。 +// - 其余错误(业务错误、上游响应体解析错误等)保留原始消息,但做 +// URL / IP 抹除作为防御性兜底。 +// +// 日志侧不受影响:调用方写日志时应使用原始 err(%v),保留完整诊断信息。 +func SafeUpstreamErrorMessage(err error) string { + if err == nil { + return "" + } + + // 传输层错误:http.Client.Do 失败时返回 *url.Error,其 Error() 形如 + // `Post "https://api.openai.com/v1/chat/completions": dial tcp: ...`, + // 完整暴露上游域名,必须归一化。 + var urlErr *url.Error + if errors.As(err, &urlErr) { + return categorizeTransportError(urlErr) + } + + // 非传输层错误:保留消息,但抹除其中可能残留的 URL / IP。 + return RedactMessage(err.Error()) +} + +// categorizeTransportError 按 *url.Error 内部成因归类为通用消息,不泄露域名。 +func categorizeTransportError(urlErr *url.Error) string { + inner := urlErr.Err + + // 客户端主动取消:不可重试,向用户说明即可 + if errors.Is(inner, context.Canceled) { + return "request canceled" + } + // 请求 / 响应超时(context 截止、i/o timeout、Client.Timeout 等) + if errors.Is(inner, context.DeadlineExceeded) { + return "upstream request timed out" + } + var netErr net.Error + if errors.As(inner, &netErr) && netErr.Timeout() { + return "upstream request timed out" + } + // 连接被上游拒绝(端口未开放 / 服务未启动) + if errors.Is(inner, syscall.ECONNREFUSED) { + return "upstream connection refused" + } + // DNS 解析失败:域名不存在或解析异常 + var dnsErr *net.DNSError + if errors.As(inner, &dnsErr) { + return "upstream host resolution failed" + } + // 连接被对端重置 / 提前关闭(GFW 干扰、上游瞬断的典型表现) + if errors.Is(inner, syscall.ECONNRESET) || + errors.Is(inner, io.EOF) || + errors.Is(inner, io.ErrUnexpectedEOF) { + return "upstream connection reset" + } + // 其余无法归类的传输层错误 + return "upstream connection failed" +} + +// RedactMessage 全量抹除字符串中的 URL / IP,替换为 [redacted]。 +// 用作非传输层错误的防御性兜底(传输层域名已由 SafeUpstreamErrorMessage +// 的结构化分类兜住,此处只处理偶发含 URL/IP 的业务错误消息)。 +func RedactMessage(msg string) string { + if msg == "" { + return msg + } + msg = redactURLPattern.ReplaceAllString(msg, redactedPlaceholder) + msg = redactIPPattern.ReplaceAllString(msg, redactedPlaceholder) + return msg +} diff --git a/relay/helper/error_message_test.go b/relay/helper/error_message_test.go new file mode 100644 index 0000000..89d122e --- /dev/null +++ b/relay/helper/error_message_test.go @@ -0,0 +1,148 @@ +package helper + +import ( + "context" + "errors" + "io" + "net" + "net/url" + "strings" + "syscall" + "testing" + + "github.com/qianfree/team-api/relay/constant" +) + +// fakeTimeoutError 是一个独立的 net.Error 实现,Timeout() 返回 true, +// 用于验证 categorizeTransportError 中 net.Error.Timeout() 分支(区别于 context.DeadlineExceeded)。 +type fakeTimeoutError struct{} + +func (fakeTimeoutError) Error() string { return "i/o timeout" } +func (fakeTimeoutError) Timeout() bool { return true } +func (fakeTimeoutError) Temporary() bool { return true } + +// 上游域名泄露的典型形态:client.Do 失败返回的 *url.Error 含完整 URL。 +func leakyURLError(inner error) *url.Error { + return &url.Error{ + Op: "Post", + URL: "https://api.openai.com/v1/chat/completions", + Err: inner, + } +} + +func TestSafeUpstreamErrorMessage_TransportCategories(t *testing.T) { + cases := []struct { + name string + err error + want string + }{ + {"超时-context截止", leakyURLError(context.DeadlineExceeded), "upstream request timed out"}, + {"超时-i/o timeout", leakyURLError(fakeTimeoutError{}), "upstream request timed out"}, + {"客户端取消", leakyURLError(context.Canceled), "request canceled"}, + {"连接拒绝", leakyURLError(syscall.ECONNREFUSED), "upstream connection refused"}, + {"DNS失败", leakyURLError(&net.DNSError{Err: "no such host", Name: "api.openai.com"}), "upstream host resolution failed"}, + {"连接重置", leakyURLError(syscall.ECONNRESET), "upstream connection reset"}, + {"EOF", leakyURLError(io.EOF), "upstream connection reset"}, + {"意外EOF", leakyURLError(io.ErrUnexpectedEOF), "upstream connection reset"}, + {"其余传输层错误", leakyURLError(errors.New("tls: handshake failure")), "upstream connection failed"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := SafeUpstreamErrorMessage(c.err) + if got != c.want { + t.Fatalf("got %q, want %q", got, c.want) + } + // 核心安全断言:任何传输层错误的归一化结果都不得包含上游域名或协议 + if strings.Contains(got, "openai.com") { + t.Errorf("结果泄露了上游域名: %q", got) + } + if strings.Contains(got, "http") { + t.Errorf("结果泄露了协议/URL: %q", got) + } + }) + } +} + +func TestSafeUpstreamErrorMessage_Nil(t *testing.T) { + if got := SafeUpstreamErrorMessage(nil); got != "" { + t.Fatalf("nil 应返回空串,got %q", got) + } +} + +func TestSafeUpstreamErrorMessage_BusinessErrorPreservedButRedacted(t *testing.T) { + // 非传输层错误:消息保留,但其中的 URL/IP 必须被抹除 + err := errors.New(`dial upstream websocket failed: Get "https://api.x.com/v1": connection refused at 10.0.0.5`) + got := SafeUpstreamErrorMessage(err) + + if strings.Contains(got, "https://api.x.com") { + t.Errorf("URL 未被抹除: %q", got) + } + if strings.Contains(got, "10.0.0.5") { + t.Errorf("IP 未被抹除: %q", got) + } + if !strings.Contains(got, "dial upstream websocket failed") { + t.Errorf("业务消息正文不应被破坏: %q", got) + } + if !strings.Contains(got, "[redacted]") { + t.Errorf("应包含 [redacted] 占位: %q", got) + } +} + +func TestSafeUpstreamErrorMessage_LegitimateBusinessMessageUntouched(t *testing.T) { + // 上游 provider 的合法业务错误(无 URL/IP)应原样保留 + cases := []string{ + "rate limit exceeded", + "invalid model: gpt-5-mini", + "This model's maximum context length is 8192 tokens.", + } + for _, msg := range cases { + got := SafeUpstreamErrorMessage(errors.New(msg)) + if got != msg { + t.Errorf("合法消息被改动: got %q, want %q", got, msg) + } + } +} + +func TestSafeUpstreamErrorMessage_WrappedRelayError(t *testing.T) { + // 端到端:编排层把 *url.Error 塞进 NewUpstreamError 的 Cause, + // 传入 SafeUpstreamErrorMessage 应能经 errors.As 找到 *url.Error 并归一化。 + relayErr := constant.NewUpstreamError(502, "upstream request failed", + leakyURLError(&net.DNSError{Err: "no such host", Name: "api.openai.com"})) + + got := SafeUpstreamErrorMessage(relayErr) + if got != "upstream host resolution failed" { + t.Fatalf("经 RelayError 包装后归一化失败,got %q", got) + } + if strings.Contains(got, "openai.com") || strings.Contains(got, "https") { + t.Errorf("仍泄露上游信息: %q", got) + } +} + +func TestRedactMessage(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"", ""}, + {"no sensitive info here", "no sensitive info here"}, + { + `Post "https://api.openai.com/v1/chat": dial tcp: i/o timeout`, + `Post "[redacted]": dial tcp: i/o timeout`, + }, + { + `connect to http://10.0.0.5:8080 failed`, + `connect to [redacted] failed`, + }, + { + "https://sub.example.co.uk/a/b?x=1 and 192.168.1.1 both redacted", + "[redacted] and [redacted] both redacted", + }, + } + for _, c := range cases { + got := RedactMessage(c.in) + if got != c.want { + t.Errorf("RedactMessage(%q)\n got %q\n want %q", c.in, got, c.want) + } + } +}