返回首页

后端 API 错误处理实践:状态码、错误信息与排查链路

API 错误处理不只是抛异常,而是要同时服务用户体验、前端分支、日志排查和安全边界。本文梳理一套适合中小型 Web 项目的错误处理方法。

64 约 4 分钟 · 3385 字 后端

错误处理为什么值得单独设计

很多后端 API 的错误处理都是从一句 throw new Error() 开始失控的。

开发阶段这样写没什么问题,但一旦进入真实用户场景,错误信息要同时满足几件事:

  • 前端知道该走哪个分支
  • 用户看到的是可理解的提示
  • 后端日志能定位问题
  • 敏感信息不会泄露
  • 同类错误保持一致

如果没有约定,最后就会出现:

{ "message": "Error" }

或者更危险:

{ "message": "SQL syntax error near password_hash..." }

前者没法排查,后者泄露实现细节。

先区分错误类型

API 错误大致可以分成四类:

类型 例子 处理重点
客户端参数错误 id 非数字、缺少 title 明确指出输入问题
权限错误 未登录、权限不足 不暴露多余信息
业务冲突 slug 重复、库存不足 告诉用户可执行的下一步
服务端异常 数据库断开、代码 bug 记录日志,给用户兜底提示

不要把所有错误都返回 500。500 的含义是服务端没有按预期工作。如果用户少传字段,应该是 400;如果没有权限,应该是 401 或 403。

状态码的实用选择

常用状态码不需要记太多:

状态码 场景
400 参数格式错误、必填项缺失
401 未登录或登录态失效
403 已登录但没有权限
404 资源不存在
409 业务冲突,例如唯一字段重复
413 请求体过大
422 参数格式对,但业务校验失败
429 请求过于频繁
500 服务端内部错误

状态码是给程序看的第一层信号。前端可以根据状态码决定:

  • 是否跳登录
  • 是否展示权限不足
  • 是否留在表单并提示字段错误
  • 是否展示通用错误页

用户提示和排查信息要分开

一个常见设计是把错误拆成两层:

{
  "code": "SLUG_CONFLICT",
  "message": "文章 slug 已存在",
  "requestId": "req_abc123"
}

其中:

  • code:给前端或日志归类用
  • message:给用户看的短提示
  • requestId:给排查链路用

小项目可以先不做完整错误码体系,但至少要保证 message 是中文、短句、可理解,不要把 stack trace 直接返回给用户。

参数校验要靠近入口

API handler 开头就应该完成参数解析和校验。

示例:

export default defineEventHandler(async (event) => {
  const id = Number(getRouterParam(event, 'id'))
  if (!Number.isFinite(id) || id <= 0) {
    throw createError({ statusCode: 400, statusMessage: 'Invalid id' })
  }

  const body = await readBody<{ title?: unknown }>(event)
  const title = String(body?.title ?? '').trim()
  if (!title) {
    throw createError({ statusCode: 400, statusMessage: '标题不能为空' })
  }

  // 业务逻辑...
})

越早拒绝错误输入,后面的业务代码越干净。

不要把参数校验散落在 SQL 前后。否则同一个字段可能在多个地方被重复判断,错误信息也会不一致。

业务错误要给下一步

业务错误不是“系统坏了”,而是当前操作无法完成。它应该告诉用户下一步怎么做。

差的提示:

保存失败

更好的提示:

文章 slug 已存在,请更换 slug 后再保存

再比如:

当前角色仍被 3 个用户使用,请先调整这些用户的角色后再删除

这种提示能直接减少用户猜测,也减少客服或管理员介入。

日志里要能还原现场

错误排查需要的信息通常包括:

  • 请求路径和方法
  • 用户 id 或匿名 IP
  • 参数摘要
  • 状态码
  • 耗时
  • requestId
  • 错误堆栈
  • 关联资源 id

但日志不是越多越好。敏感字段必须脱敏:

  • password
  • token
  • cookie
  • authorization
  • secret
  • private key

原则是:

用户能看到的信息要克制,日志能看到的信息要足够,敏感信息两边都不该完整出现。

前端如何消费错误

前端不要只写一个通用 catch。

更好的方式是根据状态码和后端提示做分支:

try {
  await $fetch('/api/admin/posts', {
    method: 'POST',
    body: form.value,
  })
  message.value = { type: 'ok', text: '已保存' }
} catch (err: any) {
  const status = err?.status || err?.statusCode
  const text = err?.statusMessage || err?.data?.statusMessage || '操作失败'

  if (status === 401) {
    message.value = { type: 'err', text: '登录已失效,请重新登录' }
  } else if (status === 403) {
    message.value = { type: 'err', text: '你没有执行此操作的权限' }
  } else {
    message.value = { type: 'err', text }
  }
}

前端不需要知道后端内部发生了什么,但需要知道用户下一步该做什么。

避免三类常见问题

1. 把异常当流程控制

能用普通分支表达的业务逻辑,不要都变成异常。例如“搜索无结果”通常返回空数组,不是 404。

2. 错误信息过度技术化

用户不需要看到“foreign key constraint failed”。他需要看到“该分类下还有文章,不能删除”。

3. 错误码设计过度

小项目不需要一开始设计几百个错误码。先保证状态码正确、提示一致、日志可查。等错误类型稳定后,再沉淀 code。

一套最小可用规范

中小型项目可以先用这套约定:

  1. 参数错误统一 400
  2. 未登录 401,无权限 403
  3. 资源不存在 404
  4. 唯一冲突或删除受限用 409
  5. 限流用 429
  6. 用户提示写中文短句
  7. 不向前端返回 stack trace
  8. 日志记录 requestId、路径、用户、耗时和错误栈
  9. 敏感字段脱敏
  10. 前端统一从 statusMessage 或 message 取提示

总结

好的 API 错误处理不是为了“看起来规范”,而是为了让系统出问题时不慌。

用户能得到明确提示,前端能稳定分支,后端能快速定位,敏感信息不会泄露。做到这几点,错误处理就已经从“抛异常”变成了真正的工程能力。


下次见。

-EOF-