本文 AI 產出,尚未審核

FastAPI

例外與錯誤處理(Exception Handling)

主題:try / except 處理例外


簡介

在 Web API 開發中,**例外(Exception)**是不可避免的。無論是使用者輸入錯誤、資料庫連線失敗,或是第三方服務回傳非預期資料,都會拋出例外。如果不適當地捕獲與回應,整個服務可能直接回傳 500 Internal Server Error,讓前端難以取得有意義的錯誤資訊,也會造成除錯成本飆升。

FastAPI 內建與 Python 完全相容的 try / except 機制,同時提供了 HTTPException、全域例外處理器等工具,讓開發者可以把例外轉換成符合 REST 標準的回應。掌握正確的例外處理方式,不僅能提升 API 的可用性與安全性,還能在日後維護與擴充時減少意外錯誤的衝擊。

本篇文章將以 try / except 為核心,逐步說明在 FastAPI 中如何捕獲、轉換與統一管理例外,並提供實務範例、常見陷阱與最佳實踐,幫助你從「會寫」到「寫得好」。


核心概念

1. 基本的 try / except 結構

from fastapi import FastAPI

app = FastAPI()

@app.get("/divide")
def divide(a: int, b: int):
    try:
        result = a / b          # 可能拋出 ZeroDivisionError
        return {"result": result}
    except ZeroDivisionError as e:
        # 把 Python 例外轉成 HTTP 回應
        return {"error": "除數不能為 0", "detail": str(e)}
  • 重點except 只捕獲我們預期會發生的例外,避免把所有錯誤都吞掉。

2. 捕獲多種例外與使用 as 取得例外資訊

@app.get("/read-file")
def read_file(path: str):
    try:
        with open(path, "r", encoding="utf-8") as f:
            return {"content": f.read()}
    except (FileNotFoundError, PermissionError) as exc:
        # 兩種例外共用同一段處理邏輯
        return {"error": "無法讀取檔案", "detail": str(exc)}
  • 技巧:將同類型的例外放在同一個 tuple 中,讓程式碼更簡潔。

3. 使用 FastAPI 的 HTTPException

HTTPException 會自動被 FastAPI 轉成符合 HTTP 標準的回應(包括 status code、detail 等),是最常見的錯誤回傳方式。

from fastapi import HTTPException, status

@app.get("/item/{item_id}")
def get_item(item_id: int):
    try:
        item = database.get(item_id)   # 假設會拋出 KeyError
        return {"item": item}
    except KeyError:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Item {item_id} 不存在"
        )
  • 優點:前端只需要根據 HTTP 狀態碼判斷錯誤類型,無需自行解析自訂的錯誤結構。

4. 自訂例外類別

在大型專案中,常會建立一套自訂例外,以統一錯誤代碼與訊息。

class BusinessError(Exception):
    """業務邏輯錯誤,會被全域處理器捕獲"""
    def __init__(self, code: int, message: str):
        self.code = code
        self.message = message
        super().__init__(message)

@app.post("/order")
def create_order(order: dict):
    if order["quantity"] <= 0:
        raise BusinessError(1001, "訂購數量必須大於 0")
    # 正常處理流程...
    return {"status": "ok"}
  • 說明:自訂例外只負責「拋出」資訊,真正的回應格式交給全域例外處理器決定。

5. 全域例外處理器(Exception Handler)

FastAPI 允許我們為特定例外類別註冊統一的處理器,讓所有拋出的例外都能得到一致的回應格式與日誌。

from fastapi.responses import JSONResponse
from fastapi.exception_handlers import http_exception_handler

@app.exception_handler(BusinessError)
async def business_error_handler(request, exc: BusinessError):
    # 這裡可以寫入日誌、上報監控系統等
    return JSONResponse(
        status_code=400,
        content={
            "error_code": exc.code,
            "error_message": exc.message,
            "detail": str(exc)
        },
    )

# 仍保留 FastAPI 內建的 HTTPException 處理
@app.exception_handler(HTTPException)
async def http_exception_handler(request, exc):
    return await http_exception_handler(request, exc)
  • 好處
    1. 統一回應格式:前端只要依照 error_codeerror_message 解析即可。
    2. 集中日誌:所有業務錯誤一次寫入,避免散落在各個 endpoint。

常見陷阱與最佳實踐

