fix(runtime): M52 把 *url.Error 直打的 19 处换成 trace.URLCause
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 状态码仍在。
This commit is contained in:
1 parent
2702777950
commit
969857682f
13 files changed
+657
-20
No files matched your search
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user