2147 字
约 7 分钟
14
S02-A23 · 4xx 与 5xx:同一个失败,两种责任

一个接口在"参数校验失败"时返回 500,因为"用了统一的异常兜底"。于是监控上"错误率"飙升,而"真正的系统故障"被淹在里面——告警噪了一周,直到有人把"5xx 全部静音"。

4xx 与 5xx 的分界不是"数字大小",而是"谁该修":4xx 是"请求方的问题",5xx 是"服务方的问题"。而它有三层实际影响:重试策略、监控告警、以及"值班的人该不该被叫醒"。

1. 三种"分界"的实际差别

分界 谁的问题 客户端该做什么
4xx 请求方 改请求,不重试
5xx 服务方 可重试(带退避)
200 + body 里的 code 说不清 只能"读 body 判断"

第三项是"最常见的坏做法":它让"HTTP 层的信息"变成"业务层的信息",这个"看不出"的连锁很长:网关的熔断统计不到、监控的错误率算不到、CDN 会把错误响应缓存起来——于是"一个失败"在四个层面都"正常"。
于是"网关、监控、CDN"全都看不出错误。

2. "该 4xx 却返 5xx"的三种代价

代价 说明
误告警 错误率虚高,真故障被淹
误重试 客户端重试"参数错"(永远错)
误归因 复盘时"以为服务不稳"

第二项最实际:"参数错"被重试 3 次,只是把"错误日志"变成"三份"。第二项的"隐性成本"是"延迟":三次重试带退避 = 1+2+4 秒,于是"用户等了 7 秒才看到'参数错误'"——而它本可以"立刻告诉他"。

3. 四种"常见错配"

情况 正确 常见错
参数校验失败 400 500
未登录 401 403/500
无权限 403 401
资源不存在 404 200 + 空

这个区分决定"客户端动作":401 → 去登录;403 → 别登录了,去申请权限——弄反了就会"登录了还是进不去",而用户会反复登录。
第二项与第三项的混淆很普遍:401 = "你是谁不清楚",403 = "知道你是谁但你不能"——而"权限不足用 401"会让客户端"去重新登录",于是"登录了还是进不去"。

第三项最隐蔽:"查不到"返回 200 + 空对象,于是"调用方无法区分'没有'与'字段为空'"。

"重试"这件事要"按码分流",否则"该重试的没重试、不该重试的狂重试":

// 一份"客户端重试策略"(三行)
1 4xx(除 408/429):不重试(请求本身有问题)
2 5xx(500/503/504):重试,带指数退避 + 上限
3 429:按 Retry-After 等,不算失败次数
// 判据:重试只该发生在"再试一次可能成功"的情况

4. "5xx 里的两种"值得分开

码 含义 客户端
500 未预期错误 可重试(谨慎)
503 暂时不可用(过载/维护) 应当重试(带退避)
504 上游超时 可重试

第二项是"过载时最该用的":**它明确告诉客户端"等一会儿再来"**第二项的"配套"是 Retry-After:没有它,"503"与"500"在客户端看来差不多——于是"该等的也立刻重试"。
——而"过载时返回 500"会让客户端"立刻重试",把雪崩推得更深。

// 过载时的三种响应(按"对客户端友好"排)
503 + Retry-After: 5      → 客户端会等 5 秒(最好)
429 + Retry-After: 5      → 限流场景(好)
500                        → 客户端可能"立即重试"(最差)
// 判据:你的"过载保护"有没有告诉对方"什么时候再来"

"映射表"值得写死在一处,而不是散在几十个 catch 里:

// 一张"异常 → 状态码"的表(一处维护)
Map<Class<?>, HttpStatus> MAP = Map.of(
    MethodArgumentNotValidException.class, BAD_REQUEST,      // 400
    AuthenticationException.class,          UNAUTHORIZED,     // 401
    AccessDeniedException.class,            FORBIDDEN,        // 403
    EntityNotFoundException.class,          NOT_FOUND,        // 404
    OptimisticLockException.class,          CONFLICT,         // 409
    RateLimitException.class,               TOO_MANY_REQUESTS // 429
);
// 兜底:未知异常 → 500(且必须"记日志 + traceId")

