📖 第05课:状态码与错误处理
阶段一:RESTful基础
HTTP状态码是API与客户端沟通的"信号灯"。正确的状态码让客户端无需解析响应体就能判断请求结果,而标准化的错误处理让问题诊断事半功倍。本课深入每个API开发者必须掌握的状态码语义和错误处理最佳实践。
🔢 HTTP状态码全解析
状态码分类
| 范围 | 类别 | 含义 |
| 2xx | 成功 | 请求被成功接收、理解和处理 |
| 3xx | 重定向 | 需要客户端进一步操作才能完成请求 |
| 4xx | 客户端错误 | 请求包含错误语法或无法完成 |
| 5xx | 服务端错误 | 服务器处理请求时发生错误 |
2xx 成功状态码
| 状态码 | 名称 | 使用场景 | 响应体 |
| 200 | OK | GET获取成功、PUT/PATCH更新成功 | 资源数据 |
| 201 | Created | POST创建成功 | 新建资源 + Location头 |
| 202 | Accepted | 异步任务已接受但未完成 | 任务状态或回调URL |
| 204 | No Content | DELETE成功、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 客户端错误状态码
| 状态码 | 名称 | 使用场景 |
| 400 | Bad Request | 请求格式错误、验证失败 |
| 401 | Unauthorized | 未认证(缺少或无效的凭据) |
| 403 | Forbidden | 已认证但无权限 |
| 404 | Not Found | 资源不存在 |
| 405 | Method Not Allowed | 不支持该HTTP方法 |
| 409 | Conflict | 请求与当前状态冲突(如重复创建) |
| 410 | Gone | 资源已被永久删除 |
| 415 | Unsupported Media Type | 不支持该Content-Type |
| 422 | Unprocessable Entity | 格式正确但语义错误 |
| 429 | Too Many Requests | 请求频率超限 |
💡 401 vs 403 的区别:401 Unauthorized表示"你是谁?"——认证缺失或失败。403 Forbidden表示"我知道你是谁,但你没权限"——认证成功但授权失败。常见错误:用403表示认证失败,或用401表示权限不足。
5xx 服务端错误状态码
| 状态码 | 名称 | 使用场景 |
| 500 | Internal Server Error | 未预期的服务端错误 |
| 502 | Bad Gateway | 网关/代理收到上游无效响应 |
| 503 | Service Unavailable | 服务暂时不可用(维护/过载) |
| 504 | Gateway 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: '上游服务超时' },
};
📝 本课小结
- 2xx成功:200获取/更新、201创建、202异步、204删除
- 4xx客户端错误:400验证、401认证、403授权、404不存在、409冲突、422语义
- 5xx服务端错误:生产环境不暴露内部细节
- 401=你是谁? 403=你不能做!必须区分
- 错误码使用大写蛇形命名,带领域前缀更清晰
- 自定义错误类 + 全局错误处理中间件是最佳实践
- 每个错误响应包含requestId,便于追踪
💪 练习
练习1:为以下场景选择正确的状态码
- 用户注册时用户名已存在
- 请求体中JSON格式错误
- 普通用户尝试删除他人文章
- 请求的API版本已停用
- 后台正在部署维护中
- 创建订单时商品已下架
- 分页请求的page参数为负数
- 使用过期的JWT令牌
参考:1.409 2.400 3.403 4.410 5.503 6.422 7.400 8.401
练习2:实现错误监控
在错误处理中间件中添加日志记录,将所有5xx错误写入文件error.log,包含requestId、时间戳、错误信息和堆栈。
🏆 本课成就:错误处理大师
- ✅ 掌握HTTP状态码的完整分类和语义
- ✅ 区分401/403/409/422的适用场景
- ✅ 实现标准化错误响应格式
- ✅ 设计可扩展的错误码体系
- ✅ 用自定义错误类简化错误处理
- ✅ 实现全局错误处理中间件