K 的一隅

Python FastAPI 后端实战

错误长得一样:统一异常处理

HTTPException 与全局 exception_handler 如何配合,把业务异常和未捕获错误映射成一致的错误体,方便前端与调用方解析。

9 分钟阅读 更新于 2026-07-28
本文目录

同一个项目里,404 有时返回 {"detail":"Not Found"},有时返回 {"message":"资源不存在","code":404},有时干脆是一段 HTML——前端要写三套解析逻辑,日志聚合也难以按字段统计。API 的错误响应和成功响应一样,值得定一套契约:状态码表达 HTTP 语义,Body 里用固定字段(如 codemessagedetails)表达业务含义。FastAPI 里这条线由 HTTPException(主动抛出的 HTTP 级错误)和 exception handler(全局或按类型的处理器)共同完成。

HTTPException:路由里的快速失败

在路由或依赖里,用 HTTPException 立刻中断请求并返回指定状态码与 body。默认 body 是 {"detail": ...}detail 可以是字符串或结构化对象。

python
from fastapi import FastAPI, HTTPException

app = FastAPI()


@app.get("/products/{sku}")
def get_product(sku: str) -> dict[str, str]:
    product = catalog.get(sku)
    if product is None:
        raise HTTPException(status_code=404, detail=f"product {sku!r} not found")
    return {"sku": sku, "name": product.name}

适合「这一层已经能判定 HTTP 语义」的场景:参数缺失 400、未认证 401、无权限 403、找不到 404。不要滥用 200 + body 里 success: false 代替 4xx——除非团队明确约定非 REST 风格。

HTTPException 继承 Starlette 的异常基类,不是 Python 内置 Exception 的普通子类在 handler 匹配上的同一套规则——注册 handler 时用 HTTPException 类型即可。detail 传 list/dict 时,OpenAPI 文档里仍会显示为 schema,但客户端解析要先约定 envelope。团队若统一包一层 error.message,handler 里要把结构化 detail 原样放进 details 字段,避免字符串化丢信息。

状态码与业务 code:两层语义

HTTP 状态码给通用语义:404 资源不存在、409 冲突、422 参数不对。Body 里的 code(如 INSUFFICIENT_STOCK)给业务细分,方便前端做 i18n 或分支逻辑,而不必解析英文 message 字符串。

HTTP status典型场景body.code 示例
400请求语义错误INVALID_COUPON
401未登录UNAUTHORIZED
403无权限FORBIDDEN
404资源不存在ORDER_NOT_FOUND
409并发/库存冲突INSUFFICIENT_STOCK
422Pydantic 校验VALIDATION_ERROR

同一 status 可以对应多种 code;反过来一个 code 应映射到固定 status,写在 handler 或小型映射表里,别在每个路由随手写不同 status。

自定义业务异常:与 HTTP 解耦

领域层更适合抛不携带 status_code 的异常,由边界层(API 或 handler)映射成 HTTP。这样 Service 单元测试不必 import FastAPI。

python
class AppError(Exception):
    def __init__(self, code: str, message: str, details: dict[str, object] | None = None) -> None:
        self.code = code
        self.message = message
        self.details = details or {}
        super().__init__(message)


class InsufficientStockError(AppError):
    def __init__(self, sku: str, requested: int, available: int) -> None:
        super().__init__(
            code="INSUFFICIENT_STOCK",
            message="not enough stock",
            details={"sku": sku, "requested": requested, "available": available},
        )

路由或 Service 在边界捕获后,要么转 HTTPException,要么交给全局 handler(见下)。

异常类不必很多:一个 AppError 基类 + 若干子类或纯 code 枚举即可。避免「每个路由一种 Exception 类」的膨胀。子类适合需要固定 details 形状的错误(库存、支付);其余用 AppError(code="...", message="...") 足够。

exception_handler:统一错误体

@app.exception_handler(SomeException) 注册处理器:任意未在路由内捕获的 SomeException 都会走这里,返回 JSONResponse(或 Response)。可以同时注册 HTTPException 的 handler,把默认 detail 包进统一 envelope。

python
from fastapi import Request
from fastapi.responses import JSONResponse


def error_body(
    *,
    status_code: int,
    code: str,
    message: str,
    details: dict[str, object] | None = None,
) -> dict[str, object]:
    return {
        "error": {
            "status": status_code,
            "code": code,
            "message": message,
            "details": details or {},
        }
    }


