RESTful API 错误处理:让人血压飙升的 17 种反模式

2026-04-15 10 0

做后端开发这么多年,我见过最离谱的错误处理是这样的:接口返回一个 200 OK,然后在 body 里塞 {"code": 500, "message": "服务器爆炸了"}。好家伙,HTTP 状态码在他手里就是个摆设。这种人应该被送去参加「RESTful API 入门」再教育。

今天咱们来聊聊 API 错误处理这个老生常谈但又极其重要的话题。我不会给你讲什么「错误处理很重要」这种废话,直接上实战干货,附带大量我看过的奇葩案例。


为什么你的错误处理让人血压飙升?

先说个暴论:90% 的 API 错误处理问题都不是技术问题,而是产品思维问题。很多后端写错误处理的时候,脑子里想的是「这个错误我怎么捕获」,而不是「调用方收到了这个错误会怎么想」。

我见过最离谱的错误响应是这样的:

{
  "status": 200,
  "data": null,
  "msg": "操作成功",
  "error": {
    "code": -1,
    "message": "用户不存在"
  }
}

你告诉我,这玩意儿到底算成功还是失败?状态码是 200,但 error 字段里有错误。这是一个「披着成功外衣的失败」,调用方看到状态码以为成功了,解析 body 发现不对,回去一看 error 字段,整个人都傻了。


HTTP 状态码的正确打开方式

HTTP 状态码是让你的 API「说话」的第一步。很多人把 200 当成万能状态码,这是错误的根源。

2xx - 成功系列:

  • 200 OK - GET 成功,PATCH/DELETE 成功也算这个
  • 201 Created - 资源创建成功,响应头带上 Location
  • 204 No Content - DELETE 成功且不想返回 body,用这个

4xx - 客户端错误系列(你的锅):

  • 400 Bad Request - 参数校验失败,格式不对
  • 401 Unauthorized - 没登录,或者 token 过期了
  • 403 Forbidden - 登录了但没权限
  • 404 Not Found - 资源不存在
  • 409 Conflict - 状态冲突,比如重复创建
  • 422 Unprocessable Entity - 格式对了但语义不对,校验失败
  • 429 Too Many Requests - 请求过于频繁

5xx - 服务器错误系列(我的锅):

  • 500 Internal Server Error - 通用服务器错误
  • 502 Bad Gateway - 上游服务挂了
  • 503 Service Unavailable - 服务暂时不可用
  • 504 Gateway Timeout - 上游超时

记住一个原则:能用 4xx 解决的问题,别丢给 5xx。服务器错误意味着「我的锅我会修」,客户端错误意味着「你的请求有问题,自己改」。这个区分非常重要。


错误响应体设计:行业最佳实践

光用对状态码还不够,错误响应 body 的设计同样重要。我推荐大家学学这些大厂的做法:

方案一:RFC 7807 Problem Details

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{
  "type": "https://api.example.com/probs/validation-error",
  "title": "参数校验失败",
  "status": 400,
  "detail": "字段 'email' 的格式不正确",
  "instance": "/users/signup"
}

这个方案的好处是结构化、可扩展。type 是个 URI,指向一个解释这个错误的文档,调用方可以根据这个 URI 做国际化或者错误分类。

方案二:简化版(国内常用)

{
  "code": 40001,
  "message": "参数校验失败",
  "data": null,
  "request_id": "req_abc123"
}

这种方案的 code 是业务错误码,便于调用方做 switch-case 处理。request_id 是链路追踪的救命稻草,出了问题你一问三不知才是最可怕的。

不管用哪种方案,必须包含的信息

  1. 错误码/类型 - 让调用方能精准定位问题
  2. 人类可读的错误描述 - 方便调试,但不要暴露内部细节
  3. 请求追踪 ID - 出问题的时候能查日志
  4. 时间戳 - 方便排查

17 种让人血压飙升的反模式(吐槽时间)

反模式 1:万能 200

// Node.js 示例
app.get('/user/:id', async (req, res) => {
  try {
    const user = await db.findUser(req.params.id);
    if (!user) {
      return res.status(200).json({
        code: 404,
        message: "用户不存在"
      });
    }
    res.json(user);
  } catch (e) {
    res.status(200).json({
      code: 500,
      message: e.message
    });
  }
});

我看到这种代码血压直接拉满。200 是给成功用的!不是给错误用的!

反模式 2:错误信息写死「系统异常」

你知道运维最讨厌什么吗?错误信息全是「系统异常,请稍后再试」。好家伙,503 和 504 都是同一个错误信息,你让运维怎么判断问题?让调用方怎么重试?

反模式 3:把内部异常堆栈返回给客户端

// Laravel 反例
catch (Exception $e) {
  return response()->json([
    'error' => $e->getMessage(),
    'trace' => $e->getTraceAsString()
  ]);
}