陷阱 可能的後果 推薦做法
使用裸 except: 捕獲所有例外 真正的程式錯誤被隱藏,難以排除 只捕獲需要的例外(如 except ValueError:
except 區塊內再次拋出未處理的例外 會變成 500 錯誤,使用者看不到友善訊息 使用 raise 重新拋出自訂例外或 HTTPException
在非同步 (async) 路由中混用同步阻塞操作 會阻塞事件迴圈,降低吞吐量 使用 awaitrun_in_threadpool
忽略例外訊息的日誌記錄 事後排查困難 在全域例外處理器中寫入結構化日誌
把業務邏輯的錯誤直接回傳 detail 給前端 可能洩漏內部實作或資料庫資訊 只回傳必要的錯誤代碼與訊息

最佳實踐

  1. 分層捕獲:在服務層(service)拋出自訂例外,在 API 層只捕獲 BusinessErrorHTTPException
  2. 統一錯誤模型:如 { "error_code": int, "error_message": str, "detail": str },讓前端 SDK 能自動映射。
  3. 結構化日誌:使用 logurustructlog 等套件,把例外類別、路徑、使用者 ID 等資訊寫入。
  4. 測試例外路徑:利用 pytestclient.get(..., raise_server_exceptions=False) 確認回傳的錯誤碼與內容。
  5. 避免在 except 內做大量運算:保持例外處理的輕量,重的邏輯應移至服務層或背景工作。

實際應用場景

1. 使用者註冊時的資料驗證

@app.post("/register")
def register_user(payload: dict):
    try:
        email = payload["email"]
        if not re.match(r".+@.+\..+", email):
            raise BusinessError(2001, "Email 格式不正確")
        # 假設下面的 DB 操作會拋出 IntegrityError
        db.create_user(email, payload["password"])
        return {"status": "registered"}
    except BusinessError as be:
        raise HTTPException(status_code=400, detail=be.message)
    except IntegrityError:
        raise HTTPException(status_code=409, detail="Email 已被使用")
  • 說明:先在業務層拋出自訂例外,最後統一轉成 HTTPException,前端只會得到 400 或 409。

2. 呼叫第三方支付 API

import httpx

@app.post("/pay")
async def pay(order_id: str):
    try:
        async with httpx.AsyncClient() as client:
            resp = await client.post(
                "https://payment.example.com/api/pay",
                json={"order_id": order_id},
                timeout=5.0,
            )
            resp.raise_for_status()        # 若非 2xx 會拋出 HTTPStatusError
            data = resp.json()
            if data["status"] != "ok":
                raise BusinessError(3001, "支付失敗:" + data["msg"])
            return {"payment_id": data["payment_id"]}
    except httpx.HTTPStatusError as e:
        raise HTTPException(
            status_code=502,
            detail=f"第三方服務回傳錯誤:{e.response.status_code}"
        )
    except httpx.RequestError as e:
        raise HTTPException(
            status_code=504,
            detail=f"連線第三方服務失敗:{str(e)}"
        )
  • 要點:把外部服務的例外轉成 5xx,讓監控系統能辨識「外部依賴」的失效。

3. 資料庫交易失敗的回滾

from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session

@app.put("/profile/{user_id}")
def update_profile(user_id: int, profile: dict, db: Session = Depends(get_db)):
    try:
        user = db.query(User).filter(User.id == user_id).one()
        for k, v in profile.items():
            setattr(user, k, v)
        db.commit()
        return {"status": "updated"}
    except SQLAlchemyError as e:
        db.rollback()                # 必須手動回滾
        raise HTTPException(status_code=500, detail="資料庫錯誤,已回滾")
  • 提醒:在捕獲資料庫例外後,務必回滾交易,否則連線會進入不一致狀態。

總結

  • try / except 是 Python 與 FastAPI 處理例外的基礎,只捕獲需要的例外,避免濫用 except:
  • 透過 HTTPException自訂例外全域例外處理器,可以把內部錯誤安全且一致地呈現給前端。
  • 統一錯誤模型結構化日誌測試例外路徑 是提升服務可觀測性與維護性的關鍵。
  • 在實務開發中,將 業務錯誤外部服務錯誤資料庫錯誤 分別以不同的 HTTP 狀態碼與錯誤代碼回傳,讓前端與監控系統都能快速定位問題。

掌握以上技巧後,你的 FastAPI 專案將能在 錯誤發生時仍保持可預測、易除錯,且不會洩漏敏感資訊,為使用者提供更可靠的 API 服務。祝開發順利!