我把前后端错误处理重新串了一遍

太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处
TypeScriptHonoAPI错误处理

这次问题是从一个很普通的 400 Bad Request 开始的。

服务端日志里有请求路径、SQL 和耗时,最后一行只告诉我接口返回了 400。客户端也弹了错误,但弹窗没有说清楚是哪条业务规则没通过。过一会儿,另一个页面又冒出一句英文错误。应用明明是中文的,错误链却像是几套临时逻辑拼在一起。

我最初只想补一条错误提示,查下去才发现问题不在某个 Toast。服务端怎样返回错误、客户端怎样解包、页面怎样判断后续动作、线上日志记录什么,这几件事没有形成一条稳定的链。

我先把文案和错误码分开

接口错误里最容易混淆的是 errorcode

{
  "error": "请设置地址",
  "code": "ADDRESS_REQUIRED"
}

error 给用户看,所以它应该是简短、明确的中文。code 给客户端判断流程,不应该直接显示。

例如客户端收到 ADDRESS_REQUIRED 后,可以打开地址设置提示;收到普通的参数错误,只需要展示服务端文案。页面不应该靠比较中文句子决定行为:

if (requestError.message === "请设置地址") {
  // 不要这样判断
}

文案会调整,也可能因为产品语言变化而重写。机器标识一旦进入接口契约,就应该保持稳定。

反过来也一样。把 ADDRESS_REQUIRED 直接塞进 Toast,技术上省了一步,用户看到的却是内部协议。因此错误码不能拿来当备用文案。

两端各写一份以后,我还是觉得不对

我一开始在服务端和客户端各定义了一份错误码:

export const ApiErrorCode = {
  Unauthorized: "UNAUTHORIZED",
  AddressRequired: "ADDRESS_REQUIRED",
} as const;

类型检查都能通过,看起来也比散落的字符串好。问题是两份常量没有联系。某天服务端改了名字,客户端仍然可以带着旧值正常编译。

既然代码在同一个 workspace 中,更直接的办法是让服务端 package 暴露一个不依赖数据库、运行时框架和环境变量的 error.ts

export const ApiError = {
  Unauthorized: {
    error: "请重新登录",
    code: "UNAUTHORIZED",
  },
  AddressRequired: {
    error: "请设置地址",
    code: "ADDRESS_REQUIRED",
  },
} as const;

export type ApiErrorCode =
  (typeof ApiError)[keyof typeof ApiError]["code"];

服务端可以直接返回完整错误:

return context.json(ApiError.AddressRequired, 403);

客户端只读取同一个对象:

import { ApiError } from "server/error";

if (requestError.code === ApiError.AddressRequired.code) {
  openAddressDialog();
}

这样文案和错误码不会分开漂移,客户端也不需要维护一份镜像。

这里有一条边界:error.ts 只能放跨端需要的稳定协议。不要从里面导入数据库对象、日志器或服务端配置,否则一次普通的前端导入可能把后端依赖拖进打包器。

RequestError 也应该认识这些错误码

共享错误对象以后,客户端的错误类型也应该使用推导出来的联合类型:

export class RequestError extends Error {
  readonly issues: RequestIssue[];
  readonly code?: ApiErrorCode;

  constructor(
    message: string,
    issues: RequestIssue[] = [],
    code?: ApiErrorCode,
  ) {
    super(message);
    this.name = "RequestError";
    this.issues = issues;
    this.code = code;
  }
}

如果这里写成 code?: string,共享类型只用了一半。页面仍然可以拿任意拼错的字符串比较,编辑器也不会提醒。

不过 HTTP 响应来自运行时,TypeScript 不能保证服务器、网关或缓存一定返回声明中的值。解析响应时仍要检查一次:

export function isApiErrorCode(value: unknown): value is ApiErrorCode {
  return Object.values(ApiError).some((item) => item.code === value);
}

这段校验只负责把不可信的 JSON 收窄成本地联合类型,与表单验证和业务规则无关。

我把错误展示收回到一个入口

另一个混乱来源是每一层都想“顺便提示一下”。请求封装弹一次,调用它的 Hook 弹一次,页面 catch 后再弹一次。一个失败请求最后出现两三个 Toast,或者上层为了防止重复提示,干脆把错误吞掉。

