01-5. 狀態碼與錯誤處理

⏱️ 閱讀時間: 12 分鐘 🎯 難度: ⭐⭐ (基礎)


🤔 一句話解釋

HTTP 狀態碼告訴客戶端「請求的結果是什麼」,錯誤處理讓你優雅地告訴客戶端「哪裡出問題了」。


📊 HTTP 狀態碼速查表

1xx - 資訊回應(很少用)
2xx - 成功
3xx - 重定向
4xx - 客戶端錯誤
5xx - 伺服器錯誤

常用狀態碼

狀態碼名稱用途
200OK成功(預設)
201Created資源建立成功
204No Content成功但無回應內容
400Bad Request請求格式錯誤
401Unauthorized未認證
403Forbidden無權限
404Not Found資源不存在
409Conflict資源衝突
422Unprocessable Entity驗證失敗
500Internal Server Error伺服器錯誤

✅ 設定成功狀態碼

方法 1:使用 status_code 參數

from fastapi import FastAPI, status

app = FastAPI()

# 建立資源 → 201 Created
@app.post("/users", status_code=status.HTTP_201_CREATED)
async def create_user(name: str):
    return {"id": 1, "name": name}

# 刪除資源 → 204 No Content
@app.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(user_id: int):
    # 刪除成功,不需要回傳內容
    return None

# 或直接用數字
@app.post("/items", status_code=201)
async def create_item():
    return {"id": 1}

常用的 status 常數

from fastapi import status

# 2xx 成功
status.HTTP_200_OK              # 200
status.HTTP_201_CREATED         # 201
status.HTTP_204_NO_CONTENT      # 204

# 3xx 重定向
status.HTTP_301_MOVED_PERMANENTLY   # 301
status.HTTP_307_TEMPORARY_REDIRECT  # 307

# 4xx 客戶端錯誤
status.HTTP_400_BAD_REQUEST         # 400
status.HTTP_401_UNAUTHORIZED        # 401
status.HTTP_403_FORBIDDEN           # 403
status.HTTP_404_NOT_FOUND           # 404
status.HTTP_422_UNPROCESSABLE_ENTITY # 422

# 5xx 伺服器錯誤
status.HTTP_500_INTERNAL_SERVER_ERROR # 500

❌ 錯誤處理

HTTPException

最常用的錯誤處理方式:

from fastapi import FastAPI, HTTPException, status

app = FastAPI()

# 模擬資料庫
users_db = {1: {"name": "John"}, 2: {"name": "Jane"}}

@app.get("/users/{user_id}")
async def get_user(user_id: int):
    if user_id not in users_db:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="使用者不存在"
        )
    return users_db[user_id]

請求不存在的使用者:

GET /users/999

回應:

{
    "detail": "使用者不存在"
}

HTTPException 完整用法

raise HTTPException(
    status_code=404,
    detail="使用者不存在",            # 可以是字串、dict、list
    headers={"X-Error": "Not Found"}  # 自訂回應 headers
)

# detail 可以是複雜結構
raise HTTPException(
    status_code=400,
    detail={
        "message": "驗證失敗",
        "errors": [
            {"field": "email", "error": "格式不正確"},
            {"field": "age", "error": "必須大於 0"}
        ]
    }
)

🎨 自訂例外類別

對於大型專案,建議定義自訂例外:

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

app = FastAPI()

# ===== 自訂例外類別 =====
class AppException(Exception):
    """應用程式基礎例外"""
    def __init__(
        self,
        status_code: int,
        message: str,
        error_code: str = None
    ):
        self.status_code = status_code
        self.message = message
        self.error_code = error_code

class NotFoundException(AppException):
    """資源未找到"""
    def __init__(self, resource: str = "資源"):
        super().__init__(
            status_code=404,
            message=f"{resource}不存在",
            error_code="NOT_FOUND"
        )

class UnauthorizedException(AppException):
    """未認證"""
    def __init__(self, message: str = "請先登入"):
        super().__init__(
            status_code=401,
            message=message,
            error_code="UNAUTHORIZED"
        )

class ForbiddenException(AppException):
    """無權限"""
    def __init__(self, message: str = "權限不足"):
        super().__init__(
            status_code=403,
            message=message,
            error_code="FORBIDDEN"
        )

