引言

文中的源码在这里: https://github.com/gota33/errors

Part 1 中我们重新认识了什么是错误. 这次我们就以 Go 语言为例, 看看如何处理下面的问题:

  • 如何告诉机器有没有错误?
  • 如何告诉机器怎么处理错误: 重试? 返回上层? …
  • 如何告诉人类错误信息: 操作路径, 上下文信息, …
  • 如何在语言无关的前提下实现上面两点 (微服务环境)

另外我们应该尽量复用现有方案, 除非:

  • 太过复杂: 增加认知成本
  • 侵入性过强: 增加耦合度

Go 中的 error

既然具体到 Go 语言这个环境, 我就就先看看语言本身对错误的支持.

早期版本中 Go 只对 error 下了个定义

// The error built-in interface type is the conventional interface for
// representing an error condition, with the nil value representing no error.
type error interface {
	Error() string
}

然后给了一个基础实现

// New returns an error that formats as the given text.
// Each call to New returns a distinct error value even if the text is identical.
func New(text string) error { return &errorString{text} }

// errorString is a trivial implementation of error.
type errorString struct { string }

func (e *errorString) Error() string { return e.s }

其实, 这个接口虽然简单, 但已经处理了上面四个问题中的两个:

  • 如何告诉机器有没有错误? nil 代表没有错误, 否则有.
  • 如何告诉人类错误信息? 通过 Error() 接口返回.

不过第二点非常依赖于具体的实现:

  • 如果破坏了约定, 没有在向外层传递时加前缀, 就没有完整的操作路径, 只能看到根错误
  • 如果没有实现 fmt.Formatter 接口, 就无法看到详细且工整的错误信息

后来 Go 1.13 通过 wrapper 进一步完善了错误处理:

// Unwrap returns the result of calling the Unwrap method on err, if err's
// type contains an Unwrap method returning error.
// Otherwise, Unwrap returns nil.
func Unwrap(err error) error 

// Is reports whether any error in err's chain matches target.
// ...
func Is(err, target error) bool

// As finds the first error in err's chain that matches target, and if so, sets
// target to that error value and returns true. Otherwise, it returns false.
// ...
func As(err error, target interface{}) bool

于是错误内部的上下文信息就可以跨层传递了.

然而标准库并没有对如何处理错误给出任何信息, 因为这几乎取决于业务场景. 但我们还是能从标准库内部看到一些痕迹. 比如 net 包中有个内部使用的 temporary 接口

type temporary interface {
	Temporary() bool
}

这也是 net.Error 接口的一部分

// An Error represents a network error.
type Error interface {
	error
	Timeout() bool   // Is the error a timeout?
	Temporary() bool // Is the error temporary?
}

它还出现在了 context 包中

// DeadlineExceeded is the error returned by Context.Err when the context's
// deadline passes.
var DeadlineExceeded error = deadlineExceededError{}

type deadlineExceededError struct{}

func (deadlineExceededError) Error() string   { return "context deadline exceeded" }
func (deadlineExceededError) Timeout() bool   { return true }
func (deadlineExceededError) Temporary() bool { return true }

不过 Temporary() 目前也存在一些争议: issue#45729, 但这个方法的用意正是想提示机器该如何处理这个错误: 重试.

微服务中的 error

想让错误跨服务传递, 就得有一套独立于语言的标准. 并且最好满足以下三点:

  • 轻量级: 降低跨语言难度
  • 易于理解: 降低认知负担
  • 扩展性: 适配不同场景

所以这里我选择直接使用 gRPC 的错误标准, 这套标准在 Google 服务中有着广泛的应用, 可以说是比较靠谱了.

不过序列化还是使用 JSON, 毕竟通用性强, 方便调试. 并且由于格式与 protobuf 的版本相同, 所以要兼容也很容易.

详细的文档可以参考 Google API design, 这里简单总结下.

错误模型

模型由三部分组成: code 错误码, message 错误信息, details 错误详情.

package google.rpc;

