本文 AI 產出,尚未審核

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 來攔截例外。最常見的兩種方式:

  1. 全域 ExceptionHandler:針對特定例外類別(如 HTTPException、自訂例外)註冊一次,所有路由皆會套用。
  2. 路由層級的 try/except:僅針對單一端點處理,彈性較高但易造成重複程式碼。

建議以 全域 handler 結合 自訂例外類別 的方式,達到 一次定義、全站共用 的目標。

3. 設計錯誤回應結構

常見的 JSON 錯誤回應範本:

{
  "code": "USER_NOT_FOUND",
  "message": "查無此使用者",
  "detail": {
    "user_id": 123,
    "timestamp": "2025-11-20T14:33:12Z"
  }
}
  • code:自訂的錯誤代碼,方便前端對應國際化或顯示不同 UI。
  • message:對使用者友善的說明文字。
  • detail:可選的補充資訊,通常放入除錯用的欄位或相關參數。

小技巧:將錯誤代碼與訊息抽離成 Enumi18n 檔案,未來多語系或錯誤碼變更時,只需要維護單一位置。


程式碼範例

以下示範 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/YAMLEnum,使用 gettext 等 i18n 機制
未對外部服務(DB、第三方 API)例外做統一處理 例外會直接傳到前端,資訊外洩 在服務層捕捉底層例外,轉換為 AppException 再上拋
缺乏日誌或監控 難以追蹤問題根源 結合 loggingSentryPrometheus,在 generic_exception_handler 中統一上報

最佳實踐小結

  1. 定義統一的錯誤模型(Pydantic + Enum)。
  2. 建立自訂例外基底,所有業務錯誤皆繼承自它。
  3. 全域註冊 exception_handler,一次設定、全站生效。
  4. 在服務層捕捉底層例外,轉成自訂例外,避免泄露實作細節。
  5. 加入日誌與監控,確保任何未預期的錯誤都有痕跡可追蹤。

實際應用場景

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),訊息使用 gettextexception_handler 內根據 Accept-Language 產生本地化文字,保持 API 回傳結構不變。

總結

自訂錯誤回應結構 是提升 API 可用性、除錯效率與維護性 的關鍵一步。透過以下步驟,你可以在 FastAPI 專案中快速落實:

  1. 設計統一的錯誤模型ErrorResponseErrorCode)。
  2. 建立基礎例外類別AppException),讓業務邏輯只需要拋出此例外。
  3. FastAPI 中註冊全域 exception_handler,一次設定,所有端點自動套用。
  4. 結合 Pydantic、日誌與監控,讓錯誤資訊完整且安全。
  5. 在服務層捕捉底層例外,轉換成自訂例外,避免資訊外洩。

這套流程不僅讓前端開發者能依 錯誤代碼 做出正確 UI 反應,也讓後端團隊在 日誌、監控、國際化 上有一致的管理方式。從今天開始,將錯誤處理視為 API 設計的一部分,你的系統將會更穩定、更易於擴充。祝開發順利 🚀!