错误处理为什么值得单独设计
很多后端 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。
一套最小可用规范
中小型项目可以先用这套约定:
- 参数错误统一 400
- 未登录 401,无权限 403
- 资源不存在 404
- 唯一冲突或删除受限用 409
- 限流用 429
- 用户提示写中文短句
- 不向前端返回 stack trace
- 日志记录 requestId、路径、用户、耗时和错误栈
- 敏感字段脱敏
- 前端统一从
statusMessage或message取提示
总结
好的 API 错误处理不是为了“看起来规范”,而是为了让系统出问题时不慌。
用户能得到明确提示,前端能稳定分支,后端能快速定位,敏感信息不会泄露。做到这几点,错误处理就已经从“抛异常”变成了真正的工程能力。
下次见。
-EOF-