@app.exception_handler(AppError)
async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse:
    status = 400
    if exc.code == "INSUFFICIENT_STOCK":
        status = 409
    return JSONResponse(
        status_code=status,
        content=error_body(
            status_code=status,
            code=exc.code,
            message=exc.message,
            details=exc.details,
        ),
    )


@app.exception_handler(HTTPException)
async def http_exception_handler(_request: Request, exc: HTTPException) -> JSONResponse:
    detail = exc.detail
    message = detail if isinstance(detail, str) else str(detail)
    return JSONResponse(
        status_code=exc.status_code,
        content=error_body(
            status_code=exc.status_code,
            code="HTTP_ERROR",
            message=message,
            details={"detail": detail} if not isinstance(detail, str) else {},
        ),
    )

这样无论是 raise HTTPException(404, ...) 还是 raise InsufficientStockError(...),客户端看到的都是 error.status / error.code / error.message 同一套形状。

Handler 可以是 async def,Starlette 会 await。在 handler 里可以读 request.state(若中间件写了 request id),打结构化日志:

python
@app.exception_handler(AppError)
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
    request_id = getattr(request.state, "request_id", None)
    logger.warning(
        "app_error code=%s request_id=%s path=%s",
        exc.code,
        request_id,
        request.url.path,
    )
    ...

多个 @app.exception_handler 注册在不同模块时,注意 import 顺序:handler 模块要在 app 启动时被 import,否则注册未执行。大型项目常把 handler 收到 errors.py,在 create_app() 里统一 register_error_handlers(app)

依赖链上的异常

依赖函数里 raise HTTPException(400, ...) 会在路由体执行前短路,客户端仍收到 JSON 错误,OpenAPI 不会把该依赖标成 optional。get_current_user 抛 401 是典型用法。若依赖抛的是 AppError,需要同样有 AppError 的 exception handler,否则可能落到裸 Exception 变成 500——依赖与路由应使用同一套领域异常。

未捕获异常:生产与开发的差别

Exception500 的 handler 里不要把完整 traceback 返回给公网客户端;记录到日志,Body 只给通用 INTERNAL_ERROR。开发环境可以单独分支返回 debug 字段,或通过 debug 配置挂载 Starlette 的 debug 中间件。

python
import logging

logger = logging.getLogger(__name__)


@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
    logger.exception("unhandled error on %s %s", request.method, request.url.path)
    return JSONResponse(
        status_code=500,
        content=error_body(
            status_code=500,
            code="INTERNAL_ERROR",
            message="internal server error",
        ),
    )

注册顺序上,更具体的异常类型应优先匹配;HTTPException 继承自 Exception 的 Starlette 版本时,需分别为 HTTPException 和裸 Exception 注册,避免 404 被当成 500。

开发环境可在 Settings.debug 为真时,在 500 handler 的 details 里附带 exc_type 与简短 message;生产关闭。不要用 return traceback.format_exc() 作为 body 默认行为——安全扫描与合规都会拦。

与校验错误对齐

RequestValidationError(422)来自 Pydantic/FastAPI 参数校验,默认 body 字段较多。若项目要求所有错误同一 envelope,可为其单独注册 handler,把 exc.errors() 塞进 details.validation

python
from fastapi.exceptions import RequestValidationError


@app.exception_handler(RequestValidationError)
async def validation_handler(_request: Request, exc: RequestValidationError) -> JSONResponse:
    return JSONResponse(
        status_code=422,
        content=error_body(
            status_code=422,
            code="VALIDATION_ERROR",
            message="request validation failed",
            details={"validation": exc.errors()},
        ),
    )

422 的 exc.errors() 是 Pydantic 标准列表,含 loctypemsg。前端可按 loc 把错误挂到表单字段。若只返回自定义 message 而丢掉 loc,调试体验会变差——details.validation 应保留完整数组。

成功体与错误体的对称

成功响应常用 response_model=ItemOut;错误体也建议在 Pydantic 里建 ErrorOut / ErrorEnvelope,在 handler 里 ErrorEnvelope.model_validate(...) 或至少文档里声明 schema,OpenAPI 才会列出 4xx/5xx 的 response model(需 responses= 参数补充)。对外 SDK 生成器依赖这份对称,减少「只有 200 有类型、错误是未知 JSON」的情况。

从 raise 到 Response 的路径

