重新思考错误处理 Part 2
引言
文中的源码在这里: 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"
}
}
]
}
}
错误类型
错误消息
错误详情
备注
-
所有的 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.RoundTripper
让http.Client
实现自动错误解码? - 如何用
middleware
让服务器实现自动错误编码? - 如何用 validator 集中处理错误, 生成 BadRequest 详情?
- …
再写点 Memo:
- 错误一般集中在服务边界, 80% 的错误出现在入口 Handler 和对外部服务的调用
- 底层错误能重试的应该尽量重试, 比如: 503 错误
- 事务级别的重试一般在业务层, 比如: 数据库锁超时要重试事务
- 重试一定要记得设置最大超时, 避免无限重试
- 处理错误最好的方法: 修改定义, 使原本出错的情景不再是错误.
- 任何情况下都不该隐藏错误, 即便是崩溃也好于隐藏.
- 机器更关注如何处理错误, 人类更关注错误信息.
还有本文的灵感来源:
最后是本文中错误处理库的源码:
感谢阅读, 评论可以到 V2ex