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

2026-10-11 18:20:04

用 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:blank
  • title [必填]:人类可读的简短标题(不随请求变化)
  • 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) 了。

五、实战踩坑与经验

  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 类型注解驱动。网关、监控、前端三方都省心。

参考:RFC 9457《Problem Details for HTTP APIs》,https://www.rfc-editor.org/rfc/rfc9457

复制全文 生成海报 API RFC9457 FastAPI ProblemDetails 错误处理

推荐文章

程序员茄子在线接单