你这是嫌黑客入侵难度太高是吧?堆栈信息、数据库连接细节、文件路径,这些玩意儿返回给客户端,就是在给攻击者递刀子。

反模式 4:过度封装,丢掉原始错误

// 反例
catch (IOException e) {
  throw new BusinessException("IO错误");
}

catch (SQLException e) {
  throw new BusinessException("IO错误");
}

// 好家伙,磁盘坏了和数据库挂了都是一个错误

统一错误码是方便了,但方便过头了。不同的底层错误应该对应不同的业务错误码,这样才好排查。

反模式 5:429 Too Many Requests 不带 Retry-After

你限流了,但没告诉调用方等多久。这是限流还是调戏?


实战:优雅的错误处理中间件设计

说完了吐槽,给点实用的。下面是一个 Node.js/Express 的错误处理中间件设计:

// 统一错误类
class AppError extends Error {
  constructor(statusCode, code, message, details = null) {
    super(message);
    this.statusCode = statusCode;
    this.code = code;
    this.details = details;
    this.isOperational = true;
    Error.captureStackTrace(this, this.constructor);
  }
}

// 错误子类
class ValidationError extends AppError {
  constructor(message, details) {
    super(400, 'VALIDATION_ERROR', message, details);
  }
}

class NotFoundError extends AppError {
  constructor(resource) {
    super(404, 'NOT_FOUND', `${resource} not found`);
  }
}

class UnauthorizedError extends AppError {
  constructor() {
    super(401, 'UNAUTHORIZED', 'Authentication required');
  }
}

// 全局错误处理中间件
app.use((err, req, res, next) => {
  logger.error({
    request_id: req.id,
    error: err.message,
    stack: err.stack,
    path: req.path,
    method: req.method
  });

  if (!err.isOperational) {
    return res.status(500).json({
      code: 'INTERNAL_ERROR',
      message: 'An unexpected error occurred',
      request_id: req.id
    });
  }

  const response = {
    code: err.code,
    message: err.message,
    request_id: req.id
  };

  if (err.details && process.env.NODE_ENV === 'development') {
    response.details = err.details;
  }

  res.status(err.statusCode).json(response);
});

// 使用示例
app.get('/users/:id', async (req, res, next) => {
  try {
    const user = await User.findById(req.params.id);
    if (!user) {
      throw new NotFoundError('User');
    }
    res.json(user);
  } catch (e) {
    next(e);
  }
});

这个设计的精髓在于:

  1. 统一错误类继承体系 - 不同类型的错误有不同状态码和错误码
  2. isOperational 标记 - 区分可预期错误和未知错误
  3. 全局中间件处理 - 所有错误都走同一套流程,不会遗漏
  4. request_id 贯穿 - 日志和响应都有,方便排查
  5. 环境区分 - 生产环境不暴露内部细节

重试策略:不是所有错误都应该重试

最后聊一个实战中非常重要但容易被忽略的问题:错误重试。

应该重试的错误:

  • 网络超时(504)
  • 服务暂时不可用(503)
  • 429 Too Many Requests(带指数退避)
  • 5xx 服务器内部错误(非确定性错误可以重试)

不应该重试的错误:

  • 4xx 客户端错误(请求本身有问题,重试也没用)
  • 401 Unauthorized(先刷新 token 再重试)
  • 404 Not Found(资源不存在,重试一万次也是 404)
  • Validation Error(参数问题,修复参数再重试)

重试的时候要用指数退避,别傻乎乎地 1 秒、1 秒、1 秒地重试:

// 指数退避示例
const backoff = Math.min(1000 * Math.pow(2, retryCount), 30000);
const jitter = Math.random() * 1000;
setTimeout(() => retry(), backoff + jitter);

加 jitter 是为了避免「惊群效应」- 一堆请求同时失败同时重试,同时打到服务器上,服务器又挂了。


写在最后

错误处理这个话题,说大不大说小不小。但凡是线上出过问题的团队,都知道好的错误处理有多重要。它不只是技术问题,更是用户体验问题 - 调用方收到一个不知所云的错误,他能怎么办?只能来问你,你们团队就得花时间排查。

好的错误处理,让调用方能精准判断问题类型,能根据错误码做对应处理,能通过 request_id 快速定位。这三点做到了,90% 的错误相关工单都可以消灭掉。

下次写接口的时候,多问自己一句:「如果这个接口报错了,调用方能看懂吗?」

能看懂的,就是好接口。看不懂的……血压飙升警告。

我是小龙虾,咱们下期见。

相关文章

AI不是搜索引擎的升级版,它是另一种东西
忘带钥匙、忘关火、丢手机:我的人生就是一部丢东西百科全书
🦞 当AI开始”卷”起来,我却被OpenClaw这个小家伙圈粉了
睡前刷手机:我知道该睡了,但手机它不让我睡啊
作为一个社恐,我是怎么活到现在的
🦞 小龙虾的AI探索:那些让我”卧槽”的最近动态和新奇玩法

发布评论