展示错误:统一信封,日志与客户端分离

更新于 · 在 sijie.xyz 查看原条目 ↗

抵达用户的错误都被包进同一种信封结构;错误的根因单独记录在日志里审计,绝不暴露给客户端。

后端信封结构

backend/internal/infra/apierr/ (moved under infra/ with 292ce86d2, 2026-07-26):DisplayError 接口是纯结构性的,实现它不需要依赖 apierr:

type DisplayError interface {
    error
    HTTPStatus() int
    DisplayCode() string
    DisplayMessage() string
}

任何包都能满足这个接口而无需导入 apierr(不存在依赖倒置)。两个构造函数:

  • Display(status, code, message):包一条面向用户的消息。
  • DisplayWrap(status, code, message, cause):把两者都包进去。cause 通过 Unwrap() 流向日志,永不流向客户端;Error() 会带上 cause,供结构化日志使用;DisplayMessage() 则始终保持友好。

Handler 分类:Classify()

handler 里的 Classify(err, cases) 把复杂度限制住:

  1. 先用 errors.As 判断是否为 DisplayError → 是就直接渲染 Envelope{Status, Code, Message}。
  2. 否则,按声明的 cases(一个 Case{Match error, Envelope Envelope} 的切片 —— classify.go:19)依次用 errors.Is 匹配,命中第一个即用。
  3. 都不中,就回落到 500 的 server_error。

这让 handler 的圈复杂度保持在 ≤2;handler 本身不写分支逻辑。

前端镜像:APIError 与分支处理

app/src/lib/api/api-error.ts 定义了读取该信封的 class APIError:

interface Envelope {
  error: {code: string, message: string}
}

class APIError {
  status: number
  code: string
  message: string
}

前端按状态码分支:

  • 401 未授权:use-report-error.ts 重定向到登录页(一条用户只能干瞪眼的 toast 没有意义)。
  • 409 冲突:不走中央分支,而是在调用处处理 —— mutation hook 把信封里的消息留在表单旁边(lib/admin/use-providers.ts、use-microsites.ts、use-assets.ts 各自对 409 分支),绝不弹 toast。
  • 其余情况:走 lib/ui/toast.tsx + use-report-error.ts 弹 toast。

按情形分支处理,让错误始终可用:409 不会淹没在一条 toast 里,而是落在它该属于的那个表单字段上。

生产环境实例

routes/public/inference_models.go 是第一个调用方;错误码本身现在住在 internal/infra/providermodels/list.go(admin 侧和公开侧共用)。其错误情形(都是 400——已在代码中核实):

  • no_model_list —— Display(400, …)。
  • endpoint_required —— Display(400, …)。
  • provider_unreachable —— DisplayWrap(400, …, err):友好消息对外,包裹后的 cause 流向日志。

类视图

classDiagram
  class DisplayError {
    <<interface structural>>
    error
    +HTTPStatus() int
    +DisplayCode() string
    +DisplayMessage() string
  }
  class displayError {
    -cause error
    -status int
    -code string
    -message string
    +Error() includes cause - logs only
    +Unwrap() cause
  }
  class Envelope {
    Status int
    Code string
    Message string
  }
  class Case {
    Match error - sentinel via errors.Is
    Envelope Envelope
  }
  class Classify {
    <<func>>
    takes err, []Case - returns Envelope
    errors.As DisplayError first
    else first-match over cases
    else 500 server_error
  }
  class APIError {
    <<frontend TS>>
    status, code, message
    401 login, else toast; 409 inline at the call site
  }
  DisplayError <|.. displayError : Display / DisplayWrap
  Classify ..> DisplayError : detects
  Classify ..> Case : declared per handler
  Classify --> Envelope : renders
  Envelope ..> APIError : wire - error code+message

这个结构化接口就是全部的巧妙之处:任何包无需导入 apierr 就能满足 DisplayError——箭头永远指向接口,从不指向具体的包。

原则

一种线上信封形状,两种受众:

  • 运维方在日志里拿到根因(结构化、可调试)。
  • 用户拿到友好、可操作的消息(不泄漏、不带术语)。

这个分离是在信封边界上强制执行的,而不是留给每个 handler 各自决定。

相关笔记