class ConflictException(AppException):
    """資源衝突"""
    def __init__(self, message: str = "資源衝突"):
        super().__init__(
            status_code=409,
            message=message,
            error_code="CONFLICT"
        )

# ===== 註冊例外處理器 =====
@app.exception_handler(AppException)
async def app_exception_handler(request: Request, exc: AppException):
    return JSONResponse(
        status_code=exc.status_code,
        content={
            "success": False,
            "error": {
                "code": exc.error_code,
                "message": exc.message
            }
        }
    )

# ===== 使用自訂例外 =====
users_db = {1: {"name": "John", "role": "user"}}

@app.get("/users/{user_id}")
async def get_user(user_id: int):
    if user_id not in users_db:
        raise NotFoundException("使用者")
    return users_db[user_id]

@app.delete("/users/{user_id}")
async def delete_user(user_id: int, current_user_role: str = "user"):
    if current_user_role != "admin":
        raise ForbiddenException("只有管理員可以刪除使用者")
    if user_id not in users_db:
        raise NotFoundException("使用者")
    del users_db[user_id]

回應格式統一:

{
    "success": false,
    "error": {
        "code": "NOT_FOUND",
        "message": "使用者不存在"
    }
}

🔧 全域錯誤處理

捕獲所有未處理的錯誤:

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from pydantic import ValidationError
import logging

app = FastAPI()

# 設定日誌
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

# 處理 Pydantic 驗證錯誤
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
    request: Request,
    exc: RequestValidationError
):
    errors = []
    for error in exc.errors():
        errors.append({
            "field": ".".join(str(x) for x in error["loc"]),
            "message": error["msg"],
            "type": error["type"]
        })

    return JSONResponse(
        status_code=422,
        content={
            "success": False,
            "error": {
                "code": "VALIDATION_ERROR",
                "message": "請求資料驗證失敗",
                "details": errors
            }
        }
    )

# 處理所有未預期的錯誤
@app.exception_handler(Exception)
async def general_exception_handler(
    request: Request,
    exc: Exception
):
    # 記錄錯誤(生產環境很重要)
    logger.error(f"Unhandled error: {exc}", exc_info=True)

    return JSONResponse(
        status_code=500,
        content={
            "success": False,
            "error": {
                "code": "INTERNAL_ERROR",
                "message": "伺服器內部錯誤,請稍後再試"
            }
        }
    )

驗證錯誤回應範例:

{
    "success": false,
    "error": {
        "code": "VALIDATION_ERROR",
        "message": "請求資料驗證失敗",
        "details": [
            {
                "field": "body.email",
                "message": "value is not a valid email address",
                "type": "value_error.email"
            },
            {
                "field": "body.age",
                "message": "Input should be greater than 0",
                "type": "greater_than"
            }
        ]
    }
}

📝 實戰範例:統一錯誤回應格式

from fastapi import FastAPI, HTTPException, Request, status
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from pydantic import BaseModel
from typing import Optional, Any
from datetime import datetime
import uuid

app = FastAPI()

# ===== 回應模型 =====
class ErrorDetail(BaseModel):
    code: str
    message: str
    details: Optional[Any] = None

class ErrorResponse(BaseModel):
    success: bool = False
    error: ErrorDetail
    request_id: str
    timestamp: datetime

class SuccessResponse(BaseModel):
    success: bool = True
    data: Any
    request_id: str
    timestamp: datetime

# ===== 中介軟體:生成 request_id =====
@app.middleware("http")
async def add_request_id(request: Request, call_next):
    request_id = str(uuid.uuid4())[:8]
    request.state.request_id = request_id
    response = await call_next(request)
    response.headers["X-Request-ID"] = request_id
    return response

# ===== 輔助函數 =====
def get_request_id(request: Request) -> str:
    return getattr(request.state, 'request_id', 'unknown')

def success_response(request: Request, data: Any) -> dict:
    return {
        "success": True,
        "data": data,
        "request_id": get_request_id(request),
        "timestamp": datetime.now()
    }

def error_response(
    request: Request,
    status_code: int,
    code: str,
    message: str,
    details: Any = None
) -> JSONResponse:
    return JSONResponse(
        status_code=status_code,
        content={
            "success": False,
            "error": {
                "code": code,
                "message": message,
                "details": details
            },
            "request_id": get_request_id(request),
            "timestamp": datetime.now().isoformat()
        }
    )

