improve(log):优化日志打印
This commit is contained in:
1 parent
03259a144a
commit
d7b034c50e
7 files changed
+271
-10
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user