第三项(登录后仍 403)的"识别"是"看用户是否'反复登录'":如果客服记录里全是"我登录了还是不行",就是码用错了。

5. "错误体"要能"定位",而不只是"报错"

字段 作用
code(业务码) 客户端分支
message(人读) 排障/提示
field/path 定位到具体输入
traceId 服务端串联日志

第四项是"跨端排查"的唯一线索:用户报"操作失败"时,客服能拿到 traceId,运维能捞出那条链路——而"没有 traceId"就只能"按时间猜"。

"错误率"口径要写清,否则"改完码照样误告警":

// 三个定义(选一个,写进看板)
1 5xx / 全部请求            → 只反映"服务端问题"(推荐)
2 (4xx + 5xx) / 全部请求    → 反映"总失败"(适合"对外 SLA")
3 业务失败数 / 业务总量      → 反映"业务健康"(需业务码)
// 判据:告警要按"谁该修"来定——"该修"的是服务端,就该用第 1 个

6. 排错:五种"状态码相关问题"

现象 成因 处理方向
错误率虚高 4xx 返成 5xx 改兜底映射
客户端疯狂重试 5xx 没有退避语义 用 503 + Retry-After
登录后仍 403 把"无权限"返成 401 改 403
调用方无法区分空与不存在 200 + 空 用 404 或显式字段
复盘说不清 无 traceId 加 traceId

"批量接口"的状态码最容易错,而它有两种正确写法:

// 两种"部分成功"的表达
1 207 Multi-Status(WebDAV 语义):逐项给状态
2 200 + 逐项 code(更常见):body 里给每一项的结果
// 反例:整批返 500 → 调用方不知道"哪些成了、哪些没成",只能"全部重试"

7. 边界:什么情况"200 + code"是合理的

场景 为什么 做法
批量接口(部分成功) 每个子项各有结果 200 + 逐项 code
业务上"失败是常态"(如"审核不通过") 不是协议错 200 + 业务状态
内部 RPC(非 HTTP 语义) 走自己的错误模型 不必套 HTTP
老客户端已依赖 200 兼容约束 保持 + 文档

最后一条与"兼容"有关的边界:"改状态码"是破坏性变更——有客户端按 200 判断"成功",于是"改成 400"会让它"走进错误分支"。所以要"双写一段时间"或"按版本分流"。
判断口径一句话:"这个失败是"请求本身有问题"还是"系统没做到"。请求有问题(参数/权限/不存在),用 4xx;系统没做到(过载/超时/内部错误),用 5xx;而"业务结果是失败但流程正常"("审核不通过"),用 200 + 业务状态。

再补一个"客户端视角"的事实:"重试"这件事在移动端更危险——弱网下"5xx 重试"可能叠加成"三倍流量",而"4xx 重试"纯属浪费电量。所以"码分对"不只是"服务端整洁",它直接决定"客户端该不该再发一次请求"。而"200 + code"最坏的地方就在这里:客户端无法在"协议层"做这个决定,只能"读业务码",而业务码因团队而异。

这也是"协议复用"的价值:约定好的东西,不需要每次重新解释。

8. 自测

  1. 4xx 与 5xx 的分界是什么?为什么"200 + body code"会让"网关、监控、CDN"都看不出错误?
  2. "参数错被重试 3 次"具体浪费了什么?
  3. 401 与 403 的区别是什么?"无权限返 401"会造成什么后果?
  4. 为什么"过载时该用 503 + Retry-After"?返 500 会怎样?
  5. "200 + 空对象"为什么让调用方无法区分"没有"与"字段为空"?
  6. 判断"能否用 200 + code"的那个问题是什么?
S02-A23 · 4xx 与 5xx:同一个失败,两种责任
https://www.clxhxhhr.top/posts/6645/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。