本文 AI 產出,尚未審核

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"
    }
  ]
}

這種格式的好處:

  1. 前端可以直接映射到表單欄位,顯示對應的錯誤訊息。
  2. 統一的結構 讓日後擴充(例如加入錯誤代碼 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_lengthEmailStrge 條件時,回傳 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.codeerror.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
過度暴露內部錯誤訊息 可能洩漏資料庫或系統實作細節 生產環境只回傳代碼與使用者可讀訊息,詳細堆疊寫入日誌

最佳實踐清單

  1. 統一錯誤格式{"error": {"code": "...", "message": "...", "details": [...]}}
  2. 使用自訂例外類別:讓業務錯誤與系統錯誤分離。
  3. detail 中加入 code:方便前端根據代碼做不同 UI 處理。
  4. 把驗證錯誤交給 Pydantic:FastAPI 會自動產生 422 結構化回應。
  5. 記錄錯誤日誌:例外處理器內部可以寫入 logger.error,不影響回傳給客戶端的內容。
  6. 測試每個例外路徑:使用 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(包含 codemessage、必要時的 details)能讓前端更快定位問題。
  • 透過 自訂例外類別 + 全域例外處理器,我們可以在整個專案內保持錯誤回應的 一致性與可維護性
  • 常見陷阱(如忘記設定狀態碼、回傳不一致格式)只要遵循 統一錯誤格式適當的日誌記錄 以及 測試,即可避免。

掌握了這套 狀態碼 + detail 格式 的最佳實踐後,您不僅能寫出符合規範的 FastAPI 服務,還能大幅提升前端開發與除錯的效率,為整個團隊帶來更流暢的開發體驗。祝開發順利! 🚀