Go 1.27 JSON 循环引用报错后,读取 UnsupportedValueError.Value 又触发 panic
JSON 循环引用已经报错,读取 UnsupportedValueError.Value 却再次 panic。先用 errors.As 与 IsValid 保护诊断,再按响应契约切断循环,区分主线修正与正式维护版本。
json.Marshal 已经返回 encountered a cycle,错误处理代码随后调用 UnsupportedValueError.Value.Type(),反而再抛一次反射 panic。在 Go 1.27 的受影响兼容路径中,循环引用错误的 Value 可能是无效的零值;先检查它,才能保住原始的序列化错误。
下面只用两个赋值就形成了循环:
type Node struct {
Next *Node
}
n := &Node{}
n.Next = n
标准库 JSON 不支持这种对象图。即使修复了错误对象中的 Value,也仍应拒绝循环序列化;不能以“库有回归”为理由忽略 Marshal 的错误并发送空内容。
区分输入循环与诊断字段缺失
在受影响的 Go 1.27.1 上,对这类值调用 encoding/json.Marshal 后,UnsupportedValueError.Str 能保留循环信息,但 Value.IsValid() 返回 false。Go 1.26 对应路径会提供有效的反射值。
这是兼容错误信息的回归,与 JSON 数据本身不合法同时存在。Go 1.27 的旧 encoding/json API 默认由新的实现支持;代码没有显式 import encoding/json/v2,也可能经过这条兼容路径。
先记录实际构建工具链和 import,再找错误处理链中访问 .Value.Type()、.Value.Interface() 的位置。日志里若只剩 reflect: call of reflect.Value... on zero Value,原始的 cycle 错误可能已经被二次异常盖住。
不要只修 NaN 或无穷大的分支就认定此处恢复:它们的 Value 填充已有单独修正,循环检测需要另行处理。
错误处理先判类型,再判 Value 是否有效
下面是可独立理解的函数示例。调用方仍需决定如何把序列化失败返回给业务:
package example
import (
"encoding/json"
"errors"
"fmt"
)
func describeJSONError(err error) string {
var unsupported *json.UnsupportedValueError
if !errors.As(err, &unsupported) || unsupported == nil {
return fmt.Sprint(err)
}
if !unsupported.Value.IsValid() {
return unsupported.Error()
}
return fmt.Sprintf("%s (type=%s)",
unsupported.Error(), unsupported.Value.Type())
}
这里不直接断言 err.(*json.UnsupportedValueError),因为返回的错误也可能来自其他编码失败或被上层包装。IsValid 检查避免为读取补充诊断再次 panic;保留原错误文字即可,不需要把整个循环对象打印进日志。
也不要递归遍历这个对象来“补出”Value,或再次调用 JSON 把它写进错误日志,否则可能重新进入相同的失败路径。
输出模型需要切断回指
接下来检查真正的返回数据。父对象指向子对象、子对象又指回父对象时,可以按接口契约把回指替换成标量 ID,或使用不包含回指的响应 DTO。对于仅作内存导航、不应输出的字段,也可以在明确接口需求后使用 json:"-"。
这些选择会改变输出结构,应同时核对客户端依赖。不能把所有关联字段统一忽略,直到 Marshal 不报错为止;这样可能丢掉接口必需的数据。
恢复标准分两层:错误路径不再二次 panic,正常输出模型也没有循环且包含契约要求的字段。故意构造的循环输入仍应稳定返回错误。仅仅让日志打印成功,不代表业务响应已经能够编码。
截至 2026 年 10 月 20 日,循环错误中填充 Value 的修正已进入主线,1.27 回移事项仍开放。采用维护版本时需要核对真实包含情况,不把主线 issue 关闭当成当前生产工具链已修复。IsValid 防护本身仍适合作为稳健的错误处理保留。


