前端 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: 0code: "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-idx-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 FormsetFields,也能在 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 维度的采样和去重,相同错误在短时间内只报一条加一个计数,告警才重新变得可读。错误上报写得太“老实”,反而会在出事的时候帮倒忙。

日志攒起来之后,还得有地方能一眼看出问题集中在哪。我们后来在监控平台上按 codepageretryable 拉了几张聚合图,谁在盯大盘就能第一时间看到某个错误码突然飙升,而不是等客服群里先炸锅。这一步花的时间不多,回报却很直接:从“用户反馈了才知道”变成“监控先一步告诉你”。

以前我觉得这属于“后端或运维的事”。现在看,复杂前端系统里的错误链路,前端必须参与设计。否则用户反馈一句“刚才保存失败了”,你甚至不知道是哪一个按钮、哪一个接口、哪一种失败。

我现在的判断

如果只是写一个很小的页面,简单 try...catch 没问题。

但只要项目开始出现这些信号,就应该尽早整理错误处理:

  • 多个页面都在复制 catch 逻辑。
  • 同一种业务错误在不同页面提示不一致。
  • 表单字段错误只能弹 toast。
  • 登录失效、权限不足、网络异常混在一起。
  • 线上问题无法通过日志复盘。

这时候不要急着再封一层请求库,而是先把错误类型、页面策略和日志字段定下来。这三件事定下来之后,新页面接入错误处理基本就是照着模板填空,不用再从头想一遍“这个错误该弹什么”。

API 错误处理不只是“请求封装”的一部分,也是产品体验的一部分。用户提交失败时能不能知道下一步怎么做,开发排查问题时能不能知道失败发生在哪里,这些都比“catch 里少写几行代码”更重要。

统一错误处理的目标不是让所有页面长得一样,而是让每一种失败都有稳定入口、清晰归因和合适的反馈。