model / multimodal / tts / embedding 里所有「把 net/http 的 *url.Error 直接打进 错误串」的地方(M51 清单的 B1 组 17 处 + 清单外的同根因 2 处)统一改走 trace.URLCause:错误串只少一段地址,底层原因、哨兵分类、状态码、重试/降级判据 一字不动。 清单内(17): - model/openai.go: Chat / ChatStream 的 NewRequest、doRequest - model/anthropic.go: chatOnce / streamOnce 的 NewRequest、doRequest - multimodal/vision/adapter.go: ask 的 NewRequest、client.Do - multimodal/voice/cloud.go: postJSON 的 NewRequest、client.Do - multimodal/voice/dashscope.go: download(预签名 OSS 地址)与 dashscopePost 各两处 - voice/tts/client.go: unreachable(返回值与同一行日志的 err 字段都只留原因, 地址只由 redactURL(endpoint) 出现一次) - embedding/api.go: embedChunk 的 client.Do、newEmbeddingRequest 的 NewRequest 清单外同根因(2,均为 %w 形态,B1 清单按 %v 搜时漏掉): - voice/tts/client.go: speak 的 NewRequest 失败 - embedding/endpoint.go: normalizeEndpointURL 的 url.Parse 失败 测试:5 个新文件 18 个用例,逐处可变异打红(实测:把任一处的 URLCause 去掉 ⇒ 对应用例红);dashscope 预签名地址单独一条用例,并另有一条钉住 403 状态码仍在。
170 lines
7.3 KiB
Go
170 lines
7.3 KiB
Go
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)
|
||
}
|