Merge branch 'feat/m52-urlcausesweep-sweep-url-error-direct' into dev
This commit is contained in:
commit
921938d084
13 files changed
+657
-20
No files matched your search
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user