用 RFC 9457 Problem Details 给 FastAPI 错误响应立规矩
{code, message, data} 全包 HTTP 200 的写法,在前端、网关、监控三处都会留下问题。RFC 9457(原 RFC 7807)的 Problem Details 给的是另一套:真实 HTTP 状态码 + 结构化错误体 + 可扩展业务字段。下面是一套可以直接搬进 FastAPI 的落地实现,以及实践里踩过的 5 个坑。
参考规范:RFC 9457《Problem Details for HTTP APIs》。
一、背景:你是不是也这样写过接口?
// 成功
{ "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 拦截器里一堆硬编码魔法数字,新人接手一脸懵。
二、核心概念速览(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:blanktitle[必填]:人类可读的简短标题(不随请求变化)status[必填]:HTTP 状态码(应和响应状态码一致)detail:本次请求的具体说明(随请求变化)instance:出问题的具体资源 URI 或 trace 标识,排查用trace_id/fields等:扩展成员,业务自定义,规范允许
场景 → 错误表达快查表(可以直接在团队里当规范用):
- 参数校验失败 → HTTP 422,
type=.../problems/validation-error,title=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)
1. 响应模型 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 —— 把「错误」变成一等公民
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 —— 核心就在这里
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:
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)
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
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()
if token != "valid-token":
raise UnauthorizedError(detail="token 无效或已过期")
return {"user_id": 1}
5. 路由里直接用 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:
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 字段写了等于没写,是这套方案里最容易踩的坑。加一层 Request-ID 中间件,把 trace 喂给错误体,前端报错时把 trace_id 甩给后端,一查一个准:
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):
curl -s http://localhost:8000/users/1 -H "Authorization: Bearer valid-token"
返回 { "id": 1, "name": "信哥" },HTTP 200,Content-Type: application/json。
参数校验失败(422):
curl -s http://localhost:8000/users/abc -H "Authorization: Bearer valid-token"
{
"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):
{
"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):
{
"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 类型注解驱动。网关、监控、前端三方都省心。
参考:RFC 9457《Problem Details for HTTP APIs》,https://www.rfc-editor.org/rfc/rfc9457