📖 第05课:状态码与错误处理

阶段一:RESTful基础

HTTP状态码是API与客户端沟通的"信号灯"。正确的状态码让客户端无需解析响应体就能判断请求结果,而标准化的错误处理让问题诊断事半功倍。本课深入每个API开发者必须掌握的状态码语义和错误处理最佳实践。

🔢 HTTP状态码全解析

状态码分类

范围类别含义
2xx成功请求被成功接收、理解和处理
3xx重定向需要客户端进一步操作才能完成请求
4xx客户端错误请求包含错误语法或无法完成
5xx服务端错误服务器处理请求时发生错误

2xx 成功状态码

状态码名称使用场景响应体
200OKGET获取成功、PUT/PATCH更新成功资源数据
201CreatedPOST创建成功新建资源 + Location头
202Accepted异步任务已接受但未完成任务状态或回调URL
204No ContentDELETE成功、PUT不需要返回数据
// 200 vs 201 vs 204 的正确使用

// GET - 200 OK
app.get('/api/users/:id', (req, res) => {
  const user = findUser(req.params.id);
  if (!user) return res.status(404).json(...);
  res.status(200).json({ success: true, data: user });  // ✅ 200
});

// POST - 201 Created
app.post('/api/users', (req, res) => {
  const user = createUser(req.body);
  res.location(`/api/users/${user.id}`);  // ✅ 设置Location头
  res.status(201).json({ success: true, data: user });  // ✅ 201
});

// DELETE - 204 No Content
app.delete('/api/users/:id', (req, res) => {
  deleteUser(req.params.id);
  res.status(204).end();  // ✅ 204,无响应体
});

// POST 异步任务 - 202 Accepted
app.post('/api/exports', (req, res) => {
  const jobId = createExportJob(req.body);
  res.status(202).json({  // ✅ 202
    success: true,
    data: { jobId, status: 'pending' },
    links: { status: `/api/exports/${jobId}/status` },
  });
});

4xx 客户端错误状态码

状态码名称使用场景
400Bad Request请求格式错误、验证失败
401Unauthorized未认证(缺少或无效的凭据)
403Forbidden已认证但无权限
404Not Found资源不存在
405Method Not Allowed不支持该HTTP方法
409Conflict请求与当前状态冲突(如重复创建)
410Gone资源已被永久删除
415Unsupported Media Type不支持该Content-Type
422Unprocessable Entity格式正确但语义错误
429Too Many Requests请求频率超限
💡 401 vs 403 的区别401 Unauthorized表示"你是谁?"——认证缺失或失败。403 Forbidden表示"我知道你是谁,但你没权限"——认证成功但授权失败。常见错误:用403表示认证失败,或用401表示权限不足。

5xx 服务端错误状态码

状态码名称使用场景
500Internal Server Error未预期的服务端错误
502Bad Gateway网关/代理收到上游无效响应
503Service Unavailable服务暂时不可用(维护/过载)
504Gateway Timeout网关等待上游响应超时
⚠️ 5xx错误的暴露风险:永远不要在5xx响应中暴露堆栈跟踪、数据库查询、内部IP等敏感信息。生产环境中,5xx只返回"服务器内部错误",详细信息记录在服务端日志中。

🛡️ 标准化错误处理

错误响应格式设计

// error-handler.js - 标准化错误处理系统

// 自定义业务错误类
class ApiError extends Error {
  constructor(statusCode, code, message, details = []) {
    super(message);
    this.statusCode = statusCode;
    this.code = code;
    this.details = details;
    this.timestamp = new Date().toISOString();
  }
}

// 预定义错误类型
class ValidationError extends ApiError {
  constructor(details) {
    super(400, 'VALIDATION_ERROR', '请求数据验证失败', details);
  }
}

class AuthenticationError extends ApiError {
  constructor(message = '认证失败,请提供有效的凭据') {
    super(401, 'AUTHENTICATION_ERROR', message);
  }
}

class AuthorizationError extends ApiError {
  constructor(message = '您没有执行此操作的权限') {
    super(403, 'AUTHORIZATION_ERROR', message);
  }
}

class NotFoundError extends ApiError {
  constructor(resource, id) {
    super(404, 'NOT_FOUND', `${resource} ${id} 不存在`);
  }
}

class ConflictError extends ApiError {
  constructor(message) {
    super(409, 'CONFLICT', message);
  }
}

