Files
Cyrene_v2/internal/runtime/model/url_cause_test.go
T
AskaEth 969857682f 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 状态码仍在。
2026-10-26 19:55:05 +08:00

170 lines
7.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}