Files
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

108 lines
4.1 KiB
Go
Raw Permalink 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 tts
// 本文件是 M52 的出口回归探针:emotionTTS 客户端里两条会把整条 endpoint 带出去的路
// ——`speak` 的建请求失败、`unreachable` 的传输失败 —— 都先过 trace.URLCause。
//
// 这一处比别处更绕:日志行早就用 `redactURL(endpoint)` 脱敏了,但**同一个 err 还被
// 同一行当作 `"err", err` 打出去**,而返回值又把它 `%w` 包着带回去 ⇒ 日志侧那道脱敏
// 等于被绕过(地址从 err 字段原样出现)。所以这里的断言是双面的:
// - 错误串里没有地址;
// - 日志里那条地址**只出现一次**(脱敏那次),不出现第二遍原串。
//
// 断言形状是「把任一处改回 err ⇒ 对应用例变红」:两处各一个用例。
import (
"bytes"
"context"
"errors"
"log/slog"
"net/http"
"strings"
"testing"
)
const (
urlCauseQuerySecret = "SECRET-2f8a1c"
urlCauseUser = "svc-user"
urlCauseBadHost = "bad host.example.com"
)
func ttsURLCauseGoodBase() string {
return "https://" + urlCauseUser + ":svc-pass@tts.example.com?access_token=" + urlCauseQuerySecret
}
func ttsURLCauseBadBase() string {
return "https://" + urlCauseUser + ":svc-pass@" + urlCauseBadHost + "?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:9880: connect: connection refused")
}
// TestUnreachableDropsURLFromErrorAndLog 钉住 unreachable:错误串与日志都只留原因,
// 地址(含 query 凭据与用户名)一个字节都不出现,且日志里那条地址只出现一次。
func TestUnreachableDropsURLFromErrorAndLog(t *testing.T) {
t.Parallel()
var logBuf bytes.Buffer
c := New(
Config{Enabled: true, BaseURL: ttsURLCauseGoodBase()},
WithHTTPClient(&http.Client{Transport: urlCauseFailTransport{}}),
WithLogger(slog.New(slog.NewJSONHandler(&logBuf, nil))),
)
_, err := c.Speech(context.Background(), "char_1", "hi")
if err == nil {
t.Fatal("want error, got nil")
}
if !errors.Is(err, ErrUnreachable) {
t.Fatalf("err = %v, want ErrUnreachable(分类不许被带偏)", err)
}
if !strings.Contains(err.Error(), "connection refused") {
t.Fatalf("错误串丢了底层原因(只许少地址):%v", err)
}
for _, surface := range []string{err.Error(), logBuf.String()} {
if strings.Contains(surface, urlCauseQuerySecret) {
t.Fatalf("query 里的凭据进了错误或日志:%s", surface)
}
if strings.Contains(surface, urlCauseUser) {
t.Fatalf("userinfo 里的用户名进了错误或日志:%s", surface)
}
}
// 「日志里不会出现两遍地址」:脱敏后的 host 只该由 endpoint 字段出现一次。
if got := strings.Count(logBuf.String(), "tts.example.com"); got != 1 {
t.Fatalf("日志里 host 出现 %d 次,want 1(endpoint 字段那次):%s", got, logBuf.String())
}
if strings.Contains(err.Error(), "tts.example.com") {
t.Fatalf("错误串带出了 endpoint 的 host:%v", err)
}
}
// TestSpeakDropsURLFromBuildRequestError 钉住 speak 里 NewRequest 失败那一处:
// 畸形 base URL 的解析错误同样是 *url.Error,会把整条地址带出来。
func TestSpeakDropsURLFromBuildRequestError(t *testing.T) {
t.Parallel()
var logBuf bytes.Buffer
c := New(
Config{Enabled: true, BaseURL: ttsURLCauseBadBase()},
WithHTTPClient(&http.Client{Transport: urlCauseFailTransport{}}),
WithLogger(slog.New(slog.NewJSONHandler(&logBuf, nil))),
)
_, err := c.Speech(context.Background(), "char_1", "hi")
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 strings.Contains(logBuf.String(), urlCauseQuerySecret) {
t.Fatalf("建请求失败也把地址写进了日志:%s", logBuf.String())
}
}