// The `Status` type defines a logical error model that is suitable for
// different programming environments, including REST APIs and RPC APIs.
message Status {
  // A simple error code that can be easily handled by the client. The
  // actual error code is defined by `google.rpc.Code`.
  int32 code = 1;

  // A developer-facing human-readable error message in English. It should
  // both explain the error and offer an actionable resolution to it.
  string message = 2;

  // Additional error information that the client code can use to handle
  // the error, such as retry info or a help link.
  repeated google.protobuf.Any details = 3;
}

JSON 格式:

和上面很像, 但是多了一个 status 字段, 并在外面包了一层 wrapper.

// The error format v2 for Google JSON REST APIs.
//
// NOTE: This schema is not used for other wire protocols.
message Error {

  // This message has the same semantics as `google.rpc.Status`. It uses HTTP
  // status code instead of gRPC status code. It has an extra field `status`
  // for backward compatibility with Google API Client Libraries.
  message Status {
  
    // The HTTP status code that corresponds to `google.rpc.Status.code`.
    int32 code = 1;
    
    // This corresponds to `google.rpc.Status.message`.
    string message = 2;
    
    // This is the enum version for `google.rpc.Status.code`.
    google.rpc.Code status = 4;
    
    // This corresponds to `google.rpc.Status.details`.
    repeated google.protobuf.Any details = 5;
  }
  
  // The actual error payload. The nested message structure is for backward
  // compatibility with Google API client libraries. It also makes the error
  // more readable to developers.
  Status error = 1;
}

JSON 示例:

{
  "error": {
    "code": 400,
    "message": "API key not valid. Please pass a valid API key.",
    "status": "INVALID_ARGUMENT",
    "details": [
      {
        "@type": "type.googleapis.com/google.rpc.ErrorInfo",
        "reason": "API_KEY_INVALID",
        "domain": "googleapis.com",
        "metadata": {
          "service": "translate.googleapis.com"
        }
      }
    ]
  }
}

错误类型

image-20210529195147166

错误消息

message

错误详情

image-20210529194616203

备注

  • 所有的 5xx 错误都是服务端错误, 其对应的 DebugInfo 是仅限于系统内部记录用的, 错误往系统外传递错误消息时一定记得把 DebugInfo 过滤掉.

  • 有两个错误是可重试的, 重试前要确保幂等性:

    • 503 UNAVAILABLE : 默认行为应该为 1s 后重试 1 次
    • 429 RESOURCE_EXHAUSTED: 由客户端重试, 间隔至少 30s, 并且只用于耗时的后台任务
  • 不应该盲目将其他系统的错误传递给自己的用户, 比如其他系统返回的 INVALID_ARGUMENT 应该内部处理后返回 INTERNAL 或其他错误

  • 实现详情和机密信息应在传播前隐藏

  • 虽然表格中的错误消息示例是中文, 但规范中错误消息应该是英文, i18n 的提示信息应该放在 LocalizedMessage 详情中.

  • 有三个 400 的错误很容易混淆:

    • 400 INVALID_ARGUMENT: 客户端传了无效的参数, 并且校验规则和服务端状态无关, 比如: Email 地址无效
    • 400 FAILED_PRECONDITION: 服务器当前状态下无效的参数, 但服务器状态改变后重试可能成功, 比如: 用户申请的资源在初始化中.
    • 400 OUT_OF_RANGE: 和上面一条很像, 但用于范围, 比如: 日期范围, 数量范围, ….
  • 几个可以重试的错误也需要区别:

    • 503 UNAVAILABLE: 因为请求实际上未到达服务器, 客户端可以立即重试
    • 409 ABORTED: 进行中的事务被终止, 客户端需要从该事务阶段的起点重试.
    • 400 FAILED_PRECONDITION: 服务器状态不满足, 客户端需要等待服务器准备完毕后再重试.

接口设计

首先回顾下需要包含在错误中的必要信息:

  • 错误码 (标识符, 一定程度上指导如何处理)
  • 执行路径 (快速定位错误)
  • 错误详情 (提供上下文信息, 关联其他外部信息)

