接口错误响应应该怎么设计

以前联调接口,后端 500 只回一段文本,前端 res.json 直接抛 SyntaxError,真正的错误被吞掉,只能弹未知错误。我后来花一个小时定了统一错误格式,前后端省下的调试时间远超十倍。
当时我犯过的一个错
我以前维护一个电商后台联调过五十多次接口,每次报错都为「后端『系统错误』,前端就只能在 catch 里写 toast『未知错误』」。原因是接口 500 的时候后端只回文本,前端拿到的 res 不是 JSON,调 res.json() 就抛 SyntaxError,把真正的错误吞了。
这事让我下决心整理一套错误的统一格式。后来事实证明,这套事只要花一个小时定下来,前后端省掉的调试时间远超十倍。
envelope 是个简单起点
我博客里所有业务接口都走的是 envelope:
{
"ok": false,
"data": null,
"message": "文章不存在"
}前端只做一件事:if (!resp.ok) showMessage(resp.message)。整个前端不用为每个错误写自定义处理逻辑,节省一大堆 try/catch。
成功响应永远长这样:
{
"ok": true,
"data": { ... },
"message": ""
}data 字段可以是对象、数组、分页结构、标量都行,但永远是给程序取用的具体值。这条规矩一旦定了,后端代码会越来越一致,新接口不用专门讨论字段格式。
HTTP 状态码永远要正确
状态码是 HTTP 协议层的语义,比业务 code 更广泛,前端 fetch 内部判断也是看这个。我见过不少人图省事,全部回 200 + 业务 code 表示错误。这种方式在浏览器 Network 面板里看不出来接口有没有失败,CDN 也无法做边缘判定,监控更是直接漏掉。
我站点的约定:
- 200:成功
- 400:参数错误(前端可读出来修正)
- 401:未登录
- 403:无权限
- 404:资源不存在
- 405:方法不允许
- 429:限流
- 500:服务内部错误
业务 code 则表达更细的原因,例如同样是 401,可能是 token 过期、签名错误、设备变更。返回结构里加一个 code 字段:
{
"ok": false,
"data": null,
"message": "登录已过期",
"code": "TOKEN_EXPIRED"
}前端可以做精细判断,比如 code === "TOKEN_EXPIRED" 时直接跳登录页。
第一版我栽过的几个具体点
{ok: false} 但状态码 200。我第一次写时为了省事,所有成功都用 200,错误也用 200。当时代码里这么写:
@app.exception_handler(Exception)
async def handler(_, exc):
return JSONResponse({"ok": False, "message": str(exc)})后来看后端监控,发现错误接口返回的全是 200,监控系统按 5xx 报警一次也没响过。改的时候把异常分类型,业务异常走 JSONResponse(status_code=400),框架级异常走 500。
详情接口 POST vs GET。我博客所有业务接口都是 POST。原因之一是 GET 路径会在 Nginx 访问日志里展开成完整 URL,路径里带查询字符串肉眼很难看清。也方便埋点抓路径。POST body 我用 JSON,便于审计入参而不污染 URL。
detail 字段放什么。我一开始什么字段都塞进 detail:用户 ID、关联的列表、字段校验的具体错。结果前端「未读消息提示」这个功能直接显示了一整段对象。原则是:detail 必须是前端不展示也不会出问题的信息,可调试就好。
{
"ok": false,
"message": "分类下还有文章",
"code": "CATEGORY_NOT_EMPTY",
"detail": { "post_count": 5, "category_slug": "engineering" }
}前端用到 post_count 时才读它,否则就忽略。展示给用户的永远只读 message。
限流和禁止访问要走 444 而不是 403
我之前在 nginx 上对扫描器做 deny,全用的 return 403。但 403 会让扫描器觉得「有戏」,会继续试探。改成 return 444 后,连接直接断,啥也没返回,扫描器一般会换目标。
后端业务上要做类似的事情:当一个用户连续触发风控,别回 403,回 422(不可处理的实体)或 429(限流)。429 比 403 礼貌,扫描器更倾向于把你排除而不是重试。
错误响应要进结构化日志
线上排查问题最高频的就是「为啥这个接口挂了」。我把每次错误响应都进结构化日志:
logger.error("api_error", extra={
"path": request.url.path,
"method": request.method,
"user_id": session.get("user_id"),
"code": "TOKEN_EXPIRED",
"status_code": 401,
"ip": request.client.host,
})这样查询「昨天 TOKEN_EXPIRED 多了多少」能从结构化日志直接 stat 出来。错误响应如果只塞文本,监控系统就只能 grep,是个灾难。
前端处理错误的标准动作
我前端 fetch 拦截器大致长这样(简化版):
async function apiRequest(path, body) {
const r = await fetch(`${baseURL}${path}`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (r.status === 401) {
navigateTo("/admin/login", { replace: true })
return { ok: false, code: "UNAUTHENTICATED" }
}
const data = await r.json()
if (!r.ok || !data.ok) {
showToast(data.message || "操作失败")
}
return data
}每个页面组件 await apiRequest("/foo", {}),拿到结构化结果自己处理。
守住的几条红线
我把这几年犯过的错浓缩成几条规则。新接口上线前我会扫一遍:
HTTP 状态码必须是真状态码,不用 200 替代 4xx 5xx。envelope {ok, data, message} 必须一致,无论成功还是失败。业务 code 用大写下划线,前端可以 switch。message 给用户看,detail 给排查看,code 给程序看,混了谁也看不清。不要在错误响应里塞敏感信息,SQL 错误堆栈、密码哈希这种最常见。错误入结构化日志,跟正常请求同等对待。
这套几年下来,我跟前端联调基本不再为「为啥这个接口不工作」开电话会。