本文 AI 產出,尚未審核

FastAPI 教學:全域錯誤攔截 (Exception Handling)

簡介

在 Web API 開發中,錯誤處理 是不可或缺的環節。若錯誤資訊直接暴露給前端或使用者,不僅會影響使用體驗,也可能洩漏系統內部細節,造成安全風險。FastAPI 提供了強大的例外攔截機制,讓開發者能以統一且可自訂的方式回應錯誤訊息。

本單元聚焦於 全域錯誤攔截(Global Exception Handling),說明如何在 FastAPI 應用程式層面捕捉未處理的例外,並回傳結構化、友善的 JSON 錯誤回應。無論是開發階段的除錯,或是上線後的錯誤監控,掌握全域錯誤攔截都是提升 API 品質的關鍵。


核心概念

1. 為什麼需要全域錯誤攔截?

  • 統一回應格式:客戶端只要依賴同一套錯誤結構,就能簡化前端的錯誤處理邏輯。
  • 隱藏敏感資訊:避免把原始例外訊息直接回傳,降低資訊洩漏的風險。
  • 集中日誌與監控:所有未捕獲的例外都會走同一條路徑,方便寫入日誌或送至外部監控平台(如 Sentry、Datadog)。

FastAPI 內建的 ExceptionHandler 讓我們可以在應用層註冊自訂的例外處理器,實現上述需求。

2. 基本的全域例外處理器

FastAPI 允許使用 app.exception_handler() 裝飾器為 任意例外類別 設定處理器。最常見的做法是針對 Exception(所有未捕獲的例外)建立一個全域捕捉

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
import traceback

app = FastAPI()

@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
    """
    捕捉所有未處理的例外,回傳統一的 JSON 結構。
    """
    # 1️⃣ 取得例外的完整堆疊資訊(僅在開發環境使用)
    tb = traceback.format_exc()
    # 2️⃣ 組合錯誤回應內容
    error_response = {
        "error": {
            "type": exc.__class__.__name__,
            "message": str(exc),
            # 開發階段可加入 stacktrace,正式環境可省略
            "stacktrace": tb if app.debug else None,
        },
        "request": {
            "method": request.method,
            "url": str(request.url),
        },
    }
    # 3️⃣ 回傳 500 Internal Server Error
    return JSONResponse(status_code=500, content=error_response)

重點app.debug(或自行設定的環境變數)決定是否把 stacktrace 暴露給前端,務必在正式環境關閉。

3. 處理 HTTPException

FastAPI 的 HTTPException 已經會自動產生 JSON 回應,但在全域攔截時,我們仍可以自訂格式,使所有錯誤(包括 404、400 等)保持一致。

from fastapi import HTTPException

@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
    """
    統一 HTTPException 的回應格式。
    """
    return JSONResponse(
        status_code=exc.status_code,
        content={
            "error": {
                "type": "HTTPException",
                "status_code": exc.status_code,
                "detail": exc.detail,
            },
            "request": {
                "method": request.method,
                "url": str(request.url),
            },
        },
    )

技巧:若想保留 FastAPI 原生的 detail 結構,只要把 exc.detail 直接塞入即可。

4. 自訂業務例外

在大型專案中,我們常會定義自己的例外類別(例如 UserNotFoundErrorInsufficientBalanceError),以便在服務層拋出有意義的錯誤。只要把這些類別繼承自 Exception,就會被前面的全域處理器捕獲;若想給予更精細的 HTTP 狀態碼,可在 Exception 子類別中加入 status_code 屬性,然後在全域處理器裡根據它回傳。

class BusinessException(Exception):
    """所有業務例外的基底類別,預設回傳 400 Bad Request。"""
    status_code: int = 400
    def __init__(self, message: str):
        super().__init__(message)

class UserNotFoundError(BusinessException):
    status_code = 404
    def __init__(self, user_id: int):
        super().__init__(f"使用者 ID {user_id} 不存在")

# 在路由中拋出例外
@app.get("/users/{user_id}")
async def get_user(user_id: int):
    # 假設查詢失敗
    raise UserNotFoundError(user_id)

全域處理器只需要再加一次判斷即可:

@app.exception_handler(BusinessException)
async def business_exception_handler(request: Request, exc: BusinessException):
    return JSONResponse(
        status_code=exc.status_code,
        content={
            "error": {
                "type": exc.__class__.__name__,
                "message": str(exc),
            },
            "request": {
                "method": request.method,
                "url": str(request.url),
            },
        },
    )

5. 整合第三方日誌與監控(以 Sentry 為例)

在實務上,我們往往會把未捕獲的例外送到外部監控平台,以便即時警報與事後分析。以下示範如何在全域處理器中加入 Sentry 的報告:

import sentry_sdk
from sentry_sdk.integrations.asgi import SentryAsgiMiddleware

# 初始化 Sentry(只在正式環境啟用)
sentry_sdk.init(
    dsn="https://examplePublicKey@o0.ingest.sentry.io/0",
    environment="production",
    traces_sample_rate=0.2,
)

# 把 Sentry 中介層掛載到 FastAPI
app.add_middleware(SentryAsgiMiddleware)

@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
    # 把例外送到 Sentry
    sentry_sdk.capture_exception(exc)

    # 其餘回應邏輯同前述
    return JSONResponse(
        status_code=500,
        content={
            "error": {"type": exc.__class__.__name__, "message": "系統內部錯誤"},
            "request": {"method": request.method, "url": str(request.url)},
        },
    )

注意:在開發環境可以把 environment="development",避免過多噪音。


常見陷阱與最佳實踐

