FastAPI – 例外與錯誤處理
主題:自訂錯誤回應結構
簡介
在 API 開發過程中,例外與錯誤處理往往是使用者體驗的關鍵。若伺服器直接回傳 500、404 等原始 HTTP 狀態碼,前端開發者或第三方服務只能得到模糊的訊息,難以判斷錯誤根本原因,甚至會導致除錯成本大幅提升。
FastAPI 內建的例外處理機制非常彈性,讓我們可以 自訂錯誤回應結構,統一回傳格式(如 code, message, detail),同時保留完整的除錯資訊給開發團隊。這不僅提升 API 的可讀性,也方便日後維護與監控。
本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,逐步帶你建立一套 一致且易於擴充 的錯誤回應機制,適用於新手與有一定 FastAPI 經驗的開發者。
核心概念
1. 為什麼要自訂錯誤回應?
| 項目 | 直接回傳原始 HTTP 錯誤 | 自訂結構化回應 |
|---|---|---|
| 前端可讀性 | ❌ 只能看到 404 Not Found |
✅ 取得 error_code, message |
| 除錯資訊 | ❌ 無法區分「參數錯誤」與「系統錯誤」 | ✅ detail 欄位提供堆疊資訊 |
| 統一格式 | ❌ 每個端點自行決定回傳內容 | ✅ 全站遵循同一 JSON Schema |
| 監控/日誌 | ❌ 難以自動化分析 | ✅ 可直接推送至 ELK、Grafana 等平台 |
結論:自訂錯誤回應不只是「好看」的需求,而是提升整體系統可觀測性與開發效率的基礎。
2. FastAPI 的例外處理機制
FastAPI 透過 Starlette 的 middleware 與 exception handlers 來攔截例外。最常見的兩種方式:
- 全域
ExceptionHandler:針對特定例外類別(如HTTPException、自訂例外)註冊一次,所有路由皆會套用。 - 路由層級的
try/except:僅針對單一端點處理,彈性較高但易造成重複程式碼。
建議以 全域 handler 結合 自訂例外類別 的方式,達到 一次定義、全站共用 的目標。
3. 設計錯誤回應結構
常見的 JSON 錯誤回應範本:
{
"code": "USER_NOT_FOUND",
"message": "查無此使用者",
"detail": {
"user_id": 123,
"timestamp": "2025-11-20T14:33:12Z"
}
}
code:自訂的錯誤代碼,方便前端對應國際化或顯示不同 UI。message:對使用者友善的說明文字。detail:可選的補充資訊,通常放入除錯用的欄位或相關參數。
小技巧:將錯誤代碼與訊息抽離成
Enum或i18n檔案,未來多語系或錯誤碼變更時,只需要維護單一位置。
程式碼範例
以下示範 5 個實用範例,從最簡單的全域 handler 到結合 Pydantic 模型、日誌、以及自訂例外類別的完整流程。
範例 1:最簡單的全域 HTTPException Handler
# main.py
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
app = FastAPI()
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
"""
捕捉所有 FastAPI 內建的 HTTPException,統一回傳結構化 JSON。
"""
return JSONResponse(
status_code=exc.status_code,
content={
"code": f"HTTP_{exc.status_code}",
"message": exc.detail,
"detail": {"path": request.url.path}
},
)
說明:只要在路由內拋出
raise HTTPException(status_code=404, detail="使用者不存在"),就會自動套用此回應格式。
範例 2:自訂例外類別 + Enum 錯誤代碼
# errors.py
from enum import Enum
from fastapi import status
class ErrorCode(str, Enum):
USER_NOT_FOUND = "USER_NOT_FOUND"
INVALID_TOKEN = "INVALID_TOKEN"
INTERNAL_ERROR = "INTERNAL_ERROR"
class AppException(Exception):
"""所有自訂例外的基底類別"""
def __init__(self, code: ErrorCode, message: str, status_code: int = status.HTTP_400_BAD_REQUEST, detail: dict | None = None):
self.code = code
self.message = message
self.status_code = status_code
self.detail = detail or {}
# main.py (續)
from fastapi import Request
from fastapi.responses import JSONResponse
from errors import AppException, ErrorCode
@app.exception_handler(AppException)
async def app_exception_handler(request: Request, exc: AppException):
"""
統一處理自訂例外,回傳 JSON 結構。
"""
return JSONResponse(
status_code=exc.status_code,
content={
"code": exc.code,
"message": exc.message,
"detail": {"path": str(request.url), **exc.detail}
},
)
說明:只要在程式中
raise AppException(ErrorCode.USER_NOT_FOUND, "查無此使用者", detail={"user_id": uid}),即可得到完整回應。
範例 3:結合 Pydantic Model 定義錯誤回應結構
# schemas.py
from pydantic import BaseModel
from typing import Any, Dict, Optional
class ErrorDetail(BaseModel):
path: str
timestamp: str
extra: Optional[Dict[str, Any]] = None
class ErrorResponse(BaseModel):
code: str
message: str
detail: ErrorDetail
# main.py (續)
from datetime import datetime
from schemas import ErrorResponse
@app.exception_handler(AppException)
async def app_exception_handler(request: Request, exc: AppException):
err = ErrorResponse(
code=exc.code,
message=exc.message,
detail=ErrorDetail(
path=str(request.url),
timestamp=datetime.utcnow().isoformat(),
extra=exc.detail
)
)
return JSONResponse(status_code=exc.status_code, content=err.dict())
說明:使用 Pydantic 不僅能保證回傳資料型別正確,還能自動產生 OpenAPI 文件中的 Error Response 範例,提升 API 文件可讀性。
範例 4:在路由內部捕捉例外並重新拋出自訂例外
# routers/user.py
from fastapi import APIRouter, Depends
from errors import AppException, ErrorCode
from models import User, get_user_by_id
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/{user_id}", response_model=User)
async def read_user(user_id: int):
"""
取得單一使用者資料。若查無資料,拋出自訂例外。
"""
user = await get_user_by_id(user_id)
if not user:
# 直接使用自訂例外,讓全域 handler 處理回應
raise AppException(
code=ErrorCode.USER_NOT_FOUND,
message="查無此使用者",
status_code=404,
detail={"user_id": user_id}
)
return user
說明:路由本身不需要自行組合回應,只要把錯誤資訊包在自訂例外中,即可保持 單一職責(SRP)。
範例 5:加入日誌與 Sentry 例外上報
# logging.py
import logging
import traceback
from fastapi import Request
from errors import AppException
logger = logging.getLogger("app")
logger.setLevel(logging.INFO)
def log_exception(request: Request, exc: Exception):
"""
將例外資訊寫入日誌,必要時上報至 Sentry。
"""
tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
logger.error(
"Exception occurred",
extra={
"path": str(request.url),
"method": request.method,
"exception": repr(exc),
"traceback": tb,
},
)
# 若有整合 Sentry,可在此呼叫 sentry_sdk.capture_exception(exc)
# main.py (續)
from logging import log_exception
@app.exception_handler(Exception)
async def generic_exception_handler(request: Request, exc: Exception):
"""
捕捉未預期的例外,回傳 500 錯誤,同時寫入日誌。
"""
log_exception(request, exc) # 記錄完整堆疊
# 統一回傳結構化錯誤
return JSONResponse(
status_code=500,
content={
"code": "INTERNAL_ERROR",
"message": "系統發生未預期的錯誤,請稍後再試。",
"detail": {"path": str(request.url)}
},
)
說明:
Exception為最上層的捕捉,確保任何未處理的錯誤都會被記錄,避免錯誤訊息直接洩漏給前端。
常見陷阱與最佳實踐
| 陷阱 | 可能的後果 | 解決方式 |
|---|---|---|
直接在路由內回傳 JSONResponse 而非拋例外 |
造成錯誤回應格式不一致,難以維護 | 統一使用 自訂例外 + 全域 handler |
在 exception_handler 中再次拋出例外 |
產生無限遞迴,導致 500 錯誤 | 確保 handler 本身不拋出例外,僅回傳 JSONResponse |
忘記設定 status_code |
前端收到 200 OK,卻是錯誤訊息 | 在 AppException 中明確指定 status_code,或在 handler 中根據 code 映射 |
| 錯誤代碼與訊息硬寫在程式碼 | 多語系、錯誤碼變更時需大量搜尋 | 把 ErrorCode、訊息抽成 JSON/YAML 或 Enum,使用 gettext 等 i18n 機制 |
| 未對外部服務(DB、第三方 API)例外做統一處理 | 例外會直接傳到前端,資訊外洩 | 在服務層捕捉底層例外,轉換為 AppException 再上拋 |
| 缺乏日誌或監控 | 難以追蹤問題根源 | 結合 logging、Sentry、Prometheus,在 generic_exception_handler 中統一上報 |
最佳實踐小結
- 定義統一的錯誤模型(Pydantic + Enum)。
- 建立自訂例外基底,所有業務錯誤皆繼承自它。
- 全域註冊
exception_handler,一次設定、全站生效。 - 在服務層捕捉底層例外,轉成自訂例外,避免泄露實作細節。
- 加入日誌與監控,確保任何未預期的錯誤都有痕跡可追蹤。
實際應用場景
1. 電子商務平台 – 購物車 API
- 需求:當使用者加入已下架的商品時,回傳
PRODUCT_UNAVAILABLE,前端需即時顯示「商品已下架」的提示。 - 實作:在商品服務層捕捉
ProductNotFoundError,拋出AppException(ErrorCode.PRODUCT_UNAVAILABLE, "商品已下架", 400, {"sku": sku}),前端依code顯示對應 UI。
2. 金融系統 – 身分驗證
- 需求:Token 無效或過期時,需要回傳
INVALID_TOKEN,同時在日誌中記錄使用者 IP、嘗試時間。 - 實作:在 JWT 驗證中拋出
AppException(ErrorCode.INVALID_TOKEN, "驗證失敗", 401, {"ip": request.client.host}),全域 handler 會把detail送至 Sentry。
3. 多語系 SaaS 平台
- 需求:同一錯誤代碼在不同語系下顯示不同訊息。
- 實作:錯誤代碼固定(如
USER_NOT_FOUND),訊息使用gettext於exception_handler內根據Accept-Language產生本地化文字,保持 API 回傳結構不變。
總結
自訂錯誤回應結構 是提升 API 可用性、除錯效率與維護性 的關鍵一步。透過以下步驟,你可以在 FastAPI 專案中快速落實:
- 設計統一的錯誤模型(
ErrorResponse、ErrorCode)。 - 建立基礎例外類別(
AppException),讓業務邏輯只需要拋出此例外。 - 在
FastAPI中註冊全域exception_handler,一次設定,所有端點自動套用。 - 結合 Pydantic、日誌與監控,讓錯誤資訊完整且安全。
- 在服務層捕捉底層例外,轉換成自訂例外,避免資訊外洩。
這套流程不僅讓前端開發者能依 錯誤代碼 做出正確 UI 反應,也讓後端團隊在 日誌、監控、國際化 上有一致的管理方式。從今天開始,將錯誤處理視為 API 設計的一部分,你的系統將會更穩定、更易於擴充。祝開發順利 🚀!