再回顾下错误处理策略:

  • 底层错误应当尽量重试
  • 应当尽量集中处理错误 (程序中处理错误的位置尽量少)

执行路径

在谈错误码之前, 我们先谈谈如何记录错误的执行路径.

我做 Go 开发之前是做 Java 的, 由于 Java 的 Exception 有内置的 StackTrace, 所以那时并没有意识到其中的问题. 有时望着如山般的错误信息, 也只能叹口气慢慢消化. 但现在回想下, 其实里面 80% 的信息对定位问题并没有帮助. 比如下面这样:

javax.servlet.ServletException: Something bad happened
    at com.example.myproject.OpenSessionInViewFilter.doFilter(OpenSessionInViewFilter.java:60)
    at org.mortbay.jetty.servlet.ServletHandler$CachedChain.doFilter(ServletHandler.java:1157)
    at com.example.myproject.ExceptionHandlerFilter.doFilter(ExceptionHandlerFilter.java:28)
    at org.mortbay.jetty.servlet.ServletHandler$CachedChain.doFilter(ServletHandler.java:1157)
    at com.example.myproject.OutputBufferFilter.doFilter(OutputBufferFilter.java:33)
    at org.mortbay.jetty.servlet.ServletHandler$CachedChain.doFilter(ServletHandler.java:1157)
    at org.mortbay.jetty.servlet.ServletHandler.handle(ServletHandler.java:388)
    at org.mortbay.jetty.security.SecurityHandler.handle(SecurityHandler.java:216)
    at org.mortbay.jetty.servlet.SessionHandler.handle(SessionHandler.java:182)
    at org.mortbay.jetty.handler.ContextHandler.handle(ContextHandler.java:765)
    at org.mortbay.jetty.webapp.WebAppContext.handle(WebAppContext.java:418)
    at org.mortbay.jetty.handler.HandlerWrapper.handle(HandlerWrapper.java:152)
    at org.mortbay.jetty.Server.handle(Server.java:326)
    at org.mortbay.jetty.HttpConnection.handleRequest(HttpConnection.java:542)
    at org.mortbay.jetty.HttpConnection$RequestHandler.content(HttpConnection.java:943)
    at org.mortbay.jetty.HttpParser.parseNext(HttpParser.java:756)
    at org.mortbay.jetty.HttpParser.parseAvailable(HttpParser.java:218)
    at org.mortbay.jetty.HttpConnection.handle(HttpConnection.java:404)
    at org.mortbay.jetty.bio.SocketConnector$Connection.run(SocketConnector.java:228)
    at org.mortbay.thread.QueuedThreadPool$PoolThread.run(QueuedThreadPool.java:582)
Caused by: com.example.myproject.MyProjectServletException
    at com.example.myproject.MyServlet.doPost(MyServlet.java:169)
    at javax.servlet.http.HttpServlet.service(HttpServlet.java:727)
    at javax.servlet.http.HttpServlet.service(HttpServlet.java:820)
    at org.mortbay.jetty.servlet.ServletHolder.handle(ServletHolder.java:511)
    at org.mortbay.jetty.servlet.ServletHandler$CachedChain.doFilter(ServletHandler.java:1166)
    at com.example.myproject.OpenSessionInViewFilter.doFilter(OpenSessionInViewFilter.java:30)
    ... 27 more
