2026-09-23·by Sijie Wang#standmeet#architecture#design

display-error-surfacing

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

上级:structure

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

后端信封结构

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.tsuse-microsites.tsuse-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 流向日志。

类视图

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

原则

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

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

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

read next
about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →