FastAPI 教學:例外與錯誤處理 ── 狀態碼與 detail 格式
簡介
在建構 API 時,錯誤回應的可讀性與一致性 直接影響前端開發者、測試人員以及最終使用者的體驗。FastAPI 內建的例外處理機制不只讓我們能夠自訂 HTTP 狀態碼,還能以結構化的 detail 欄位傳遞錯誤資訊,讓客戶端可以輕鬆解析與呈現。
本篇文章將說明 FastAPI 中如何正確設定回應狀態碼,以及 detail 欄位的最佳格式,並提供多個實作範例、常見陷阱與實務應用情境,協助從初學者到中階開發者都能寫出既符合 REST 原則,又易於維護的錯誤回應。
核心概念
1️⃣ HTTP 狀態碼的意義
| 類別 | 代表範圍 | 常見狀態碼 | 說明 |
|---|---|---|---|
| 1xx | 資訊回應 | 100, 101 | 請求已被接收,繼續處理 |
| 2xx | 成功回應 | 200, 201 | 請求成功,資源已建立 |
| 3xx | 重導向 | 301, 302 | 請求的資源已搬移 |
| 4xx | 客戶端錯誤 | 400, 401, 403, 404, 422 | 請求格式錯誤、授權失敗、資源不存在… |
| 5xx | 伺服器錯誤 | 500, 502, 503 | 伺服器內部錯誤或服務不可用 |
在 API 設計中,正確的狀態碼是溝通失敗原因的第一層訊息。例如,資料驗證失敗應回傳 422(Unprocessable Entity),而非 400,讓前端能依據不同的類別採取不同的錯誤處理流程。
2️⃣ FastAPI 的例外基礎
FastAPI 直接使用 Starlette 的例外類別,最常見的是 HTTPException:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="找不到指定的使用者")
status_code:回傳的 HTTP 狀態碼detail:錯誤訊息的內容,預設會以 JSON 格式回傳{ "detail": "..."}
如果不指定 detail,FastAPI 會使用預設訊息(例如 "Not Found"),但自訂訊息有助於 API 使用者快速定位問題。
3️⃣ detail 的結構化寫法
雖然 detail 可以是單純的字串,但在實務上,我們常會傳回 更具結構的 JSON,例如:
{
"detail": [
{
"loc": ["body", "username"],
"msg": "長度必須至少 3 個字元",
"type": "value_error.str.min_length"
},
{
"loc": ["body", "email"],
"msg": "必須是有效的電子郵件格式",
"type": "value_error.email"
}
]
}
這種格式的好處:
- 前端可以直接映射到表單欄位,顯示對應的錯誤訊息。
- 統一的結構 讓日後擴充(例如加入錯誤代碼
code)更方便。
FastAPI 內建的 Pydantic 驗證失敗時,就會自動以類似結構回傳。
4️⃣ 自訂例外類別
若要在多個路由中重複使用相同的錯誤格式,建議 自訂例外類別 並搭配 全域例外處理器:
class BusinessException(HTTPException):
def __init__(self, status_code: int, code: str, message: str):
super().__init__(status_code=status_code,
detail={"code": code, "message": message})
再於 app 中註冊:
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
@app.exception_handler(BusinessException)
async def business_exception_handler(request: Request, exc: BusinessException):
return JSONResponse(
status_code=exc.status_code,
content={"error": exc.detail}
)
這樣在任何路由拋出 BusinessException,回傳的 JSON 都會是:
{
"error": {
"code": "USER_NOT_FOUND",
"message": "找不到指定的使用者"
}
}
程式碼範例
以下提供 5 個實用範例,從最簡單到較進階的自訂處理,都以完整的 FastAPI 應用程式說明。
範例 1:最基礎的 HTTPException
# example_01.py
from fastapi import FastAPI, HTTPException
app = FastAPI()
@app.get("/items/{item_id}")
async def read_item(item_id: int):
if item_id == 0:
# 回傳 404 並自訂字串訊息
raise HTTPException(status_code=404, detail="找不到此商品")
return {"item_id": item_id, "name": f"商品-{item_id}"}
說明
- 當
item_id為 0 時,拋出 404。 - 客戶端會收到
{ "detail": "找不到此商品" }。
範例 2:使用 Pydantic 進行驗證,讓 FastAPI 自動回傳結構化 detail
# example_02.py
from fastapi import FastAPI
from pydantic import BaseModel, Field, EmailStr
app = FastAPI()
class UserCreate(BaseModel):
username: str = Field(..., min_length=3, max_length=20)
email: EmailStr
age: int = Field(..., ge=0)
@app.post("/users")
async def create_user(user: UserCreate):
# 若驗證失敗,FastAPI 會自動回傳 422 且結構化的 detail
return {"msg": "使用者建立成功", "user": user}
說明
- 輸入不符合
min_length、EmailStr或ge條件時,回傳 422,detail為列表,每個元素包含loc、msg、type。
範例 3:自訂 detail 為字典,加入錯誤代碼
# example_03.py
from fastapi import FastAPI, HTTPException
app = FastAPI()
@app.get("/orders/{order_id}")
async def read_order(order_id: int):
if order_id < 1:
raise HTTPException(
status_code=400,
detail={"code": "INVALID_ORDER_ID", "message": "訂單編號必須大於 0"}
)
# 假設找不到資料
raise HTTPException(
status_code=404,
detail={"code": "ORDER_NOT_FOUND", "message": f"訂單 {order_id} 不存在"}
)
說明
detail為字典,前端可以直接取error.code與error.message。
範例 4:自訂例外類別 + 全域例外處理器
# example_04.py
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
app = FastAPI()
class BusinessException(HTTPException):
"""自訂業務例外,包含 code 與 message"""
def __init__(self, status_code: int, code: str, message: str):
super().__init__(status_code=status_code,
detail={"code": code, "message": message})
@app.exception_handler(BusinessException)
async def business_exception_handler(request: Request, exc: BusinessException):
return JSONResponse(
status_code=exc.status_code,
content={"error": exc.detail}
)
@app.get("/products/{pid}")
async def get_product(pid: int):
if pid == 13: # 假設 13 為禁售商品
raise BusinessException(403, "PRODUCT_FORBIDDEN", "此商品已被禁售")
return {"pid": pid, "name": f"商品-{pid}"}
說明
- 只要拋出
BusinessException,回傳格式固定為{ "error": { "code": "...", "message": "..." } }。
範例 5:結合多層例外與日誌記錄
# example_05.py
import logging
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
logger = logging.getLogger("uvicorn.error")
app = FastAPI()
class ServiceError(HTTPException):
def __init__(self, status_code: int, code: str, message: str):
super().__init__(status_code, detail={"code": code, "message": message})
@app.exception_handler(ServiceError)
async def service_error_handler(request: Request, exc: ServiceError):
# 記錄錯誤資訊(包含請求路徑與參數)
logger.error(
f"ServiceError on {request.url.path}: {exc.detail['code']} - {exc.detail['message']}"
)
return JSONResponse(status_code=exc.status_code, content={"error": exc.detail})
@app.get("/external/{resource_id}")
async def call_external(resource_id: str):
# 假設呼叫外部系統失敗
try:
if resource_id == "timeout":
raise TimeoutError("外部系統逾時")
# 正常回傳
return {"resource_id": resource_id, "status": "ok"}
except TimeoutError as e:
raise ServiceError(504, "EXTERNAL_TIMEOUT", str(e))
說明
- 當外部系統逾時時,拋出
ServiceError(504, ...),全域處理器會同時寫入日誌,方便排錯。
常見陷阱與最佳實踐
| 陷阱 | 為什麼會發生 | 解決方式 |
|---|---|---|
只回傳字串 detail |
前端無法直接對應欄位,需自行解析字串 | 建議使用結構化的字典或列表,統一格式 {"code": "...", "message": "..."} |
| 忘記設定正確的 HTTP 狀態碼 | 預設 200,導致錯誤被當作成功處理 | 依照 REST 原則,明確使用 4xx/5xx 系列 |
在路由內直接 return {"detail": ...} |
這樣不會改變回應的 HTTP status,仍是 200 | 使用 HTTPException 或自訂例外,再由例外處理器回傳 |
| 例外處理器返回不一致的結構 | 客戶端需要寫多套解析程式 | 全域例外處理器 應該返回統一的 JSON schema |
| 過度暴露內部錯誤訊息 | 可能洩漏資料庫或系統實作細節 | 生產環境只回傳代碼與使用者可讀訊息,詳細堆疊寫入日誌 |
最佳實踐清單
- 統一錯誤格式:
{"error": {"code": "...", "message": "...", "details": [...]}}。 - 使用自訂例外類別:讓業務錯誤與系統錯誤分離。
- 在
detail中加入code:方便前端根據代碼做不同 UI 處理。 - 把驗證錯誤交給 Pydantic:FastAPI 會自動產生 422 結構化回應。
- 記錄錯誤日誌:例外處理器內部可以寫入
logger.error,不影響回傳給客戶端的內容。 - 測試每個例外路徑:使用
TestClient確認狀態碼與回傳結構正確。
實際應用場景
場景一:表單驗證失敗
前端提交註冊表單,若使用者名稱過短、密碼不符合規則,API 會回傳 422,detail 為列表。前端只要遍歷 detail,把 loc 中的欄位名稱對應到表單元件,即可顯示逐欄錯誤訊息。
場景二:業務規則錯誤
例如「使用者已被停權」或「購買的商品已售完」。這類錯誤不是程式錯誤,而是 業務邏輯。我們使用 BusinessException(403, "USER_SUSPENDED", "..."),前端根據 code 顯示「帳號已被停用」的提示,或直接導向客服頁面。
場景三:外部服務逾時
在微服務架構下,若某個服務呼叫第三方支付平台逾時,我們拋出 ServiceError(504, "PAYMENT_TIMEOUT", "...")。前端收到 504 後,可顯示「付款暫時無法完成,請稍後再試」並提供重新嘗試的按鈕。
場景四:統一錯誤 API 文件
透過 FastAPI 的 OpenAPI 自動產生文件,我們可以在 app.exception_handler 中加入 responses 設定,讓 Swagger UI 顯示每個錯誤代碼的範例回應。這對 API 使用者(前端或第三方)非常友好。
總結
- 狀態碼是 HTTP 協議的第一層溝通訊號,必須依照 REST 原則正確使用。
detail欄位不應只是字串,結構化的 JSON(包含code、message、必要時的details)能讓前端更快定位問題。- 透過 自訂例外類別 + 全域例外處理器,我們可以在整個專案內保持錯誤回應的 一致性與可維護性。
- 常見陷阱(如忘記設定狀態碼、回傳不一致格式)只要遵循 統一錯誤格式、適當的日誌記錄 以及 測試,即可避免。
掌握了這套 狀態碼 + detail 格式 的最佳實踐後,您不僅能寫出符合規範的 FastAPI 服務,還能大幅提升前端開發與除錯的效率,為整個團隊帶來更流暢的開發體驗。祝開發順利! 🚀