Caused by: org.hibernate.exception.ConstraintViolationException: could not insert: [com.example.myproject.MyEntity]
    at org.hibernate.exception.SQLStateConverter.convert(SQLStateConverter.java:96)
    at org.hibernate.exception.JDBCExceptionHelper.convert(JDBCExceptionHelper.java:66)
    at org.hibernate.id.insert.AbstractSelectingDelegate.performInsert(AbstractSelectingDelegate.java:64)
    at org.hibernate.persister.entity.AbstractEntityPersister.insert(AbstractEntityPersister.java:2329)
    at org.hibernate.persister.entity.AbstractEntityPersister.insert(AbstractEntityPersister.java:2822)
    at org.hibernate.action.EntityIdentityInsertAction.execute(EntityIdentityInsertAction.java:71)
    at org.hibernate.engine.ActionQueue.execute(ActionQueue.java:268)
    at org.hibernate.event.def.AbstractSaveEventListener.performSaveOrReplicate(AbstractSaveEventListener.java:321)
    at org.hibernate.event.def.AbstractSaveEventListener.performSave(AbstractSaveEventListener.java:204)
    at org.hibernate.event.def.AbstractSaveEventListener.saveWithGeneratedId(AbstractSaveEventListener.java:130)
    at org.hibernate.event.def.DefaultSaveOrUpdateEventListener.saveWithGeneratedOrRequestedId(DefaultSaveOrUpdateEventListener.java:210)
    at org.hibernate.event.def.DefaultSaveEventListener.saveWithGeneratedOrRequestedId(DefaultSaveEventListener.java:56)
    at org.hibernate.event.def.DefaultSaveOrUpdateEventListener.entityIsTransient(DefaultSaveOrUpdateEventListener.java:195)
    at org.hibernate.event.def.DefaultSaveEventListener.performSaveOrUpdate(DefaultSaveEventListener.java:50)
    at org.hibernate.event.def.DefaultSaveOrUpdateEventListener.onSaveOrUpdate(DefaultSaveOrUpdateEventListener.java:93)
    at org.hibernate.impl.SessionImpl.fireSave(SessionImpl.java:705)
    at org.hibernate.impl.SessionImpl.save(SessionImpl.java:693)
    at org.hibernate.impl.SessionImpl.save(SessionImpl.java:689)
    at sun.reflect.GeneratedMethodAccessor5.invoke(Unknown Source)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:25)
    at java.lang.reflect.Method.invoke(Method.java:597)
    at org.hibernate.context.ThreadLocalSessionContext$TransactionProtectionWrapper.invoke(ThreadLocalSessionContext.java:344)
    at $Proxy19.save(Unknown Source)
    at com.example.myproject.MyEntityService.save(MyEntityService.java:59) <-- relevant call (see notes below)
    at com.example.myproject.MyServlet.doPost(MyServlet.java:164)
    ... 32 more
Caused by: java.sql.SQLException: Violation of unique constraint MY_ENTITY_UK_1: duplicate value(s) for column(s) MY_COLUMN in statement [...]
    at org.hsqldb.jdbc.Util.throwError(Unknown Source)
    at org.hsqldb.jdbc.jdbcPreparedStatement.executeUpdate(Unknown Source)
    at com.mchange.v2.c3p0.impl.NewProxyPreparedStatement.executeUpdate(NewProxyPreparedStatement.java:105)
    at org.hibernate.id.insert.AbstractSelectingDelegate.performInsert(AbstractSelectingDelegate.java:57)
    ... 54 more

上面这个例子算是比较直观的, 越复杂的系统这个堆栈信息就越长. 如果内部使用了响应式或者函数式的框架, 那绝对头大, 可能盯着看上半小时也看不出个所以然来.

除非系统特别复杂, 不然大部分时候简单的执行路径足以帮助我们定位问题. 比如我们写个简单的接口:

// 用户对象
type User struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

// HTTP 接口
type rest struct { srv *business }

func (h *rest) Welcome(w http.ResponseWriter, r *http.Request) {
	name := r.URL.Query().Get("name")
	user, err := h.srv.WelcomeByName(r.Context(), name)
	if err != nil {
		err = &url.Error{
			Op:  r.Method,
			URL: r.URL.String(),
			Err: fmt.Errorf("rest.Welcome: %w", err),
		}
		log.Println(err)
		http.NotFound(w, r)
	} else {
		message := fmt.Sprintf("Hello, %s!", user.Name)
		_, _ = io.WriteString(w, message)
	}
}

// 业务层
type business struct { d *dao }

