如何在Golang中区分业务错误与系统错误_Golang错误分类设计

业务错误应使用自定义 error 类型而非 errors.New,因其支持类型判断与结构化字段;系统错误需谨慎使用 %w 单次包装以保留错误链;HTTP handler 中应优先用 errors.Is 和 errors.As 区分错误类型。

业务错误该用自定义 error 类型,而不是 errors.New

业务错误的核心特征是:可预期、需被上层逻辑识别并处理(比如返回特定 HTTP 状态码或重试策略),而 errors.New 生成的只是普通字符串 error,无法携带类型信息或结构化字段。一旦用 errors.New("user not found"),调用方只能靠 strings.Containserror.Error() 匹配字符串——这极易因拼写变动或翻译导致漏判。

正确做法是定义带接口实现的结构体:

type UserNotFoundError struct {
    UserID int
}

func (e *UserNotFoundError) Error() string {
    return fmt.Sprintf("user not found: id=%d", e.UserID)
}

func (e *UserNotFoundError) Is(target error) bool {
    _, ok := target.(*UserNotFoundError)
    return ok
}

这样调用方可安全使用 errors.Is(err, &UserNotFoundError{}) 判断,且不依赖字符串内容。

系统错误必须保留原始 error 链,避免用 fmt.Errorf("%w") 外包两次

系统错误(如数据库连接失败、网络超时、文件 I/O 错误)往往需要完整上下文用于排查。Go 1.13+ 的错误链机制依赖 %w 动词单次包装。常见错误是层层用 fmt.Errorf("xxx: %w", err) 套娃,导致错误链过长、关键底层错误被埋没,甚至触发 errors.Unwrap 无限循环。

应遵循原则:

  • 只在必要位置(如跨层边界)用一次 %w 包装
  • 绝不把已包装过的 error 再用 %w 二次包装
  • 日志中用 fmt.Printf("%+v", err) 查看完整栈和链

例如:

// ✅ 正确:仅在 service 层包装一次
if err := repo.GetUser(ctx, id); err != nil {
    return nil, fmt.Errorf("failed to get user from repo: %w", err)
}

// ❌ 错误:handler 层又包一次
if err := svc.GetUser(id); err != nil {
    return fmt.Errorf("failed to handle user request: %w", err) // 这会让原始 error 被包两层
}

HTTP handler 中区分两类错误要靠 errors.As 和 errors.Is,不是 switch err.(type)

switch err.(type) 只能匹配 error 接口的具体类型,但业务错误常被中间件或日志工具用 fmt.Errorf 包装过,原始类型丢失。此时 errors.Is(判断是否为某类错误)和 errors.As(提取具体错误实例)才是可靠手段。

典型 handler 模式:

func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
    id := parseID(r)
    user, err := h.service.GetUser(r.Context(), id)
    if err != nil {
        var notFound *UserNotFoundError
        if errors.As(err, ¬Found) {
            http.Error(w, "user not found", http.StatusNotFound)
            return
        }
        if errors.Is(err, context.DeadlineExceeded) {
            http.Error(w, "request timeout", http.StatusGatewayTimeout)
            return
        }
        // 其他系统错误统一 500
        http.Error(w, "internal error",

http.StatusInternalServerError) log.Printf("unexpected error: %+v", err) return } json.NewEncoder(w).Encode(user) }

注意:errors.As 要传入指针地址(¬Found),否则无法赋值;errors.Is 对标准库错误(如 context.DeadlineExceeded)直接有效,无需自定义 Is 方法。

全局错误码映射表容易忽略 error 类型一致性

有些团队会建一个 map[error]int 映射业务错误到 HTTP 状态码,但若 map key 是 error 接口值(如 map[error]int{&UserNotFoundError{}: 404}),实际运行时因每次 new 出的指针地址不同,查不到对应码。更糟的是,如果 map key 是字符串(err.Error()),又回到字符串匹配的老问题。

安全做法只有两种:

  • errors.Is + 预定义变量(如 var ErrUserNotFound = &UserNotFoundError{}),再用 if/else 或 map[*UserNotFoundError]int
  • 让业务错误类型实现一个 StatusCode() int 方法,统一调用 err.StatusCode()

后者更灵活,但要注意:系统错误(如 os.PathError)没有该方法,需 fallback 到默认 500。

这类细节在压力测试或并发场景下才暴露——比如多个 goroutine 同时触发同一类业务错误,却因类型判断失效返回了 200。