errors — 错误模型
一个错误由三部分组成:Kind(映射成 HTTP 状态)、业务 code(客户端用来分支的常量)、
消息(给人的话)。这个示例把三者的每一种组合都跑一遍,你会看到错误在穿越
fmt.Errorf 包装后仍然能被识别,而数据库的原始报错永远进不了响应体。
运行
go run ./examples/errors
纯库演示:没有外部依赖,不监听端口,也不需要 make dev。
它演示了什么
- 九个 Kind 各映射一个状态码:从
invalid_argument → 400到timeout → 504。 - 快捷构造:
errs.NotFound(40401, "user %d not found", 42)这类函数是写 handler 时的默认选择。 errs.Status(err)返回三个值:(status, code, msg),就是 HTTP 层渲染响应所需的全部。- 包装可穿透:
errs.Wrap或普通fmt.Errorf("…: %w", err)包过之后,errs.Status仍取得到 Kind/code。 - cause 只进日志:
Wrap的Error()含 cause,但响应只回msg。 - 兜底:非 matex 错误统一
500 / 50000 / "internal error",内部细节不外泄。
Kind 到状态码
下面这段来自 examples/errors/main.go,它构造了全部九个 Kind 并逐个打印
HTTPStatus()。这张表就是 HTTP 层的映射依据。
fmt.Println("Kind → HTTP")
for _, c := range kinds() {
fmt.Printf(" %-18s → %d\n", c.name, c.kind.HTTPStatus())
}
// ── 2. 快捷构造 + Status 提取 ─────────────────────────────────────
fmt.Println("\nerrs.Status(err) → (status, code, msg)")
for _, err := range constructors() {
status, code, msg := errs.Status(err)
fmt.Printf(" %-3d %-6d %s\n", status, code, msg)
}
它打印出的九行对应关系是:400 / 401 / 403 / 404 / 409 / 429 / 500 / 503 / 504。
包装后取出的仍然是同一件事
这段同样来自 examples/errors/main.go。要点有两个:普通的
fmt.Errorf("handler: %w", base) 也能被穿透;
而 *errs.Error 上的 Cause 字段是留给日志的,
不出现在客户端拿到的 msg 里。
base := errs.Wrap(errs.KindNotFound, 40401, errors.New("sql: no rows in result set"), "user 42 not found")
wrapped := fmt.Errorf("handler: %w", base) // 普通 %w 包装也能被穿透
status, code, msg := errs.Status(wrapped)
fmt.Printf(" 穿透 fmt.Errorf: status=%d code=%d msg=%q\n", status, code, msg)
if e, ok := errors.AsType[*errs.Error](wrapped); ok {
fmt.Printf(" 取出 *errs.Error: Kind=%d Cause=%v(只进日志)\n", e.Kind, e.Cause)
}
dbErr := errs.Wrap(errs.KindConflict, 40901,
errors.New(`pq: duplicate key value violates unique constraint "users_name_key"`), "创建用户失败")
status, code, msg = errs.Status(dbErr)
fmt.Printf(" Wrap(cause) : Error()=%q\n", dbErr.Error())
验证
示例不提供 HTTP 接口,看标准输出即可。形状如下(取自 main.go 的打印格式):
Kind → HTTP
invalid_argument → 400
unauthorized → 401
forbidden → 403
not_found → 404
conflict → 409
rate_limited → 429
internal → 500
unavailable → 503
timeout → 504
errs.Status(err) → (status, code, msg)
400 40001 name is required
401 40101 token expired
...
包装(errs.Wrap / fmt.Errorf %w):
穿透 fmt.Errorf: status=404 code=40401 msg="user 42 not found"
取出 *errs.Error: Kind=4 Cause=sql: no rows in result set(只进日志)
Wrap(cause) : Error()="创建用户失败: pq: duplicate key value violates unique constraint \"users_name_key\""
对客户端只回: status=409 code=40901 msg="创建用户失败"
code=0 时回退为 状态码*100:
KindConflict, code=0 → code=40900 msg="已存在"
非 matex 错误:
status=500 code=50000 msg="internal error"
这里最值得记住的是最后两行:code 传 0 时回退为
状态码 × 100(409 → 40900),而普通错误被兜成
500 / 50000 / "internal error"——"internal detail nobody should see"
这句话没有出现在响应里。
flowchart LR
E["任意 error"] --> Q{"是 *errs.Error 吗?
(穿透 %w 链)"}
Q -- 是 --> K["取 Kind
→ HTTP 状态"]
K --> C{"code 为 0?"}
C -- 是 --> F["code = 状态码 × 100"]
C -- 否 --> U["用业务 code"]
Q -- 否 --> D["500 / 50000
internal error"]
F --> R["响应
{code, msg, data:null}"]
U --> R
D --> R
X["cause(SQL、内部细节)"] -.->|只进日志| L["日志"]
classDef bad fill:#fdf0f0,stroke:#a33,color:#2b2722
class X,L bad
errs.Status 的全部决策。cause 有一条独立的出口通向上游日志,
从不进入响应体。
测试
main_test.go 断言的是这些具体事实:
TestKindStatusMapping:九个 Kind 各自映射到 400/401/403/404/409/429/500/503/504,一个都不能偏。TestStatusThroughWrapping:被%w包过之后,errs.Status仍返回(404, 40401, "user 42 not found")。TestCodeFallback:KindConflict配code=0→40900。TestWrapKeepsCauseOutOfResponse:Error()恰好是"创建用户失败: pq: duplicate key",而Status给出的是(409, 40901, "创建用户失败")——cause 没漏进去; 同时errors.Is(err, cause)为真。TestPlainErrorIsInternal:普通错误 →(500, 50000, "internal error")。
go test ./examples/errors
相关
相关示例
- examples/http:errs 值怎么被渲染成
{code,msg,data}封套。 - examples/resilience:幂等中间件按 4xx/5xx 分流,靠的正是 Kind。
- examples/observability:cause 最终去哪了——结构化日志。