结论先行:如果你还在用
{code, message, data}把所有响应包成 HTTP 200,前端、网关、监控其实都在"裸奔"。RFC 9457(原 7807)的 Problem Details 才是正解------真实 HTTP 状态码 + 结构化错误体 + 可扩展业务字段,三件一起给你。本文给一套能在 FastAPI 里直接抄的落地实现,外加我踩过的 5 个坑。
一、背景:你是不是也这样写过接口?
json
// 成功
{ "code": 0, "message": "ok", "data": { "id": 1 } }
// 失败(注意:HTTP 还是 200!)
{ "code": 40400, "message": "用户不存在", "data": null }
这套"统一响应写法"在早年很流行,但它在中大型项目里会慢慢反噬你:
- 网关/负载均衡看不懂:Nginx、Kong、云厂商的熔断和限流都是看 HTTP 状态码的,你永远返回 200,它们一律认为"请求成功",错误流量被当成正常流量。
- 监控告警变脆弱 :想统计"昨天 404 了多少次",只能去解析 body 里的
code字段,Prometheus 配起来又臭又长。 - 前端每个请求都要写
if (res.code !== 0):axios 拦截器里一堆硬编码魔法数字,新人接手一脸懵。
我自己的 fastapi-backend 最早就是这套"统一响应写法",后来整体翻成了 RFC 9457 + 真实状态码 + 类型注解驱动序列化,下面把完整做法摊开讲。
二、核心概念速览(RFC 9457 快查表)
RFC 9457《Problem Details for HTTP APIs》是 RFC 7807 的继承/更新版,核心格式没变,只是把规范做更严谨。它定义了一种错误媒体的标准形态:
Content-Type: application/problem+json- 标准字段(前 5 个是规范的,后面可自由扩展)
| 字段 | 必填 | 含义 |
|---|---|---|
type |
✅ | 问题类型 URI,如 https://api.xxx/problems/not-found,或 about:blank |
title |
✅ | 人类可读的简短标题(不随请求变化) |
status |
✅ | HTTP 状态码(应和响应状态码一致) |
detail |
本次请求的具体说明(随请求变化) | |
instance |
出问题的具体资源 URI 或 trace 标识,排查用 | |
trace_id / fields 等 |
扩展成员:业务自定义,规范允许的 |
场景 → 错误表达 快查表(直接在团队里当规范用)
| 业务场景 | HTTP 状态 | type | title |
|---|---|---|---|
| 参数校验失败 | 422 | .../problems/validation-error |
Request Validation Failed |
| 未登录/鉴权失败 | 401 | .../problems/unauthorized |
Unauthorized |
| 无权限 | 403 | .../problems/forbidden |
Forbidden |
| 资源不存在 | 404 | .../problems/not-found |
Resource Not Found |
| 业务冲突(如重复创建) | 409 | .../problems/conflict |
Conflict |
| 服务端内部错误 | 500 | .../problems/internal |
Internal Server Error |
关键点 :业务错误类型用 type 这个 URI 表达,别塞进 HTTP 状态码 (HTTP 状态码就那几十个,塞不下你的业务码)。扩展字段(如 trace_id、fields)才是你放业务细节的地方。
三、完整实现步骤
技术栈:FastAPI + Pydantic v2。下面 6 个文件可直接复制进项目。
1. 响应模型(schemas.py)
python
# schemas.py
from typing import Any, Optional
from pydantic import BaseModel, Field
class ProblemDetail(BaseModel):
"""RFC 9457 Problem Details 响应模型"""
type: str = Field(default="about:blank", description="问题类型 URI")
title: str = Field(..., description="简短标题,不随请求变化")
status: int = Field(..., description="HTTP 状态码,应与响应一致")
detail: Optional[str] = Field(default=None, description="本次请求的具体说明")
instance: Optional[str] = Field(default=None, description="出问题的资源 URI 或 trace")
# ↓↓↓ 扩展成员:业务自定义字段,规范明确允许 ↓↓↓
trace_id: Optional[str] = None
fields: Optional[dict[str, Any]] = None
2. 业务异常层(apperr.py)------ 把"错误"变成一等公民
python
# apperr.py
from typing import Optional
from fastapi import status
class AppError(Exception):
"""业务异常基类:自带真实 HTTP 状态码 + RFC9457 字段"""
def __init__(
self,
http_status: int,
problem_type: str,
title: str,
detail: Optional[str] = None,
instance: Optional[str] = None,
):
self.http_status = http_status
self.problem_type = problem_type
self.title = title
self.detail = detail
self.instance = instance
super().__init__(title)
# 常用错误(快查表里的那些,直接复用)
class ValidationError(AppError):
def __init__(self, detail=None):
super().__init__(
status.HTTP_422_UNPROCESSABLE_ENTITY,
"https://api.example.com/problems/validation-error",
"Request Validation Failed", detail)
class UnauthorizedError(AppError):
def __init__(self, detail=None):
super().__init__(
status.HTTP_401_UNAUTHORIZED,
"https://api.example.com/problems/unauthorized",
"Unauthorized", detail)
class ForbiddenError(AppError):
def __init__(self, detail=None):
super().__init__(
status.HTTP_403_FORBIDDEN,
"https://api.example.com/problems/forbidden",
"Forbidden", detail)
class NotFoundError(AppError):
def __init__(self, detail=None):
super().__init__(
status.HTTP_404_NOT_FOUND,
"https://api.example.com/problems/not-found",
"Resource Not Found", detail)
class ConflictError(AppError):
def __init__(self, detail=None):
super().__init__(
status.HTTP_409_CONFLICT,
"https://api.example.com/problems/conflict",
"Conflict", detail)
3. 全局异常处理器(handlers.py)------ 核心就在这里
python
# handlers.py
import logging
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from .apperr import AppError
from .schemas import ProblemDetail
logger = logging.getLogger("api")
def _build(err: AppError, request: Request) -> ProblemDetail:
# 从 request.state 取 trace_id(由下面的 Request-ID 中间件注入)
trace_id = getattr(request.state, "trace_id", None)
return ProblemDetail(
type=err.problem_type,
title=err.title,
status=err.http_status,
detail=err.detail,
instance=str(request.url),
trace_id=trace_id,
)
def register_handlers(app: FastAPI) -> None:
@app.exception_handler(AppError)
async def on_app_error(request: Request, exc: AppError):
problem = _build(exc, request)
# 关键:状态码用真实的,媒体类型用 problem+json
return JSONResponse(
status_code=exc.http_status,
content=problem.model_dump(exclude_none=True),
media_type="application/problem+json",
)
@app.exception_handler(RequestValidationError)
async def on_validation_error(request: Request, exc: RequestValidationError):
problem = ProblemDetail(
type="https://api.example.com/problems/validation-error",
title="Request Validation Failed",
status=422,
detail="字段校验未通过",
instance=str(request.url),
trace_id=getattr(request.state, "trace_id", None),
)
return JSONResponse(
status_code=422,
content=problem.model_dump(exclude_none=True),
media_type="application/problem+json",
)
4. 依赖注入里直接抛错(deps.py)
python
# deps.py
from fastapi import Depends, Header
from .apperr import UnauthorizedError
async def get_current_user(authorization: str = Header(default="")):
if not authorization.startswith("Bearer "):
raise UnauthorizedError(detail="缺少或错误的 Authorization 头")
token = authorization.removeprefix("Bearer ").strip()
# 这里换成你真实的 token 校验逻辑
if token != "valid-token":
raise UnauthorizedError(detail="token 无效或已过期")
return {"user_id": 1}
5. 路由里直接用(routes.py)
python
# routes.py
from typing import Optional
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from .apperr import NotFoundError, ConflictError
from .deps import get_current_user
router = APIRouter()
class UserOut(BaseModel):
id: int
name: str
_FAKE_DB = {1: UserOut(id=1, name="信哥")}
@router.get("/users/{uid}", response_model=UserOut)
async def get_user(uid: int, _: dict = Depends(get_current_user)):
user = _FAKE_DB.get(uid)
if user is None:
# 抛业务异常,由全局 handler 转成 404 + problem+json
raise NotFoundError(detail=f"用户 {uid} 不存在")
return user
@router.post("/users/{uid}", response_model=UserOut)
async def create_user(uid: int, _: dict = Depends(get_current_user)):
if uid in _FAKE_DB:
raise ConflictError(detail=f"用户 {uid} 已存在,请勿重复创建")
_FAKE_DB[uid] = UserOut(id=uid, name="新用户")
return _FAKE_DB[uid]
6. 接入 Request-ID:让 instance / trace_id 真正有用
我踩过最大的坑就是 instance 字段写了等于没写。后来在 fastapi-backend 里加了层 Request-ID 中间件,把 trace 喂给错误体,前端报错时把 trace_id 甩给后端,一查一个准:
python
# middleware.py
import uuid
from starlette.middleware.base import BaseHTTPMiddleware
class RequestIDMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
request.state.trace_id = (
request.headers.get("X-Request-ID") or f"req-{uuid.uuid4().hex}"
)
response = await call_next(request)
response.headers["X-Request-ID"] = request.state.trace_id
return response
架构/流程图
四、接口调用效果演示
✅ 成功(正常走 response_model)
bash
curl -s http://localhost:8000/users/1 \
-H "Authorization: Bearer valid-token"
json
{ "id": 1, "name": "信哥" }
HTTP 200,Content-Type
application/json,标准业务模型。
❌ 参数校验失败(422)
bash
# uid 传个字符串触发校验
curl -s http://localhost:8000/users/abc -H "Authorization: Bearer valid-token"
json
{
"type": "https://api.example.com/problems/validation-error",
"title": "Request Validation Failed",
"status": 422,
"detail": "字段校验未通过",
"instance": "http://localhost:8000/users/abc",
"trace_id": "req-3f2a9c1b"
}
HTTP 422,Content-Type
application/problem+json。
❌ 业务错误:资源不存在(404)
bash
curl -s http://localhost:8000/users/999 -H "Authorization: Bearer valid-token"
json
{
"type": "https://api.example.com/problems/not-found",
"title": "Resource Not Found",
"status": 404,
"detail": "用户 999 不存在",
"instance": "http://localhost:8000/users/999",
"trace_id": "req-7b1d4e8a"
}
❌ 鉴权失败(401)
bash
curl -s http://localhost:8000/users/1 # 不带 Authorization
json
{
"type": "https://api.example.com/problems/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "缺少或错误的 Authorization 头"
}
前端现在怎么写? axios 拦截器里只看 response.status:401 跳登录、403 弹无权限、422 抖表单、500 上报。再也不用满屏 if (res.code !== 0) 了。
五、个人实战踩坑与经验
- 坑:HTTP 永远 200 的"伪成功"。网关、负载均衡、APM、云厂商的限流熔断,清一色只看 HTTP 状态码。你永远返回 200,它们一律判"成功",错误流量被当正常流量放过,限流熔断形同虚设。改真实状态码后,光是"错误流量能进监控大盘"这一项就值回票价。
- 坑:response_model 用
dict/Union导致 OpenAPI 丢结构 。前端联调时 Swagger 里看不到错误体长啥样。正确做法:成功响应用response_model=UserOut(类型注解驱动序列化),错误统一走 handler,不在路由签名里掺和。 - 坑:
application/problem+json老客户端不认 。个别古董 SDK 只认application/json。折中:保留problem+json(规范推荐),但若你的调用方很老,降级成application/json也完全能工作,字段不变。 - 坑:
instance写成空或写死 。排查时毫无用处。务必接 Request-ID 中间件,把trace_id同时塞进响应头和错误体,形成闭环。 - 经验:用"快查表"统一团队表达。我们内部把上面对照表钉在 Confluence 首页,新人报错先查表再写,杜绝了"张三用 code=1001、李四用 status='ERR_USER'"的混乱。
六、总结
一句话 :FastAPI 错误处理别再 200 一把梭------用 AppError 携带真实 HTTP 状态码,全局 handler 统一渲染成 RFC 9457 的 ProblemDetail,成功响应交给 response_model 类型注解驱动。网关、监控、前端三方都省心,新人也能照着快查表写。
完整代码已按模块拆分,复制即跑。下一篇可以聊聊怎么给这套错误体接 Sentry + 结构化日志,感兴趣的评论区扣 1。