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:
AskaEth committed 2026-10-26 19:55:05 +08:00
1 parent 2702777950
commit 969857682f
13 files changed
+657 -20

No files matched your search

+15 -3
View File
@@ -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