class RateLimitError extends ApiError {
  constructor(retryAfter) {
    super(429, 'RATE_LIMIT_EXCEEDED', '请求频率超限,请稍后重试');
    this.retryAfter = retryAfter;
  }
}

class BusinessError extends ApiError {
  constructor(code, message, details = []) {
    super(422, code, message, details);
  }
}

// Express错误处理中间件
function errorHandler(err, req, res, next) {
  const requestId = req.requestId || 'unknown';

  // 已知的API错误
  if (err instanceof ApiError) {
    const response = {
      success: false,
      error: {
        code: err.code,
        message: err.message,
      },
      meta: {
        requestId,
        timestamp: err.timestamp,
      },
    };

    if (err.details && err.details.length > 0) {
      response.error.details = err.details;
    }

    if (err.retryAfter) {
      res.setHeader('Retry-After', err.retryAfter);
      response.error.retryAfter = err.retryAfter;
    }

    // 开发环境包含文档链接
    if (process.env.NODE_ENV === 'development') {
      response.error.documentation = `https://docs.example.com/api/errors#${err.code.toLowerCase()}`;
    }

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

  // JSON解析错误
  if (err.type === 'entity.parse.failed') {
    return res.status(400).json({
      success: false,
      error: {
        code: 'INVALID_JSON',
        message: '请求体不是有效的JSON格式',
      },
      meta: { requestId, timestamp: new Date().toISOString() },
    });
  }

  // 未知错误
  console.error(`[${requestId}] 未处理错误:`, err);

  const response = {
    success: false,
    error: {
      code: 'INTERNAL_ERROR',
      message: '服务器内部错误',
    },
    meta: { requestId, timestamp: new Date().toISOString() },
  };

  // 开发环境包含堆栈信息
  if (process.env.NODE_ENV === 'development') {
    response.error.stack = err.stack;
  }

  res.status(500).json(response);
}

module.exports = {
  ApiError, ValidationError, AuthenticationError,
  AuthorizationError, NotFoundError, ConflictError,
  RateLimitError, BusinessError, errorHandler,
};

在路由中使用错误类

// routes.js - 使用自定义错误类
const express = require('express');
const {
  ValidationError, NotFoundError, ConflictError,
  AuthorizationError, BusinessError, errorHandler
} = require('./error-handler');

const app = express();
app.use(express.json());

const articles = new Map();

// 创建文章
app.post('/api/articles', (req, res, next) => {
  try {
    const { title, content, authorId } = req.body;

    // 验证
    const errors = [];
    if (!title) errors.push({ field: 'title', message: '标题为必填项' });
    if (!content) errors.push({ field: 'content', message: '内容为必填项' });
    if (errors.length > 0) throw new ValidationError(errors);

    // 业务检查
    if (title.length > 200) {
      throw new BusinessError('TITLE_TOO_LONG', '标题不能超过200个字符');
    }

    // 冲突检查
    if ([...articles.values()].some(a => a.title === title)) {
      throw new ConflictError(`标题为"${title}"的文章已存在`);
    }

    const id = Date.now();
    articles.set(id, { id, title, content, authorId, createdAt: new Date().toISOString() });

    res.status(201).json({ success: true, data: articles.get(id) });
  } catch (err) {
    next(err);
  }
});

// 获取文章
app.get('/api/articles/:id', (req, res, next) => {
  try {
    const article = articles.get(parseInt(req.params.id));
    if (!article) throw new NotFoundError('文章', req.params.id);
    res.json({ success: true, data: article });
  } catch (err) {
    next(err);
  }
});

// 删除文章(需要权限)
app.delete('/api/articles/:id', (req, res, next) => {
  try {
    const article = articles.get(parseInt(req.params.id));
    if (!article) throw new NotFoundError('文章', req.params.id);

    // 检查权限
    const userId = req.headers['x-user-id'];
    if (article.authorId !== parseInt(userId)) {
      throw new AuthorizationError('只能删除自己创建的文章');
    }

    articles.delete(parseInt(req.params.id));
    res.status(204).end();
  } catch (err) {
    next(err);
  }
});

// 注册错误处理中间件(必须在所有路由之后)
app.use(errorHandler);

app.listen(3000, () => console.log('🚀 运行在 http://localhost:3000'));

测试错误处理

# 测试1: 验证错误 400
curl -s -X POST http://localhost:3000/api/articles \
  -H "Content-Type: application/json" \
  -d '{"title":""}' | jq .
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "请求数据验证失败",
    "details": [
      {"field":"title","message":"标题为必填项"},
      {"field":"content","message":"内容为必填项"}
    ]
  },
  "meta": {"requestId":"...","timestamp":"..."}
}
# 测试2: 资源不存在 404
curl -s http://localhost:3000/api/articles/99999 | jq .