func (s *business) WelcomeByName(ctx context.Context, name string) (user User, err error) {
	if user, err = s.d.GetUserByName(ctx, name); err != nil {
		err = fmt.Errorf("business.WelcomeByName: %w", err) // 记录错误路径
	}
	return
}

// DAO 层
type dao struct{}

func (d *dao) GetUserByName(ctx context.Context, name string) (user User, err error) {
	err = fmt.Errorf("dao.GetUserByName: %w", err) // 记录错误路径
	return
}

客户端收到的错误信息:

404 page not found

服务端记录的日志:

2021/05/29 22:24:41 GET "http://api.demo.com/welcome?name=Bob": rest.Welcome: business.WelcomeByName: dao.GetUserByName: sql: no rows in result set

看起来是不是很清爽呢? 再看下记录全堆栈的版本.

2021/05/29 22:32:10 goroutine 1 [running]:
runtime/debug.Stack(0x9ac15f, 0x15, 0xc00011dd98)
	C:/code/go/src/runtime/debug/stack.go:24 +0xa5
main.(*dao).GetUserByName(0xc00011df47, 0x9e3a10, 0xc000014040, 0xc00000e367, 0x3, 0xc00000e362, 0x9a74ba, 0x4, 0x92f701, 0xc00007ad80, ...)
	C:/Users/a/AppData/Roaming/JetBrains/GoLand2021.1/scratches/scratch_8.go:69 +0xa5
main.(*business).WelcomeByName(0xc00011df50, 0x9e3a10, 0xc000014040, 0xc00000e367, 0x3, 0xc000024240, 0xc0000524e0, 0x38, 0x157fbdd0108, 0xc00014a000, ...)
	C:/Users/a/AppData/Roaming/JetBrains/GoLand2021.1/scratches/scratch_8.go:59 +0x7e
main.(*rest).Welcome(0xc00011df58, 0x9e3930, 0xc00005e200, 0xc000152000)
	C:/Users/a/AppData/Roaming/JetBrains/GoLand2021.1/scratches/scratch_8.go:39 +0xa5
main.main()
	C:/Users/a/AppData/Roaming/JetBrains/GoLand2021.1/scratches/scratch_8.go:24 +0x113

一般人可能要个反应个 10 秒才能提取出执行路径吧? 如果是较复杂的调用路径, 可能就是上面 Java 版的那个样子了.

另外, 简单的信息有助于我们解决另一个难题: 如何跨服务传播错误? 堆栈信息其实不太容易传播, 特别是跨语言时更难. 但是只包含业务执行路径的错误消息却十分容易传播. 看起来就像这样:

2021/05/29 22:24:41 
GET "http://api.demo.com/welcome?name=Bob": rest.Welcome: business.Welcome: api.getUser:
GET "http://api.demo.com/users?name=Bob": rest.getUser: dao.GetUser: sql: no rows in result set

所以, 我建议只在错误中记录业务相关的执行路径. 只在必要的时候才记录完整的堆栈信息: 通过开关的方式, 或者记录在 DebugInfo 这样的详情中.

虽说由于我们对微服务可观测性的要求越来越高, 有越来越多的工具可以帮助我们整理凌乱的错误信息. 比如 jaeger, sentry, …

但是, 这些外部依赖也在无形中提高我们的使用和运维成本. 处理不好的话, 甚至可能出现脱离了特定环境就两眼一抹黑, 或者根本启动不了的情况.

所以还是应当先处理好核心的东西, 再去享受这些运维工具带来的便利.

最后, 这部分其实很容易实现, 使用 fmt.Errorf() 就已经完全足够了.

错误码

错误码算是对错误的一种分类, 我们之前的分类方式粒度太粗, 易于理解但不便于使用.

回顾一下: (可重试, 不可重试) x (内部, 外部). 一共四个分类.

所以我们还是使用 gRPC 中久经考验的状态码规范.

type StatusCode int

