错误码与异常信息设计实战:让 AI Agent 和前端都能读懂你的错误
错误是最常被调用的接口资产:RFC 9457 标准错误体、UPPER_SNAKE_CASE 稳定错误码、HTTP 状态码决策表、i18n 文案工程、traceId 贯穿、前端判别式类型与 Agent 可执行错误契约——一套 5 年不腐烂的错误体系实战。

先划三条界,免得和站内已有的指南撞车:本文不讲错误发生之后如何监控和报警——错误监控(Sentry/Datadog 集成、SLO 与 error budget 的设计)请移步《错误监控指南》;也不讲表单校验的交互细节(哪种错误提示放字段下、实时校验的时机),那是《表单校验 UX 指南》的地盘。本文只解决一个问题:当你的 API 或系统返回一个错误时,它的"形状"长什么样?错误码怎么命名才能 5 年不腐烂?type/title/status/detail/instance 五个字段各自承载什么信息?5xx 的细节透露到哪一层为止?前端 SDK 和 AI Agent 又该如何基于这套契约做自动降级与用户指引?
错误响应是整套系统里被调用最频繁的"接口资产",却常常是设计最随意的那一块。典型的 vibe 项目错误长这样:
HTTP/1.1 200 OK
{"success": false, "message": "出错了"}
这段响应有四个致命问题:HTTP 状态码是 200,网关、CDN、浏览器缓存全部会把它当成功处理;message 是写给人看的中文,任何一次文案改版都会让前端的 if (message.includes('出错了')) 悄悄失效;没有机器可读的稳定标识,AI Agent 拿到它只能把整段话喂给 LLM 猜;没有请求追踪标识,用户报障时客服无从查证。更糟的是,这类错误设计会在你接入第三方 SDK、开放 OpenAPI、让 Agent 自动调用 API 时成倍放大——每个调用方都要重新"猜"你的错误语义。
本文给出一套完整方案:错误码命名规范(UPPER_SNAKE_CASE 稳定标识)+ RFC 9457 Problem Details 响应形状 + HTTP 状态码映射决策表 + i18n 文案工程 + traceId 贯穿 + 前端判别式类型 + Agent 可操作的扩展字段 + 错误码治理。每一节都给出可直接落地的代码和配置,照着做,一下午就能把现有项目的错误体系重建成"5 年不腐烂"的版本。
一、错误码命名规范:稳定标识与 HTTP 状态码解耦
错误码设计的第一性原理是:错误码是机器读的契约标识,不是给人看的文案。一旦发布,码值永不改变含义——只新增、不复用、不删除语义。这意味着命名时就要避开所有"会变"的东西:不要带版本号(V2_PLAN_LIMIT)、不要带内部模块缩写(BILL_MOD_004)、不要用纯数字(10023 毫无自解释性,Twilio 那种 21211 数字码体系只适合超大规模平台,且需要配文档站才能用)。
推荐的命名法是 UPPER_SNAKE_CASE + 领域前缀 + 具体原因:
// 好:自解释、稳定、可 grep
PLAN_LIMIT_REACHED // 套餐额度用尽
PAYMENT_CARD_DECLINED // 银行卡被拒
AUTH_TOKEN_EXPIRED // 登录态过期
RESOURCE_NOT_FOUND // 资源不存在
QUOTA_RATE_LIMITED // 触发限流
// 坏:含义会漂移、无法自解释
ERROR_400 // 和状态码耦合,状态码一改就烂
BIZ_10023 // 纯数字,查文档才能懂
PLAN_LIMIT // 是"达到"还是"查询"?动词缺失,语义模糊
三条铁律:第一,错误码与 HTTP 状态码解耦。HTTP 状态码只表达"传输层语义"(401 未认证、404 不存在、429 限流),错误码表达"业务语义"(是套餐额度用尽还是试用过期)。同一个 PLAN_LIMIT_REACHED 今天可能返回 402/403,未来业务调整也可能返回 429,前端和 Agent 只认码值不认状态码,状态码变了业务逻辑不用改。第二,禁止用 400 做万能兜底。所有"不知道怎么分类"的错误都塞进 400,是错误体系腐烂的起点——调用方永远无法区分"参数错了"还是"服务端 bug",重试策略无从谈起。兜底码只能是 500 系的 INTERNAL_ERROR,且必须带 traceId(见第五节)。第三,码值一旦发布永不改含义。如果 PLAN_LIMIT_REACHED 的触发条件变了(比如从"月额度"变成"日额度"),宁可新增 PLAN_DAILY_LIMIT_REACHED,也不要偷偷改旧码的语义。老客户端、老 Agent 的决策逻辑都依赖旧语义,改语义等于线上埋雷。
参考现实世界的标杆:Stripe 的错误体是业界公认写得最好的之一——
{
"error": {
"code": "card_declined",
"decline_code": "insufficient_funds",
"type": "card_error",
"message": "Your card has insufficient funds."
}
}
注意它的三层结构:type 是大类(card_error / api_error / idempotency_error),code 是稳定机器码(snake_case),decline_code 是发卡行返回的细分原因,message 是给人看的文案。四层各司其职,互不替代。Twilio 则用数字码(如 21211 表示"无效的目标号码"),每个码在文档站有独立页面——数字码可读性差,但它的"一码一文档页"治理思想值得借鉴(见第九节)。
二、RFC 9457 Problem Details:错误的"标准形状"
2023 年 6 月发布的 RFC 9457《Problem Details for HTTP APIs》正式取代了 RFC 7807,成为 HTTP API 错误响应的标准形状。它只做一件事:规定错误体的五个核心字段和媒体类型 application/problem+json(另有 application/problem+xml)。凡是返回错误的接口,把 Content-Type 设为 application/problem+json,调用方(包括通用 HTTP 客户端和 Agent 框架)立刻就知道"这是一个标准错误体",可以按固定字段解析。

