Files
Cyrene_v2/internal/runtime/multimodal/voice/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

174 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 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")
}