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
@@ -31,6 +31,8 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// 本包的哨兵错误。每个都以 %w 包装,调用方既能按类别判断,也拿得到原始原因。
|
||||
@@ -171,7 +173,7 @@ func (c *Client) speak(ctx context.Context, character, text string) (Audio, erro
|
||||
endpoint := c.cfg.BaseURL + speechPath
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return Audio{}, fmt.Errorf("voice/tts: build request: %w", err)
|
||||
return Audio{}, fmt.Errorf("voice/tts: build request: %w", trace.URLCause(err))
|
||||
}
|
||||
// Content-Type 必须是 application/json:正文本身已是 UTF-8(见文件头)。
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -212,6 +214,15 @@ func encodeSpeech(text, character string) ([]byte, error) {
|
||||
|
||||
// unreachable 把"没拿到响应"收敛成 ErrUnreachable(超时/取消/连接失败都归这里),
|
||||
// 并记一条带来源的 WARN。正文**不进日志**。
|
||||
//
|
||||
// 原因在**进日志与进错误之前**先过 trace.URLCause:`client.Do` 回来的 `*url.Error`
|
||||
// 会把整条 endpoint(含 userinfo / query 里的签名)渲染进错误串,而本函数把同一个
|
||||
// err 既写日志又包进返回值 —— 只脱敏 endpoint 字段是不够的,那条 err 会把地址
|
||||
// 原样带出去。因此 endpoint 只以 redactURL 的形态出现,err 只以「已剥壳的原因」出现
|
||||
// (地址不外泄一遍,也不会两处各出现一遍)。
|
||||
//
|
||||
// 分类仍按**原来的 err** 判断(不按剥壳后的 cause):本函数唯一的改变是「少一段地址」,
|
||||
// ErrUnreachable 与 context.* 哨兵链、WARN 的 reason 取值都不受影响。
|
||||
func (c *Client) unreachable(ctx context.Context, endpoint string, err error) error {
|
||||
reason := "connection failed"
|
||||
switch {
|
||||
@@ -222,8 +233,9 @@ func (c *Client) unreachable(ctx context.Context, endpoint string, err error) er
|
||||
case errors.Is(err, context.Canceled):
|
||||
reason = "context canceled"
|
||||
}
|
||||
c.warn("voice tts unreachable", "reason", reason, "endpoint", redactURL(endpoint), "err", err)
|
||||
return fmt.Errorf("%w: %s: %w", ErrUnreachable, reason, err)
|
||||
cause := trace.URLCause(err)
|
||||
c.warn("voice tts unreachable", "reason", reason, "endpoint", redactURL(endpoint), "err", cause)
|
||||
return fmt.Errorf("%w: %s: %w", ErrUnreachable, reason, cause)
|
||||
}
|
||||
|
||||
// upstreamStatus 把非 200 收敛成 ErrUpstream,并只记**元数据**(状态码、Content-Type
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user