本文目录
同一个项目里,404 有时返回 {"detail":"Not Found"},有时返回 {"message":"资源不存在","code":404},有时干脆是一段 HTML——前端要写三套解析逻辑,日志聚合也难以按字段统计。API 的错误响应和成功响应一样,值得定一套契约:状态码表达 HTTP 语义,Body 里用固定字段(如 code、message、details)表达业务含义。FastAPI 里这条线由 HTTPException(主动抛出的 HTTP 级错误)和 exception handler(全局或按类型的处理器)共同完成。
HTTPException:路由里的快速失败
在路由或依赖里,用 HTTPException 立刻中断请求并返回指定状态码与 body。默认 body 是 {"detail": ...},detail 可以是字符串或结构化对象。
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 |
| 422 | Pydantic 校验 | VALIDATION_ERROR |
同一 status 可以对应多种 code;反过来一个 code 应映射到固定 status,写在 handler 或小型映射表里,别在每个路由随手写不同 status。
自定义业务异常:与 HTTP 解耦
领域层更适合抛不携带 status_code 的异常,由边界层(API 或 handler)映射成 HTTP。这样 Service 单元测试不必 import FastAPI。
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。
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),打结构化日志:
@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——依赖与路由应使用同一套领域异常。
未捕获异常:生产与开发的差别
Exception 或 500 的 handler 里不要把完整 traceback 返回给公网客户端;记录到日志,Body 只给通用 INTERNAL_ERROR。开发环境可以单独分支返回 debug 字段,或通过 debug 配置挂载 Starlette 的 debug 中间件。
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:
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 标准列表,含 loc、type、msg。前端可按 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 更能反映业务健康。客户端解析时优先读 code,message 仅展示;details 给表单级错误定位。约定写进 README 的 API 错误章节,减少前后端口头同步成本。