错误模型
一个错误 = Kind(决定 HTTP 状态)+ 业务 code + 给人看的消息。
业务代码只管返回 errs 值,映射成状态码与响应体是 HTTP 层的事。
Error 是什么
type Kind int
const (
KindInvalidArgument Kind = iota + 1 // 400
KindUnauthorized // 401
KindForbidden // 403
KindNotFound // 404
KindConflict // 409
KindRateLimited // 429
KindInternal // 500
KindUnavailable // 503
KindTimeout // 504
)
type Error struct {
Kind Kind // 决定 HTTP 状态
Code int // 业务码,每服务自己的常量
Msg string // 回给客户端的消息
Cause error // 内部原因,只进日志
}
Error 实现 Error() 与 Unwrap():Error()
在有 cause 时返回 Msg + ": " + Cause.Error(),所以日志里有完整因果链;
Unwrap() 让 errors.Is / errors.As 能穿透到 cause。
Kind 到 HTTP 状态
Kind.HTTPStatus() 是唯一的映射点,未识别的 Kind 一律 500。
| Kind | HTTP | 语义 |
|---|---|---|
KindInvalidArgument | 400 | 参数缺失或格式错误 |
KindUnauthorized | 401 | 没有身份或凭据已失效 |
KindForbidden | 403 | 有身份但无权访问该资源 |
KindNotFound | 404 | 资源不存在 |
KindConflict | 409 | 状态冲突,例如唯一键重复 |
KindRateLimited | 429 | 被限流拒绝 |
KindInternal | 500 | 服务内部错误 |
KindUnavailable | 503 | 依赖未配置或暂不可用 |
KindTimeout | 504 | 超时 |
<http-status>*100 + 子号:40401 是 404 下的
"用户不存在",40901 是 409 下的"名字已被占用"。这样看日志里的 code
就能直接知道状态码类别,且各子号在服务内自洽。
构造:New / Newf / Wrap
// 定值消息
err := errs.New(errs.KindNotFound, 40401, "user not found")
// 格式化消息
err := errs.Newf(errs.KindNotFound, 40401, "user %d not found", 42)
// 带上内部原因:Msg 给客户端,cause 只进日志
err := errs.Wrap(errs.KindConflict, 40901, dbErr, "创建用户失败")
Wrap 是唯一需要 cause 的构造方式,也是唯一会把内部细节带进
Error() 的方式。只用 New / Newf 时,
Error() 就等于 Msg。
快捷构造
每个 Kind 一个同名函数,签名统一为 (code int, format string, a ...any),
内部就是 Newf。只有 InternalWrap 多一个 cause 参数。
| 构造 | Kind | 典型用法 |
|---|---|---|
errs.Invalid(40001, "name is required") | KindInvalidArgument | 请求参数校验失败 |
errs.Unauthorized(40101, "token expired") | KindUnauthorized | 凭据缺失或过期 |
errs.Forbidden(40301, "not your resource") | KindForbidden | 越权访问 |
errs.NotFound(40401, "user %d not found", 42) | KindNotFound | 查询无结果 |
errs.Conflict(40901, "name already taken") | KindConflict | 唯一键冲突、状态冲突 |
errs.RateLimited(42901, "too many requests") | KindRateLimited | 限流器拒绝 |
errs.Internal(50001, "boom") | KindInternal | 内部错误,无 cause |
errs.InternalWrap(50002, cause, "load config") | KindInternal | 内部错误,带 cause |
errs.Unavailable(50301, "database is not configured") | KindUnavailable | 依赖不可用 |
errs.Timeout(50401, "upstream timeout") | KindTimeout | 下游超时 |
// internal/domain/helloworld/handler.go
var errNoDB = errs.Unavailable(50301, "database is not configured")
func (h *Handler) Greet(ctx context.Context, r *http.Request) (any, error) {
name := r.PathValue("name")
if name == "" {
return nil, errs.Invalid(40001, "name is required")
}
return h.svc.Greet(ctx, name)
}
Status:三个返回值
HTTP 层只调一个函数。它返回 (httpStatus, code, msg),
三者的关系就上面那两条约定:
func Status(err error) (httpStatus, code int, msg string) {
if e, ok := errors.AsType[*Error](err); ok {
c := e.Code
if c == 0 {
c = e.Kind.HTTPStatus() * 100 // code 省略时回退为 状态码*100
}
return e.Kind.HTTPStatus(), c, e.Msg
}
// 裸 context 错误保留其含义,不降级成笼统 500
if errors.Is(err, context.DeadlineExceeded) {
return KindTimeout.HTTPStatus(), KindTimeout.HTTPStatus() * 100, "request timeout"
}
if errors.Is(err, context.Canceled) {
return KindUnavailable.HTTPStatus(), KindUnavailable.HTTPStatus() * 100, "request canceled"
}
return 500, 50000, "internal error"
}
| 输入 | 返回 |
|---|---|
errs.NotFound(40401, …) | 404, 40401, "…" |
errs.New(errs.KindConflict, 0, "已存在") | 409, 40900, "已存在"(code 省略 → 状态码 ×100) |
context.DeadlineExceeded | 504, 50400, "request timeout" |
context.Canceled | 503, 50300, "request canceled" |
errors.New("internal detail…") | 500, 50000, "internal error" |
普通 error 一律 500,且不泄露细节
非 errs 的错误没有经过设计,谁也不能保证它的话术适合给客户端看——
它可能带着 SQL、文件路径、内网主机名。所以 Status 走兜底分支时
只返回固定串 "internal error",原始文本被丢掉:
// 真实输出(examples/errors):
// status=500 code=50000 msg="internal error"
status, code, msg := errs.Status(errors.New("internal detail nobody should see"))
但 5xx 不代表日志里也该沉默。httpx 在写响应前先按原错误记一条 Error 日志,
带上完整的 err(含 cause 与调用栈语义),排查时从这里看:
// pkg/core/httpx/server.go
func (s *Server) writeError(ctx context.Context, w http.ResponseWriter, err error) {
status, code, msg := errs.Status(err)
if status >= 500 {
obs.Log(ctx).Error("request failed", "err", err, "status", status)
}
writeJSON(w, status, envelope{Code: code, Msg: msg})
}
errs.Wrap(KindConflict, 40901, errors.New(`pq: duplicate key … "users_name_key"`), "创建用户失败")
的 Error() 含库名与约束名,但响应体只有 {"code":40901,"msg":"创建用户失败"}。
设计上"能定位问题"和"能对客户端说话"是两件事,不要为了排障方便把 cause 拼进 Msg。
包装链穿透
Status 与 KindOf 都用 errors.AsType[*Error]
取最外层的 *Error,所以中间隔多少层普通包装都不影响:
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) // 404 40401 "user 42 not found"
e, ok := errors.AsType[*errs.Error](wrapped)
// e.Kind = KindNotFound,e.Cause = sql: no rows in result set(只进日志)
flowchart LR
W["fmt.Errorf 包装(%w 保留因果链)"] --> B["*errs.Error
Kind=NotFound
Code=40401
Msg="user 42 not found""]
B --> C["Cause
sql: no rows in result set"]
W -. "errors.AsType 穿透" .-> B
B -- "Status() → 404 / 40401 / Msg" --> R["响应体
{code,msg,data}"]
C -- "Error() → 完整因果链" --> L["日志 stdout
级别 Error"]
classDef hide fill:#fdf6f0,stroke:#c8641f,color:#2b2722
class L hide
Status 取到的是 Kind/code/Msg,走响应;
cause 只沿 Error() 去日志。橙色一侧是客户端永远看不到的部分。
需要按错误类型分支时用 errors.Is / errors.As
(或 Go 1.26 的泛型形式 errors.AsType[T],仓库里统一用后者),
不要做字符串比较:
if errors.Is(err, db.ErrNoRow) {
return nil, errs.NotFound(40401, "user %s not found", id)
}
if err != nil {
return nil, err // 交给框架 → 500
}
KindOf:只要类别
KindOf(err) 返回 Kind,兜底同样是 KindInternal,
裸 context 错误分别归到 KindTimeout / KindUnavailable。
熔断器、指标分类这类"按错误类别计数"的地方用它——它们需要的是 4xx 还是 5xx
这种粗粒度判断,不是 code:
if errs.KindOf(err) == errs.KindInternal {
// 计入下游失败
}
在 handler 里怎么用
三条规则:校验失败立刻返回 errs.Invalid;业务失败返回最贴近事实的
Kind;拿不准就原样返回 err,让框架兜底成 500——不要为了"好看"
把内部错误包成 400。
func (h *Handler) Get(ctx context.Context, r *http.Request) (any, error) {
id := r.PathValue("id")
if id == "" {
return nil, errs.Invalid(40001, "id is required") // → 400
}
u, err := h.svc.Get(ctx, id)
if errors.Is(err, db.ErrNoRow) {
return nil, errs.NotFound(40401, "user %s not found", id) // → 404
}
if err != nil {
return nil, err // → 500,细节只进日志
}
return u, nil
}
相关示例
- examples/errors:Kind → 状态的九条映射、快捷构造、Wrap 的响应与日志分叉。
- examples/http:封套渲染,错误如何变成
{code,msg,data}。 - HTTP 与路由:返回值到响应的完整映射表。