Merge branch 'feat/m52-urlcausesweep-sweep-url-error-direct' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

This commit is contained in:
AskaEth committed 2026-10-26 20:03:29 +08:00
commit 921938d084
13 files changed
+657 -20

No files matched your search

+2 -2
View File
@@ -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 != "" {
+4 -1
View File
@@ -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:原样返回,让后续的报错如实暴露配置问题,
@@ -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)
}
}
+4 -3
View File
@@ -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
}
+4 -3
View File
@@ -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
}
+169
View File
@@ -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)
}
@@ -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 {
@@ -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)
}
}
+3 -2
View File
@@ -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()
@@ -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 {
@@ -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")
}
+15 -3
View File
@@ -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
@@ -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())
}
}