陷阱 說明 解決方案
直接回傳原始例外訊息 會洩漏程式碼路徑、資料庫結構等資訊。 只回傳 自訂的錯誤訊息,如 "系統內部錯誤",堆疊資訊僅寫入日誌。
忘記在 Exception 處理器內呼叫 await request.body() 若例外發生在讀取請求體之後,可能導致無法取得完整的 request 資訊。 必要時使用 await request.body() 取得原始 payload,或在 middleware 中捕獲。
在全域處理器內再次拋出例外 會形成遞迴,最終造成 500 內部錯誤無法回傳。 確保處理器本身 不會產生新的例外,如使用 try/except 包住日誌寫入程式碼。
未考慮非同步例外 某些背景任務(BackgroundTasks)拋出的例外不會走 FastAPI 的路由層。 為背景任務自行加上 try/except,或使用 anyioTaskGroup 監控。
在測試環境忘記關閉 debug 測試時過度暴露 stacktrace,可能造成 CI 日誌過大。 透過環境變數或 app.debug = False 控制。

最佳實踐

  1. 統一錯誤模型
    建議所有回應都遵循同一個 JSON schema,例如:

    {
      "error": {
        "type": "ExceptionClassName",
        "code": 1234,
        "message": "說明文字",
        "detail": {...}
      },
      "request": { "method": "...", "url": "..." }
    }
    
  2. 分層處理

    • 路由層:只捕捉與業務相關的例外(如 BusinessException)。
    • 全域層:捕捉所有未處理的系統例外,回傳 500。
    • 中介層(Middleware):可在此做 日誌、追蹤,避免在每個處理器內重複寫程式。
  3. 環境切換
    使用 os.getenv("ENV")pydantic.BaseSettings 來決定是否顯示 stacktrace、是否啟用 Sentry。

  4. 自訂 HTTP 狀態碼
    若業務例外需要不同的 HTTP 狀態碼,直接在例外類別裡加入 status_code 屬性,並在處理器中使用。


實際應用場景

1. 電商平台的訂單 API

在訂單建立流程中,可能會發生以下錯誤:

錯誤類型 例外 HTTP 狀態碼
商品庫存不足 InsufficientStockError (自訂) 409 Conflict
使用者未登入 AuthenticationError (自訂) 401 Unauthorized
系統錯誤(資料庫斷線) Exception 500 Internal Server Error

透過全域錯誤攔截,我們可以保證 所有錯誤回傳格式一致,前端只要根據 error.typeerror.code 做對應即可。

class InsufficientStockError(BusinessException):
    status_code = 409
    def __init__(self, product_id: int, requested: int, available: int):
        super().__init__(
            f"商品 {product_id} 庫存不足:要求 {requested},僅剩 {available}"
        )

2. 多服務微服務架構

在微服務環境下,各服務之間的錯誤往往需要 傳遞 給呼叫端。例如,支付服務回傳 PaymentGatewayError,我們可以在 API Gateway 使用全域處理器把它轉成標準化的錯誤回應,並同時在日誌中紀錄跨服務追蹤 ID(如 X-Request-ID)。

@app.exception_handler(PaymentGatewayError)
async def payment_error_handler(request: Request, exc: PaymentGatewayError):
    # 取得跨服務追蹤 ID
    trace_id = request.headers.get("X-Request-ID", "unknown")
    # 寫入集中日誌
    logger.error(f"[{trace_id}] Payment error: {exc}")
    return JSONResponse(
        status_code=502,
        content={
            "error": {
                "type": "PaymentGatewayError",
                "message": "第三方支付服務暫時無法使用,請稍後再試",
                "trace_id": trace_id,
            },
            "request": {"method": request.method, "url": str(request.url)},
        },
    )

3. API 文件與前端協議

使用 OpenAPI 產生的文件時,若全域錯誤回應的 schema 與文件不符,前端會無法正確解析。解決方法是把全域錯誤模型加到 app.openapi_schema 中:

from fastapi.openapi.utils import get_openapi

def custom_openapi():
    if app.openapi_schema:
        return app.openapi_schema
    openapi_schema = get_openapi(
        title="My Store API",
        version="1.0.0",
        routes=app.routes,
    )
    # 加入全域錯誤的 schema
    error_schema = {
        "title": "ErrorResponse",
        "type": "object",
        "properties": {
            "error": {
                "type": "object",
                "properties": {
                    "type": {"type": "string"},
                    "code": {"type": "integer"},
                    "message": {"type": "string"},
                },
                "required": ["type", "message"],
            },
            "request": {"type": "object"},
        },
        "required": ["error"],
    }
    openapi_schema["components"]["schemas"]["ErrorResponse"] = error_schema
    app.openapi_schema = openapi_schema
    return app.openapi_schema

app.openapi = custom_openapi

如此一來,Swagger UI 會自動顯示錯誤回應的結構,讓前端開發者一目了然。


總結

全域錯誤攔截是 FastAPI 應用程式不可或缺的基礎建設。透過 app.exception_handler() 我們可以:

  • 統一錯誤回應,讓前端只需依賴單一 JSON schema 即可處理所有錯誤。
  • 隱藏敏感資訊,僅在開發環境提供 stacktrace,正式環境回傳簡潔訊息。
  • 集中日誌與監控,結合 Sentry、Datadog 等第三方服務,實現即時告警與事後分析。
  • 支援自訂業務例外,讓服務層拋出的錯誤可以直接映射到合適的 HTTP 狀態碼與訊息。

在實務開發中,建議遵循以下三點:

  1. 先設計錯誤模型,再實作全域處理器。
  2. 分層捕捉:路由層處理業務例外,全域層捕捉系統例外。
  3. 環境感知:根據 debugENV 等變數決定是否顯示堆疊或啟用外部監控。

掌握了全域錯誤攔截,你的 FastAPI 專案將更安全、可維護、且易於與前端團隊協作。祝開發順利,API 穩定上線! 🚀