diff --git a/internal/runtime/embedding/api.go b/internal/runtime/embedding/api.go index 2340316..e278468 100644 --- a/internal/runtime/embedding/api.go +++ b/internal/runtime/embedding/api.go @@ -231,7 +231,7 @@ func (e *APIEmbedder) embedChunk(ctx context.Context, texts []string) ([]Vector, resp, err := e.client.Do(req) if err != nil { - return nil, fmt.Errorf("embedding: request: %w", err) + return nil, fmt.Errorf("embedding: request: %w", trace.URLCause(err)) } defer func() { _ = resp.Body.Close() }() @@ -298,7 +298,7 @@ func (e *APIEmbedder) newEmbeddingRequest(ctx context.Context, texts []string) ( req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) if err != nil { - return nil, fmt.Errorf("embedding: build request: %w", err) + return nil, fmt.Errorf("embedding: build request: %w", trace.URLCause(err)) } req.Header.Set("Content-Type", "application/json") if e.cfg.APIKey != "" { diff --git a/internal/runtime/embedding/endpoint.go b/internal/runtime/embedding/endpoint.go index 8725d78..4d0dc07 100644 --- a/internal/runtime/embedding/endpoint.go +++ b/internal/runtime/embedding/endpoint.go @@ -5,6 +5,8 @@ import ( "net" "net/url" "strings" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) // 本文件只管**端点地址的规范化**:把「连接语义上不可靠」的回环主机名别名改写 @@ -39,7 +41,8 @@ const loopbackIPv4 = "127.0.0.1" func normalizeEndpointURL(raw string) (string, error) { u, err := url.Parse(raw) if err != nil { - return "", fmt.Errorf("embedding: parse api url: %w", err) + // 不回原文:url.Parse 的 *url.Error 会把整条地址(query 里可能是 api_key)渲染进去。 + return "", fmt.Errorf("embedding: parse api url: %w", trace.URLCause(err)) } if u.Scheme != "http" && u.Scheme != "https" { // 不是本客户端能拨的 scheme:原样返回,让后续的报错如实暴露配置问题, diff --git a/internal/runtime/embedding/url_cause_test.go b/internal/runtime/embedding/url_cause_test.go new file mode 100644 index 0000000..f1a0c88 --- /dev/null +++ b/internal/runtime/embedding/url_cause_test.go @@ -0,0 +1,89 @@ +package embedding + +// 本文件是 M52 的出口回归探针:embedding 客户端里「把 net/http 的 *url.Error 直接 +// 打进错误串」的两处 —— `embedChunk` 的传输失败与 `normalizeEndpointURL` 的解析失败 —— +// 一律先过 trace.URLCause。 +// +// 为什么这两处特别敏感:/embeddings 的端点常常配成带 `?api_key=` 的网关地址,而 +// `client.Do` 失败时 net/http 会把**整条地址**渲染进错误串(密码被打码、用户名与 query +// 不打码)。原文进日志就等于把网关凭据交出去。 +// +// 这一包与 model/vision 的差别:这里原本用的是 `%w`(不是 `%v`),因此除了「去地址」, +// 还必须钉住 `%w` 的链没被削弱 —— 把 URLCause 换成「新建一个错误」会让 +// errors.Is(err, context.DeadlineExceeded) 变红。 + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" +) + +const ( + urlCauseQuerySecret = "SECRET-2f8a1c" + urlCauseUser = "svc-user" + urlCauseBadHost = "bad host.example.com" +) + +func embedURLCauseGoodURL() string { + return "https://" + urlCauseUser + ":svc-pass@embed.example.com/v1/embeddings?api_key=" + urlCauseQuerySecret +} + +func embedURLCauseBadURL() string { + return "https://" + urlCauseUser + ":svc-pass@" + urlCauseBadHost + "/v1/embeddings?api_key=" + urlCauseQuerySecret +} + +// TestEmbedDropsURLFromTransportError 钉住 embedChunk 里 `e.client.Do` 失败那一处, +// 并钉住 `%w` 的哨兵链仍然成立(context.DeadlineExceeded 可 errors.Is)。 +func TestEmbedDropsURLFromTransportError(t *testing.T) { + t.Parallel() + emb := NewAPI( + config.EmbeddingConfig{APIURL: embedURLCauseGoodURL()}, + WithRoundTripper(roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, fmt.Errorf("net/http: %w", context.DeadlineExceeded) + })), + ) + + _, err := emb.Embed(context.Background(), []string{"hi"}) + if err == nil { + t.Fatal("want error, got nil") + } + if strings.Contains(err.Error(), urlCauseQuerySecret) { + t.Fatalf("错误串带出了 query 里的凭据:%v", err) + } + if strings.Contains(err.Error(), urlCauseUser) { + t.Fatalf("错误串带出了 userinfo 里的用户名:%v", err) + } + if !strings.Contains(err.Error(), "deadline exceeded") { + t.Fatalf("错误串丢了底层原因(只许少地址):%v", err) + } + // `%w` 链必须还在:URLCause 只剥 *url.Error 外壳,内层原因原样返回。 + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("err = %v, want errors.Is(context.DeadlineExceeded)(哨兵链不许被带偏)", err) + } +} + +// TestEmbedDropsURLFromParseError 钉住 normalizeEndpointURL 的解析失败: +// url.Parse 的 *url.Error 里就是整条 api URL。 +func TestEmbedDropsURLFromParseError(t *testing.T) { + t.Parallel() + emb := NewAPI(config.EmbeddingConfig{APIURL: embedURLCauseBadURL()}) + + _, err := emb.Embed(context.Background(), []string{"hi"}) + if err == nil { + t.Fatal("want error, got nil") + } + if strings.Contains(err.Error(), urlCauseQuerySecret) || strings.Contains(err.Error(), urlCauseUser) { + t.Fatalf("解析失败的串带出了 api URL 里的凭据:%v", err) + } + if !strings.Contains(err.Error(), "invalid character") { + t.Fatalf("错误串丢了底层解析原因(只许少地址):%v", err) + } + if !strings.Contains(err.Error(), "parse api url") { + t.Fatalf("错误串丢了来源标记:%v", err) + } +} diff --git a/internal/runtime/model/anthropic.go b/internal/runtime/model/anthropic.go index ca92fa5..8784a41 100644 --- a/internal/runtime/model/anthropic.go +++ b/internal/runtime/model/anthropic.go @@ -12,6 +12,7 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) // Anthropic Messages(/v1/messages)线协议常量。 @@ -246,7 +247,7 @@ func (a *AnthropicChatAdapter) chatOnce(ctx context.Context, req Request) (Respo } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint(), bytes.NewReader(body)) if err != nil { - return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, err) + return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, trace.URLCause(err)) } resp, err := a.doRequest(httpReq, false) if err != nil { @@ -303,7 +304,7 @@ func (a *AnthropicChatAdapter) streamOnce(ctx context.Context, req Request, onEv } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint(), bytes.NewReader(body)) if err != nil { - return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, err) + return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, trace.URLCause(err)) } resp, err := a.doRequest(httpReq, true) if err != nil { @@ -398,7 +399,7 @@ func (a *AnthropicChatAdapter) doRequest(httpReq *http.Request, stream bool) (*h } resp, err := a.client.Do(httpReq) if err != nil { - return nil, fmt.Errorf("model: %w: %v", ErrUpstream, err) + return nil, fmt.Errorf("model: %w: %v", ErrUpstream, trace.URLCause(err)) } return resp, nil } diff --git a/internal/runtime/model/openai.go b/internal/runtime/model/openai.go index 0612259..76eb315 100644 --- a/internal/runtime/model/openai.go +++ b/internal/runtime/model/openai.go @@ -11,6 +11,7 @@ import ( "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) // OpenAI 兼容 /chat/completions 协议的线格式约束。 @@ -134,7 +135,7 @@ func (a *OpenAIChatAdapter) Chat(ctx context.Context, req Request, opts Options) } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint(), bytes.NewReader(body)) if err != nil { - return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, err) + return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, trace.URLCause(err)) } resp, err := a.doRequest(httpReq, false) if err != nil { @@ -171,7 +172,7 @@ func (a *OpenAIChatAdapter) ChatStream(ctx context.Context, req Request, onEvent } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint(), bytes.NewReader(body)) if err != nil { - return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, err) + return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, trace.URLCause(err)) } resp, err := a.doRequest(httpReq, true) if err != nil { @@ -226,7 +227,7 @@ func (a *OpenAIChatAdapter) doRequest(httpReq *http.Request, stream bool) (*http } resp, err := a.client.Do(httpReq) if err != nil { - return nil, fmt.Errorf("model: %w: %v", ErrUpstream, err) + return nil, fmt.Errorf("model: %w: %v", ErrUpstream, trace.URLCause(err)) } return resp, nil } diff --git a/internal/runtime/model/url_cause_test.go b/internal/runtime/model/url_cause_test.go new file mode 100644 index 0000000..d08dad5 --- /dev/null +++ b/internal/runtime/model/url_cause_test.go @@ -0,0 +1,169 @@ +package model + +// 本文件是 M52 的出口回归探针:model 两个适配器里所有「把 net/http 的 +// *url.Error 直接打进错误串」的地方,一律先过 trace.URLCause。 +// +// 为什么要测:`client.Do` 失败时 net/http 会包出 +// `Post "https://user:***@h/p?access_token=…": dial tcp …` —— 密码被打码,但 +// **用户名与整条 query(含 access_token / 预签名参数)原样在串里**,而这条串会进日志、 +// 进工单。`http.NewRequest` 的解析失败同样是 *url.Error,把整条 base URL 带出来。 +// +// 断言形状是「把任一处改回 err ⇒ 对应用例变红」:两个适配器 × 两条路(建请求 / +// 发送)各一个子用例,不复用同一条断言链,因此变异能定位到具体那一处。 +// 同时钉住「只少地址、不少原因」:底层原因(connection refused)与哨兵 ErrUpstream +// 都必须还在 —— 这批只许去掉地址,不许把排障信息一起删。 + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" +) + +const ( + // urlCauseQuerySecret 是藏在 query 里的凭据。net/http **不会**打码 query, + // 因此它是这批病害最真实的载体。 + urlCauseQuerySecret = "SECRET-2f8a1c" + // urlCauseUser 是 userinfo 里的用户名(密码由 net/http 打码成 ***, + // 因此用例只对用户名与 query 值断言 —— 那两段才是真正会漏的)。 + urlCauseUser = "svc-user" + // urlCauseBadHost 里的空格让 url.Parse 失败 ⇒ 「建请求」那条路的入口。 + urlCauseBadHost = "bad host.example.com" +) + +// urlCauseBadBase 是「建请求」路径的 base URL:解析必然失败,且原文里同时带着 +// userinfo 与 query 凭据。 +func urlCauseBadBase() string { + return "https://" + urlCauseUser + ":svc-pass@" + urlCauseBadHost + "/v1?access_token=" + urlCauseQuerySecret +} + +// urlCauseGoodBase 是「发送」路径的 base URL:能解析、能建请求,失败由 transport 造成。 +func urlCauseGoodBase() string { + return "https://" + urlCauseUser + ":svc-pass@api.example.com/v1?access_token=" + urlCauseQuerySecret +} + +// urlCauseFailTransport 让任何请求都在传输层失败(不经网络)。 +type urlCauseFailTransport struct{} + +func (urlCauseFailTransport) RoundTrip(*http.Request) (*http.Response, error) { + return nil, errors.New("dial tcp 10.0.0.1:443: connect: connection refused") +} + +// urlCauseFailClient 是一个只回传输错误的 client。 +func urlCauseFailClient() *http.Client { return &http.Client{Transport: urlCauseFailTransport{}} } + +// assertNoURLCarried 断言错误串里既没有 query 凭据也没有用户名,但底层原因还在。 +func assertNoURLCarried(t *testing.T, err error, sentinel error) { + t.Helper() + if err == nil { + t.Fatal("want error, got nil") + } + if strings.Contains(err.Error(), urlCauseQuerySecret) { + t.Fatalf("错误串带出了 query 里的凭据:%v", err) + } + if strings.Contains(err.Error(), urlCauseUser) { + t.Fatalf("错误串带出了 userinfo 里的用户名:%v", err) + } + if !strings.Contains(err.Error(), "connection refused") { + t.Fatalf("错误串丢了底层原因(只许少地址):%v", err) + } + if !errors.Is(err, sentinel) { + t.Fatalf("err = %v, want errors.Is(%v)", err, sentinel) + } +} + +// assertNoURLCarriedInBuild 是「建请求」路径的同口径断言:那一条路的底层原因是 +// url.Parse 的解析错误,因此存在性断言换成解析错误文本。 +func assertNoURLCarriedInBuild(t *testing.T, err error, sentinel error) { + t.Helper() + if err == nil { + t.Fatal("want error, got nil") + } + if strings.Contains(err.Error(), urlCauseQuerySecret) || strings.Contains(err.Error(), urlCauseUser) { + t.Fatalf("建请求失败的串带出了 base URL 里的凭据:%v", err) + } + if !strings.Contains(err.Error(), "invalid character") { + t.Fatalf("错误串丢了底层解析原因(只许少地址):%v", err) + } + if !errors.Is(err, sentinel) { + t.Fatalf("err = %v, want errors.Is(%v)", err, sentinel) + } +} + +// TestOpenAIChatDropsURLFromTransportError 钉住 OpenAIChatAdapter.doRequest 那一处 +// (`client.Do` 失败)。 +func TestOpenAIChatDropsURLFromTransportError(t *testing.T) { + t.Parallel() + ad := NewOpenAIChat( + config.LLMConfig{BaseURL: urlCauseGoodBase(), Model: "m"}, + WithHTTPClient(urlCauseFailClient()), + ) + _, err := ad.Chat(context.Background(), + Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{}) + assertNoURLCarried(t, err, ErrUpstream) +} + +// TestOpenAIChatDropsURLFromBuildRequestError 钉住 OpenAIChatAdapter.Chat 里 +// NewRequest 失败那一处。 +func TestOpenAIChatDropsURLFromBuildRequestError(t *testing.T) { + t.Parallel() + ad := NewOpenAIChat(config.LLMConfig{BaseURL: urlCauseBadBase(), Model: "m"}) + _, err := ad.Chat(context.Background(), + Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{}) + assertNoURLCarriedInBuild(t, err, ErrUpstream) +} + +// TestOpenAIChatStreamDropsURLFromBuildRequestError 钉住 ChatStream 里的同一个 +// 同族点(两条路各写了一次,改一处漏一处同样会漏地址)。 +func TestOpenAIChatStreamDropsURLFromBuildRequestError(t *testing.T) { + t.Parallel() + ad := NewOpenAIChat(config.LLMConfig{BaseURL: urlCauseBadBase(), Model: "m"}) + _, err := ad.ChatStream(context.Background(), + Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, func(StreamEvent) {}) + assertNoURLCarriedInBuild(t, err, ErrUpstream) +} + +// TestOpenAIChatStreamDropsURLFromTransportError 钉住 ChatStream 复用 doRequest 的那条路。 +func TestOpenAIChatStreamDropsURLFromTransportError(t *testing.T) { + t.Parallel() + ad := NewOpenAIChat( + config.LLMConfig{BaseURL: urlCauseGoodBase(), Model: "m"}, + WithHTTPClient(urlCauseFailClient()), + ) + _, err := ad.ChatStream(context.Background(), + Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, func(StreamEvent) {}) + assertNoURLCarried(t, err, ErrUpstream) +} + +// TestAnthropicChatDropsURLFromTransportError 钉住 AnthropicChatAdapter.doRequest。 +func TestAnthropicChatDropsURLFromTransportError(t *testing.T) { + t.Parallel() + ad := NewAnthropicChat( + config.LLMConfig{AnthropicBaseURL: urlCauseGoodBase(), AnthropicModel: "m", AnthropicAPIKey: "k"}, + WithAnthropicHTTPClient(urlCauseFailClient()), + ) + _, err := ad.Chat(context.Background(), + Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{}) + assertNoURLCarried(t, err, ErrUpstream) +} + +// TestAnthropicChatDropsURLFromBuildRequestError 钉住 chatOnce 里的 NewRequest 失败。 +func TestAnthropicChatDropsURLFromBuildRequestError(t *testing.T) { + t.Parallel() + ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: urlCauseBadBase(), AnthropicModel: "m", AnthropicAPIKey: "k"}) + _, err := ad.Chat(context.Background(), + Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{}) + assertNoURLCarriedInBuild(t, err, ErrUpstream) +} + +// TestAnthropicChatStreamDropsURLFromBuildRequestError 钉住 streamOnce 里的同族点。 +func TestAnthropicChatStreamDropsURLFromBuildRequestError(t *testing.T) { + t.Parallel() + ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: urlCauseBadBase(), AnthropicModel: "m", AnthropicAPIKey: "k"}) + _, err := ad.ChatStream(context.Background(), + Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, func(StreamEvent) {}) + assertNoURLCarriedInBuild(t, err, ErrUpstream) +} diff --git a/internal/runtime/multimodal/vision/adapter.go b/internal/runtime/multimodal/vision/adapter.go index e9d3563..74d3289 100644 --- a/internal/runtime/multimodal/vision/adapter.go +++ b/internal/runtime/multimodal/vision/adapter.go @@ -159,7 +159,7 @@ func (a *OpenAICompatibleAdapter) ask(ctx context.Context, model string, img Ima } req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(a.cfg.BaseURL, "/")+"/chat/completions", bytes.NewReader(payload)) if err != nil { - return "", fmt.Errorf("vision: %w: build request: %v", ErrUpstream, err) + return "", fmt.Errorf("vision: %w: build request: %v", ErrUpstream, trace.URLCause(err)) } req.Header.Set("Content-Type", "application/json") if a.cfg.APIKey != "" { @@ -167,7 +167,7 @@ func (a *OpenAICompatibleAdapter) ask(ctx context.Context, model string, img Ima } resp, err := a.client.Do(req) if err != nil { - return "", fmt.Errorf("vision: %w: %v", ErrUpstream, err) + return "", fmt.Errorf("vision: %w: %v", ErrUpstream, trace.URLCause(err)) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { diff --git a/internal/runtime/multimodal/vision/url_cause_test.go b/internal/runtime/multimodal/vision/url_cause_test.go new file mode 100644 index 0000000..cbea534 --- /dev/null +++ b/internal/runtime/multimodal/vision/url_cause_test.go @@ -0,0 +1,80 @@ +package vision + +// 本文件是 M52 的出口回归探针:vision 适配器里「把 net/http 的 *url.Error 直接打进 +// 错误串」的两处(建请求 / 发送)一律先过 trace.URLCause。 +// +// 病害形状:`client.Do` 失败时 net/http 包出 +// `Post "https://user:***@h/p?access_token=…": dial tcp …` —— 密码被打码,但用户名与 +// 整条 query(含凭据/签名)原样在串里;`http.NewRequest` 的解析失败则把整条 base URL +// 带出来。这条串会进日志与工单。 +// +// 断言形状是「把任一处改回 err ⇒ 对应用例变红」:两处各一个用例,且都同时钉住 +// 「只少地址、不少原因」(底层原因与 ErrUpstream 哨兵必须还在)。 + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" +) + +const ( + urlCauseQuerySecret = "SECRET-2f8a1c" + urlCauseUser = "svc-user" + urlCauseBadHost = "bad host.example.com" +) + +func visionURLCauseGoodBase() string { + return "https://" + urlCauseUser + ":svc-pass@vision.example.com/v1?access_token=" + urlCauseQuerySecret +} + +func visionURLCauseBadBase() string { + return "https://" + urlCauseUser + ":svc-pass@" + urlCauseBadHost + "/v1?access_token=" + urlCauseQuerySecret +} + +// TestDescribeDropsURLFromTransportError 钉住 ask 里 `a.client.Do` 失败那一处。 +func TestDescribeDropsURLFromTransportError(t *testing.T) { + t.Parallel() + ad := newAdapter(visionURLCauseGoodBase()) + ad.client = &http.Client{Transport: stubTransport(func(*http.Request) (*http.Response, error) { + return nil, errors.New("dial tcp 10.0.0.1:443: connect: connection refused") + })} + + _, err := ad.Describe(context.Background(), ImageRef{Source: SourceBase64, Data: "aGVsbG8="}, "") + if err == nil { + t.Fatal("want error, got nil") + } + if strings.Contains(err.Error(), urlCauseQuerySecret) { + t.Fatalf("错误串带出了 query 里的凭据:%v", err) + } + if strings.Contains(err.Error(), urlCauseUser) { + t.Fatalf("错误串带出了 userinfo 里的用户名:%v", err) + } + if !strings.Contains(err.Error(), "connection refused") { + t.Fatalf("错误串丢了底层原因(只许少地址):%v", err) + } + if !errors.Is(err, ErrUpstream) { + t.Fatalf("err = %v, want ErrUpstream(分类不许被带偏)", err) + } +} + +// TestDescribeDropsURLFromBuildRequestError 钉住 ask 里 `http.NewRequest` 失败那一处。 +func TestDescribeDropsURLFromBuildRequestError(t *testing.T) { + t.Parallel() + ad := newAdapter(visionURLCauseBadBase()) + + _, err := ad.Describe(context.Background(), ImageRef{Source: SourceBase64, Data: "aGVsbG8="}, "") + if err == nil { + t.Fatal("want error, got nil") + } + if strings.Contains(err.Error(), urlCauseQuerySecret) || strings.Contains(err.Error(), urlCauseUser) { + t.Fatalf("建请求失败的串带出了 base URL 里的凭据:%v", err) + } + if !strings.Contains(err.Error(), "invalid character") { + t.Fatalf("错误串丢了底层解析原因(只许少地址):%v", err) + } + if !errors.Is(err, ErrUpstream) { + t.Fatalf("err = %v, want ErrUpstream(分类不许被带偏)", err) + } +} diff --git a/internal/runtime/multimodal/voice/cloud.go b/internal/runtime/multimodal/voice/cloud.go index a8fdcdf..583182c 100644 --- a/internal/runtime/multimodal/voice/cloud.go +++ b/internal/runtime/multimodal/voice/cloud.go @@ -13,6 +13,7 @@ import ( "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) // cloudMaxResponseBytes 限制读取云端语音响应的体积,使过大 @@ -178,7 +179,7 @@ func (s *HTTPTTS) TTS(ctx context.Context, text string) (Audio, error) { func postJSON(ctx context.Context, client *http.Client, apiKey, url string, payload []byte) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) if err != nil { - return nil, fmt.Errorf("voice: %w: build: %v", ErrProviderFailed, err) + return nil, fmt.Errorf("voice: %w: build: %v", ErrProviderFailed, trace.URLCause(err)) } req.Header.Set("Content-Type", "application/json") if apiKey != "" { @@ -186,7 +187,7 @@ func postJSON(ctx context.Context, client *http.Client, apiKey, url string, payl } resp, err := client.Do(req) if err != nil { - return nil, fmt.Errorf("voice: %w: request: %v", ErrProviderFailed, err) + return nil, fmt.Errorf("voice: %w: request: %v", ErrProviderFailed, trace.URLCause(err)) } if resp.StatusCode != http.StatusOK { _ = resp.Body.Close() diff --git a/internal/runtime/multimodal/voice/dashscope.go b/internal/runtime/multimodal/voice/dashscope.go index 6320708..dac9f54 100644 --- a/internal/runtime/multimodal/voice/dashscope.go +++ b/internal/runtime/multimodal/voice/dashscope.go @@ -12,6 +12,7 @@ import ( "unicode" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) // 语音链云端一级的 DashScope(阿里百炼)STT/TTS 适配器。请求 @@ -246,11 +247,11 @@ func (t *DashscopeTTS) audioBytes(ctx context.Context, body []byte) ([]byte, err func (t *DashscopeTTS) download(ctx context.Context, url string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { - return nil, fmt.Errorf("voice: %w: build: %v", ErrProviderFailed, err) + return nil, fmt.Errorf("voice: %w: build: %v", ErrProviderFailed, trace.URLCause(err)) } resp, err := t.client.Do(req) if err != nil { - return nil, fmt.Errorf("voice: %w: download: %v", ErrProviderFailed, err) + return nil, fmt.Errorf("voice: %w: download: %v", ErrProviderFailed, trace.URLCause(err)) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { @@ -380,7 +381,7 @@ func firstNonEmpty(a, b string) string { func dashscopePost(ctx context.Context, client *http.Client, apiKey, url string, payload []byte, sse string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) if err != nil { - return nil, fmt.Errorf("voice: %w: build: %v", ErrProviderFailed, err) + return nil, fmt.Errorf("voice: %w: build: %v", ErrProviderFailed, trace.URLCause(err)) } req.Header.Set("Content-Type", "application/json") if apiKey != "" { @@ -391,7 +392,7 @@ func dashscopePost(ctx context.Context, client *http.Client, apiKey, url string, } resp, err := client.Do(req) if err != nil { - return nil, fmt.Errorf("voice: %w: request: %v", ErrProviderFailed, err) + return nil, fmt.Errorf("voice: %w: request: %v", ErrProviderFailed, trace.URLCause(err)) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { diff --git a/internal/runtime/multimodal/voice/url_cause_test.go b/internal/runtime/multimodal/voice/url_cause_test.go new file mode 100644 index 0000000..9e84280 --- /dev/null +++ b/internal/runtime/multimodal/voice/url_cause_test.go @@ -0,0 +1,173 @@ +package voice + +// 本文件是 M52 的出口回归探针:voice 内置的两条 HTTP 链(cloud 的 postJSON、 +// DashScope 的 dashscopePost / download)里「把 net/http 的 *url.Error 直接打进 +// 错误串」的 6 处,一律先过 trace.URLCause。 +// +// 最要紧的是 DashScope 的 `download`:那是**预签名 OSS 地址**,签名与 STS 令牌就在 +// query 里。`client.Do` 失败时 net/http 会把整条地址渲染进错误串,于是「下载失败」 +// 这条最常见的报错就等于把一小时有效的签名写进日志。因此这一处单独给了用例。 +// +// 断言形状是「把任一处改回 err ⇒ 对应用例变红」:6 处各一个用例,全部同时钉住 +// 「只少地址、不少原因」(底层原因与 ErrProviderFailed 哨兵必须还在)。 + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" +) + +const ( + urlCauseQuerySecret = "SECRET-2f8a1c" + urlCauseUser = "svc-user" + urlCauseBadHost = "bad host.example.com" +) + +// urlCausePresigned 是一条**带签名**的 OSS 地址:签名、STS 令牌、AccessKeyId 全在 +// query 里。它们一旦进错误串/日志,就等于交付了一份可用的下载授权。 +const urlCausePresigned = "https://oss-cn-hangzhou.aliyuncs.com/out.wav" + + "?Expires=1700000000&OSSAccessKeyId=AKIA-SENTINEL&Signature=SIG-SENTINEL-9f2c&security-token=STS-SENTINEL-4b1" + +var ( + urlCausePresignSecrets = []string{"SIG-SENTINEL-9f2c", "STS-SENTINEL-4b1", "AKIA-SENTINEL"} +) + +func voiceURLCauseGoodBase() string { + return "https://" + urlCauseUser + ":svc-pass@voice.example.com/v1?access_token=" + urlCauseQuerySecret +} + +func voiceURLCauseBadBase() string { + return "https://" + urlCauseUser + ":svc-pass@" + urlCauseBadHost + "/v1?access_token=" + urlCauseQuerySecret +} + +// urlCauseFailTransport 让任何请求都在传输层失败(不经网络)。 +type urlCauseFailTransport struct{} + +func (urlCauseFailTransport) RoundTrip(*http.Request) (*http.Response, error) { + return nil, errors.New("dial tcp 10.0.0.1:443: connect: connection refused") +} + +func urlCauseFailClient() *http.Client { return &http.Client{Transport: urlCauseFailTransport{}} } + +// urlCauseStatusTransport 恒定回一个非 200 的响应(带一个可读的错误体)。 +func urlCauseStatusTransport(status int) *http.Client { + return &http.Client{Transport: urlCauseRoundTrip(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"message":"denied"}`)), + }, nil + })} +} + +// urlCauseRoundTrip 把函数适配成 http.RoundTripper。 +type urlCauseRoundTrip func(*http.Request) (*http.Response, error) + +func (f urlCauseRoundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +// assertNoURLKept 断言错误串不含给定凭据、但含给定原因,且哨兵仍成立。 +func assertNoURLKept(t *testing.T, err error, secrets []string, reason string) { + t.Helper() + if err == nil { + t.Fatal("want error, got nil") + } + for _, s := range secrets { + if strings.Contains(err.Error(), s) { + t.Fatalf("错误串带出了凭据 %q:%v", s, err) + } + } + if strings.Contains(err.Error(), urlCauseUser) { + t.Fatalf("错误串带出了 userinfo 里的用户名:%v", err) + } + if !strings.Contains(err.Error(), reason) { + t.Fatalf("错误串丢了底层原因(只许少地址):%v", err) + } + if !errors.Is(err, ErrProviderFailed) { + t.Fatalf("err = %v, want ErrProviderFailed(分类/降级判据不许被带偏)", err) + } +} + +// TestHTTPTTSDropsURLFromTransportError 钉住 cloud 的 postJSON 里 `client.Do` 那一处。 +func TestHTTPTTSDropsURLFromTransportError(t *testing.T) { + t.Parallel() + tts := NewHTTPTTS(voiceURLCauseGoodBase(), "k", WithHTTPClient(urlCauseFailClient())) + _, err := tts.TTS(context.Background(), "hi") + assertNoURLKept(t, err, []string{urlCauseQuerySecret}, "connection refused") + if !errors.Is(err, ErrProviderFailed) { + t.Fatalf("err = %v, want ErrProviderFailed", err) + } +} + +// TestHTTPSTTDropsURLFromBuildRequestError 钉住 cloud 的 postJSON 里 NewRequest 那一处。 +func TestHTTPSTTDropsURLFromBuildRequestError(t *testing.T) { + t.Parallel() + stt := NewHTTPSTT(voiceURLCauseBadBase(), "k", WithHTTPClient(urlCauseFailClient())) + _, err := stt.STT(context.Background(), Audio{Format: "wav", Data: []byte("x")}) + assertNoURLKept(t, err, []string{urlCauseQuerySecret}, "invalid character") +} + +// TestDashscopeDownloadDropsPresignedURLFromTransportError 钉住 DashScope 的 +// `download`:预签名 OSS 地址在传输失败时**整条**不进错误串。 +func TestDashscopeDownloadDropsPresignedURLFromTransportError(t *testing.T) { + t.Parallel() + tts := NewDashscopeTTS(config.VoiceConfig{DashscopeAPIKey: "k"}) + tts.client = urlCauseFailClient() + + _, err := tts.download(context.Background(), urlCausePresigned) + assertNoURLKept(t, err, urlCausePresignSecrets, "connection refused") + // 地址的 host 段同样不该出现:这条串会进日志,而"哪个 OSS 域名"本身不是排障必需, + // 真正的排障线索(连接被拒 / 超时)与 ErrProviderFailed 分类都还在。 + if strings.Contains(err.Error(), "oss-cn-hangzhou") { + t.Fatalf("错误串带出了预签名地址的 host:%v", err) + } +} + +// TestDashscopeDownloadDropsPresignedURLFromBuildRequestError 钉住 `download` 里 +// NewRequest 那一处(畸形地址仍会回 *url.Error,同样是整条地址)。 +func TestDashscopeDownloadDropsPresignedURLFromBuildRequestError(t *testing.T) { + t.Parallel() + tts := NewDashscopeTTS(config.VoiceConfig{DashscopeAPIKey: "k"}) + tts.client = urlCauseFailClient() + + _, err := tts.download(context.Background(), "https://"+urlCauseUser+":svc-pass@"+urlCauseBadHost+"/out.wav?Signature=SIG-SENTINEL-9f2c") + assertNoURLKept(t, err, []string{"SIG-SENTINEL-9f2c"}, "invalid character") +} + +// TestDashscopeDownloadKeepsStatus 钉住「只少地址、不少状态」:被拒时状态码仍在错误串里。 +func TestDashscopeDownloadKeepsStatus(t *testing.T) { + t.Parallel() + tts := NewDashscopeTTS(config.VoiceConfig{DashscopeAPIKey: "k"}) + tts.client = urlCauseStatusTransport(http.StatusForbidden) + + _, err := tts.download(context.Background(), urlCausePresigned) + if err == nil { + t.Fatal("want error, got nil") + } + if !strings.Contains(err.Error(), "403") { + t.Fatalf("错误串丢了状态码(排障会变盲):%v", err) + } + for _, s := range urlCausePresignSecrets { + if strings.Contains(err.Error(), s) { + t.Fatalf("错误串带出了凭据 %q:%v", s, err) + } + } +} + +// TestDashscopePostDropsURLFromTransportError 钉住 dashscopePost 里 `client.Do` 那一处。 +func TestDashscopePostDropsURLFromTransportError(t *testing.T) { + t.Parallel() + _, err := dashscopePost(context.Background(), urlCauseFailClient(), "k", voiceURLCauseGoodBase(), []byte(`{}`), "") + assertNoURLKept(t, err, []string{urlCauseQuerySecret}, "connection refused") +} + +// TestDashscopePostDropsURLFromBuildRequestError 钉住 dashscopePost 里 NewRequest 那一处。 +func TestDashscopePostDropsURLFromBuildRequestError(t *testing.T) { + t.Parallel() + _, err := dashscopePost(context.Background(), urlCauseFailClient(), "k", voiceURLCauseBadBase(), []byte(`{}`), "") + assertNoURLKept(t, err, []string{urlCauseQuerySecret}, "invalid character") +} diff --git a/internal/runtime/voice/tts/client.go b/internal/runtime/voice/tts/client.go index d0afafb..1089758 100644 --- a/internal/runtime/voice/tts/client.go +++ b/internal/runtime/voice/tts/client.go @@ -31,6 +31,8 @@ import ( "net/url" "strings" "sync/atomic" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) // 本包的哨兵错误。每个都以 %w 包装,调用方既能按类别判断,也拿得到原始原因。 @@ -171,7 +173,7 @@ func (c *Client) speak(ctx context.Context, character, text string) (Audio, erro endpoint := c.cfg.BaseURL + speechPath req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { - return Audio{}, fmt.Errorf("voice/tts: build request: %w", err) + return Audio{}, fmt.Errorf("voice/tts: build request: %w", trace.URLCause(err)) } // Content-Type 必须是 application/json:正文本身已是 UTF-8(见文件头)。 req.Header.Set("Content-Type", "application/json") @@ -212,6 +214,15 @@ func encodeSpeech(text, character string) ([]byte, error) { // unreachable 把"没拿到响应"收敛成 ErrUnreachable(超时/取消/连接失败都归这里), // 并记一条带来源的 WARN。正文**不进日志**。 +// +// 原因在**进日志与进错误之前**先过 trace.URLCause:`client.Do` 回来的 `*url.Error` +// 会把整条 endpoint(含 userinfo / query 里的签名)渲染进错误串,而本函数把同一个 +// err 既写日志又包进返回值 —— 只脱敏 endpoint 字段是不够的,那条 err 会把地址 +// 原样带出去。因此 endpoint 只以 redactURL 的形态出现,err 只以「已剥壳的原因」出现 +// (地址不外泄一遍,也不会两处各出现一遍)。 +// +// 分类仍按**原来的 err** 判断(不按剥壳后的 cause):本函数唯一的改变是「少一段地址」, +// ErrUnreachable 与 context.* 哨兵链、WARN 的 reason 取值都不受影响。 func (c *Client) unreachable(ctx context.Context, endpoint string, err error) error { reason := "connection failed" switch { @@ -222,8 +233,9 @@ func (c *Client) unreachable(ctx context.Context, endpoint string, err error) er case errors.Is(err, context.Canceled): reason = "context canceled" } - c.warn("voice tts unreachable", "reason", reason, "endpoint", redactURL(endpoint), "err", err) - return fmt.Errorf("%w: %s: %w", ErrUnreachable, reason, err) + cause := trace.URLCause(err) + c.warn("voice tts unreachable", "reason", reason, "endpoint", redactURL(endpoint), "err", cause) + return fmt.Errorf("%w: %s: %w", ErrUnreachable, reason, cause) } // upstreamStatus 把非 200 收敛成 ErrUpstream,并只记**元数据**(状态码、Content-Type diff --git a/internal/runtime/voice/tts/url_cause_test.go b/internal/runtime/voice/tts/url_cause_test.go new file mode 100644 index 0000000..03b8bc2 --- /dev/null +++ b/internal/runtime/voice/tts/url_cause_test.go @@ -0,0 +1,107 @@ +package tts + +// 本文件是 M52 的出口回归探针:emotionTTS 客户端里两条会把整条 endpoint 带出去的路 +// ——`speak` 的建请求失败、`unreachable` 的传输失败 —— 都先过 trace.URLCause。 +// +// 这一处比别处更绕:日志行早就用 `redactURL(endpoint)` 脱敏了,但**同一个 err 还被 +// 同一行当作 `"err", err` 打出去**,而返回值又把它 `%w` 包着带回去 ⇒ 日志侧那道脱敏 +// 等于被绕过(地址从 err 字段原样出现)。所以这里的断言是双面的: +// - 错误串里没有地址; +// - 日志里那条地址**只出现一次**(脱敏那次),不出现第二遍原串。 +// +// 断言形状是「把任一处改回 err ⇒ 对应用例变红」:两处各一个用例。 + +import ( + "bytes" + "context" + "errors" + "log/slog" + "net/http" + "strings" + "testing" +) + +const ( + urlCauseQuerySecret = "SECRET-2f8a1c" + urlCauseUser = "svc-user" + urlCauseBadHost = "bad host.example.com" +) + +func ttsURLCauseGoodBase() string { + return "https://" + urlCauseUser + ":svc-pass@tts.example.com?access_token=" + urlCauseQuerySecret +} + +func ttsURLCauseBadBase() string { + return "https://" + urlCauseUser + ":svc-pass@" + urlCauseBadHost + "?access_token=" + urlCauseQuerySecret +} + +// urlCauseFailTransport 让任何请求都在传输层失败(不经网络)。 +type urlCauseFailTransport struct{} + +func (urlCauseFailTransport) RoundTrip(*http.Request) (*http.Response, error) { + return nil, errors.New("dial tcp 10.0.0.1:9880: connect: connection refused") +} + +// TestUnreachableDropsURLFromErrorAndLog 钉住 unreachable:错误串与日志都只留原因, +// 地址(含 query 凭据与用户名)一个字节都不出现,且日志里那条地址只出现一次。 +func TestUnreachableDropsURLFromErrorAndLog(t *testing.T) { + t.Parallel() + var logBuf bytes.Buffer + c := New( + Config{Enabled: true, BaseURL: ttsURLCauseGoodBase()}, + WithHTTPClient(&http.Client{Transport: urlCauseFailTransport{}}), + WithLogger(slog.New(slog.NewJSONHandler(&logBuf, nil))), + ) + + _, err := c.Speech(context.Background(), "char_1", "hi") + if err == nil { + t.Fatal("want error, got nil") + } + if !errors.Is(err, ErrUnreachable) { + t.Fatalf("err = %v, want ErrUnreachable(分类不许被带偏)", err) + } + if !strings.Contains(err.Error(), "connection refused") { + t.Fatalf("错误串丢了底层原因(只许少地址):%v", err) + } + for _, surface := range []string{err.Error(), logBuf.String()} { + if strings.Contains(surface, urlCauseQuerySecret) { + t.Fatalf("query 里的凭据进了错误或日志:%s", surface) + } + if strings.Contains(surface, urlCauseUser) { + t.Fatalf("userinfo 里的用户名进了错误或日志:%s", surface) + } + } + // 「日志里不会出现两遍地址」:脱敏后的 host 只该由 endpoint 字段出现一次。 + if got := strings.Count(logBuf.String(), "tts.example.com"); got != 1 { + t.Fatalf("日志里 host 出现 %d 次,want 1(endpoint 字段那次):%s", got, logBuf.String()) + } + if strings.Contains(err.Error(), "tts.example.com") { + t.Fatalf("错误串带出了 endpoint 的 host:%v", err) + } +} + +// TestSpeakDropsURLFromBuildRequestError 钉住 speak 里 NewRequest 失败那一处: +// 畸形 base URL 的解析错误同样是 *url.Error,会把整条地址带出来。 +func TestSpeakDropsURLFromBuildRequestError(t *testing.T) { + t.Parallel() + var logBuf bytes.Buffer + c := New( + Config{Enabled: true, BaseURL: ttsURLCauseBadBase()}, + WithHTTPClient(&http.Client{Transport: urlCauseFailTransport{}}), + WithLogger(slog.New(slog.NewJSONHandler(&logBuf, nil))), + ) + + _, err := c.Speech(context.Background(), "char_1", "hi") + if err == nil { + t.Fatal("want error, got nil") + } + if strings.Contains(err.Error(), urlCauseQuerySecret) || strings.Contains(err.Error(), urlCauseUser) { + t.Fatalf("建请求失败的串带出了 base URL 里的凭据:%v", err) + } + if !strings.Contains(err.Error(), "invalid character") { + t.Fatalf("错误串丢了底层解析原因(只许少地址):%v", err) + } + if strings.Contains(logBuf.String(), urlCauseQuerySecret) { + t.Fatalf("建请求失败也把地址写进了日志:%s", logBuf.String()) + } +}