前端 API 错误处理:统一兜底不是弹同一句话
以前我对“统一错误处理”的理解比较浅:请求失败了,就在封装层里 catch 一下,弹个 toast,最多再把 401 跳登录。这样写在小项目里确实很省事,但中后台页面一多,问题会很快暴露出来。
有一次做配置平台,列表页、详情页、导入页、审批页分别接了不同接口。后端返回格式看起来差不多,但细节不完全一致:有的用 code,有的用 errorCode,有的把字段错误塞在 data.errors 里,还有的 HTTP 200 里返回业务失败。
一开始大家各写各的兜底,结果线上出现了几个很尴尬的问题:
- 同一个权限错误,有的页面提示“请求失败”,有的页面提示“无权限”,有的页面直接空白。
- 表单提交失败后,字段错误没有落到字段上,只弹了一个通用 toast。
- 导入接口超时后,用户以为没提交成功,重复点了好几次。
- 登录过期和接口异常混在一起,排查日志时只能靠猜。
最离谱的一次,是导入接口在网关层超时返回了 504,但前端封装层只读了响应体里的 message 字段,结果 message 是 undefined,最后 toast 弹出来一行 undefined。用户截图发到群里问“这是什么意思”,我盯着那张图看了半天,意识到问题根本不在那句文案,而在于我们从来没认真对待过“请求失败”这件事本身。
那次之后我们达成一个共识:API 错误处理不是“统一弹一句错误文案”,而是要先把错误变成前端能理解的结构。
先承认错误不是一种东西
我现在会先把 API 错误拆成几层,而不是一上来就写 toast。
第一层是网络错误。请求根本没正常完成,比如断网、超时、请求被取消。
第二层是协议错误。HTTP 状态码已经说明请求失败,比如 401、403、404、500。
第三层是业务错误。HTTP 请求成功了,但业务 code 表示失败,比如库存不足、配置冲突、字段校验不通过。
第四层是页面场景错误。比如同样是 403,在列表页可能显示无权限空状态,在保存按钮上可能要保留表单并提示用户找管理员开权限。
这四层的划分听起来有点学院派,但我后来发现它在团队里最大的价值是“吵架时有了共同语言”。以前评审一个错误处理逻辑,大家会争“这里到底该弹 toast 还是跳页面”,争半天其实是在不同层上各说各话。后来我们约定:先问这个错误是哪一层的,再讨论怎么处理。网络层和协议层基本能在请求封装里收敛,业务层和场景层才是真正需要业务同学参与决策的地方。讨论效率高了不少。
我自己有个简单的判断习惯:如果一个错误的处理方式在所有页面都一样,那它大概率属于前两层,应该收敛到底层;如果同一个错误码在不同页面要长出不同的样子,那它一定是场景错误,绝对不能在请求层写死。早期我犯过的错,就是把一个 PERMISSION_DENIED 在 axios 拦截器里直接 message.error("无权限"),结果后来有个页面想做“申请权限”的引导,发现拦截器已经把这个错误“吃掉”并弹过 toast 了,根本没机会接管。改起来还得加各种 skipGlobalError 的开关,越改越脏。
以前我经常把这几层混在一起,所以代码里到处都是:
1try { 2 await saveConfig(values); 3 message.success("保存成功"); 4} catch (error) { 5 message.error(error.message || "保存失败"); 6}
这段代码不是不能跑,但它把所有错误都压成了一个 message。等业务复杂后,页面已经没有能力判断这个错误到底能不能重试、要不要跳登录、要不要落到字段、要不要记录日志。
请求层只做翻译,不替页面做决定
后来我把请求层的职责收窄成一件事:把各种不稳定的后端响应,翻译成稳定的前端错误对象。
1class ApiError extends Error { 2 constructor({ type, message, code, status, retryable, fieldErrors, raw, requestId }) { 3 super(message); 4 this.name = "ApiError"; 5 this.type = type; 6 this.code = code; 7 this.status = status; 8 this.retryable = retryable; 9 this.fieldErrors = fieldErrors || {}; 10 this.raw = raw; 11 this.requestId = requestId; 12 } 13} 14 15function createApiError(error) { 16 if (error.name === "AbortError") { 17 return new ApiError({ 18 type: "cancelled", 19 message: "请求已取消", 20 retryable: false, 21 raw: error, 22 }); 23 } 24 25 if (!navigator.onLine) { 26 return new ApiError({ 27 type: "network", 28 message: "网络连接异常,请检查后重试", 29 retryable: true, 30 raw: error, 31 }); 32 } 33 34 return new ApiError({ 35 type: "unknown", 36 message: "系统暂时不可用,请稍后重试", 37 retryable: true, 38 raw: error, 39 }); 40}
请求层可以知道这是网络错误、鉴权错误还是业务错误,但它不应该替所有页面决定展示方式。列表、表单、导入任务、弹窗确认,它们需要的反馈完全不一样。
我比较喜欢让请求方法只返回两种结果:成功的数据,或者稳定的 ApiError。
1async function request(url, options) { 2 try { 3 const response = await fetch(url, options); 4 const payload = await response.json().catch(() => null); 5 6 if (!response.ok) { 7 throw new ApiError({ 8 type: "http", 9 status: response.status, 10 code: payload?.code, 11 message: payload?.message || `请求失败:${response.status}`, 12 retryable: response.status >= 500, 13 raw: payload, 14 requestId: getRequestId(response), 15 }); 16 } 17 18 if (!isBizSuccess(payload)) { 19 throw new ApiError({ 20 type: "business", 21 code: payload?.code, 22 message: payload?.message || "业务处理失败", 23 retryable: false, 24 fieldErrors: payload?.fieldErrors, 25 raw: payload, 26 }); 27 } 28 29 return payload.data; 30 } catch (error) { 31 if (error instanceof ApiError) { 32 throw error; 33 } 34 35 throw createApiError(error); 36 } 37}
这里面有几个我踩过的细节,值得单独说。
一个是 response.json() 必须包一层 catch。后端在 500、502 的时候很可能返回的是一段 HTML 错误页或者干脆空响应体,直接 await response.json() 会抛 SyntaxError,然后这个 JSON 解析错误会盖掉真正的 HTTP 状态码,你在日志里看到的就是一句莫名其妙的 Unexpected token < in JSON,完全猜不到其实是网关挂了。我现在统一用 .catch(() => null),让 payload 允许为空,再用 response.status 来判断。
另一个是“业务成功的判定标准”不要硬编码 code === 0。我们后端历史上同时存在 code: 0、code: "0"、success: true 三种成功标记,不同年份不同小组写的接口口径不一样。我后来在请求层抽了一个 isBizSuccess(payload) 专门收敛这件事,把这种历史包袱锁在一个函数里,别让它渗透到业务代码:
1function isBizSuccess(payload) { 2 if (payload == null) return false; 3 if (typeof payload.success === "boolean") return payload.success; 4 // 兼容字符串和数字两种 code 5 return String(payload.code) === "0"; 6}
还有一个我现在一定会做的细节:把响应头里的 request id 也收进错误对象。很多网关或后端服务会返回 x-request-id、x-trace-id 之类的字段,如果请求层不拿,后面用户截图反馈时就只能靠时间和接口路径猜。
1function getRequestId(response) { 2 return ( 3 response.headers.get("x-request-id") || 4 response.headers.get("x-trace-id") || 5 response.headers.get("traceparent") 6 ); 7}
这个函数我直接放进了前面 request() 抛 HTTP 错误的那一段里,type: "http" 分支多带一个 requestId: getRequestId(response) 字段。这个字段不一定展示给用户,但一定要进日志。统一错误处理如果只统一了文案,没有统一排查上下文,线上复盘还是会很痛苦。
还有个容易忽略的点:fetch 在 HTTP 4xx、5xx 时是不会 reject 的,它只在网络层面失败(断网、CORS、DNS)才 reject。这跟 axios 的默认行为正好相反,从 axios 切到 fetch 的人很容易在这里栽跟头,以为 catch 能兜住 500,结果根本进不去。所以上面那段代码里我必须显式判断 !response.ok 再手动 throw。
这类代码的重点不是写得多“优雅”,而是让页面不再面对一堆不确定的后端格式。换个角度说,请求层就是一道“消毒”工序:进去的是后端五花八门、版本各异的响应,出来的永远是 data 或者一个结构稳定的 ApiError,没有第三种可能。页面只要面对这两种东西,心智负担就小很多。
表单错误要能落到字段上
我以前最容易忽略的是表单错误。
用户提交一个 SKU 配置,后端返回“第 3 行规格值重复”。如果前端只弹一句“保存失败”,用户还是不知道该改哪里。更糟的是,复杂表单往往已经填了很多内容,用户会担心刷新、回退、重新提交会不会丢数据。
后来我会让后端字段错误尽量进入统一结构:
1function normalizeFieldErrors(error) { 2 if (error.code === "SKU_NAME_DUPLICATED") { 3 return { 4 "items.2.name": "这个规格名已经存在", 5 }; 6 } 7 8 if (error.code === "PRICE_RANGE_INVALID") { 9 return { 10 "price.min": "最低价不能高于最高价", 11 "price.max": "最高价不能低于最低价", 12 }; 13 } 14 15 return {}; 16}
这里有个跟后端拉扯的细节:字段路径用什么格式。我们最后约定用 items.2.name 这种点路径,因为它能直接喂给 antd Form 的 setFields,也能在 Element Plus 的表格表单里通过 prop 定位。如果后端直接返回 items[2].name 这种带方括号的,或者只给一个行号 row: 3 而不给字段名,前端就得自己再翻译一遍,很容易翻译错。所以我宁可前端多写一个 normalizeFieldErrors 做映射层,也不让这种不确定的格式直接流进表单组件。
拿到字段错误之后,在 antd 里我一般这样回填,并且顺手滚动到第一个出错的字段:
1async function applyFieldErrors(form, fieldErrors) { 2 const fields = Object.entries(fieldErrors).map(([name, msg]) => ({ 3 name: name.split("."), 4 errors: [msg], 5 })); 6 form.setFields(fields); 7 8 const first = fields[0]?.name; 9 if (first) { 10 form.scrollToField(first, { behavior: "smooth", block: "center" }); 11 } 12}
scrollToField 这步看着不起眼,但对长表单体验差别很大。配置类页面经常一屏放不下,用户点了保存,错误字段在视口外,他只看到一个红色 toast 一闪而过,完全不知道发生了什么。把视口主动滚到第一个错误那里,用户的注意力一下就被带过去了。
页面拿到以后,再决定是展示在字段下方、滚动到第一个错误,还是在表格行内标红。
这一步对 B 端体验很关键。很多中后台页面的“难用”,不是功能做不到,而是失败后不给用户明确路径。用户不知道错在哪里,就会觉得整个系统不可靠。
不是所有错误都应该 toast
以前我默认错误就 toast,后来发现 toast 很容易被滥用。
列表加载失败,更适合空状态加重试按钮。表单保存失败,要保留输入并标记字段。权限不足,可能要显示申请入口。导入任务失败,应该给下载错误报告的入口。后台静默刷新失败,有时只需要记录日志,不应该打断用户。
我现在一般按场景处理:
1function handlePageError(error) { 2 if (error.type === "auth") { 3 redirectToLogin(); 4 return; 5 } 6 7 if (error.type === "permission") { 8 showNoPermissionState(); 9 return; 10 } 11 12 if (error.retryable) { 13 showRetryState(error.message); 14 return; 15 } 16 17 showInlineMessage(error.message); 18}
这里有个细节:统一错误处理不等于所有错误都自动处理。请求层负责统一转换,业务层负责统一策略,页面层仍然要保留表达具体场景的空间。
还有一类我后来专门拎出来处理的,是“被取消的请求”不该报错。中后台页面切 tab、改筛选条件特别频繁,一个列表请求还没回来用户就切走了,我用 AbortController 取消它,这时候底层会抛 AbortError。如果不特判,它会顺着 catch 一路弹出一个“请求已取消”的红色提示,用户莫名其妙。所以在 handlePageError 里,cancelled 类型应该直接 return,什么都不做:
1function handlePageError(error) { 2 if (error.type === "cancelled") { 3 return; // 用户主动切走了,不是错误 4 } 5 6 if (error.type === "auth") { 7 redirectToLogin(); 8 return; 9 } 10 // ...其余分支同上 11}
auth 那个分支也有坑。我们曾经在多个并发请求同时 401 时,连着跳了好几次登录页,地址栏上 redirect 参数套娃了三层。后来给 redirectToLogin 加了个“正在跳转”的内存锁,第一个 401 触发跳转后,后面的 401 全部静默忽略。这种细节平时想不到,线上才会冒出来。
重试和重复提交是两件事
开头提到导入超时用户连点好几次,这件事我后来想明白了:重试和防重复提交看起来像,实际是两个问题,得分开解。
retryable 解决的是“失败之后能不能再来一次”。我现在的口径很简单:网络错误、超时、5xx 标记为可重试,4xx 和业务错误一律不可重试。因为 4xx 通常是参数本身有问题,你重试一百次结果都一样,只是白白增加后端压力。重试我也不喜欢用户手动点,对幂等的 GET 请求会在请求层做有限次自动重试,带一点退避:
1async function requestWithRetry(url, options, max = 2) { 2 for (let attempt = 0; ; attempt++) { 3 try { 4 return await request(url, options); 5 } catch (error) { 6 const canRetry = 7 error instanceof ApiError && error.retryable && attempt < max; 8 if (!canRetry) throw error; 9 await sleep(300 * 2 ** attempt); // 300ms、600ms 退避 10 } 11 } 12}
但写接口提交这种带副作用的请求,自动重试就很危险了。导入、下单、扣款这类操作,超时不代表后端没执行成功,很可能请求到了、处理完了,只是响应在回来的路上丢了。这时候盲目重试会造成重复导入、重复下单。真正的解法是幂等:让前端在发起时带一个 idempotency-key(一般用 uuid),后端在一个时间窗内对相同 key 只执行一次。前端则只负责两件事——按钮在请求期间禁用防止手抖连点,以及超时后引导用户去“导入记录”里确认结果,而不是直接给一个“重试”按钮:
1const key = crypto.randomUUID(); 2await request("/api/imports", { 3 method: "POST", 4 headers: { "Idempotency-Key": key }, 5 body, 6});
把“可重试”和“能不能安全重试”分清楚之后,那个连点导入的问题才算真正解决,而不是靠加个 loading 遮罩糊过去。
这两年我更重视可观测性
2025 年我对前端工程化最大的变化之一,是不再只关心“用户看到了什么错误提示”,也开始关心“开发能不能复盘这个错误”。
一个线上错误如果只剩一句“系统异常”,排查会很痛苦。至少要带上这些信息:
- request id
- 接口地址和方法
- HTTP 状态码
- 业务 code
- 页面路径
- 用户操作阶段
- 是否重试过
当然,这些信息不一定都展示给用户,但要进入日志或监控。
1function reportApiError(error, context) { 2 sendLog({ 3 type: "api_error", 4 page: location.pathname, 5 action: context.action, 6 code: error.code, 7 status: error.status, 8 retryable: error.retryable, 9 requestId: error.requestId, 10 }); 11}
这里最关键的一个字段是 requestId。我们让后端在每个响应头里都带一个 x-request-id,前端在请求层把它从响应里取出来挂到 ApiError 实例的 requestId 字段上,再原样上报。这样用户一截图反馈,我拿着这个 id 去后端日志里一搜,整条链路(网关、服务、数据库)就全串起来了。在有这个之前,排查一个偶现的保存失败,基本靠在测试环境复现,复现不出来就只能挂着。有了 requestId 之后,很多问题十分钟就定位了,这是投入产出比最高的一个改动。
还有一点经验是关于上报本身的稳定性。错误上报不能因为页面正在卸载就丢掉——用户点了保存失败,然后立刻关页面或者跳走,这时候用 fetch 发上报很可能被浏览器中断。我现在用 navigator.sendBeacon 来发这种“临终”日志,它专门保证页面关闭时也能把数据送出去:
1function sendLog(data) { 2 const body = JSON.stringify(data); 3 if (navigator.sendBeacon) { 4 navigator.sendBeacon("/api/logs", body); 5 } else { 6 fetch("/api/logs", { method: "POST", body, keepalive: true }); 7 } 8}
另外要给上报本身限流。有一次某个轮询接口在后端故障期间疯狂报错,一分钟刷了上万条日志,既把监控刷爆了,也把告警淹没了。后来我加了按 code + page 维度的采样和去重,相同错误在短时间内只报一条加一个计数,告警才重新变得可读。错误上报写得太“老实”,反而会在出事的时候帮倒忙。
日志攒起来之后,还得有地方能一眼看出问题集中在哪。我们后来在监控平台上按 code、page、retryable 拉了几张聚合图,谁在盯大盘就能第一时间看到某个错误码突然飙升,而不是等客服群里先炸锅。这一步花的时间不多,回报却很直接:从“用户反馈了才知道”变成“监控先一步告诉你”。
以前我觉得这属于“后端或运维的事”。现在看,复杂前端系统里的错误链路,前端必须参与设计。否则用户反馈一句“刚才保存失败了”,你甚至不知道是哪一个按钮、哪一个接口、哪一种失败。
我现在的判断
如果只是写一个很小的页面,简单 try...catch 没问题。
但只要项目开始出现这些信号,就应该尽早整理错误处理:
- 多个页面都在复制
catch逻辑。 - 同一种业务错误在不同页面提示不一致。
- 表单字段错误只能弹 toast。
- 登录失效、权限不足、网络异常混在一起。
- 线上问题无法通过日志复盘。
这时候不要急着再封一层请求库,而是先把错误类型、页面策略和日志字段定下来。这三件事定下来之后,新页面接入错误处理基本就是照着模板填空,不用再从头想一遍“这个错误该弹什么”。
API 错误处理不只是“请求封装”的一部分,也是产品体验的一部分。用户提交失败时能不能知道下一步怎么做,开发排查问题时能不能知道失败发生在哪里,这些都比“catch 里少写几行代码”更重要。
统一错误处理的目标不是让所有页面长得一样,而是让每一种失败都有稳定入口、清晰归因和合适的反馈。