跳到主要内容

Go 错误处理:从 if err != nil 到统一错误响应

· 阅读需 9 分钟
阿飞
阿飞

if err != nil 本身不难,难的是错误在不同层该怎么处理:

  • 这个错误该给用户看"用户名已存在",还是"服务器内部错误"?
  • 为什么生产日志里全是 ERROR create user failed: duplicate key value violates unique constraint "idx_users_email",而用户看到的是白屏?
  • 为什么前端拿到的 409 里带出了数据库表名?
  • 为什么一条请求失败,日志里出现了 3 条错误记录?

关键是约定好错误如何转换、对谁展示,以及由哪一层记录日志。下面以 gin-web-api 为例,介绍这套处理方式及其取舍。

起点:三种常见写法,三种麻烦​

没有统一约定时,常见的写法有几种。

写法一:一路 return err​

func (h *UserHandler) Get(c *gin.Context) {
user, err := h.svc.Get(c.Request.Context(), c.Param("id"))
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
response.OK(c, user)
}

这样一来,记录不存在也会返回 500。前端无法区分请求错误和服务故障,重试与告警也会失准。

如果直接把 err.Error() 返回给前端,还可能泄露数据库或网络等内部信息。

写法二:handler 里遍地 errors.Is​

if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(404, gin.H{"error": "not found"})
return
}
if errors.Is(err, gorm.ErrDuplicatedKey) {
c.JSON(409, gin.H{"error": "conflict"})
return
}

状态码虽然正确了,但 HTTP 层也因此依赖 GORM。更换存储或 RPC 实现时 handler 也要改,相同判断还容易散落在多个 handler 中。

写法三:哨兵错误 + 字符串比较​

var ErrUserNotFound = errors.New("user not found")

if err.Error() == "user not found" { ... }
if strings.Contains(err.Error(), "not found") { ... }

字符串比较很脆弱:错误被包装或文案变动后,判断就会失效,编译器也无法检查。

我想要的四条约束​

因此,错误处理需要满足几条约束:

约束含义
分流面向用户的信息和面向开发的信息必须分开存放
解耦HTTP 状态码是传输层的事,业务码是领域的事,两者可独立演进
兼容必须支持 errors.Is / errors.As,不能破坏标准库的错误链
唯一一条错误只在一处记日志,避免同一条错误刷三条

设计:AppError​

核心就一个类型,四个字段:

// AppError 表示一个可安全暴露给客户端的业务错误。
type AppError struct {
Code int // 业务错误码
HTTPStatus int // HTTP 状态码
Message string // 面向客户端的提示信息
Cause error // 内部原因,仅用于日志,不对外暴露
}

Code、HTTPStatus 和 Message 用于构造客户端响应;Cause 保留内部原因,供日志排查。响应只使用 Message,不要直接返回 Error()。

实现 error 并保留内部原因​

func (e *AppError) Error() string {
if e.Cause != nil {
return fmt.Sprintf("[%d] %s: %v", e.Code, e.Message, e.Cause)
}
return fmt.Sprintf("[%d] %s", e.Code, e.Message)
}

// Unwrap 支持 errors.Is / errors.As 链式判断。
func (e *AppError) Unwrap() error { return e.Cause }

Error() 包含 Cause,便于日志记录底层原因;不要用它生成客户端响应。

Unwrap 则保留了错误链,翻译成业务错误后仍可使用 errors.Is / errors.As:

appErr := apperror.Internal("查询用户失败").WithCause(gorm.ErrRecordNotFound)

errors.Is(appErr, gorm.ErrRecordNotFound) // true

为什么 WithCause 要返回副本​

func (e *AppError) WithCause(err error) *AppError {
clone := *e
clone.Cause = err
return &clone
}

业务错误可能定义为包级变量复用:

var ErrUserNotFound = apperror.NotFound("用户不存在")

如果 WithCause 原地修改,并发请求就可能竞争写入,并让一个请求的错误原因出现在另一个请求的日志里。

返回副本可以避免修改共享错误值。

构造函数一族 + 一个入口​

func BadRequest(message string) *AppError // 400
func Unauthorized(message string) *AppError // 401
func Forbidden(message string) *AppError // 403
func NotFound(message string) *AppError // 404
func Conflict(message string) *AppError // 409
func TooManyRequests(message string) *AppError // 429
func Internal(message string) *AppError // 500

业务码按段划分,便于定位:

段含义示例
10xxx通用 / 参数10001 参数错误
11xxx认证鉴权11001 未认证、11002 无权限
12xxx业务12001 资源不存在、12002 资源冲突
13xxx限流13001 请求过于频繁
50xxx系统50000 内部错误

然后是兜底入口 From:

// From 将任意 error 归一化为 *AppError;未知错误统一视为内部错误且不暴露细节。
func From(err error) *AppError {
if err == nil {
return nil
}
var appErr *AppError
if errors.As(err, &appErr) {
return appErr
}
return Internal("服务器内部错误").WithCause(err)
}

From 的处理分两种情况:

  • 已经是 *AppError(包括被 %w 包装的情况),则取出并保留。
  • 其他错误统一转成 500,原始错误放入 Cause,不暴露给客户端。

这样,即使调用方忘了翻译错误,客户端也不会看到底层错误细节。

边界:错误在哪一层翻译​

各层的职责约定如下:service 将底层错误翻译为业务错误,handler 上报错误,中间件负责响应和日志。

层职责是否 import apperror
repository只返回底层错误原样(gorm.ErrRecordNotFound)否
service把底层错误翻译成业务语义,用 WithCause 保留现场是
handler只做 c.Error(err) + return,不做任何判断否
middleware统一渲染响应体、决定日志级别是