# ===== 例外處理器 =====
@app.exception_handler(RequestValidationError)
async def validation_error_handler(request: Request, exc: RequestValidationError):
    errors = [
        {
            "field": ".".join(str(x) for x in e["loc"][1:]),  # 移除 "body" 前綴
            "message": e["msg"]
        }
        for e in exc.errors()
    ]
    return error_response(
        request,
        status_code=422,
        code="VALIDATION_ERROR",
        message="請求資料驗證失敗",
        details=errors
    )

@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
    return error_response(
        request,
        status_code=exc.status_code,
        code=f"HTTP_{exc.status_code}",
        message=exc.detail
    )

@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
    return error_response(
        request,
        status_code=500,
        code="INTERNAL_ERROR",
        message="伺服器內部錯誤"
    )

# ===== API 端點 =====
users_db = {1: {"id": 1, "name": "John", "email": "john@example.com"}}

class UserCreate(BaseModel):
    name: str
    email: str

@app.get("/users/{user_id}")
async def get_user(request: Request, user_id: int):
    if user_id not in users_db:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="使用者不存在"
        )
    return success_response(request, users_db[user_id])

@app.post("/users", status_code=201)
async def create_user(request: Request, user: UserCreate):
    new_id = max(users_db.keys()) + 1
    new_user = {"id": new_id, "name": user.name, "email": user.email}
    users_db[new_id] = new_user
    return success_response(request, new_user)

成功回應:

{
    "success": true,
    "data": {
        "id": 1,
        "name": "John",
        "email": "john@example.com"
    },
    "request_id": "abc12345",
    "timestamp": "2025-12-17T10:30:00"
}

錯誤回應:

{
    "success": false,
    "error": {
        "code": "HTTP_404",
        "message": "使用者不存在",
        "details": null
    },
    "request_id": "abc12345",
    "timestamp": "2025-12-17T10:30:00"
}

✅ 重點總結

狀態碼選擇指南

┌─────────────────────────────────────────────┐
│  操作類型             推薦狀態碼             │
├─────────────────────────────────────────────┤
│  GET 成功             200 OK                │
│  POST 建立成功        201 Created           │
│  DELETE 成功          204 No Content        │
│  PUT/PATCH 成功       200 OK                │
├─────────────────────────────────────────────┤
│  驗證失敗             422 Unprocessable     │
│  資源不存在           404 Not Found         │
│  未認證               401 Unauthorized      │
│  無權限               403 Forbidden         │
│  資源衝突             409 Conflict          │
│  伺服器錯誤           500 Internal Error    │
└─────────────────────────────────────────────┘

最佳實踐

  1. 使用語義化的狀態碼 - 不要全部用 200
  2. 統一錯誤格式 - 所有錯誤回應結構一致
  3. 提供有意義的錯誤訊息 - 幫助前端和使用者理解
  4. 記錄 500 錯誤 - 方便除錯和監控
  5. 不要洩漏敏感資訊 - 生產環境隱藏堆疊追蹤

🎤 面試這樣答

Q: 什麼情況用 401,什麼情況用 403?

答案:

  • 401 Unauthorized:使用者沒有提供認證資訊,或認證資訊無效。需要先登入。

  • 403 Forbidden:使用者已認證,但沒有權限存取該資源。例如普通使用者嘗試存取管理員功能。

簡單說:401 是「你是誰?」,403 是「我知道你是誰,但你沒權限」。

Q: 為什麼 DELETE 要用 204 而不是 200?

答案:

204 No Content 表示操作成功,但沒有需要回傳的內容。DELETE 操作後,資源已經不存在了,沒有什麼需要回傳的,所以用 204 更準確。

如果用 200,通常需要回傳一些內容(如確認訊息),但這不是必要的。


🤓 小測驗

  1. POST 建立資源成功應該回傳什麼狀態碼?

  2. 如何在 FastAPI 中拋出 404 錯誤?

  3. 驗證失敗(如 email 格式錯誤)應該用什麼狀態碼?


上一篇: 01-4. 請求體與回應模型 下一篇: 01-6. 自動生成 API 文件


最後更新:2025-12-17

0%