Files
Cyrene_v2/internal/runtime/trace/urlcause.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

76 lines
4.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
import (
"errors"
"net/url"
)
// 本文件是「从 net/http 的 *url.Error 里取回原因」的**唯一**实现。
//
// 为什么需要它:*url.Error 的 Error() 会把**整条地址**渲染进错误串 ——
// `Get "https://h/p?access_token=…": dial tcp …`。而 net/http 在两条路上都会包出它:
// 传输层失败(`client.Do`)与地址解析失败(`http.NewRequest`)。这条错误串会进日志、
// 进工单、进模型可见视图,而平台的资源地址几乎都带凭据(预签名上传地址的
// `encrypted_query_param`、CDN 的 `rkey`、Bot API 路径里的 `/bot<token>/`、OAuth 回调
// 里的 `access_token`、user:pass@),于是「原样外抛」等于把凭据写进日志。
//
// 为什么不能 `fmt.Errorf("...: %w", err)` 直接外抛:%w 只给错误串加前缀,地址仍在串里
// —— 包一层不是修复,只是换个位置泄漏。剥壳(取内层 cause)才是。
//
// 这段逻辑曾被五个客户端各写一份逐字同形的三行(mediafetch.sanitizeErr /
// httpclient.transportCause / vision.fetchCause / telegram.callCause /
// weixin_ilink.urlFreeCause,共 13 个调用点)。同形实现一分家,最容易发生的漂移是
// 「其中一份为了多带点诊断把地址留下来」,而那正是凭据进日志的那一条路 —— 因此收敛
// 到这里,调用点只剩一次函数调用(AGENTS.md §10「三次复制必须抽取」)。
// URLCause 剥掉 err 里**所有层**的 *url.Error 外壳,返回不携带任何 URL 的内层原因。
//
// 三条口径:
// - **逐层剥**:内层若还是 *url.Error(调用方又包了一次 Do、或链上叠了两层),继续
// 往下剥 —— 只剥一层的话内层那条仍然带着同一段地址;
// - **只丢地址、不丢原因**:返回的是原来的底层 error 本身(不新增包装),因此
// errors.Is/As 对 context.Canceled、context.DeadlineExceeded、*net.DNSError 等
// 哨兵仍然成立 —— 调用方的分类(重试 / 降级 / 熔断)不受影响,也绝不会把同一个
// 原因在文案里说两遍;
// - **非 *url.Error 原样返回**:同一个值、不加包装,可安全当作「可能剥壳、也可能
// 什么都没做」的透传。
//
// 唯一的例外是 `*url.Error` 没有内层原因(Err 为 nil)或**层数超限**:那种错误
// **无法**在不带地址的前提下原样返回,于是退化成只含操作名(Get/Post/parse)的替身
// —— 宁可少一条原因,也不回落到原串(回落就是泄漏)。net/http 的两处构造都填了
// Err,且叠不过两层,因此这条路在真实调用中到不了。
//
// 终止条件**只靠层数上限**(maxURLCauseDepth),不靠「拿两个 error 比相等」:
// - 比相等挡不住**多节点环**(a.Err=b / b.Err=a),那种链会一直转到栈外的调用方
// 超时为止 —— 一个错误路径上的死循环等于打挂当前请求的 goroutine;
// - 接口值 `==` 在两侧动态类型相同且**不可比较**(含 slice/map 的错误实现)时
// **panic**(`comparing uncomparable type`),而 panic 同样打挂当前请求的
// goroutine。今天 13 个调用点传的都是 net/http 现场构造的可比较无环错误,因此
// 这条路不可达;但「今天不可达」会随时间衰减,而护栏的成本只是一次计数。
//
// 超限时的口径:**返回只含 Op 的替身,不返回当前层**。当前层仍是 `*url.Error`,
// 它的 Error() 里就是整条地址 —— 为防死循环而把地址带出去,正是本文件要修的病害
// 反过来复发。诊断面只丢「更内层的原因」,status/host 由调用方的文案补齐。
func URLCause(err error) error {
for depth := 0; ; depth++ {
var uerr *url.Error
if !errors.As(err, &uerr) {
return err
}
if uerr.Err == nil || depth >= maxURLCauseDepth {
return urlOpOnly(uerr.Op)
}
err = uerr.Err
}
}
// maxURLCauseDepth 是剥壳的层数上限,**唯一的终止条件**(见 URLCause)。取 8 的
// 理由:真实链最多两层(`client.Do` 包一次、调用方再 `%w` 一层),8 是它的四倍余量,
// 正常路径永远碰不到;同时小到环/超深链在微秒级就停住,不会拖住请求。
const maxURLCauseDepth = 8
// urlOpOnly 是「*url.Error 没有可用内层原因」时的替身:只留操作名,地址一个字节都不留。
func urlOpOnly(op string) error {
return errors.New("url " + op + " failed")
}