Recovery 捕获 panic 之后到底做了什么?客户端看到什么、日志里有什么、哪些头会被打码?断管错误为什么特殊对待?这篇从源码的 30 行出发,用 8 种 panic 值、debug 与 release 两种模式把它的行为一项项核对。
recovery.go(Gin v1.12.0):
64 | err, ok := rec.(error)
65 | if ok {
66 | isBrokenPipe = errors.Is(err, syscall.EPIPE) ||
67 | errors.Is(err, syscall.ECONNRESET) ||
68 | errors.Is(err, http.ErrAbortHandler)
69 | }
70 | if logger != nil {
71 | if isBrokenPipe {
72 | logger.Printf("%s\n%s%s", rec, secureRequestDump(c.Request), reset)
73 | } else if IsDebugging() {
74 | logger.Printf("[Recovery] %s panic recovered:\n%s\n%s\n%s%s",
75 | timeFormat(time.Now()), secureRequestDump(c.Request), rec, stack(stackSkip), reset)
76 | } else {
77 | logger.Printf("[Recovery] %s panic recovered:\n%s\n%s%s",
78 | timeFormat(time.Now()), rec, stack(stackSkip), reset)
79 | }
80 | }
81 | if isBrokenPipe {
82 | // If the connection is dead, we can't write a status to it.
83 | c.Error(err) //nolint: errcheck
84 | c.Abort()
85 | } else {
86 | handle(c, rec)
87 | }
88 | }
89 | }()
90 | c.Next()
91 | }
92 | }
93 |
94 | // secureRequestDump returns a sanitized HTTP request dump where the Authorization header,
95 | // if present, is replaced with a masked value ("Authorization: *") to avoid leaking sensitive credentials.
96 | //
97 | // Currently, only the Authorization header is sanitized. All other headers and request data remain unchanged.
98 | func secureRequestDump(r *http.Request) string {
99 | httpRequest, _ := httputil.DumpRequest(r, false)
100 | lines := strings.Split(bytesconv.BytesToString(httpRequest), "\r\n")Recovery 是 defer + recover 加三个分支:判断是不是断管错误、写日志、写响应。普通 panic 交给恢复函数(默认 AbortWithStatus(500));断管错误则只 c.Error 并 Abort,因为连接已经没法再写状态了。
客户端:状态 500,正文 "",Content-Type=""
Recovery 写到日志的内容(release 模式):
| 2026/10/11 19:20:51 [Recovery] 2026/10/11 - 19:20:51 panic recovered:
| boom 的细节
| <gtmp>/n42a/main.go:24 (0x…)
| main.func1: r.GET("/boom", func(c *gin.Context) { panic("boom 的细节") })
| <gtmp>/_gopath/pkg/mod/github.com/gin-gonic/gin@v1.12.0/context.go:192 (0x…)
| (*Context).Next: c.handlers[c.index](c)
| <gtmp>/_gopath/pkg/mod/github.com/gin-gonic/gin@v1.12.0/recovery.go:90 (0x…)
| CustomRecoveryWithWriter.func1: c.Next()
| <gtmp>/_gopath/pkg/mod/github.com/gin-gonic/gin@v1.12.0/context.go:192 (0x…)
| …(共 23 行,其余是栈帧)
panic 之后同一个引擎继续服务:/ok → 200 "ok"客户端拿到 500 和空正文,没有 Content-Type。日志默认写到 gin.DefaultErrorWriter(标准错误),内容是 panic 值和带源码行的栈,栈里第一帧就是出事的那一行。panic 只影响当前请求,同一个引擎继续服务。
[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
- using env: export GIN_MODE=release
- using code: gin.SetMode(gin.ReleaseMode)
[GIN-debug] GET /boom --> main.main.func1 (2 handlers)
含 SECRET-TOKEN: false;含 Authorization: *: true;含 Cookie 内容 sid=abc: true;含 X-Api-Key 内容 k-123: true
| 2026/10/11 19:20:52 [Recovery] 2026/10/11 - 19:20:52 panic recovered:
| GET /boom?x=1 HTTP/1.1
| Host: example.com
| Authorization: *
| Cookie: sid=abc
| X-Api-Key: k-123
|
|
| boom
| <gtmp>/n42b/main.go:24 (0x…)
| main.func1: r.GET("/boom", func(c *gin.Context) { panic("boom") })
| <gtmp>/_gopath/pkg/mod/github.com/gin-gonic/gin@v1.12.0/context.go:192 (0x…)
| …(共 30 行,其余是栈帧)debug 模式的日志会附上请求转储,其中只有 Authorization 头被替换成 *(源码注释写明了“目前只处理 Authorization”),Cookie、X-Api-Key 等头原样输出。线上要用 release 模式(它不输出请求转储),并且别把 debug 日志送进共享的日志系统。
收到 err: string → text
收到 err: *errors.errorString → an error
收到 err: main.MyErr → {42}
收到 err: *runtime.PanicNilError → runtime error: panic called with nil argument
收到 err: runtime.plainError → assignment to entry in nil map
收到 err: runtime.boundsError → runtime error: index out of range [5] with length 1
收到 err: runtime.errorString → runtime error: invalid memory address or nil pointer dereference
收到 err: runtime.errorString → runtime error: integer divide by zero恢复函数拿到的 err 就是 panic 的原值,可能是字符串、error、你自己的类型,或者各种运行时错误(下标越界、空指针、除零、写 nil map)。panic(nil) 在 Go 1.21 以后会变成 *runtime.PanicNilError,所以 err != nil 的判断不会漏掉它。
/early → 状态 500 正文 "{\"error\":\"内部错误\",\"id\":\"req-1\"}" 日志里有栈:true 自定义函数累计调用:1 次 c.Errors:0 条
/late → 状态 200 正文 "已经写出的前半段{\"error\":\"内部错误\",\"id\":\"req-1\"}" 日志里有栈:true 自定义函数累计调用:2 次 c.Errors:0 条
/abort → 状态 200 正文 "" 日志里有栈:false 自定义函数累计调用:2 次 c.Errors:1 条
/epipe → 状态 200 正文 "" 日志里有栈:false 自定义函数累计调用:2 次 c.Errors:1 条
/wrapped → 状态 200 正文 "" 日志里有栈:false 自定义函数累计调用:2 次 c.Errors:1 条常见的自定义是返回统一的 JSON 错误体。注意:如果 panic 之前响应已经写出了一部分,状态码改不了,JSON 会拼在已发出的半截正文后面——这种响应是被破坏的,需要在更早的地方避免(先构造完整响应再一次性写出)。断管类 panic 不会调用你的函数,也不记栈。