Web Day 10 錯誤處理與 API 回應設計
執行需求:CPU 可跑。昨天的遷移管理把資料庫結構變更變成可控的流程,但應用程式跑起來之後,「使用者送進來的請求不可能永遠合法」這件事還沒處理。今天我們要設計一套「統一例外類別 + 全域處理器 + 標準化錯誤回應」的架構,讓每個端點在各種錯誤情境下都能回傳結構一致的 JSON,並且讓前端、開發者、維運人員都能從錯誤回應裡快速判斷發生了什麼事。
引言
到目前為止,我們的範例多半專注在「成功路徑」:使用者送對請求、伺服器回傳 200 與資料。但真實世界的 API 面對的是「使用者送出錯誤的 ID、缺欄位、權限不足、外部服務掛了、資料庫連不到」這堆麻煩事。如果每個端點都自己處理錯誤,最後會出現三種亂象:第一,每個端點回傳的錯誤結構不同,前端要寫一堆 if/else;第二,有些錯誤沒被處理,直接把 Python 的 traceback 丟給使用者(這是非常嚴重的資安問題);第三,HTTP 狀態碼用得很隨意,例如「找不到資料」回 200 但 body 帶錯誤訊息,讓監控系統誤判。
今天的目標是建立一套「FastAPI 錯誤處理」的標準模式。讀完之後,你會學到:自訂例外類別的設計原則、exception_handler 的註冊方式、如何把 Pydantic 驗證錯誤、SQLModel 找不到資料、未預期例外三種情況統一成一致的 JSON 結構,並且知道「5xx 錯誤不要洩漏內部細節」這個資安基本原則。這套架構會在後續的認證(Web Day 12–13)與專案篇(Web Day 35+)持續沿用,今天的程式碼會成為整個系列的基礎之一。
為什麼需要標準化錯誤回應
在談怎麼做之前,先談為什麼這樣做。一個好的錯誤回應要滿足四個條件。第一,「結構一致」:所有錯誤都帶有相同的 JSON 欄位,例如 code、message、details,前端只要寫一支通用解析函式就能處理。第二,「HTTP 狀態碼正確」:找不到資料回 404、權限不足回 403、驗證失敗回 422、伺服器內部錯誤回 500,這些是 HTTP 規範的基本要求,不能亂給。第三,「錯誤訊息友善」:對外(給使用者)的訊息要避免暴露內部細節,例如資料表名稱、檔案路徑、SQL 指令;對內(給開發者)的訊息要能定位問題,通常會另外寫進伺服器 log。第四,「可追蹤」:每個錯誤回應帶一個唯一的 request id 或 trace id,前端出問題時能貼給開發者,開發者就能在 log 裡找到對應紀錄。
FastAPI 內建支援上述大部分需求:HTTPException 可以丟出帶狀態碼的例外、RequestValidationError 會自動把 Pydantic 驗證失敗的細節包成 422 回應、global exception handler 可以攔截所有未處理的例外並回傳統一結構。我們今天的設計就是圍繞這三個機制展開。
很多團隊會選擇「錯誤時一律回 200,body 帶錯誤碼」這種設計,這在內部系統還可以,但在對外 API 會出問題:很多監控與快取系統(例如 CDN、API gateway、客戶端的 retry 機制)會根據 HTTP 狀態碼決定行為,把所有錯誤都回成 200 會讓這些機制失靈。HTTP 規範之所以設計 4xx(使用者錯誤)與 5xx(伺服器錯誤)兩大類,就是為了讓中間層能正確處理。我們今天嚴格遵守這個原則。
錯誤回應的 JSON 結構
先把「目標樣貌」定義清楚。我們要讓所有錯誤回應長得像這樣:
{
"error": {
"code": "HERO_NOT_FOUND",
"message": "找不到這位英雄",
"details": null,
"request_id": "f7e1..."
}
}
四個欄位的意義:code 是機器可讀的錯誤代號,用底線分隔的全大寫英文,方便前端 switch;message 是給人看的中文訊息;details 是選填的補充資訊(例如 Pydantic 驗證失敗時的欄位清單);request_id 是這次請求的唯一識別碼,用來對應伺服器 log。當錯誤發生時,HTTP 狀態碼仍然照規範設定(例如 404、422、500),但 body 統一用這套結構。
定義這個結構的 Pydantic 模型:
# app/errors.py
# 統一的錯誤回應結構
from typing import Any, Optional
from pydantic import BaseModel
class ErrorBody(BaseModel):
# error.code:機器可讀的錯誤代號
code: str
# error.message:給人看的訊息(中文)
message: str
# error.details:選填補充資訊
details: Optional[Any] = None
# error.request_id:對應伺服器 log 的識別碼
request_id: Optional[str] = None
class ErrorResponse(BaseModel):
error: ErrorBody
這個檔案放在 app/errors.py,裡面只放「錯誤的形狀」定義,不放實際的處理邏輯。把它放在獨立的模組是為了讓其他模組(例如下一篇的認證模組)也能 import 同一份定義,避免錯誤結構各處重複定義最後不一致。
自訂例外類別
FastAPI 的 HTTPException 雖然方便,但它把錯誤代號塞在 detail 裡,很難強型別地處理。我們自訂一套例外類別,把「錯誤代號」「HTTP 狀態碼」「給使用者的訊息」綁在一起:
# app/errors.py(接續上面)
from fastapi import HTTPException, status
class AppError(HTTPException):
# 所有應用程式自訂例外的共同基底
code: str = "INTERNAL_ERROR"
message: str = "伺服器內部錯誤"
def __init__(
self,
message: Optional[str] = None,
details: Optional[Any] = None,
headers: Optional[dict] = None,
) -> None:
self.details = details
super().__init__(
status_code=self.status_code,
detail=message or self.message,
headers=headers,
)
@property
def status_code(self) -> int:
# 子類別覆寫這個屬性來設定 HTTP 狀態碼
return status.HTTP_500_INTERNAL_SERVER_ERROR
class NotFoundError(AppError):
code = "NOT_FOUND"
message = "找不到資源"
@property
def status_code(self) -> int:
return status.HTTP_404_NOT_FOUND
class ValidationFailedError(AppError):
# 業務邏輯驗證失敗(與 Pydantic 結構驗證不同)
code = "VALIDATION_FAILED"
message = "輸入資料不符合業務規則"
@property
def status_code(self) -> int:
return status.HTTP_422_UNPROCESSABLE_ENTITY
class ConflictError(AppError):
# 重複註冊、唯一鍵衝突等情境
code = "CONFLICT"
message = "資源已存在或狀態衝突"
@property
def status_code(self) -> int:
return status.HTTP_409_CONFLICT
class UnauthorizedError(AppError):
# 未登入或 token 失效
code = "UNAUTHORIZED"
message = "需要登入"
@property
def status_code(self) -> int:
return status.HTTP_401_UNAUTHORIZED
class ForbiddenError(AppError):
# 已登入但權限不足
code = "FORBIDDEN"
message = "權限不足"
@property
def status_code(self) -> int:
return status.HTTP_403_FORBIDDEN
這套設計把「例外類別」與「HTTP 回應」綁在一起:當你在端點裡 raise NotFoundError("找不到這位英雄"),FastAPI 看到的是 HTTPException(status_code=404, detail="找不到這位英雄");但因為我們等一下會註冊全域處理器,它會把 code 與 details 一起包進 JSON 回應。每個子類別只需要覆寫 code、message、status_code 三個屬性即可,使用方式非常一致。這種「集中定義錯誤型別」的做法在大團隊特別有用:新人只要看 errors.py 就知道整個系統有哪些錯誤,不需到處翻路由程式碼。
為什麼不直接用 HTTPException(status_code=404, detail="找不到")?因為這樣每個端點都要記得帶正確的狀態碼、寫一致的訊息,久了會出現「有人忘了帶 404」「有人寫了 404 但訊息是英文」這類不一致。自訂例外把這些決定集中在類別定義裡,新增錯誤類別也只需要加一支 class。
全域錯誤處理器
現在要把上面定義的例外與「統一 JSON 結構」接起來。在 FastAPI 裡,這件事用 exception_handler 裝飾器完成。建立 app/exception_handlers.py:
# app/exception_handlers.py
import logging
import uuid
from typing import Any
from fastapi import FastAPI, Request, status
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from sqlalchemy.exc import IntegrityError
from starlette.exceptions import HTTPException as StarletteHTTPException
from app.errors import AppError, ErrorBody, ErrorResponse
logger = logging.getLogger(__name__)
def _build_error_response(
code: str,
message: str,
status_code: int,
request: Request,
details: Any = None,
) -> JSONResponse:
# 為這次請求產生一個識別碼(用來對應 log)
request_id = getattr(request.state, "request_id", None) or uuid.uuid4().hex
body = ErrorResponse(
error=ErrorBody(
code=code,
message=message,
details=details,
request_id=request_id,
)
)
return JSONResponse(
status_code=status_code,
content=jsonable_encoder(body),
)
async def app_error_handler(
request: Request, exc: AppError
) -> JSONResponse:
# 處理自訂例外:狀態碼與訊息都從例外物件取
logger.info(
"AppError raised: code=%s status=%s message=%s",
exc.code, exc.status_code, exc.detail,
)
return _build_error_response(
code=exc.code,
message=str(exc.detail),
status_code=exc.status_code,
request=request,
details=exc.details,
)
async def validation_error_handler(
request: Request, exc: RequestValidationError
) -> JSONResponse:
# 處理 Pydantic 結構驗證錯誤(422)
logger.info("RequestValidationError: %s", exc.errors())
return _build_error_response(
code="INVALID_INPUT",
message="請求內容驗證失敗",
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
request=request,
details=exc.errors(),
)
async def integrity_error_handler(
request: Request, exc: IntegrityError
) -> JSONResponse:
# 處理 SQLAlchemy/SQLModel 的唯一鍵衝突等錯誤
logger.warning("IntegrityError: %s", exc.orig)
return _build_error_response(
code="CONFLICT",
message="資料衝突,請檢查唯一鍵或外鍵",
status_code=status.HTTP_409_CONFLICT,
request=request,
)
async def unhandled_exception_handler(
request: Request, exc: Exception
) -> JSONResponse:
# 處理所有未預期的例外:對外隱藏細節,只回通用訊息
request_id = uuid.uuid4().hex
logger.exception(
"Unhandled exception (request_id=%s)", request_id
)
return _build_error_response(
code="INTERNAL_ERROR",
message="伺服器內部錯誤,請稍後再試",
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request,
)
def register_exception_handlers(app: FastAPI) -> None:
# 把所有處理器註冊到 FastAPI 應用
app.add_exception_handler(AppError, app_error_handler)
app.add_exception_handler(RequestValidationError, validation_error_handler)
app.add_exception_handler(IntegrityError, integrity_error_handler)
app.add_exception_handler(StarletteHTTPException, app_error_handler)
app.add_exception_handler(Exception, unhandled_exception_handler)
這支檔案有五個關鍵設計:第一,_build_error_response 是共用的小工具,負責組裝 JSON 結構與 HTTP 狀態碼;第二,app_error_handler 處理我們的自訂例外;第三,validation_error_handler 把 FastAPI 內建的 RequestValidationError 包成統一格式;第四,integrity_error_handler 把 SQLAlchemy 的 IntegrityError(唯一鍵衝突、外鍵約束)翻成 409;第五,unhandled_exception_handler 是最後一道防線,攔截所有沒被前面接住的例外,回 500 並把 traceback 寫進 log(logger.exception),但對外只給通用訊息,避免暴露內部細節。
注意 StarletteHTTPException 也掛上 app_error_handler,這會讓直接呼叫 raise HTTPException(status_code=404) 的程式碼也走同一套結構,確保任何 HTTP 錯誤都長一樣。
把處理器註冊到 FastAPI 應用
把 register_exception_handlers(app) 放進 main.py 的啟動流程:
# app/main.py
import logging
import uuid
from fastapi import FastAPI, Request
from app.exception_handlers import register_exception_handlers
from app.routers import heroes
# 基本 log 設定
logging.basicConfig(level=logging.INFO)
app = FastAPI(title="Web Day 10 範例", version="0.1.0")
# 註冊所有錯誤處理器
register_exception_handlers(app)
@app.middleware("http")
async def add_request_id(request: Request, call_next):
# 為每個請求產生 request_id,方便對應 log
request_id = uuid.uuid4().hex
request.state.request_id = request_id
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
return response
# 註冊路由(Web Day 7 風格)
app.include_router(heroes.router, prefix="/api")
這段用 FastAPI 的 middleware 機制為每個請求加上一個 request_id,並放到 request.state,這樣所有處理器都能讀到。回應的 header 也帶一份(X-Request-ID),前端出問題時可以把這個 header 貼給開發者,開發者到 log 裡搜尋就能還原當時的情境。
在端點裡使用自訂例外
現在來看一個使用範例。建立 app/routers/heroes.py,裡面會示範「找不到資料」、「驗證失敗」、「衝突」三種情境:
# app/routers/heroes.py
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from app.db import get_session
from app.errors import ConflictError, NotFoundError, ValidationFailedError
from app.models import Hero
from app.schemas import HeroCreate
router = APIRouter(prefix="/heroes", tags=["heroes"])
@router.get("/{hero_id}", response_model=Hero)
def get_hero(hero_id: int, session: Session = Depends(get_session)) -> Hero:
hero = session.get(Hero, hero_id)
if hero is None:
# 用自訂例外取代 raise HTTPException(...)
raise NotFoundError(
message=f"找不到 ID 為 {hero_id} 的英雄",
details={"hero_id": hero_id},
)
return hero
@router.post("/", response_model=Hero, status_code=201)
def create_hero(
payload: HeroCreate, session: Session = Depends(get_session)
) -> Hero:
# 業務邏輯驗證:英雄名稱不可為空(雖然 Pydantic 已檢查型別,這裡再加一層語意)
if not payload.name.strip():
raise ValidationFailedError(
message="英雄名稱不可為空白",
details={"field": "name"},
)
# 檢查唯一性:避免同名的英雄
existing = session.exec(
select(Hero).where(Hero.name == payload.name)
).first()
if existing is not None:
raise ConflictError(
message=f"英雄名稱「{payload.name}」已被使用",
details={"name": payload.name},
)
hero = Hero(name=payload.name, secret_name=payload.secret_name)
session.add(hero)
session.commit()
session.refresh(hero)
return hero
這個範例展示三種典型錯誤:「找不到」用 NotFoundError、「業務驗證」用 ValidationFailedError、「唯一鍵衝突」用 ConflictError。每個例外都帶了 message 與 details,前者給使用者、後者給前端或開發者。所有錯誤會自動被前面的全域處理器包成統一 JSON 結構,端點程式碼本身不需要處理 try/except。
驗證:用 httpx 測錯誤回應
啟動服務(命令列指令):
uvicorn app.main:app --reload --port 8000
用 httpx 測三種錯誤情境:
# test_errors.py
import httpx
BASE = "http://127.0.0.1:8000/api"
with httpx.Client(base_url=BASE, timeout=5.0) as client:
# 情境 1:找不到英雄(404)
r1 = client.get("/heroes/9999")
print(r1.status_code, r1.json())
# 輸出(範例):
# 404 {'error': {'code': 'NOT_FOUND', 'message': '找不到 ID 為 9999 的英雄', ...}}
# 情境 2:建立時缺欄位(422,自動被 Pydantic 攔下)
r2 = client.post("/heroes/", json={"name": ""})
print(r2.status_code, r2.json())
# 輸出(範例):422 {'error': {'code': 'INVALID_INPUT', ...}}
# 情境 3:建立時名稱衝突(409)
r3 = client.post(
"/heroes/", json={"name": "Spider-Man", "secret_name": "Peter"}
)
print(r3.status_code, r3.json())
# 輸出(範例):409 {'error': {'code': 'CONFLICT', ...}}
三種情境的回應都帶 error 物件、code 與 message,結構完全一致。前端可以根據 code 顯示對應的錯誤訊息(例如「找不到資料」對應 toast 提示、「衝突」對應表單錯誤),而不必解析 HTTP 狀態碼與訊息字串。
未預期例外:500 的處理
最關鍵的設計是「未預期例外」的處理。寫一支故意會爆炸的端點測試:
# app/routers/heroes.py(新增)
@router.get("/_boom")
def boom() -> dict:
# 故意觸發例外
raise RuntimeError("這是預期外的錯誤")
用 httpx 打:
# test_500.py
import httpx
with httpx.Client(base_url="http://127.0.0.1:8000/api", timeout=5.0) as client:
r = client.get("/heroes/_boom")
print(r.status_code, r.json())
# 輸出(範例):
# 500 {'error': {'code': 'INTERNAL_ERROR', 'message': '伺服器內部錯誤,請稍後再試', ...}}
回應的訊息是「伺服器內部錯誤,請稍後再試」,沒有任何 traceback 或內部資訊。但伺服器端的 log 裡,logger.exception 會把完整 traceback 寫下來,包含是哪支函式、哪一行出錯。這個分離是資安設計的核心:對外只給必要訊息,對內給完整細節。
常見錯誤與踩雷
第一個常見的踩雷是「忘了註冊處理器」。寫完自訂例外之後,沒有呼叫 register_exception_handlers(app),結果端點 raise NotFoundError(...) 時,FastAPI 仍然用預設的 HTTPException 處理,回應結構還是 {"detail": "..."} 而不是 {"error": {...}}。檢查方式是 app.exception_handlers 字典裡有沒有你註冊的類別。
第二個是「HTTPException 在子路由裡 raise 卻被全域攔截成另一個」。FastAPI 的 add_exception_handler 是用「類別匹配」處理多型的:如果子類別先註冊,應該讓子類別的處理器贏。但實務上很多人忘了 StarletteHTTPException 是基底類別,把 HTTPException 視為「所有例外都會走它」的保險,結果自訂例外反而沒被接到。建議:自訂例外都繼承 AppError,不要直接繼承 HTTPException,並在 main.py 註冊處理器時把它們都列出來。
第三個是「log 寫太多或太少」。logger.exception 寫的是 traceback 詳細內容,適合未預期例外;但如果是 4xx(使用者錯誤),其實只要 logger.info 就好,否則 log 會被一大堆 404 淹沒,找真正的 5xx 反而困難。實務上把 4xx 視為「正常使用」、5xx 視為「需要處理的問題」是常見策略。
第四個是「request_id 沒串起來」。我們今天用 middleware 為每個請求產生 ID,但有些團隊會希望把 trace id 從上游(API gateway、load balancer)傳下來,這時應該讀 header(例如 X-Request-ID 或 W3C Trace Context),而不是自己產生。沒串起來時,跨服務除錯會非常痛苦。
效能與實務提醒
錯誤處理對效能的影響主要在兩個地方。第一,「錯誤回應的細節」:details 欄位若塞了完整 Pydantic 錯誤物件(包含 loc、msg、type、input 等),在大量驗證失敗時 body 可能變大。實務上可以視需要「過濾敏感欄位」(例如 input 可能含有使用者提交的密碼)。第二,「log 的 IO」:logger.exception 會把 traceback 序列化並寫到 log 檔,I/O 是同步的。在高 QPS 環境,建議用非同步 log handler(python-logging-async 之類的套件)或送到集中式 log 服務(ELK、Loki)。
另外,HTTP 狀態碼有一個常被忽略的細節:「422 與 400 的差別」。RFC 4918 把 422 Unprocessable Entity 定義為「伺服器理解請求內容的語法,但無法處理」(例如欄位語意錯誤);400 Bad Request 是「伺服器無法理解請求」(例如 JSON 壞掉)。FastAPI 預設用 422 表示 Pydantic 驗證失敗,這是符合規範的。我們今天設計的 ValidationFailedError 也用 422。但「找不到資源」要回 404 而不是 422,這是初學者容易搞混的地方。常見的對照:422 表示「我看了你寫的東西,邏輯上不對」;404 表示「我要找的東西不在這裡」;403 表示「東西在,但你不能碰」;409 表示「東西在,但目前狀態不允許你這個動作」。這四種狀態碼混用的後果是監控報表失準、除錯時找不到正確的入口,因此每一支端點都要先想清楚「這個錯誤對應到哪個 HTTP 狀態碼」。
最後是「錯誤代號的命名風格」。我們用全大寫底線(HERO_NOT_FOUND),這是業界常見做法(例如 AWS、Google Cloud API 的錯誤代號)。把資源名稱放進去(HERO_NOT_FOUND、TEAM_NOT_FOUND)能讓前端快速定位是哪個資源出問題。如果團隊較大,建議把錯誤代號集中在一個 enum 或文件,方便查表與翻譯。
小結
今天我們把 FastAPI 的錯誤處理從「各自例外」升級成「統一結構」。自訂例外類別把 HTTP 狀態碼與錯誤代號綁在一起,全域處理器把它們包成 {error: {code, message, details, request_id}} 的固定 JSON 結構,並透過 middleware 為每個請求帶上 request_id,方便對應 log。我們也處理了 Pydantic 結構驗證錯誤、SQLAlchemy 唯一鍵衝突、未預期例外三種典型情境。這套架構會在接下來的安全主題(Web Day 11–13)持續沿用,到時候 UnauthorizedError、ForbiddenError 都會接上同一個處理器,整個系統的錯誤回應會保持一致。
結語
今天把 API 回應設計的地基打好了,明天我們要進入安全主題的第一篇:密碼雜湊與註冊流程。你會學到為什麼不能直接存明文密碼、bcrypt 與 argon2 的取捨、怎麼用 passlib 或 argon2-cffi 把密碼正確地雜湊並驗證,並設計一支「註冊端點」,把今天設計的 ConflictError 拿來處理「帳號已被註冊」的情境。整套架構會從今天延伸到後續的認證與權限(Web Day 12–13)。
延伸資源
- FastAPI 官方文件:處理錯誤(0.116,2025):
https://fastapi.tiangolo.com/tutorial/handling-errors/ - FastAPI 官方文件:自訂 Response(0.116,2025):
https://fastapi.tiangolo.com/advanced/custom-response/ - Pydantic v2 錯誤結構(2.11,2025):
https://docs.pydantic.dev/latest/errors/ - HTTP 狀態碼標準(IETF RFC 9110,2025):
https://www.rfc-editor.org/rfc/rfc9110.html
留言
張貼留言