Files
Cyrene_v2/internal/runtime/trace/urlcause_test.go
T
AskaEth 4c187ac070 refactor(trace): 把剥 url.Error 壳的实现收敛成 trace.URLCause
五处逐字同形的三行(mediafetch.sanitizeErr / httpclient.transportCause /
vision.fetchCause / telegram.callCause / weixin_ilink.urlFreeCause)收敛为
trace 包里的唯一实现,13 个调用点改为直接调用;5 份 body 只剩 1 份,5 个
未导出符号与其文件内注释整份删除(不留薄封装)。

行为等价:只剥 *url.Error、不新增包装,内层哨兵链(context 取消/超时、
*net.DNSError 等)原样透传,因此重试/降级/熔断的分类判据不变。两处刻意的
收紧:
- 逐层剥:内层若还是 *url.Error 也一起剥(只剥一层时内层仍带同一段地址);
- *url.Error 的 Err 为 nil(或链自引用)时不再回落到原串,退化成只含 Op 的
  替身 —— 那种错误无法在不带地址的前提下原样返回,而地址里就是凭据。

顺带把 httpclient 的 user:pass@ 用例改成变异可打红:net/http 自己在
*url.Error 里就把密码打成 ***,「密码不出现」在剥壳与不剥壳下都成立(空跑),
用户名才是分水岭。

验收:go build ./... + go vet ./... + 指定包 go test -count=1 全绿;
变异验证(把 URLCause 改成透传)⇒ 五处「不含 URL」断言全红。
2026-10-26 18:45:34 +08:00

163 lines
6.5 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 trace
// 本文件钉住 URLCause 的唯一职责:把 *url.Error 剥到内层 cause,且**不携带任何 URL**。
//
// 为什么值得单独一条:net/http 在传输层失败与地址解析失败时都会包出 *url.Error,
// 而它的 Error() 里就是整条地址 —— 平台的资源地址几乎都带签名凭据(预签名 URL、
// CDN rkey、Bot API 的 /bot<token>/、OAuth 的 access_token、user:pass@)。旧实现是
// 五个客户端各写一份同形的三行剥离,任一份漂移就等于多一条凭据进日志的路。
//
// 每条用例都是「把它改回去就变红」的形状:断言的是剥壳**之后**的错误文本,也就是
// 调用方实际打进日志的那段字符串。
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"testing"
"time"
)
// urlCauseTests 覆盖带凭据的三种地址形态:查询串里的 access_token、user:pass@
// 用户信息、以及路径里的 token(Bot API 把 bot token 写在路径里)。
var urlCauseTests = []struct {
name string
rawURL string
secret string
}{
{"query-access-token", "https://cdn.example.com/a/b?access_token=QUERY-ACCESS-TOKEN&x=1", "QUERY-ACCESS-TOKEN"},
{"userinfo", "https://user:USER-PASS-SECRET@api.example.com/x", "USER-PASS-SECRET"},
{"path-token", "https://api.example.com/bot12345:BOT-TOKEN-SECRET/getUpdates", "BOT-TOKEN-SECRET"},
}
// TestURLCauseStripsURLKeepsCause 剥壳后错误串里只剩原因:地址、路径、查询串、用户
// 信息一个片段都不出现,而底层原因仍然在。改回原样 `%w` 外抛(或只剥一层)就变红。
func TestURLCauseStripsURLKeepsCause(t *testing.T) {
t.Parallel()
inner := errors.New("dial tcp 10.0.0.1:443: connection refused")
for _, tc := range urlCauseTests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := URLCause(&url.Error{Op: "Get", URL: tc.rawURL, Err: inner})
msg := got.Error()
if strings.Contains(msg, tc.secret) {
t.Fatalf("地址里的凭据进了错误:%v", msg)
}
for _, fragment := range []string{"https://", "cdn.example.com", "api.example.com", "/bot12345", "?x=1"} {
if strings.Contains(msg, fragment) {
t.Fatalf("地址片段 %q 进了错误:%v", fragment, msg)
}
}
if !strings.Contains(msg, "connection refused") {
t.Fatalf("剥掉地址时把原因一起丢了:%v", msg)
}
})
}
}
// TestURLCauseKeepsSentinelChain 剥壳不得破坏哨兵链:调用方靠 errors.Is 分类
// (重试 / 降级 / 熔断),而地址只是副作用面。改回「重新构造一条新错误」就变红。
func TestURLCauseKeepsSentinelChain(t *testing.T) {
t.Parallel()
err := &url.Error{Op: "Get", URL: "https://h/x?access_token=S", Err: context.DeadlineExceeded}
got := URLCause(err)
if !errors.Is(got, context.DeadlineExceeded) {
t.Fatalf("哨兵链断了:%v", got)
}
if strings.Contains(got.Error(), "access_token") {
t.Fatalf("剥壳后仍带地址:%v", got)
}
}
// TestURLCauseStripsEveryLayer 内层若还是 *url.Error(调用方又包了一次 Do、或链上
// 叠了两层),必须一起剥 —— 只剥一层的话内层那条仍然带着同一段地址。
func TestURLCauseStripsEveryLayer(t *testing.T) {
t.Parallel()
inner := errors.New("connection reset by peer")
nested := &url.Error{Op: "Get", URL: "https://inner.example.com/p?access_token=INNER-SECRET", Err: inner}
outer := &url.Error{Op: "Get", URL: "https://outer.example.com/p?access_token=OUTER-SECRET", Err: nested}
msg := URLCause(outer).Error()
for _, secret := range []string{"INNER-SECRET", "OUTER-SECRET"} {
if strings.Contains(msg, secret) {
t.Fatalf("某一层的凭据进了错误(%s):%v", secret, msg)
}
}
if !errors.Is(URLCause(outer), inner) {
t.Fatalf("多层剥壳后没回到最内层原因:%v", msg)
}
}
// TestURLCauseStripsUnderOuterWrapper 调用方用 %w 包过一层时同样剥掉:外层前缀是
// 我们自己的文案,带地址的仍然是里层那个 *url.Error。
func TestURLCauseStripsUnderOuterWrapper(t *testing.T) {
t.Parallel()
inner := errors.New("i/o timeout")
wrapped := fmt.Errorf("client: %w", &url.Error{Op: "Post", URL: "https://h/p?access_token=WRAPPED-SECRET", Err: inner})
msg := URLCause(wrapped).Error()
if strings.Contains(msg, "WRAPPED-SECRET") || strings.Contains(msg, "https://") {
t.Fatalf("包一层的 *url.Error 仍把地址带了出来:%v", msg)
}
if !strings.Contains(msg, "i/o timeout") {
t.Fatalf("剥掉地址时把原因一起丢了:%v", msg)
}
}
// TestURLCausePassesThroughNonURL 非 *url.Error 原样返回(同一个值、不加包装):
// 五个调用点把它当「可能剥壳、可能什么都没做」的透传,多包一层会把同一个原因在
// 文案里说两遍。
func TestURLCausePassesThroughNonURL(t *testing.T) {
t.Parallel()
sentinel := errors.New("boom")
if got := URLCause(sentinel); got != sentinel {
t.Fatalf("非 *url.Error 被改写了:got %v", got)
}
wrapped := fmt.Errorf("ctx: %w", sentinel)
if got := URLCause(wrapped); got != wrapped {
t.Fatalf("非 *url.Error 的包装被拆开了:got %v", got)
}
if got := URLCause(nil); got != nil {
t.Fatalf("nil 被改写成了 %v", got)
}
}
// TestURLCauseWithoutCauseStaysURLLess *url.Error 没有内层原因时(Err 为 nil)也无法
// 原样返回 —— 它照样会把地址渲染进 Error()。此时只留操作名:宁可少一条原因,也不
// 回落到原串。改回「Err 为 nil 就 return err」就变红。
func TestURLCauseWithoutCauseStaysURLLess(t *testing.T) {
t.Parallel()
got := URLCause(&url.Error{Op: "Get", URL: "https://h/p?access_token=NIL-CAUSE-SECRET"})
if got == nil {
t.Fatal("返回了 nil")
}
msg := got.Error()
if strings.Contains(msg, "NIL-CAUSE-SECRET") || strings.Contains(msg, "https://") {
t.Fatalf("没有内层原因时回落到了原串(泄漏地址):%v", msg)
}
if !strings.Contains(msg, "Get") {
t.Fatalf("连操作名都没留下(诊断面全丢):%v", msg)
}
}
// TestURLCauseSelfReferentialChainTerminates 自引用的错误链不能让剥壳死循环。
func TestURLCauseSelfReferentialChainTerminates(t *testing.T) {
t.Parallel()
self := &url.Error{Op: "Post", URL: "https://h/p?access_token=SELF-SECRET"}
self.Err = self
done := make(chan error, 1)
go func() { done <- URLCause(self) }()
select {
case got := <-done:
if got == nil {
t.Fatal("返回了 nil")
}
if strings.Contains(got.Error(), "SELF-SECRET") {
t.Fatalf("自引用链把地址带了出来:%v", got)
}
case <-time.After(5 * time.Second):
t.Fatal("URLCause 在自引用链上没有终止")
}
}