// 错误码枚举
const (
    // 200 OK 为默认值
	OK StatusCode = iota
	//...
	Unauthenticated

    // 错误码总数, 总放在最后
	totalStatus
)
var (
    // 错误码名称列表
	statusList = [totalStatus]string{
		"OK",
		// ...
		"UNAUTHENTICATED",
	}
    // 错误码对应的 HttpStatusCode 列表
	httpList = [totalStatus]int{
		http.StatusOK,
		// ...
		http.StatusUnauthorized,
	}
)

// 错误码可以直接作为 Root Cause 使用
func (c StatusCode) Error() string {
	return fmt.Sprintf("%d %s", c.Http(), c.String())
}

// 返回错误码名称, 非法错误码如 -1 返回 Code(-1)
func (c StatusCode) String() string {
	if c.Valid() {
		return statusList[c]
	}
	return "Code(" + strconv.FormatInt(int64(c), 10) + ")"
}

// 返回错误码对应的 HttpStatusCode, 未知错误返回 500
func (c StatusCode) Http() int {
	if  c.Valid() {
		return httpList[c]
	}
	return http.StatusInternalServerError
}

Internal 为例:

  • Http() => 500
  • String() => INTERNAL
  • Error() => 500 INTERNAL

接下来, 我们需要一个方便的机制来标记我们的错误类型, 就像 fmt.Errorf() 那样. 这里我们定义一个 Annotate() 函数.

这个函数使用起来应该像这样:

err := Annotate(sql.ErrNoRows, NotFound)

Annotation 方法和 annotated 自定义错误:

type annotated struct {
	cause   error
	code    StatusCode
}

func (e annotated) Unwrap() error { return e.cause }
func (e annotated) Error() string { return e.cause.Error() }

type Annotation interface {
    Annotate(a *annotated)
}

func Annotate(cause error, annotations ...Annotation) error {
    if cause == nil || len(annotations) == 0 {
        return cause
    }
    
    a := &annotated{ cause: cause }
    for _, annotation := range annotations {
        annotation.Annotate(a)
    }
    return a
}

StatusCode 实现 Annotation 接口

func (c StatusCode) Annotate(a *annotated) { a.code = c }

还需要一个 Code() 函数获取当前的错误码:

code := Code(err)  // 404 NotFound

Code() 方法

func Code(err error) (out StatusCode) {
    if err == nil {
		return OK
	}
	var a *annotated
	if errors.As(err, &a) {
		return a.code
	}
	return Unknown
}

很好, 现在我们就可以为任何错误附加错误码了, 并且可以用新的错误码覆盖原有的.

错误详情

在之前的表格中, 每个错误码都是有对应的详情的. 现在来实现这部分, 就以 DebugInfo 为例.

type Any interface {
    Annotation
    TypeUrl() string  // 序列化时的 @type 字段需要
}

type DebugInfo struct {
	StackEntries []string `json:"stackEntries,omitempty"`
	Detail       string   `json:"detail,omitempty"`
}

func (d DebugInfo) TypeUrl() string       { return "type.googleapis.com/google.rpc.DebugInfo" }
func (d DebugInfo) Annotate(a *annotated) { a.details = append(a.details, d) }

给自定义错误加上 details 字段

type annotated struct {
	cause   error
	code    StatusCode
    details []Any
}

使用方式和设置状态码一摸一样

info := DebugInfo{[]string{"step1", "step2"}, "some message"}
err := Annotate(sql.ErrNoRows, NotFound, info)

别忘了详情获取的函数

func Details(err error) (details []Any) {
    next := err
	for next != nil {
		if a, ok := next.(*annotated); ok {
			details = append(details, a.details...)
		}
		next = errors.Unwrap(next)
	}
	return
}

使用示例:

details := Details(err)  // [{["step1", "step2"], "some message"}]

错误处理行为

顺带支持一下 Temporary()

func Temporary(err error) bool {
    return Code(err) == Unavailable
}

只有 503 Unavailable 是可以 100% 重试的, 因为请求还未到达服务器, 其他错误则要看具体业务才能确认幂等性.

Encode & Decode & Formatter

