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:
AskaEth committed 2026-10-26 19:55:05 +08:00
1 parent 2702777950
commit 969857682f
13 files changed
+657 -20

No files matched your search

+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)
}