repository:不表态​

func (r *userRepository) GetByID(ctx context.Context, id uuid.UUID) (*model.User, error) {
return gen.User[model.User](r.db).GetByID(ctx, id)
}

仓储层只返回存储操作的结果,不依赖 HTTP 或业务错误类型,便于被其他入口复用。

service:翻译 + 保留现场​

// mapNotFound 将 GORM 的记录不存在错误映射为业务错误,其余归为内部错误。
func mapNotFound(err error, message string) error {
if errors.Is(err, gorm.ErrRecordNotFound) {
return apperror.NotFound(message)
}
return apperror.Internal("数据库操作失败").WithCause(err)
}

在 service 层用 errors.Is 将存储错误映射为业务语义,可以避免让 HTTP 层依赖数据库实现。

一个真实的业务错误长这样:

if err := s.repo.Create(ctx, user); err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return nil, apperror.Conflict("用户名或邮箱已存在").WithCause(err)
}
return nil, apperror.Internal("创建用户失败").WithCause(err)
}

客户端得到 409 和业务提示,日志则保留数据库错误,便于排查。

同理,路径参数解析这种"输入校验"也在 service 层翻译:

func parseID(id string) (uuid.UUID, error) {
uid, err := uuid.Parse(id)
if err != nil {
return uuid.Nil, apperror.BadRequest("无效的 ID")
}
return uid, nil
}

handler:只上报,不判断​

func (h *PostHandler) Get(c *gin.Context) {
resp, err := h.svc.Get(c.Request.Context(), c.Param("id"))
if err != nil {
c.Error(err)
return
}
response.OK(c, resp)
}

handler 只处理成功响应;发生错误时交给后续中间件,不判断错误类型或设置状态码。

渲染:c.Error 与那个绕不开的 return​

c.Error(err) 只会把错误追加到 c.Errors,不会写响应,也不会中断执行:

func (c *Context) Error(err error) *Error {
// 追加到 c.Errors,仅此而已
}

因此调用后必须 return。如果遗漏,handler 可能继续写出 200 响应,之后 ErrorHandler 再写 500:

[WARNING] Headers were already written. Wanted to override status code 404 with 500

客户端可能拿到 200 和空响应,同时日志出现容易被忽略的 warning。

渲染在中间件里完成,顺序是关键:

func ErrorHandler() gin.HandlerFunc {
return func(c *gin.Context) {
c.Next() // 先让整条链路跑完

if len(c.Errors) == 0 {
return
}

appErr := apperror.From(c.Errors.Last().Err)
if appErr.HTTPStatus >= 500 {
log := logger.FromGin(c)
log.Error("request failed",
zap.String("method", c.Request.Method),
zap.String("path", c.Request.URL.Path),
zap.Error(appErr),
)
}
response.Fail(c, appErr)
}
}

中间件的几个选择:

  • 取 Last():一条请求可能记录多个错误,这里用最后一个错误生成响应。
  • 只对 5xx 记 error 日志:项目的访问日志已记录 4xx,避免重复。
  • 集中在中间件渲染:让错误响应格式保持一致。

错误响应只包含必要字段:

type ErrorBody struct {
Code int `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id,omitempty"`
}
{ "code": 12001, "message": "用户不存在", "request_id": "01JB8Z9K4K3M2N1P0Q" }

request_id 可用于根据用户反馈定位对应日志。

panic:不走错误链,但要汇入同一个出口​

panic 不走普通错误链,但可以返回相同格式的错误响应:

func Recovery() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if rec := recover(); rec != nil {
log := logger.FromGin(c)
log.Error("panic recovered",
zap.Any("panic", rec),
zap.String("path", c.Request.URL.Path),
zap.Stack("stack"),
)
response.Fail(c, apperror.Internal("服务器内部错误"))
}
}()
c.Next()
}
}

要点:

  • 记录堆栈,便于定位 panic。
  • 直接调用 response.Fail,避免再把错误塞入 c.Errors。
  • 不要用 panic 处理常规业务分支。

测试:断言契约,而不是断言字符串​

测试重点应放在状态码、业务码和敏感信息是否泄露,而不是固定提示文案:

func TestGetUserNotFound(t *testing.T) {
w := httptest.NewRecorder()
// ... 构造请求
assert.Equal(t, http.StatusNotFound, w.Code)

var body response.ErrorBody
json.Unmarshal(w.Body.Bytes(), &body)
assert.Equal(t, apperror.CodeNotFound, body.Code)
assert.NotEmpty(t, body.RequestID)
assert.NotContains(t, body.Message, "record not found") // 内部细节不得泄露
}

还可以单测 apperror.From,确认未知错误不会暴露给客户端,同时保留错误链:

func TestFromUnknownErrorHidesCause(t *testing.T) {
secret := errors.New(`pq: password authentication failed for user "postgres"`)
appErr := apperror.From(fmt.Errorf("query: %w", secret))

assert.Equal(t, http.StatusInternalServerError, appErr.HTTPStatus)
assert.Equal(t, "服务器内部错误", appErr.Message)
assert.NotContains(t, appErr.Message, "password")
assert.True(t, errors.Is(appErr, secret)) // 现场仍在,只是不外露
}

总结​

错误处理的关键是明确边界:repository 返回底层错误,service 将它翻译成业务错误,handler 上报错误,中间件统一记录日志并生成响应。客户端只拿到安全、明确的提示,内部原因仍保留在错误链中供排查。再用测试守住状态码、业务码和信息不泄露这几条约定,错误处理就不必散落在每个 handler 里。

ICP备案/许可证号:蜀ICP备2025139266号-1
beian 川公网安备51013202000132号