7.2.4 错误响应格式
一句话破题
好的错误响应要回答三个问题:错了什么(code)、为什么错(message)、怎么修复(details)。
统一错误格式
typescript
interface ErrorResponse {
error: {
code: string // 机器可读的错误码
message: string // 用户可读的错误信息
details?: ErrorDetail[] // 详细的字段级错误
traceId?: string // 请求追踪 ID
}
}
interface ErrorDetail {
field: string // 出错的字段
message: string // 字段错误信息
code?: string // 字段错误码
}示例
json
{
"error": {
"code": "VALIDATION_ERROR",
"message": "请求参数验证失败",
"details": [
{ "field": "email", "message": "邮箱格式不正确", "code": "INVALID_FORMAT" },
{ "field": "password", "message": "密码至少 8 个字符", "code": "TOO_SHORT" }
],
"traceId": "abc-123-xyz"
}
}错误码设计
命名规范
typescript
// 使用大写下划线分隔
const ErrorCodes = {
// 认证相关
UNAUTHORIZED: 'UNAUTHORIZED',
TOKEN_EXPIRED: 'TOKEN_EXPIRED',
INVALID_TOKEN: 'INVALID_TOKEN',
// 权限相关
FORBIDDEN: 'FORBIDDEN',
INSUFFICIENT_PERMISSIONS: 'INSUFFICIENT_PERMISSIONS',
// 资源相关
NOT_FOUND: 'NOT_FOUND',
ALREADY_EXISTS: 'ALREADY_EXISTS',
// 验证相关
VALIDATION_ERROR: 'VALIDATION_ERROR',
INVALID_FORMAT: 'INVALID_FORMAT',
REQUIRED_FIELD: 'REQUIRED_FIELD',
// 业务相关
INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE',
ORDER_CANCELLED: 'ORDER_CANCELLED',
// 系统相关
INTERNAL_ERROR: 'INTERNAL_ERROR',
SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
RATE_LIMITED: 'RATE_LIMITED',
} as const错误码与状态码对应
| 错误码 | HTTP 状态码 | 说明 |
|---|---|---|
| UNAUTHORIZED | 401 | 未认证 |
| TOKEN_EXPIRED | 401 | Token 过期 |
| FORBIDDEN | 403 | 无权限 |
| NOT_FOUND | 404 | 资源不存在 |
| ALREADY_EXISTS | 409 | 资源已存在 |
| VALIDATION_ERROR | 400/422 | 验证失败 |
| RATE_LIMITED | 429 | 限流 |
| INTERNAL_ERROR | 500 | 服务器错误 |
实现统一错误处理
错误类定义
typescript
// lib/errors.ts
export class AppError extends Error {
constructor(
public code: string,
message: string,
public statusCode: number = 400,
public details?: ErrorDetail[]
) {
super(message)
this.name = 'AppError'
}
}
export class ValidationError extends AppError {
constructor(details: ErrorDetail[]) {
super('VALIDATION_ERROR', '请求参数验证失败', 400, details)
}
}
export class NotFoundError extends AppError {
constructor(resource: string) {
super('NOT_FOUND', `${resource}不存在`, 404)
}
}
export class UnauthorizedError extends AppError {
constructor(message = '请先登录') {
super('UNAUTHORIZED', message, 401)
}
}
export class ForbiddenError extends AppError {
constructor(message = '无权访问此资源') {
super('FORBIDDEN', message, 403)
}
}错误响应工具
typescript
// lib/api-response.ts
export function errorResponse(error: AppError, traceId?: string) {
return NextResponse.json(
{
error: {
code: error.code,
message: error.message,
details: error.details,
traceId,
},
},
{ status: error.statusCode }
)
}
export function successResponse<T>(data: T, status = 200) {
return NextResponse.json({ data }, { status })
}API 路由使用
typescript
// app/api/users/route.ts
import { AppError, ValidationError, NotFoundError } from '@/lib/errors'
import { errorResponse, successResponse } from '@/lib/api-response'
export async function POST(request: NextRequest) {
const traceId = crypto.randomUUID()
try {
const body = await request.json()
// 验证
const errors: ErrorDetail[] = []
if (!body.email) {
errors.push({ field: 'email', message: '邮箱不能为空', code: 'REQUIRED' })
}
if (!body.password || body.password.length < 8) {
errors.push({ field: 'password', message: '密码至少 8 个字符', code: 'TOO_SHORT' })
}
if (errors.length > 0) {
throw new ValidationError(errors)
}
// 检查邮箱是否已存在
const existing = await prisma.user.findUnique({
where: { email: body.email },
})
if (existing) {
throw new AppError('EMAIL_EXISTS', '邮箱已被注册', 409)
}
// 创建用户
const user = await prisma.user.create({ data: body })
return successResponse(user, 201)
} catch (error) {
if (error instanceof AppError) {
return errorResponse(error, traceId)
}
console.error(`[${traceId}] Unexpected error:`, error)
return errorResponse(
new AppError('INTERNAL_ERROR', '服务器内部错误', 500),
traceId
)
}
}前端错误处理
typescript
// lib/api-client.ts
interface ApiError {
code: string
message: string
details?: { field: string; message: string }[]
traceId?: string
}
async function fetchApi<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, options)
const data = await response.json()
if (!response.ok) {
const error = data.error as ApiError
throw new ApiClientError(error)
}
return data.data
}
class ApiClientError extends Error {
constructor(public error: ApiError) {
super(error.message)
}
// 获取字段错误
getFieldError(field: string): string | undefined {
return this.error.details?.find(d => d.field === field)?.message
}
// 判断错误类型
isUnauthorized() {
return this.error.code === 'UNAUTHORIZED'
}
isValidationError() {
return this.error.code === 'VALIDATION_ERROR'
}
}表单中使用
typescript
// 表单提交
async function handleSubmit(data: FormData) {
try {
await fetchApi('/api/users', {
method: 'POST',
body: JSON.stringify(data),
})
toast.success('注册成功')
} catch (error) {
if (error instanceof ApiClientError) {
if (error.isValidationError()) {
// 显示字段错误
setErrors({
email: error.getFieldError('email'),
password: error.getFieldError('password'),
})
} else {
toast.error(error.message)
}
}
}
}国际化支持
typescript
// lib/error-messages.ts
const errorMessages: Record<string, Record<string, string>> = {
zh: {
UNAUTHORIZED: '请先登录',
TOKEN_EXPIRED: '登录已过期,请重新登录',
FORBIDDEN: '无权访问此资源',
NOT_FOUND: '资源不存在',
VALIDATION_ERROR: '请求参数验证失败',
INTERNAL_ERROR: '服务器内部错误',
},
en: {
UNAUTHORIZED: 'Please login first',
TOKEN_EXPIRED: 'Login expired, please login again',
FORBIDDEN: 'Access denied',
NOT_FOUND: 'Resource not found',
VALIDATION_ERROR: 'Validation failed',
INTERNAL_ERROR: 'Internal server error',
},
}
export function getErrorMessage(code: string, locale = 'zh'): string {
return errorMessages[locale]?.[code] || errorMessages.zh[code] || code
}觉知:常见问题
1. 错误信息不一致
typescript
// ❌ 每个接口返回格式不同
{ "error": "用户不存在" }
{ "message": "参数错误" }
{ "err": { "msg": "失败" } }
// ✅ 统一格式
{
"error": {
"code": "NOT_FOUND",
"message": "用户不存在"
}
}2. 暴露敏感信息
typescript
// ❌ 暴露内部实现细节
{
"error": {
"message": "查询失败:SELECT * FROM users WHERE id = '1' OR '1'='1'"
}
}
// ✅ 返回通用信息
{
"error": {
"code": "INTERNAL_ERROR",
"message": "服务器内部错误",
"traceId": "abc-123" // 用于日志追踪
}
}3. 验证错误不够具体
typescript
// ❌ 只返回"验证失败"
{ "error": { "message": "验证失败" } }
// ✅ 返回详细的字段错误
{
"error": {
"code": "VALIDATION_ERROR",
"message": "请求参数验证失败",
"details": [
{ "field": "email", "message": "邮箱格式不正确" },
{ "field": "age", "message": "年龄必须大于 0" }
]
}
}本节小结
| 要点 | 说明 |
|---|---|
| 统一格式 | code + message + details |
| 错误码 | 大写下划线,机器可读 |
| 错误类 | 封装常见错误类型 |
| 前端处理 | 根据 code 做不同处理 |