一次 raise AppError(...) 从 Service 冒出后,调用栈向上回溯:路由里没有 try 则交给 Starlette 异常中间件,匹配已注册的 handler,构造 JSONResponse,再经 outbound 中间件(如 CORS)返回客户端。理解这条路径后,你会明白为何在 handler 里统一打日志比在几十个路由里 try/except 更可靠——漏网之鱼也会落到 Exception handler,至少留下 500 与 request id。

主动 return JSONResponse(status_code=404, ...) 而不 raise,也能达到客户端效果,但绕过了 exception handler 体系,OpenAPI 与监控统计可能不一致。团队应约定:预期错误用 raise(HTTPException 或 AppError),handler 负责形状;只有极少数需要自定义 header 且框架 hook 不够用时,才在路由 return Response。

错误体版本与兼容

对外 API 改 error schema 是 breaking change。若必须从 {detail: string} 迁到 {error: {...}},可在一段时间内双写:details.legacy_detail 保留旧字段,文档标注废弃时间。Monitor 按 error.code 聚合时,应保证同一类业务错误 code 稳定,而不是每次改文案就换 code。

文档里要有一张「全局异常表」

课件强调:OpenAPI 应能看到全局错误约定。做法是在 FastAPI(..., responses={...}) 或常用路由的 responses= 里声明 ErrorEnvelope,并在文档站 / README 列一张表:code → HTTP status → 含义。前端按 code 分支,而不是解析 message 字符串。校验错误(VALIDATION_ERROR)、未登录(UNAUTHORIZED)、业务冲突(如 INSUFFICIENT_STOCK)都应出现在这张表里,并与 handler 映射保持同步——改 handler 忘改表,联调成本会立刻回来。

多语言与客户端友好 message

message 字段可以是英文常量 code 的人类可读版,也可以只返回 code、由前端 i18n。后端若直接返回中文,要约定单一语言或 Accept-Language 分支,并在 handler 里统一处理,别有的路由中文有的英文。details 适合放结构化数据(缺哪个字段、当前库存多少),message 保持短句,便于日志与 UI 共用。

与 Starlette 默认行为的差异

未注册 handler 时,Starlette 对 HTTPException 返回 {"detail":...},对未捕获异常返回 plain text 或 debug HTML。一旦项目承诺统一 envelope,所有 outbound error 都应经过 handler,包括 BackgroundTasks 里抛出的异常——那些不在 HTTP 请求栈里,要单独 try/except 记日志。后台任务失败不会自动变成 500 给客户端,但应有 dead letter 或 retry 指标。

第三方库异常包装

SQLAlchemy IntegrityError、httpx TimeoutException 不应原样 500 给客户端。在 Repository 或 Service 捕获后转成 AppError(code="CONFLICT", ...) 或允许冒泡到专用 handler。为少数第三方异常注册 handler 可以,但别为每个驱动写一套——优先在边界 adapter 消化。

常见误解

在 Service 里到处 raise HTTPException 领域层与 HTTP 耦合,复用和单测变差;Service 抛 AppError,API 层或 handler 映射。

detail 里塞敏感信息。 数据库连接串、内部路径不要进响应;写进日志即可。

每个路由 try/except 包一层。 全局 handler 已覆盖的类型,路由不必重复捕获再包装,除非要在该层追加局部 context 后 re-raise。

日志级别与告警

4xx 通常 logger.warning 或 info,5xx 与未捕获异常 logger.exception。对同一 error.code 配置告警阈值(如五分钟內 INSUFFICIENT_STOCK 超过基线)比裸监控 HTTP 500 更有业务意义。handler 里打日志时带上 path、method、request id,但别把 PII(邮箱、手机号)写进 message 字段——放 hashed id 或省略。

统一异常处理解决的是「出站形状」;下一篇看请求进站时,中间件如何在路由之前和之后织入日志、CORS、计时等横切逻辑。

统一异常处理解决的是「出站形状」;下一篇看请求进站时,中间件如何在路由之前和之后织入日志、CORS、计时等横切逻辑。

上线前用 TestClient 故意触发各类错误,核对 JSON 字段是否稳定。监控侧按 error.code 做 dashboard,比按 status alone 更能反映业务健康。客户端解析时优先读 codemessage 仅展示;details 给表单级错误定位。约定写进 README 的 API 错误章节,减少前后端口头同步成本。