别再 HTTP 200 一把梭了:用 RFC 9457 Problem Details 给 FastAPI 错误响应"立规矩"

0 阅读8分钟

结论先行:如果你还在用 {code, message, data} 把所有响应包成 HTTP 200,前端、网关、监控其实都在"裸奔"。RFC 9457(原 7807)的 Problem Details 才是正解——真实 HTTP 状态码 + 结构化错误体 + 可扩展业务字段,三件一起给你。本文给一套能在 FastAPI 里直接抄的落地实现,外加我踩过的 5 个坑。


一、背景:你是不是也这样写过接口?

// 成功
{ "code": 0, "message": "ok", "data": { "id": 1 } }
// 失败(注意:HTTP 还是 200!)
{ "code": 40400, "message": "用户不存在", "data": null }

这套"统一响应写法"在早年很流行,但它在中大型项目里会慢慢反噬你:

  1. 网关/负载均衡看不懂:Nginx、Kong、云厂商的熔断和限流都是看 HTTP 状态码的,你永远返回 200,它们一律认为"请求成功",错误流量被当成正常流量。
  2. 监控告警变脆弱:想统计"昨天 404 了多少次",只能去解析 body 里的 code 字段,Prometheus 配起来又臭又长。
  3. 前端每个请求都要写 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人类可读的简短标题(不随请求变化)
statusHTTP 状态码(应和响应状态码一致)
detail本次请求的具体说明(随请求变化)
instance出问题的具体资源 URI 或 trace 标识,排查用
trace_id / fields扩展成员:业务自定义,规范允许的

场景 → 错误表达 快查表(直接在团队里当规范用)

业务场景HTTP 状态typetitle
参数校验失败422.../problems/validation-errorRequest Validation Failed
未登录/鉴权失败401.../problems/unauthorizedUnauthorized
无权限403.../problems/forbiddenForbidden
资源不存在404.../problems/not-foundResource Not Found
业务冲突(如重复创建)409.../problems/conflictConflict
服务端内部错误500.../problems/internalInternal Server Error

关键点:业务错误类型用 type 这个 URI 表达,别塞进 HTTP 状态码(HTTP 状态码就那几十个,塞不下你的业务码)。扩展字段(如 trace_idfields)才是你放业务细节的地方。


三、完整实现步骤

技术栈:FastAPI + Pydantic v2。下面 6 个文件可直接复制进项目。

1. 响应模型(schemas.py)

# 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)—— 把"错误"变成一等公民

# 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)—— 核心就在这里

# 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)

# 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)

# 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 甩给后端,一查一个准:

# 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

架构/流程图

flowchart TD
    A[客户端请求] --> B{路由 / 依赖注入}
    B --> C[业务逻辑]
    C -->|正常返回| D[Typed Model<br/>response_model 序列化]
    C -->|抛出 AppError| E[AppError 处理器]
    B -->|参数校验失败| F[RequestValidationError 处理器]
    E --> G[ProblemDetail<br/>真实 HTTP 状态 + application/problem+json]
    F --> G
    D --> H[客户端]
    G --> H
    style G fill:#ffe0e0,stroke:#c0392b
    style D fill:#e0f7e0,stroke:#27ae60

四、接口调用效果演示

✅ 成功(正常走 response_model)

curl -s http://localhost:8000/users/1 \
  -H "Authorization: Bearer valid-token"
{ "id": 1, "name": "信哥" }

HTTP 200,Content-Type application/json,标准业务模型。

❌ 参数校验失败(422)

# uid 传个字符串触发校验
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)

curl -s http://localhost:8000/users/999 -H "Authorization: Bearer valid-token"
{
  "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)

curl -s http://localhost:8000/users/1   # 不带 Authorization
{
  "type": "https://api.example.com/problems/unauthorized",
  "title": "Unauthorized",
  "status": 401,
  "detail": "缺少或错误的 Authorization 头"
}

前端现在怎么写? axios 拦截器里只看 response.status:401 跳登录、403 弹无权限、422 抖表单、500 上报。再也不用满屏 if (res.code !== 0) 了。


五、个人实战踩坑与经验

  1. 坑:HTTP 永远 200 的"伪成功"。网关、负载均衡、APM、云厂商的限流熔断,清一色只看 HTTP 状态码。你永远返回 200,它们一律判"成功",错误流量被当正常流量放过,限流熔断形同虚设。改真实状态码后,光是"错误流量能进监控大盘"这一项就值回票价。
  2. 坑:response_model 用 dict / Union 导致 OpenAPI 丢结构。前端联调时 Swagger 里看不到错误体长啥样。正确做法:成功响应用 response_model=UserOut(类型注解驱动序列化),错误统一走 handler,不在路由签名里掺和。
  3. 坑:application/problem+json 老客户端不认。个别古董 SDK 只认 application/json。折中:保留 problem+json(规范推荐),但若你的调用方很老,降级成 application/json 也完全能工作,字段不变。
  4. 坑:instance 写成空或写死。排查时毫无用处。务必接 Request-ID 中间件,把 trace_id 同时塞进响应头和错误体,形成闭环。
  5. 经验:用"快查表"统一团队表达。我们内部把上面对照表钉在 Confluence 首页,新人报错先查表再写,杜绝了"张三用 code=1001、李四用 status='ERR_USER'"的混乱。

六、总结

一句话:FastAPI 错误处理别再 200 一把梭——用 AppError 携带真实 HTTP 状态码,全局 handler 统一渲染成 RFC 9457 的 ProblemDetail,成功响应交给 response_model 类型注解驱动。网关、监控、前端三方都省心,新人也能照着快查表写。

完整代码已按模块拆分,复制即跑。下一篇可以聊聊怎么给这套错误体接 Sentry + 结构化日志,感兴趣的评论区扣 1。