我最后保留的链条很短:

HTTP 响应
  -> 请求解包函数读取 error、code、errors
  -> 构造 RequestError
  -> 统一展示服务端 error
  -> 页面只处理确实需要分支的 code

页面捕获 RequestError 时,默认不再重复展示。只有上传文件、读取本地缓存这类没有经过请求解包函数的异常,页面才补一个兜底提示。

参数校验错误可以额外带字段列表:

{
  "error": "标题不能为空",
  "code": "VALIDATION_FAILED",
  "errors": [
    {
      "field": "title",
      "message": "标题不能为空"
    }
  ]
}

通用页面展示第一条中文错误。表单如果需要定位控件,再读取 errors。客户端不重新判断长度、范围和业务关系,这些规则仍由服务端负责。

业务错误没有全部搬进 error.ts

建立 error.ts 后,很容易继续走到另一个极端:把所有错误文案都搬进去。

export const EverythingThatCanGoWrong = {
  ItemNotFound: "内容不存在",
  ItemCannotMove: "当前不可移动",
  ItemCannotDelete: "当前不可删除",
  // 后面还有几百项
};

这种文件看起来统一,实际会让业务判断和错误原因分离。修改状态机时,要在路由和错误表之间来回跳,还要想办法给相似错误命名。

适合集中的是跨越多个入口、客户端需要识别的固定错误,例如未登录、角色不足、缺少必要资料、参数验证失败和路由不存在。只在某条业务分支出现的错误,留在判断旁边反而更清楚:

if (record.status !== "PENDING") {
  return context.json({ error: "当前不可修改" }, 400);
}

需要统一的是错误结构和展示出口。业务文案仍然贴着对应判断写。

六层三元表达式被我换成了 switch

全局异常处理还需要把没有业务文案的 HTTPException 转成中文。这个逻辑很简单,用 switch 就够了:

export function getHttpErrorMessage(status: number) {
  switch (status) {
    case 400:
      return "参数错误";
    case 401:
      return "请重新登录";
    case 403:
      return "无权操作";
    case 404:
      return "内容不存在";
    case 409:
      return "状态已变化";
    case 429:
      return "请求过于频繁";
    default:
      return status >= 500 ? "服务器错误" : "请求失败";
  }
}

我之前见过把同一段逻辑写成六层三元表达式。代码行数少了一点,读的时候却要从左到右维护一棵条件树。错误兜底本来就会在排查问题时反复阅读,没有必要省这几行。

全局错误处理也不能把异常对象的 message 原样发给客户端。数据库错误、对象存储响应和第三方 SDK 异常都可能带英文、内部地址甚至实现细节。服务端日志保存原异常,接口只返回受控的中文文案。

线上我仍然要看到 400

错误响应整理完,日志策略也要跟上。

生产环境保留访问日志是有用的:

[2026-08-11 16:46:15.024 Asia/Shanghai] <-- GET /resource/3
[2026-08-11 16:46:15.028 Asia/Shanghai] --> GET /resource/3 200 4ms

它能回答请求有没有到达、状态码是什么、耗时多久。400 和 403 也应该记录,因为它们经常对应真实的业务失败,不是可以忽略的“客户端问题”。

但开发期的 SQL、缓存细节和每分钟执行一次的调度器心跳,不应该原样进入生产日志。比较实用的分层是:

  • 访问日志始终保留。
  • error 始终保留,400 以上响应记录受控中文错误。
  • debug 只在开发环境开启,用来打印 SQL、任务细节和临时诊断信息。

时间也要统一。容器设置 TZ 并不保证所有日志库都按本地时区格式化,尤其是代码直接调用 toISOString() 时,它永远输出 UTC。日志器应明确读取时区并在时间戳中写出来,不要让排查者自己加八小时。

我以为 import type 不会再读取服务端

整理跨端错误时,我顺手重新看了一遍 Hono RPC 类型。服务端通常会导出:

export type AppType = typeof app;

客户端再交给 hc

import type { AppType } from "server/app-type";

const client = hc<AppType>(baseUrl);