五个字段的分工非常精妙,各承载一种信息,绝不混用:
HTTP/1.1 402 Payment Required
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/plan-limit-reached",
"title": "Plan Limit Reached",
"status": 402,
"detail": "Monthly AI token quota of 1,000,000 exhausted; resets on 2026-11-01.",
"instance": "/v1/projects/9f2a.../generate",
"code": "PLAN_LIMIT_REACHED",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"balance": 0,
"required": 1200,
"retryAfter": 2592000
}
- type:问题类型的文档 URI,标识"这是一类什么问题"。可以是真实可访问的文档页(如上例),也可以是约定的 URN。RFC 规定缺省值为
about:blank。它的作用是给人和机器一个"去哪儿查"的入口——Agent 拿到未知 type 可以抓取该 URI 了解含义。 - title:该问题类型的稳定短标题。RFC 原文要求:同一类问题的 title 必须保持一致,只允许为了本地化而变化。也就是说 title 是"问题类型的人类可读名",不是本次错误的描述。千万别把 title 写成"用户 xxx 的额度用完了"这种每次都变的话。
- status:本次响应的 HTTP 状态码在 body 里的镜像。它存在的唯一理由是:某些代理或日志只记录 body,status 让 body 自包含。但它必须和 HTTP 响应行里的状态码一致,写错会造成双重语义灾难。
- detail:本次错误的具体说明,写给人看的,可以每次都不同。但注意:detail 是给"看日志的人"看的,不是给"终端用户"看的——终端用户看到的文案走 i18n 词典(见第四节),detail 里可以带技术细节(如配额数字、重置时间)。
- instance:本次错误实例的定位引用,通常是发生错误的请求路径或事件 ID。用户报障时说"我的 instance 是 /v1/projects/…/generate",客服立刻能定位。
三个常被忽略但很重要的点:第一,RFC 9457 明确允许扩展成员(如上例的 code、traceId、balance),问题类型定义可以声明自己的扩展字段——这正是第七节 Agent 契约的合法性来源。第二,Content-Type 必须如实声明为 application/problem+json,而不是 application/json,否则调用方无法区分"正常 JSON 响应"和"标准错误体"。第三,Spring Framework 6 开始内置了 org.springframework.http.ProblemDetail 类原生支持这一规范;Express/NestJS/FastAPI 则需要自己封装一个中间件/过滤器,核心就是三行:定形状、定 Content-Type、统一出口。
// Express 示例:统一的 problem+json 出口(app.use 挂在所有路由之后)
app.use((err, req, res, next) => {
const status = err.status ?? 500;
res.status(status).type('application/problem+json').json({
type: err.problemType ?? 'about:blank',
title: err.title ?? 'Internal Server Error',
status,
detail: status >= 500 ? undefined : err.detail, // 5xx 的 detail 不向外暴露
instance: req.originalUrl,
code: err.code ?? 'INTERNAL_ERROR',
traceId: req.traceId, // 见第五节:请求级追踪标识
});
});
三、HTTP 状态码映射决策表:语义红线
状态码选错是最常见的错误设计事故。下面这张决策表覆盖了 API 项目 95% 的场景,照着选就不会错:
| 场景 | 状态码 | 语义红线 |
|---|---|---|
| 请求体 JSON 解析失败、缺少必填字段 | 400 Bad Request | 纯粹的"请求格式"问题,服务端连业务逻辑都没进 |
| 缺少/无效的认证凭证 | 401 Unauthorized | 必须带 WWW-Authenticate 头告诉客户端怎么认证;注意 401 的名字是历史包袱,实际含义是"未认证" |
| 已认证但无权限 | 403 Forbidden | 和 401 的区别:401 是"你是谁",403 是"你没资格"。不要为了"隐藏资源存在性"滥用 404 代替 403——那会让调试者抓狂 |
| 资源不存在 | 404 Not Found | 仅用于"定位不到资源";鉴权失败伪装成 404 是反模式 |
| 业务规则冲突(重复创建、状态机非法流转、幂等键冲突) | 409 Conflict | 409 的精髓是"当前资源状态下这个操作做不了",客户端换个参数或等状态变了可重试;幂等键(Idempotency-Key)冲突必须返回 409 而不是 400 |
| 语义正确但业务校验不通过 | 422 Unprocessable Content | JSON 格式完全合法,但"邮箱格式不对""余额不足""日期在过去"——这是 422 和 400 的分水岭。注意 RFC 9110 已把 422 的名字从 Unprocessable Entity 改为 Unprocessable Content |
| 限流/配额超限 | 429 Too Many Requests | 必须带 Retry-After 响应头(秒数或 HTTP 日期,RFC 6585),否则客户端只能盲等盲重试 |
几个高频踩坑点展开说。422 vs 400:很多框架(包括早期的 Rails)把校验错误都归为 422,而另一些团队全用 400。选哪个都可以,但必须全站统一,并且 422 的错误体要带结构化校验明细(见第八节)。403 vs 429 配额:套餐额度用尽到底是 403 还是 429?看语义——429 意味着"过一段时间自动恢复"(配合 Retry-After),403 意味着"你的身份/套餐决定了你做不了"。月度 AI token 额度用尽、下月重置,用 429 + Retry-After 更诚实,客户端可以自动排队等到下月;"免费版不允许用该功能"则是纯 403。反模式之王:200 包裹 {"success": false}。它会破坏所有基础设施的假设:CDN 会缓存你的"错误"、负载均衡健康检查会误判、fetch/axios 的错误分支永远走不到、Prometheus 的 5xx 告警永远不触发。只有一个例外:某些 webhook 回调方(如微信支付通知)要求必须回 200,否则会疯狂重试——那是在"回调确认"这个特殊协议里的妥协,不是通用 API 的设计。
四、i18n 文案工程:title 是 key,不是句子
错误文案的国际化不是"找个翻译把中文换成英文"那么简单,而是一套工程约束:前端和 Agent 永远不直接消费自然语言文案做逻辑判断;文案只走词典 key;detail 按 Accept-Language 内容协商;缺省回退英文。
具体做法是三层分离。第一层,title 和错误码是稳定的词典 key,例如 PLAN_LIMIT_REACHED 对应词典条目 errors.PLAN_LIMIT_REACHED.title,中英文词典各一份,前端按当前 locale 取值,绝不硬编码中文字符串做 includes 判断(那是第一章批判的脆弱模式)。第二层,detail 由后端按请求头的 Accept-Language 做内容协商生成——后端持有 i18n 资源(如 errors.zh-CN.json / errors.en.json),detail 模板里插值具体参数(剩余额度、重置时间),而不是拼接字符串。第三层,缺省回退:当请求的语言没有对应翻译时,回退英文而不是回退 key 本身——用户看到英文文案还能理解,看到 PLAN_LIMIT_REACHED 裸 key 只会困惑;同时 CI 必须保证英文词典 100% 覆盖所有错误码(见第九节)。
// errors/en.json(英文为缺省回退语言,必须全量覆盖)
{
"PLAN_LIMIT_REACHED": {
"title": "Plan limit reached",
"detail": "Monthly AI token quota of {quota} exhausted; resets on {resetDate}."
}
}
// errors/zh-CN.json
{
"PLAN_LIMIT_REACHED": {
"title": "套餐额度已用尽",
"detail": "本月 AI token 额度 {quota} 已用完,将于 {resetDate} 重置。"
}
}
还有两个细节。第一,detail 模板插值用命名参数({quota})而不是位置参数,因为不同语言的语序不同,位置参数在翻译时会错位——这是 ICU MessageFormat 解决过的经典问题,词典格式建议直接采用 ICU 规范。第二,面向终端用户的文案和面向开发者的 detail 要分开:detail 可以写"quota=1000000, used=1000000, reset=2026-11-01",但用户界面显示的应该是词典里的友好文案 + 行动指引("升级套餐"按钮 / "下月再试"提示)。混在一起的结果就是:要么用户看不懂技术黑话,要么开发者在日志里找不到关键参数。
五、traceId 贯穿:5xx 的信息披露分层
一个错误响应里最重要的字段可能不是 code,而是 traceId——它是连接"用户看到的错误"和"服务端日志"的唯一桥梁。没有它,用户报障说"我刚才出错了",客服只能让他复述操作步骤;有了它,用户说"错误码 PLAN_LIMIT_REACHED,追踪码 4bf92f35",客服 10 秒就能在日志系统里定位到完整调用链。
traceId 的生成与贯穿有标准做法:网关/入口中间件为每个请求生成一个唯一 ID(UUID v4 或 ULID 均可),把它同时做三件事——放进响应体的 traceId 字段、放进响应头(推荐 X-Request-Id,这是 Heroku/NGINX 生态的事实标准)、注入日志上下文(MDC/AsyncLocalStorage)。如果系统已经接入 OpenTelemetry,那就直接用 OTel 的 trace_id(32 位十六进制,如 4bf92f3577b34da6a3ce929d0e0e4736),并遵循 W3C Trace Context 规范通过 traceparent: 00-<trace-id>-<span-id>-01 头在服务间传递——这样 traceId 天然就是分布式追踪 ID,一份标识同时服务于用户报障和链路追踪。
5xx 错误的信息披露必须分层,这是安全红线:堆栈、SQL 语句、文件路径、环境变量,永远只进服务端日志,绝不出现在响应体里。给用户看的 5xx 响应应该是这样的:
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json
{
"type": "about:blank",
"title": "Internal Server Error",
"status": 500,
"detail": "Something went wrong on our end. Please try again, or contact support with trace ID 4bf92f3577b34da6a3ce929d0e0e4736.",
"instance": "/v1/projects/generate",
"code": "INTERNAL_ERROR",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736"
}
注意 detail 里只有"请重试/联系客服 + 追踪码",没有任何技术细节。而服务端日志里同一条 traceId 下记录的是完整堆栈、请求参数(脱敏后)、SQL。实现上很简单:全局异常过滤器 catch 住未处理异常后,log.error 带上 traceId 和堆栈,然后返回上面这个固定形状。NestJS 用 @Catch() 的全局 ExceptionFilter,Express 用错误处理中间件,FastAPI 用 @app.exception_handler(Exception)——模式都一样:日志里详细,响应里克制。
六、前端 TypeScript:用判别式联合类型终结字符串匹配
有了稳定的错误码,前端终于可以扔掉 message.includes('额度') 这种脆弱写法。正确姿势是把错误体建模为 TypeScript 判别式联合类型(discriminated union),以 code 为判别字段:
// errors.ts —— 错误码即类型,新增错误码就是新增一个联合成员
type ApiError =
| { code: 'PLAN_LIMIT_REACHED'; status: 402; balance: number; required: number; retryAfter: number; traceId: string }
| { code: 'AUTH_TOKEN_EXPIRED'; status: 401; traceId: string }
| { code: 'QUOTA_RATE_LIMITED'; status: 429; retryAfter: number; traceId: string }
| { code: 'VALIDATION_FAILED'; status: 422; errors: FieldError[]; traceId: string }
| { code: 'INTERNAL_ERROR'; status: 500; traceId: string };
// UI 层:switch 穷尽匹配,漏掉一个分支编译器报错
function handleApiError(err: ApiError) {
switch (err.code) {
case 'PLAN_LIMIT_REACHED':
return showUpgradeDialog({ balance: err.balance, required: err.required });
case 'AUTH_TOKEN_EXPIRED':
return redirectToLogin();
case 'QUOTA_RATE_LIMITED':
return showRetryCountdown(err.retryAfter);
case 'VALIDATION_FAILED':
return highlightFields(err.errors);
case 'INTERNAL_ERROR':
return showGenericError(err.traceId); // 通用兜底 + 追踪码
}
}
这套写法的三个好处:第一,switch 的穷尽检查是编译器帮你做的——后端新增一个错误码,前端 ApiError 类型没同步,TS 直接报错,而不是线上悄悄走进 default 分支。第二,可重试判断被封装成纯函数,而不是散落在各处的魔法数字:
// 可重试 = 明确的瞬态错误;未知 code 默认不可盲重试
const RETRYABLE = new Set(['QUOTA_RATE_LIMITED', 'INTERNAL_ERROR', 'SERVICE_UNAVAILABLE']);
function isRetryable(code: string): boolean {
return RETRYABLE.has(code);
}
// 调用方:指数退避 + 尊重 Retry-After
async function withRetry<T>(fn: () => Promise<T>, maxAttempts = 3): Promise<T> {
for (let attempt = 1; ; attempt++) {
try { return await fn(); }
catch (e) {
const code = (e as ApiError).code;
if (attempt >= maxAttempts || !isRetryable(code)) throw e;
const waitMs = (e as ApiError & { retryAfter?: number }).retryAfter
? retryAfter * 1000 : 2 ** attempt * 1000;
await sleep(waitMs);
}
}
}
第三,未知 code 的降级兜底是强制要求:任何没见过的 code 都走"通用错误文案 + 显示 traceId + 上报日志"的兜底路径,绝不能白屏或吞掉错误。这条要写进上线检查清单(见第十节),因为后端新增错误码而前端没发版是常态,兜底是前后端发版节奏解耦的最后一道防线。
七、AI Agent 场景的错误契约:让错误"可执行"
当调用方从"人"变成 AI Agent,错误体的设计目标要升级:Agent 不应该为了"理解错误"再发一次请求。人看到"余额不足"会去点"充值"按钮,Agent 需要的是机器可直接消费的决策参数。这就是 RFC 9457 允许扩展成员的价值所在——在标准五个字段之外,携带 Agent 可操作的扩展字段:
{
"type": "https://api.example.com/problems/plan-limit-reached",
"title": "Plan Limit Reached",
"status": 402,
"code": "PLAN_LIMIT_REACHED",
"detail": "Monthly AI token quota exhausted.",
"instance": "/v1/projects/generate",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"balance": 0, // 当前剩余额度:机器可读
"required": 1200, // 本次操作需要的额度:机器可读
"retryAfter": 2592000, // 距下次重置的秒数:机器可读
"action": "upgrade_or_wait" // 建议的行动枚举:upgrade | wait | contact_support
}
拿到这个错误体,Agent 的决策链是确定性的,不需要 LLM 猜:balance(0) < required(1200) → 本次操作不可能成功 → action=upgrade_or_wait → 查 retryAfter 决定是"等待 30 天"(不现实,转人工)还是"建议用户升级套餐"。对比没有扩展字段的版本,Agent 只能把 detail 的自然语言喂给 LLM,让它猜"大概是额度不够吧",然后可能再调一次"查询额度"接口确认——多一次请求、多一次幻觉机会。
给 Agent 设计错误契约的三条原则:第一,扩展字段用机器单位,不用人类单位。retryAfter 用秒数(2592000)而不是"30 天后",时区、换算都留给消费方;金额用最小货币单位(如"分")而不是"¥12.00"字符串。第二,行动建议用封闭枚举,不用自然语言。action 的取值是 retry | upgrade | wait | contact_support | fix_input 之一,Agent 做 switch 匹配;自然语言的"suggestedFix"只能做 LLM 的辅助输入,不能做决策依据。第三,错误体本身要自包含 OpenAPI 可描述。把扩展字段写进 OpenAPI 的 schema(或 x-error-codes 扩展,见第九节),Agent 的工具描述(tool description)里就能直接看到"这个工具可能返回哪些错误码、每个码带哪些字段"——这是 Agent 在调用前就做好错误预案的关键。
八、422 校验细则:结构化错误明细
表单/参数校验错误值得单独一节,因为它是 422 响应里最容易写烂的部分。反模式是把所有校验失败拼成一段话:"detail": "邮箱格式错误;密码太短;年龄必须是数字"——前端想把错误标到对应输入框下,只能再写一遍正则去"解析"这段话。正确做法是 errors 数组 + JSON Pointer 定位字段:
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/validation-failed",
"title": "Validation Failed",
"status": 422,
"code": "VALIDATION_FAILED",
"instance": "/v1/signup",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"errors": [
{ "field": "email", "pointer": "#/data/email", "code": "FORMAT_INVALID", "message": "邮箱格式不正确" },
{ "field": "password", "pointer": "#/data/password", "code": "TOO_SHORT", "message": "密码至少 8 位" },
{ "field": "age", "pointer": "#/data/age", "code": "TYPE_MISMATCH", "message": "年龄必须是数字" }
]
}
四个字段各有分工:field 是前端表单绑定的字段名(扁平),pointer 是 RFC 6901 JSON Pointer(如 #/data/email),精确定位到请求体里的嵌套路径——嵌套对象、数组元素的校验错误(如 #/data/items/2/price)只能靠 pointer 表达;code 是校验规则码(FORMAT_INVALID / TOO_SHORT / REQUIRED / TYPE_MISMATCH 等,可复用同一套命名规范);message 是按 Accept-Language 本地化后的单条文案,前端直接渲染到字段下方,无需二次翻译。
还有一个生产环境必须做的保护:批量校验截断。CSV 批量导入 1 万行、每行 5 个错误,朴素实现会返回 5 万条 errors——错误体轻松突破 1MB,前端解析卡死、日志系统爆掉。做法是服务端设上限(如 MAX_VALIDATION_ERRORS = 20),超限后截断并追加一条元信息:{"code": "TOO_MANY_ERRORS", "message": "仅显示前 20 条,共 532 条错误"}。校验是"抽样反馈"不是"全量审计",用户修完前 20 条重新提交即可。
九、错误码治理工程化:注册表 + CI + 文档站
错误码一旦多起来(超过 30 个),靠口头约定和散落在代码里的字符串常量必然腐烂。需要三件套:注册表、CI 校验、文档站。
注册表有两种务实做法。轻量做法是在仓库里建 errors/ 目录,每个错误码一个 YAML 文件,写清码值、HTTP 状态、title 中英文、detail 模板、扩展字段、引入版本:
# errors/PLAN_LIMIT_REACHED.yaml
code: PLAN_LIMIT_REACHED
httpStatus: 402
title: { zh: "套餐额度已用尽", en: "Plan limit reached" }
detailTemplate:
zh: "本月 AI token 额度 {quota} 已用完,将于 {resetDate} 重置。"
en: "Monthly AI token quota of {quota} exhausted; resets on {resetDate}."
extensions: [balance, required, retryAfter, action]
since: "v2.4.0"
deprecated: false
重一点的做法是把错误码声明进 OpenAPI 文档,用 x-error-codes 扩展字段挂在每个 operation 下面,列出该接口可能返回的错误码及各自的扩展字段 schema——这样 Agent 的工具描述可以直接从 OpenAPI 生成(呼应第七节)。两种做法不互斥:YAML 注册表是"源码真相",OpenAPI 扩展是"对外契约",CI 保证两者一致。
CI 校验是防止腐烂的真正防线,在 PR 流水线里加一个检查脚本,规则很直白:新增错误码必须同时有 YAML 注册表条目、中英文词典条目、文档站页面占位;httpStatus 必须在决策表(第三节)的允许集合里;deprecated: true 的码不允许出现在新代码的抛出点;YAML 里的 code 必须和代码里实际抛出的字符串常量逐字一致(用 grep/AST 扫描双向核对)。这条检查第一次加上时会扫出一堆历史欠账——正常,还旧账就是治理的开始。
文档站可以从注册表自动生成:每个错误码一个页面,含码值、含义、触发场景、示例响应、"用户看到什么/开发者看到什么/ Agent 该怎么做"三视角说明。Twilio 的错误码文档站就是这个思路的标杆——它的数字码本身不可读,但"一码一页面 + 搜索"让不可读变得可用。文档站的 URL 模式固定下来后,还可以直接用作 RFC 9457 type 字段的值(如 https://docs.example.com/errors/PLAN_LIMIT_REACHED),形成"错误体 → 文档"的闭环。
十、上线检查清单:发布前逐项打勾
把错误体系上线前要验证的事收敛成一张清单,贴在 PR 模板里:
- 未知 code 降级兜底:前端对所有未收录的 code 走通用错误页(友好文案 + traceId + "联系客服"入口),并上报一条
unknown_error_code日志方便补录。测试方法: mock 一个后端还没发布的假码,看 UI 是否白屏。 - 日志脱敏:
detail、日志的请求参数、错误上下文里不得出现邮箱、密码、token、身份证号。实现一个redact()工具函数,在写日志和拼 detail 之前统一过一遍;CI 里加一条正则扫描,命中password明文等模式直接失败。 - 5xx 零信息泄露:用模糊测试打所有接口,断言 500 响应体里不出现
stack、sql、文件绝对路径关键字。这是安全红线,不是体验优化。 - 灰度兼容性:错误码变更(新增码、detail 模板改版)走灰度时,老版本前端/Agent 必须能处理新码——靠的就是第六节的未知 code 兜底。灰度期间监控
unknown_error_code日志的量,异常飙升说明有客户端没跟上。 - Retry-After 真实可用:429 响应必须带
Retry-After头,且值与 body 里retryAfter扩展字段一致;写一个集成测试断言两者相等。 - Content-Type 声明正确:所有错误响应的
Content-Type都是application/problem+json,而不是application/json。网关层(如 NGINX、Cloudflare Workers)抛出的错误也要统一形状,避免"网关 502 是 HTML,业务 500 是 JSON"的双标。 - 错误文档可达:
type字段里的文档 URI 逐个 curl 验证返回 200,死链等于把"去哪儿查"的入口堵死。
最后回到开头那句话:错误是最常被调用的接口资产。一个好的错误体系有三个试金石——前端敢不敢删掉所有 message.includes(敢,说明码值够稳定);客服能不能让用户只贴一个 traceId 就定位问题(能,说明贯穿做对了);Agent 能不能不靠猜、不靠二次请求就做出正确决策(能,说明扩展字段给够了)。三条都满足,你的错误体系就能 5 年不腐烂。剩下的就是第九节的治理工作:注册表、CI、文档站,让时间成为盟友而不是敌人。
评论 (0)
相关文章

Web Speech API vs 云 ASR、speechSynthesis vs 云 TTS、VAD→ASR→LLM→TTS 四段延迟预算、打断处理、成本账与降级链——附可运行的浏览器语音输入框 demo。

爬虫 24 小时批量搬走你的内容、图片和商品数据怎么办?从 Cloudflare 四层检测、JA3/JA4 握手指纹、抓爬检测规则,到签名 URL、水印取证与投入产出清单——一份算过账的中小站长防爬实战指南。

扫码登录六步链路、动态分享码与渠道统计、一码一用核销与防重放、生成选型、微信生态限制——附真实跑通过的 Node/Express ticket 轮询 demo。