Files
Cyrene_v2/internal/runtime/trace/urlcause_test.go
T
AskaEth 92b0f89492 fix(trace): URLCause 用层数上限终止,去掉不可比较的 error 比较
手写终止条件只认「长度 1 的自引用」,于是:
- 两节点环(a.Err=b / b.Err=a)⇒ 死循环,打挂当前请求的 goroutine;
- uerr.Err == err 在两侧动态类型相同且不可比较时 ⇒ panic
  (comparing uncomparable type),同样打挂当前请求。

改为唯一终止条件 maxURLCauseDepth=8:超限返回只含 Op 的替身,不返回当前层
(当前层是 *url.Error,Error() 里就是整条地址,不能为防死循环把地址漏回去)。
正常单层/双层路径逐字不变。用例:两节点环 1s 内返回、不可比较类型不 panic、
自引用仍退化成替身、超限路径无 URL 片段、上限内(8 层)仍剥到内层原因。
2026-10-26 19:01:15 +08:00

296 lines
12 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@)。旧实现是
// 五个客户端各写一份同形的三行剥离,任一份漂移就等于多一条凭据进日志的路。
//
// 每条用例都是「把它改回去就变红」的形状:断言的是剥壳**之后**的错误文本,也就是
// 调用方实际打进日志的那段字符串。
//
// 另一半是**终止性**:剥壳跑在错误路径上,所以它自己不许成为故障源 —— 环(自引用、
// 两节点环)不许死循环,不可比较的动态类型不许 panic,超限时也不许把地址带出来。
// 那几条用例用「1s 内必须返回 + recover 到的 panic 一律判红」钉住(见 urlCauseWithin)。
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)
}
}
// urlCauseWithin 在后台调 URLCause,**1s 内没回来**就当死循环判红;期间发生的
// panic 被 recover 成第二个返回值。两条都是「错误路径炸掉当前请求 goroutine」的
// 形状,因此都算红 —— 而剥壳本身在真实链上只要几纳秒,1s 是一万倍的余量。
func urlCauseWithin(t *testing.T, err error) (cause error, panicked any) {
t.Helper()
type outcome struct {
cause error
reason any
}
done := make(chan outcome, 1)
go func() {
var out outcome
defer func() {
out.reason = recover()
done <- out
}()
out.cause = URLCause(err)
}()
select {
case out := <-done:
return out.cause, out.reason
case <-time.After(time.Second):
t.Fatalf("URLCause 1s 内没有返回:终止条件失效(错误路径上的死循环)")
return nil, nil
}
}
// urlCauseRedlines 是「剥壳结果里一个地址片段都不许出现」的统一断言。
var urlCauseRedlines = []string{"https://", ".example.com", "access_token", "SECRET"}
// assertURLLess 断言剥壳结果非 nil 且不含任何地址片段,并回传错误文本。
func assertURLLess(t *testing.T, got error) string {
t.Helper()
if got == nil {
t.Fatal("返回了 nil")
}
msg := got.Error()
for _, fragment := range urlCauseRedlines {
if strings.Contains(msg, fragment) {
t.Fatalf("地址片段 %q 进了错误:%v", fragment, msg)
}
}
return msg
}
// TestURLCauseSelfReferentialChainTerminates 自引用链(a.Err=a)必须终止,且退化成
// 只含操作名的替身:回到「返回当前层」或「原串」都会把地址带出来。
func TestURLCauseSelfReferentialChainTerminates(t *testing.T) {
t.Parallel()
self := &url.Error{Op: "Post", URL: "https://h/p?access_token=SELF-SECRET"}
self.Err = self
got, panicked := urlCauseWithin(t, self)
if panicked != nil {
t.Fatalf("自引用链上 panic 了:%v", panicked)
}
if msg := assertURLLess(t, got); msg != "url Post failed" {
t.Fatalf("自引用链没有退化成只含 Op 的替身:%v", msg)
}
}
// TestURLCauseTwoNodeCycleTerminates 两节点环(a.Err=b / b.Err=a)必须终止:只认
// 「长度 1 的自引用」的终止条件在这里会一直转下去(旧实现的形状),把当前请求的
// goroutine 卡死。
func TestURLCauseTwoNodeCycleTerminates(t *testing.T) {
t.Parallel()
a := &url.Error{Op: "Get", URL: "https://a.example.com/p?access_token=CYCLE-A-SECRET"}
b := &url.Error{Op: "Get", URL: "https://b.example.com/p?access_token=CYCLE-B-SECRET"}
a.Err = b
b.Err = a
got, panicked := urlCauseWithin(t, a)
if panicked != nil {
t.Fatalf("两节点环上 panic 了:%v", panicked)
}
msg := assertURLLess(t, got)
if strings.Contains(msg, "CYCLE-A-SECRET") || strings.Contains(msg, "CYCLE-B-SECRET") {
t.Fatalf("环上终止把地址带了出来:%v", msg)
}
}
// uncomparableErr 的**动态类型不可比较**(struct 值里带 slice):接口值 `==` 拿它
// 比较会 panic(comparing uncomparable type),而 Unwrap 指回外层 *url.Error 让整条
// 链成环。两者合起来正是「今天的终止条件换成不可比较的 error 就 panic」的形状。
type uncomparableErr struct {
parts []string
wrap error
}
func (e uncomparableErr) Error() string { return strings.Join(e.parts, " ") }
func (e uncomparableErr) Unwrap() error { return e.wrap }
// TestURLCauseUncomparableDynamicTypeDoesNotPanic 不可比较的动态类型不得 panic:
// 错误路径上的 panic 会打挂当前请求的 goroutine。改成 `uerr.Err == err` 就变红。
func TestURLCauseUncomparableDynamicTypeDoesNotPanic(t *testing.T) {
t.Parallel()
outer := &url.Error{Op: "Post", URL: "https://api.example.com/p?access_token=UNCOMPARABLE-SECRET"}
outer.Err = uncomparableErr{parts: []string{"dial", "tcp", "refused"}, wrap: outer}
got, panicked := urlCauseWithin(t, outer)
if panicked != nil {
t.Fatalf("不可比较的动态类型上 panic 了(错误路径上的 panic 会打挂当前请求):%v", panicked)
}
assertURLLess(t, got)
}
// TestURLCauseDepthCapKeepsURLLess 链深超过上限时**仍然不许**出现地址:为防死循环
// 而退回「当前层」(*url.Error,Error() 里就是整条地址)等于把本文件要修的病害从
// 后门放回来。超限的口径是「只含 Op 的替身」,连带把更深层的原因一起丢掉。
func TestURLCauseDepthCapKeepsURLLess(t *testing.T) {
t.Parallel()
var chain error = errors.New("connection reset by peer")
for i := maxURLCauseDepth + 4; i > 0; i-- {
chain = &url.Error{
Op: "Get",
URL: fmt.Sprintf("https://h%d.example.com/p?access_token=DEEP-%d-SECRET", i, i),
Err: chain,
}
}
got, panicked := urlCauseWithin(t, chain)
if panicked != nil {
t.Fatalf("超深链上 panic 了:%v", panicked)
}
if msg := assertURLLess(t, got); msg != "url Get failed" {
t.Fatalf("超限后的口径不是「只含 Op 的替身」:%v", msg)
}
}
// TestURLCauseStripsExactlyUpToCap 上限之内的深链仍然**逐层剥到**内层原因:护栏只
// 收紧「超过上限」那一侧,不得顺带削掉正常路径的诊断面。
func TestURLCauseStripsExactlyUpToCap(t *testing.T) {
t.Parallel()
inner := errors.New("i/o timeout")
var chain error = inner
for i := 0; i < maxURLCauseDepth; i++ {
chain = &url.Error{Op: "Get", URL: fmt.Sprintf("https://h%d.example.com/p?access_token=CAP-%d-SECRET", i, i), Err: chain}
}
got, panicked := urlCauseWithin(t, chain)
if panicked != nil {
t.Fatalf("上限内的深链上 panic 了:%v", panicked)
}
assertURLLess(t, got)
if !errors.Is(got, inner) {
t.Fatalf("%d 层链(正好等于上限)没有剥到内层原因:%v", maxURLCauseDepth, got)
}
}