我实际改成直接导入以后,客户端类型检查马上开始读取服务端路由。import type 确实会在运行时代码中消失,但 TypeScript 为了算出 typeof app,仍然要继续读取路由链、校验器和响应类型。服务端源码里的路径别名、Bun 类型和数据库驱动声明也跟着进来了。

这也是“直接导入类型”和“导入编译好的声明”之间的区别。前者不产生运行时代码,却仍然展开源码;后者读取的是已经计算好的 .d.ts

我原来怎样生成 Hono RPC 类型

直接导入没有我想得那么轻,我又回头看原来的类型生成命令。这里一直用的是 dts-bundle-generator,它从 Hono 入口生成一个文件:

dts-bundle-generator \
  -o ../client/src/generated/hono-with-types.d.ts \
  src/index.ts \
  --no-check

src/index.ts 是声明入口,-o 后面是客户端使用的生成文件。工具会沿着 AppType 展开本地类型,再把结果合并到这个 .d.ts。客户端读取的是已经算好的声明,不再碰服务端源码。

我之前把它和 Hono RPC 混在一起理解了。Hono 只负责从 typeof app 推导接口类型,dts-bundle-generator 负责把这份类型整理成一个可以交给客户端的文件。换掉生成器不会改变 Hono 的类型推导,只会改变声明怎样输出。

重新看命令时,--no-check 让我有点不放心。这个参数会跳过生成声明的最终校验。现在客户端类型检查紧跟在生成命令后面,所以错误还能被下一步拦住;我还是更倾向于先试着删掉它,而不是长期依赖后续检查兜底。

我为什么暂时没有换成 tsc

看到项目里多了一个声明生成器,我第一反应是改回 TypeScript 原生的声明构建:

{
  "compilerOptions": {
    "declaration": true,
    "emitDeclarationOnly": true,
    "declarationDir": "./dist/types"
  }
}

tsc 默认按源码模块生成一组声明文件。routes/example.tstypes.tsindex.ts 会有各自的 .d.ts,文件之间继续通过 import 关联。这要求服务端先有清楚的 package 出口,内部导入也必须能被客户端解析。还要补上构建顺序:先生成服务端声明,再检查客户端。

dts-bundle-generator 没有把整棵声明目录交给客户端。它从入口展开本地类型,只留下入口引用的部分,最后得到一份单文件 RPC 类型。服务端还有不少内部模块和路径别名时,这种隔离反而省事。

所以我暂时没有换。以后如果把服务端整理成真正的 TypeScript package,我会再用 tsc 生成声明目录,并把构建顺序交给 workspace 管理。现在只是把 Hono RPC 类型交给客户端,单文件声明更直接。

共享错误对象还有一个看得见的副作用。对象使用 as const 后,Hono 生成的响应声明会保留只读属性、错误码字面量,甚至中文文案字面量。类型更精确了,生成文件的 diff 也会明显变大。这不影响运行,但代码审查时要确认变化来自类型收窄,而不是接口突然增加了几十种响应。

无论选择哪种方式,生成后的声明都要交给客户端类型检查。声明文件本身也是接口产物,不能只确认命令退出码。

最后我检查了什么

错误处理很难靠一次手工点击覆盖。我会固定检查下面几件事:

  • 扫描服务端直接返回的错误文案,确认没有英文漏到客户端。
  • 测试常用 HTTP 状态码都映射成预期中文。
  • 验证已声明错误码能通过类型守卫,未知字符串会被拒绝。
  • 重新生成 RPC 声明,再运行客户端类型检查。
  • 用浏览器目标打包一次共享 error.ts,确认 package 导出不是只在编辑器里可见。

日志还要单独测生产配置。访问日志应保留,调试日志应关闭,400 响应和未处理异常仍然要出现。时间戳则用一个固定时间做断言,避免测试只检查“看起来像时间”。

这次整理以后,我对错误处理的要求少了很多。服务端给出准确的中文原因,code 只处理稳定分支;客户端只有一个解包入口,页面不重复弹错。共享文件放跨端协议,业务错误仍留在判断现场。

以后增加错误时,我只需要先判断它是不是跨端流程的一部分。不是的话,就近返回中文文案即可。这样比继续扩充错误框架更容易维护。

参考资料