返回首页

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

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

54 约 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. 前端统一从 statusMessagemessage 取提示

总结

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

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


下次见。

-EOF-