这部分涉及到格式化, 代码展开占的篇幅就太长了, 可以到 GitHub 上看源码, 简单提下要点.

  • 解码 Detail 时要根据不同的 @type 选择不同的 Detail 实现
  • 需要有一个 Register() 函数注册自定义的 Detail 实现
  • 需要有一个 Filter 支持只编码部分 Detail (向外传播时要隐藏内部信息)

再看两个例子, 先是格式化:

status: "504 DEADLINE_EXCEEDED"
message: "context deadline exceeded"
detail[0]:
	type: "type.googleapis.com/google.rpc.DebugInfo"
	detail: "heavy job"
	stack:
		goroutine 1 [running]:
		runtime/debug.Stack(0xc00005e980, 0x40, 0x40)
			/home/user/go/src/runtime/debug/stack.go:24 +0xa5
		github.com/gota33/errors.StackTrace.Annotate(0xfe36af, 0x9, 0x1056490, 0xc00005e980)
			/home/user/github/gota33/errors/detail.go:368 +0x2d
		github.com/gota33/errors.Annotate(0x1051780, 0x1257e60, 0xc00010fc00, 0x5, 0x5, 0xc00010fba8, 0x10)
			/home/user/github/gota33/errors/errors.go:79 +0x97
		github.com/gota33/errors.ExampleAnnotate()
			/home/user/github/gota33/errors/example_test.go:10 +0x251
		testing.runExample(0xfe589a, 0xf, 0xfff6c0, 0xfead08, 0x1a, 0x0, 0x0)
			/home/user/go/src/testing/run_example.go:63 +0x222
		testing.runExamples(0xc00010fed0, 0x120aee0, 0x3, 0x3, 0x0)
			/home/user/go/src/testing/example.go:44 +0x185
		testing.(*M).Run(0xc000114100, 0x0)
			/home/user/go/src/testing/testing.go:1419 +0x27d
		main.main()
			_testmain.go:71 +0x145
detail[1]:
	type: "type.googleapis.com/google.rpc.RequestInfo"
	request_id: "<uuid>"
	serving_data: ""
detail[2]:
	type: "type.googleapis.com/google.rpc.LocalizedMessage"
	local: "en-US"
	message: "Background task timeout"
detail[3]:
	type: "type.googleapis.com/google.rpc.LocalizedMessage"
	local: "zh-CN"
	message: "后台任务超时"

还有 JSON 序列化:

{
  "error": {
    "code": 504,
    "message": "context deadline exceeded",
    "status": "DEADLINE_EXCEEDED",
    "details": [
      {
        "@type": "type.googleapis.com/google.rpc.RequestInfo",
        "requestId": "<uuid>"
      },
      {
        "@type": "type.googleapis.com/google.rpc.LocalizedMessage",
        "local": "en-US",
        "message": "Background task timeout"
      },
      {
        "@type": "type.googleapis.com/google.rpc.LocalizedMessage",
        "local": "zh-CN",
        "message": "后台任务超时"
      }
    ]
  }
}

总结

写到这里篇幅已经很长了, 不过依然有很多方面没有提到. 比如:

  • 如何用 http.RoundTripperhttp.Client 实现自动错误解码?
  • 如何用 middleware 让服务器实现自动错误编码?
  • 如何用 validator 集中处理错误, 生成 BadRequest 详情?

再写点 Memo:

  • 错误一般集中在服务边界, 80% 的错误出现在入口 Handler 和对外部服务的调用
  • 底层错误能重试的应该尽量重试, 比如: 503 错误
  • 事务级别的重试一般在业务层, 比如: 数据库锁超时要重试事务
  • 重试一定要记得设置最大超时, 避免无限重试
  • 处理错误最好的方法: 修改定义, 使原本出错的情景不再是错误.
  • 任何情况下都不该隐藏错误, 即便是崩溃也好于隐藏.
  • 机器更关注如何处理错误, 人类更关注错误信息.

还有本文的灵感来源:

最后是本文中错误处理库的源码:

感谢阅读, 评论可以到 V2ex