错误模型

一个错误 = 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 语义
KindInvalidArgument400参数缺失或格式错误
KindUnauthorized401没有身份或凭据已失效
KindForbidden403有身份但无权访问该资源
KindNotFound404资源不存在
KindConflict409状态冲突,例如唯一键重复
KindRateLimited429被限流拒绝
KindInternal500服务内部错误
KindUnavailable503依赖未配置或暂不可用
KindTimeout504超时
编码约定 业务 code 写成 <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.DeadlineExceeded504, 50400, "request timeout"
context.Canceled503, 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})
}
cause 永远不进响应体 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
}

相关示例