error 是值 — 哨兵错误 / %w 包装链 / errors.Is / As / Join: 把"哪一层知道怎么处理"直接写进类型设计
Go 没有 try/catch: error 就是个普通值, 函数多返回一个它, 调用方 if err != nil 自己决定怎么办。真正的功夫在"链": %w 把根因包在上下文里一路上传, errors.Is/As 沿链往回找 —— 于是类型设计就等于在回答"这个错谁能处理": 仓储层定义 NotFound 带 OrderID, handler 层 As 到它转 404, 中间层只包装不记日志。错误信息一层都不丢, 处理点一处都不多。
interface{ Error() string }。任何类型实现了它就是 error —— 所以错误可以带字段带方法, 是可编程的值, 不只是字符串。type MyErr struct{ Code int } func (e *MyErr) Error() string { // 实现它就是 error return fmt.Sprintf("code %d", e.Code) } var e error = &MyErr{Code: 404} // 关键: 带字段的可编程值
var ErrNotFound = errors.New("not found") 包级导出, 作为跨包契约; 调用方用 errors.Is 判定, 风格对齐 io.EOF。绝不字符串匹配。var ErrNotFound = errors.New("not found") if errors.Is(err, ErrNotFound) { // 对: 判定走 Is return nil } // 关键: 风格对齐 io.EOF, 绝不字符串匹配
type NotFoundError struct{ Table, Key string } + Error() 方法: 业务字段随错误走, 上层能精确响应而不是解析字符串。type NotFoundError struct{ Table, Key string } func (e *NotFoundError) Error() string { return e.Table + ": " + e.Key + " not found" } // 关键: 业务字段随错误走, 上层 As 取 Key 精确响应
fmt.Errorf("get order %s: %w", id, err): 保留根因引用形成链; %v 只拼字符串, 链断掉, Is/As 全部失效。err = fmt.Errorf("get order %s: %w", id, err) // 对: 链保留 err = fmt.Errorf("get order: %v", err) // 错: 链断, Is/As 失效
Unwrap 链逐层 == 比较目标哨兵; 包装任意层都认得。判断"是不是这类事"用它。err := fmt.Errorf("get config: %w", os.ErrNotExist) fmt.Println(errors.Is(err, os.ErrNotExist)) // → true 关键: 沿 Unwrap 链逐层 ==, 包装几层都认得
&nfe)。判断"是不是这个类型并取字段"用它。var nfe *NotFoundError if errors.As(err, &nfe) { // 关键: 目标必须取址 respond(404, nfe.Key) // 命中取业务字段 }
j := errors.Join(os.ErrNotExist, os.ErrPermission) fmt.Println(errors.Is(j, os.ErrPermission)) // → true Is 对每个分支逐一检查 // Error() → 两行: not exist / permission denied 各占一行
Unwrap() error; 树形 Unwrap() []error(Join 产物)。自定义错误记得实现, 否则链在你这断掉。func (e *StorageError) Unwrap() error { return e.Err } // 单链 // 树形: Unwrap() []error (Join 产物的内部实现) // 关键: 忘了实现 → Is/As 的链在你这层断掉
fmt.Errorf("Get order failed.") // 错: 大写开头+句号 fmt.Errorf("get order %s", id) // 对: 小写, 无标点收尾 // 关键: 链式拼接 "x: get order 10086" 句读才自然
"repo.GetOrder: ...", 排障时一眼看出是哪层哪个函数报的, 不用猜调用栈。return fmt.Errorf("repo.GetOrder: %w", err) // 关键: 首段是操作名 — 哪层哪个函数报的, 一眼看出
if err != nil { return err } // 可预期失败 → error // 文件不存在/超时/参数非法/下游 503 全是 error // panic 只留给: 越界/nil 解引用/不变量被破坏
switch { case errors.As(err, &nfe): w.WriteHeader(404) // NotFound case errors.Is(err, context.DeadlineExceeded): w.WriteHeader(504) // 超时 default: w.WriteHeader(500) // 未知不外漏细节 }
(*T)(nil) 装进 error 接口后 err != nil 为 true: 接口有类型信息就不是 nil。返回前先判具体指针。func find() error { var e *MyErr // typed nil return e // 错: err != nil → true! } // 对: 返回前判具体指针, 直接 return nil 字面量
把"订单不存在"翻译成 404 而不是 500, 靠类型不靠字符串:
type NotFoundError struct{ Table, Key string } func (e *NotFoundError) Error() string { return fmt.Sprintf("%s: %s not found", e.Table, e.Key) // 小写开头 } func (r *OrderRepo) Get(ctx context.Context, id string) (*Order, error) { o, err := r.q.GetOrder(ctx, id) if errors.Is(err, pgx.ErrNoRows) { // 驱动哨兵翻译成自家类型 return nil, &NotFoundError{"orders", id} } if err != nil { return nil, fmt.Errorf("get order %s: %w", id, err) // 根因保留 } return o, nil } // handler 层: 类型命中, 业务字段直接可用 // var nfe *repo.NotFoundError // if errors.As(err, &nfe) { respond(w, 404, nfe.Key) }
错误类型是层与层之间的契约, 与函数签名同等重要。
每一层只加"我这一层知道的事", 不复述不改写下层消息:
func (s *OrderService) Charge(ctx context.Context, id string) error { o, err := s.repo.Get(ctx, id) if err != nil { return fmt.Errorf("charge order %s: get: %w", id, err) } // ^op ^字段 ^根因链 if err := s.gateway.Pay(ctx, o); err != nil { return fmt.Errorf("charge order %s: pay: %w", id, err) } return nil } // 边界层只打一条日志, 信息已经完整: // charge order 10086: get: get order 10086: orders: 10086 not found // 哪层(op) / 哪单(字段) / 根因(链尾) 一眼读全
加字段的原则: 能帮排障定位的 (订单号/表名/地址), 不加敏感的 (密钥/全量 SQL)。
SDK 的"连接正在关闭"要让所有调用方统一识别, 必须是导出的哨兵:
package client var ErrShutdown = errors.New("connection shutting down") // 包级契约 func (c *Conn) Read(p []byte) (int, error) { c.mu.Lock() defer c.mu.Unlock() if c.closed { return 0, ErrShutdown // 返回哨兵本体, 绝不现场 errors.New } return c.readLocked(p) } // 调用方: if errors.Is(err, client.ErrShutdown) { reconnect() } // 反例: errors.New("shutting down") — 每次新实例, Is 永远 false, // 调用方只能去 match 字符串 — 契约就此崩坏
哨兵一旦发布, 消息文本就冻结了: 改文案等于删 API。
导入 1 万行, fail-fast 只知道第一行错哪; 运营要完整清单:
func importAll(ctx context.Context, rows []Row) error { var errs error // nil 可直接 Join, 语义就是"追加" for _, r := range rows { if err := upsert(ctx, r); err != nil { errs = errors.Join(errs, fmt.Errorf("row %d: %w", r.ID, err)) continue // 不中断: 批处理要完整错误清单 } } return errs // nil 或聚合错误; Error() 用换行连接各分支 } // 上层判定: errors.Is(errs, ErrDupKey) 会遍历每个分支检查, // 需要逐条展示就 strings.Split(err.Error(), "\n")
注意体量: 10 万行全失败会得到巨型错误, 记得设上限或采样。
handler 各自手写 respond 必然风格漂移, 错误处理要收口到一处:
type Handler func(w http.ResponseWriter, r *http.Request) error func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { err := h(w, r) // 全站唯一的错误出口 if err == nil { return } var nfe *repo.NotFoundError switch { case errors.As(err, &nfe): writeJSON(w, 404, nfe.Key) // 业务错: 带字段给前端 case errors.Is(err, context.DeadlineExceeded): writeJSON(w, 504, "timeout") default: rid := middleware.RequestID(r.Context()) slog.Error("unhandled", "req_id", rid, "err", err) writeJSON(w, 500, "internal") // 内部错不外漏细节 } }
业务 handler 从此只 return err, 响应格式/日志/打点全在一处演进。
重试是错误处理的下半场 —— 判错错了, 要么 5xx 风暴要么重复扣款:
func retryable(err error) bool { if errors.Is(err, sql.ErrNoRows) || // 业务缺数据: 重试无意义 errors.Is(err, context.Canceled) { // 用户主动断开 return false } var netErr net.Error if errors.As(err, &netErr) { return true // 瞬时网络错/超时: 指数退避后重试 } var pgErr *pgconn.PgError if errors.As(err, &pgErr) { return pgErr.Code == "57P01" // admin shutdown 才重试 } return false // 未知错误默认不重试: 保守 }
配合幂等键使用; 判定只信 Is/As, 绝不信错误字符串前缀。
存储从 GCS 换 S3 时, 上层代码不该改一行 —— 前提是错误早就翻译过了:
type StorageError struct { Op string // "put" / "get" / "delete" Key string Err error // 根因保留给日志, 不出现在 API 签名 } func (e *StorageError) Error() string { return fmt.Sprintf("%s %s: %v", e.Op, e.Key, e.Err) } func (e *StorageError) Unwrap() error { return e.Err } // 别忘: Is/As 靠它 func (s *GCS) Put(ctx context.Context, k string, b []byte) error { if err := s.client.Upload(ctx, k, b); err != nil { return &StorageError{Op: "put", Key: k, Err: err} } return nil } // 将来换 S3: 只改翻译层; 上层 errors.As(*StorageError) 零改动
边界规则: 项目内部只流通自家错误类型, SDK 类型不出包。
500 响应只告诉你"炸了", 错误分类计数才告诉你"往哪查":
var errCounter = prometheus.NewCounterVec( prometheus.CounterOpts{Name: "api_errors_total"}, []string{"route", "class"}, // class: not_found/timeout/… ) func classify(err error) string { var nfe *repo.NotFoundError switch { case errors.As(err, &nfe): return "not_found" case errors.Is(err, context.DeadlineExceeded): return "timeout" default: return "internal" } } // 中间件里: errCounter.WithLabelValues(route, classify(err)).Inc() // 告警: internal 环比涨 3 倍 → 电话值班; not_found 高 → 前端传参 bug
class 维度来自类型判定, 与日志同一套 classify, 口径天然一致。
并发任务"第一个失败就整体失败", errgroup.WithContext 内置这套语义:
func fanOut(ctx context.Context, srcs []Source) ([]Result, error) { g, ctx := errgroup.WithContext(ctx) // 首错自动 cancel 其余 results := make([]Result, len(srcs)) for i, s := range srcs { g.Go(func() error { // 首个 return err 触发 ctx 取消 r, err := fetch(ctx, s) if err != nil { return fmt.Errorf("src %s: %w", s.Name, err) } results[i] = r return nil }) } if err := g.Wait(); err != nil { return nil, err // 只返回第一个错, 其余已被取消 } return results, nil // 要全部错误: 换场景 4 的 errors.Join }
并发模式细节见 channel/patterns 相关页; 这里只记一条: goroutine 里绝不 panic, 错误一律走 return。
磁盘写满时 f.Close() 才报错, 吞掉它 = "写了一半"被当成功:
func writeAtomic(path string, data []byte) (err error) { f, err := os.Create(path + ".tmp") if err != nil { return fmt.Errorf("create %s: %w", path, err) } defer func() { // 落盘/关闭失败与主错误汇合 err = errors.Join(err, f.Sync(), f.Close()) }() if _, err := f.Write(data); err != nil { return fmt.Errorf("write %s: %w", path, err) } return nil // 返回前 defer 把 Sync/Close 的错误并进来 } // 只写 f.Close() 不接 err: 磁盘满时报错被吞, 数据静默丢失 // os.Rename(path+".tmp", path) 放在 err == nil 确认之后
Join(nil, nil, nil) 就是 nil, 主错误优先保留, 收尾错误一个不丢。
errors.Is/As 全部 false, 只剩一串文本. 原因: %v 只拼字符串不建链. 正解: 要保留根因一律 %w, 确实要断链时写注释说明。return fmt.Errorf("get order: %v", err) // 错: 链断, Is/As 全 false return fmt.Errorf("get order: %w", err) // 对: 根因保留成链
"Get order failed." 被包装后拼出 get order: Get order failed. 这种怪物. 正解: 消息小写开头, 不以标点收尾, 链式拼接才通顺。fmt.Errorf("Get order failed.") // 错: 拼出 "x: Get order failed." fmt.Errorf("get order %s", id) // 对: 小写开头, 无标点收尾
if err != nil { log.Error(err) // 错: log and return 同时干 return err // 上层再 log → 同错 4 条日志 } // 对: 要么处理掉, 要么包好 return, 只在边界层落日志
if err != nil { slog.Error("bad", "err", err) // 错: 记了不返回 } data := parse(raw) // 拿脏状态继续算, 产出错误数据 // 对: log 之后必须 return 或进明确降级路径
if err == sql.ErrNoRows 在错误被包过一层后永远不成立, 分支静默失效. 正解: 一律 errors.Is(err, sql.ErrNoRows)。if err == sql.ErrNoRows {} // 错: 包过后永远不成立 if errors.Is(err, sql.ErrNoRows) {} // 对: 沿链判定
fmt.Errorf("exec %s: %v", dsn, err) // 错: 连接串带密码 fmt.Errorf("query orders uid=%d: %w", uid, err) // 对: 只放定位字段
fmt.Errorf("wrap: %v", ErrShutdown) 之后 Is 判定失效. 原因: %v 把哨兵降格成字符串. 正解: 保身份用 %w; 哨兵一旦发布消息文本就冻结, 改文案等于删 API。fmt.Errorf("wrap: %v", ErrShutdown) // 错: 降格成字符串, Is 失效 fmt.Errorf("wrap: %w", ErrShutdown) // 对: 保身份
Unwrap(), 链断在你这层, 上层 Is/As 查不到根因. 正解: struct 里存 Err error 字段并实现 Unwrap() error { return e.Err }。type WrapErr struct{ Op string; Err error } func (e *WrapErr) Error() string { ... } // 错: 链断在这层 func (e *WrapErr) Unwrap() error { return e.Err } // 对: 补上
errors.As(err, nfe) 编译不过或永远 false. 原因: As 要把命中的值写进目标, 目标必须可寻址. 正解: errors.As(err, &nfe), 目标是 *NotFoundError 型变量取址。var nfe *NotFoundError errors.As(err, nfe) // 错: panic: target must be a non-nil pointer errors.As(err, &nfe) // 对: 目标取址, 命中写进 nfe
return nil 具体指针类型, 调用方 err != nil 竟然为 true, 走进错误分支. 原因: 接口装了"类型信息非 nil 值为 nil"的指针. 正解: 返回前判具体指针, 直接 return nil 字面量。func find() error { var e *MyErr // typed nil return e // 错: err != nil → true, 走错分支 } // 对: 返回前判具体指针, 直接 return nil 字面量
if err == nil 里的代码全是错误处理, 偶发路径歪曲. 正解: happy path 永远在 err != nil 早返回之后; code review 盯住第一行判断。if err == nil { // 错: 错误处理进了 nil 分支 return err } if err != nil { // 对: 早返回, happy path 在后 return err }
func Parse(s string) Config { if s == "" { panic("empty") } // 错: 脏输入打挂服务 } func Parse(s string) (Config, error) { // 对: 库只 return error if s == "" { return Config{}, ErrEmpty } }
_ = f.Close() / _ = w.Flush() 满天飞, 磁盘满/管道断的错误全部蒸发. 正解: 收尾错误用 errors.Join 并进主错误(场景 10); 实在不关心就写注释说明为什么。_ = f.Close() // 错: 磁盘满错误蒸发 defer func() { // 对: Join 进主错误 err = errors.Join(err, f.Close()) }() // 实在不关心就写注释说明为什么
defer func(){ err = errors.Join(err, f.Close()) }()。defer f.Close() // 错: Close 失败没人接 defer func() { // 对: 命名返回值 + Join err = errors.Join(err, f.Sync(), f.Close()) }()
default 或宽泛的 net.Error 写在 *PgError 之前, 具体分支永远走不到. 正解: 类型分支从具体到宽泛排列, default 收尾。switch { case errors.As(err, &netErr): // 错: 宽类型在前吞掉一切 case errors.As(err, &pgErr): // 具体分支永远走不到 } // 对: 从具体到宽泛排列, default 收尾
fmt.Errorf("bad req: %w", &req{...10KB...}), 错误被日志/链路长期持有, 内存翻倍. 正解: 错误只存定位字段, 大对象进日志或 trace, 需要时存引用并注明生命周期。fmt.Errorf("bad req: %w", &req) // 错: 10KB 被错误链持有 fmt.Errorf("bad req id=%d: %w", req.ID, err) // 对: 只存定位字段
connection reset by peer, 不知道哪个接口哪张表. 正因: 每层包装都偷懒没加 op. 正解: 消息首段放 "repo.GetOrder:" 这类操作名(场景 2 模板)。return err // 错: 只有 connection reset return fmt.Errorf("repo.GetOrder: %w", err) // 对: 首段 op 名
if err = f(); err != nil 复用外层变量. 正解: 循环内 := 新变量, 要累积用 errors.Join。var err error for _, r := range rows { if err = step(r); err != nil {} // 错: 互相覆盖 if e := step(r); e != nil { // 对: := 新变量 errs = errors.Join(errs, e) // 累积用 Join } }
os.Exit(1), slog 异步 buffer 的错误日志全丢, 现场消失. 正解: 退出前 logger.Flush()/sync.Once 收尾; 或 os.Exit 只放在最后一行统一出口。func main() { defer logger.Flush() // 错: Exit 后不执行, 日志丢 if err := run(); err != nil { os.Exit(1) // 对: 只放统一出口最后一行 } }
go func() { panic("boom") }() // 错: 带走整个进程 go func() { // 对: 入口 recover 转 error defer func() { if r := recover(); r != nil { errCh <- fmt.Errorf("panic: %v", r) } }() work() }()