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
@@ -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")
|
||||
}
|
||||
Reference in new issue
Block a user