bigywater.tech
Gin · 46

Recovery:捕获 panic 之后做了什么,broken pipe 的特判

Recovery 捕获 panic 之后到底做了什么?客户端看到什么、日志里有什么、哪些头会被打码?断管错误为什么特殊对待?这篇从源码的 30 行出发,用 8 种 panic 值、debug 与 release 两种模式把它的行为一项项核对。

500 + 空正文
默认 Recovery 之后客户端看到的响应
Authorization: *
debug 日志里只有这个头被打码,Cookie 与 X-Api-Key 原样出现
3 类
EPIPE / ECONNRESET / ErrAbortHandler 走断管分支:不记栈、不调自定义函数

骨架

实测 · 本机执行输出
  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 只影响当前请求,同一个引擎继续服务。

debug 日志里的敏感信息

实测 · 本机执行输出
  [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 日志送进共享的日志系统。

panic 的值

实测 · 本机执行输出
    收到 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 不会调用你的函数,也不记栈。

分步演示

Recovery
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew)/ Gin v1.12.0(go.mod 里 require,源码在模块缓存里);用 go run / go test 直接编译运行,需要库时经代理从 proxy.golang.org 下载;HTTP 实验用 httptest 或本机回环端口(9700 以上);计时用 time.Now / testing.B,数字每次运行会有小幅抖动。日志写入 bytes.Buffer(栈帧里的地址被替换成 0x…,路径经过整理);请求用 httptest 直接调用 ServeHTTP;debug 模式用 GIN_MODE=debug 运行;每种情形各一次
    • 没有验证:真实断管(客户端中途关闭连接)时 Gin 是否会 panic(写入失败通常只返回 error,不会 panic,没有测);goroutine 里的 panic(Recovery 管不到,见后面的文章);栈信息读取源码文件的开销与并发;Recovery 写日志时的 isatty 颜色前缀(\x1b[31m)在文件日志里的表现
    京ICP备2026067502号-1京公网安备11010502063487号