# 测试3: 冲突 409
curl -s -X POST http://localhost:3000/api/articles \
  -H "Content-Type: application/json" \
  -d '{"title":"测试文章","content":"内容"}'
# 再次创建同标题
curl -s -X POST http://localhost:3000/api/articles \
  -H "Content-Type: application/json" \
  -d '{"title":"测试文章","content":"内容2"}' | jq .

# 测试4: 权限不足 403
curl -s -X DELETE http://localhost:3000/api/articles/1 \
  -H "X-User-ID: 999" | jq .

📊 错误码设计规范

错误码命名规范

规范示例说明
大写蛇形VALIDATION_ERROR标准风格
领域前缀AUTH_INVALID_TOKEN按模块分类
动作+原因ORDER_INSUFFICIENT_STOCK清晰表达
避免数字码ERR_1001数字码不可读

常见错误码表

// 完整错误码映射
const ERROR_CODES = {
  // 通用 4xx
  VALIDATION_ERROR:       { status: 400, message: '请求数据验证失败' },
  INVALID_JSON:           { status: 400, message: '无效的JSON格式' },
  AUTH_MISSING:           { status: 401, message: '缺少认证信息' },
  AUTH_INVALID_TOKEN:     { status: 401, message: '认证令牌无效或已过期' },
  AUTH_INVALID_CREDENTIAL:{ status: 401, message: '用户名或密码错误' },
  FORBIDDEN:              { status: 403, message: '无权访问此资源' },
  FORBIDDEN_ROLE:         { status: 403, message: '当前角色无权执行此操作' },
  NOT_FOUND:              { status: 404, message: '请求的资源不存在' },
  METHOD_NOT_ALLOWED:     { status: 405, message: '不支持该HTTP方法' },
  CONFLICT:               { status: 409, message: '资源冲突' },
  CONFLICT_DUPLICATE:     { status: 409, message: '资源已存在' },
  GONE:                   { status: 410, message: '资源已被永久删除' },
  UNSUPPORTED_MEDIA:      { status: 415, message: '不支持的媒体类型' },
  UNPROCESSABLE:          { status: 422, message: '请求语义无法处理' },
  RATE_LIMIT:             { status: 429, message: '请求频率超限' },

  // 业务 422
  INSUFFICIENT_STOCK:     { status: 422, message: '库存不足' },
  ORDER_CANNOT_CANCEL:    { status: 422, message: '订单状态不允许取消' },
  PASSWORD_TOO_WEAK:      { status: 422, message: '密码强度不足' },
  EMAIL_NOT_VERIFIED:     { status: 422, message: '邮箱未验证' },

  // 服务端 5xx
  INTERNAL_ERROR:         { status: 500, message: '服务器内部错误' },
  SERVICE_UNAVAILABLE:    { status: 503, message: '服务暂时不可用' },
  UPSTREAM_TIMEOUT:       { status: 504, message: '上游服务超时' },
};

📝 本课小结

  1. 2xx成功:200获取/更新、201创建、202异步、204删除
  2. 4xx客户端错误:400验证、401认证、403授权、404不存在、409冲突、422语义
  3. 5xx服务端错误:生产环境不暴露内部细节
  4. 401=你是谁? 403=你不能做!必须区分
  5. 错误码使用大写蛇形命名,带领域前缀更清晰
  6. 自定义错误类 + 全局错误处理中间件是最佳实践
  7. 每个错误响应包含requestId,便于追踪

💪 练习

练习1:为以下场景选择正确的状态码

  1. 用户注册时用户名已存在
  2. 请求体中JSON格式错误
  3. 普通用户尝试删除他人文章
  4. 请求的API版本已停用
  5. 后台正在部署维护中
  6. 创建订单时商品已下架
  7. 分页请求的page参数为负数
  8. 使用过期的JWT令牌

参考:1.409 2.400 3.403 4.410 5.503 6.422 7.400 8.401

练习2:实现错误监控

在错误处理中间件中添加日志记录,将所有5xx错误写入文件error.log,包含requestId、时间戳、错误信息和堆栈。

🏆 本课成就:错误处理大师