Effect 進階錯誤管理 (二)
在前一篇文章中,我們學習了 Effect 重試機制的概念與實作。然而,重試機制並非萬能,當所有重試都失敗時,我們需要一個降級策略來確保系統的可用性。這篇文章將深入探討 Effect 的降級策略,讓您的應用在面對持續性錯誤時仍能優雅地提供基本功能。
什麼是降級策略?
降級策略(Fallback Strategy)是系統設計中的最後防線,當主要功能完全失敗時,提供一個可接受的替代方案。這不是失敗,而是一種優雅的降級。
為什麼需要降級策略?
在真實的生產環境中,我們會遇到各種無法預期的情況:
- 網路不穩定:用戶在移動環境中使用應用
- 服務器過載:高流量時期的系統壓力
- 第三方服務故障:依賴的外部 API 暫時不可用
- 資料庫連接問題:暫時性的資料庫維護
沒有降級策略的系統在遇到這些問題時會:
- ❌ 顯示錯誤頁面或白屏
- ❌ 用戶體驗完全中斷
- ❌ 業務功能完全停擺
有了降級策略的系統則能:
- ✅ 提供基本功能,保持系統可用性
- ✅ 優雅地處理錯誤,提升用戶體驗
- ✅ 確保核心業務流程不中斷
Effect 的降級策略:retryOrElse
Effect 提供了 retryOrElse 方法來實現降級策略,它結合了重試機制和降級策略:
基本語法
const retryWithFallback = Effect.retryOrElse(
originalEffect, // 要重試的 Effect
retrySchedule, // 重試策略
fallbackEffect // 降級策略
)降級策略的三大類型
| 降級類型 | 核心概念 | 實際應用 | 用戶體驗 |
|---|---|---|---|
| 功能降級 | 提供基本功能替代完整功能 | 用戶資料 → 預設頭像 | 避免功能完全失效 |
| 資料降級 | 使用快取或預設資料 | 即時資料 → 快取資料 | 保持內容可見性 |
| 服務降級 | 切換到備用服務 | 支付服務 → 備用支付 | 確保業務流程不中斷 |
降級策略的設計原則
- 功能完整性:降級後仍能提供核心功能
- 用戶體驗:避免完全失敗,保持基本可用性
- 資源效率:使用本地資源或輕量級替代方案
- 可觀測性:記錄降級事件,便於監控和除錯
實戰範例:用戶資料獲取的降級策略
讓我們通過一個完整的實戰範例來學習降級策略的實作。這個範例模擬了一個真實的用戶資料獲取場景,展示如何在 API 失敗時優雅地降級到基本功能。
場景
假設我們正在開發一個電商網站的用戶個人頁面,需要獲取用戶的詳細資料來顯示畫面。在正常情況下,可以從 API 獲取完整的用戶資料,但當 API 回應失敗時,我們需要提供一個降級方案,以維持用戶基本體驗。目標是無論如何都要讓用戶看到個人頁面,不能出現白屏或資料空白的情況。
降級策略設計
降級策略設計如下:
graph LR
subgraph "主要服務"
A[API 資料庫<br/>最新資料]
end
subgraph "第一層降級"
B[Redis 快取<br/>快取資料]
end
subgraph "第二層降級"
C[LocalStorage<br/>本地資料]
end
subgraph "第三層降級"
D[預設資料<br/>靜態資料]
end
A -->|API 失敗| B
B -->|Redis 失敗| C
C -->|LocalStorage 失敗| DMock API 模擬器
這部分沒興趣可以直接跳過沒關係,我們的重點是理解 Effect 中的降級策略如何實踐
為了測試降級策略,我們需要創建可以精準控制第幾次才會得到成功的 API 模擬函數,分別是 getFromAPI、getFromRedis、getFromLocalStorage,程式碼如下:
import { Effect, Schedule } from "effect"
// 通用模擬函數配置類型
type MockConfig = {
name: string
icon: string
failureThreshold: number
delay: number
successMessage: string
failureMessage: string
dataGenerator: (userId: string) => any
}
// 通用模擬函數工廠
const createMockFunction = (config: MockConfig) => {
let attemptCount = 0
return (userId: string) => {
return Effect.tryPromise({
try: () => {
attemptCount++
console.log(`${config.icon} 嘗試從 ${config.name} 獲取用戶 ${userId} 的資料 (嘗試 ${attemptCount})`)
return new Promise((resolve, reject) => {
setTimeout(() => {
if (attemptCount <= config.failureThreshold) {
console.log(`❌ ${config.name} 第 ${attemptCount} 次嘗試失敗`)
reject(new Error(`${config.failureMessage} - 嘗試 ${attemptCount}`))
} else {
console.log(`✅ ${config.successMessage}`)
resolve(config.dataGenerator(userId))
}
}, config.delay)
})
},
catch: (error) => new Error(`${config.name} 錯誤: ${error}`)
})
}
}
// 模擬函數配置
const mockConfigs = {
api: {
name: "API",
icon: "🔄",
failureThreshold: 2,
delay: 1000,
successMessage: "API 調用成功",
failureMessage: "API 調用失敗",
dataGenerator: (userId: string) => ({
id: userId,
name: `User ${userId}`,
email: `user${userId}@example.com`,
avatar: `https://i.pravatar.cc/150?img=${userId}`,
phone: `+1-555-${userId.padStart(4, "0")}`,
website: `https://user${userId}.example.com`,
company: `Company ${userId}`,
source: "API"
})
},
redis: {
name: "Redis",
icon: "🔍",
failureThreshold: 2,
delay: 500,
successMessage: "Redis 連接成功,返回快取資料",
failureMessage: "Redis 連接失敗",
dataGenerator: (userId: string) => ({
id: userId,
name: `Cached User ${userId}`,
email: `cached${userId}@example.com`,
avatar: `https://i.pravatar.cc/150?img=${userId}`,
phone: `+1-555-${userId.padStart(4, "0")}`,
website: `https://cached${userId}.example.com`,
company: `Cached Company ${userId}`,
source: "Redis Cache"
})
},
localStorage: {
name: "LocalStorage",
icon: "💾",
failureThreshold: 1,
delay: 300,
successMessage: "LocalStorage 讀取成功,返回本地資料",
failureMessage: "LocalStorage 讀取失敗",
dataGenerator: (userId: string) => ({
id: userId,
name: `Local User ${userId}`,
email: `local${userId}@example.com`,
avatar: `https://i.pravatar.cc/150?img=${userId}`,
phone: `+1-555-${userId.padStart(4, "0")}`,
website: `https://local${userId}.example.com`,
company: `Local Company ${userId}`,
source: "LocalStorage"
})
}
}
// 創建模擬函數實例
const getFromAPI = createMockFunction(mockConfigs.api)
const getFromRedis = createMockFunction(mockConfigs.redis)
const getFromLocalStorage = createMockFunction(mockConfigs.localStorage)這個模擬器核心功能如下:
- 通用模擬函數工廠:透過
createMockFunction創建可配置的模擬函數 - 可控的失敗模式:每個模擬器都有獨立的
failureThreshold設定失敗次數 - 模擬真實延遲:透過
delay參數模擬網路延遲情境 - 詳細的日誌記錄:記錄每次嘗試的狀態和結果,方便觀察降級流程
- 多層級模擬:支援 API、Redis、LocalStorage 三種不同層級的資料來源
用我們前面教過的 Effect Schedule 建置重試策略配置
// 重試策略配置
// 最多重試 3 次,每次間隔 1 秒 => 也就是總共最多會嘗試 1(初次)+ 3(重試)= 4 次。
const retryPolicy = Schedule.compose(
Schedule.recurs(3),
Schedule.fixed("1 seconds")
)第一層:主要 API 調用 + 重試機制 (核心業務邏輯)
export const fetchUserFromAPI = (userId: string) =>
Effect.retryOrElse(
getFromAPI(userId),
retryPolicy,
() => getCachedUserData(userId) // 降級到快取
)第二層:Redis 快取降級
const getCachedUserData = (userId: string) =>
Effect.retryOrElse(
getFromRedis(userId),
retryPolicy,
() => getLocalStorageData(userId) // 降級到本地儲存
)第三層:本地儲存降級
const getLocalStorageData = (userId: string) =>
Effect.retryOrElse(
getFromLocalStorage(userId),
retryPolicy,
() => getDefaultUserData(userId) // 降級到預設資料
)第四層:預設資料降級
const getDefaultUserData = (userId: string) =>
Effect.succeed({
id: userId,
name: "訪客用戶",
avatar: "/assets/default-avatar.png"
// ... 其他預設資料
})從上面程式碼可以發現,我們透過 Effect.retryOrElse 將各層的 API 調用組合起來,讓每層資料獲取失敗後,自動切換到下一層的降級策略。
完整資料流向圖
flowchart TD
Start([用戶點擊個人頁面]) --> API[嘗試從 API 獲取資料]
API --> API_Success{API 成功?}
API_Success -->|是| Success[顯示最新資料]
API_Success -->|否| Retry1[重試 API<br/>最多 3 次,間隔 1 秒]
Retry1 --> Retry1_Success{重試成功?}
Retry1_Success -->|是| Success
Retry1_Success -->|否| Level1[第一層降級<br/>從 Redis 快取獲取]
Level1 --> Redis_Success{Redis 成功?}
Redis_Success -->|是| Cache1[顯示快取資料]
Redis_Success -->|否| Retry2[重試 Redis<br/>最多 3 次,間隔 1 秒]
Retry2 --> Retry2_Success{重試成功?}
Retry2_Success -->|是| Cache1
Retry2_Success -->|否| Level2[第二層降級<br/>從 LocalStorage 獲取]
Level2 --> Local_Success{LocalStorage 成功?}
Local_Success -->|是| Cache2[顯示本地資料]
Local_Success -->|否| Retry3[重試 LocalStorage<br/>最多 3 次,間隔 1 秒]
Retry3 --> Retry3_Success{重試成功?}
Retry3_Success -->|是| Cache2
Retry3_Success -->|否| Level3[第三層降級<br/>使用預設資料]
Level3 --> Default[顯示預設訪客資料<br/>靜態資料]
Success --> End([用戶看到個人頁面])
Cache1 --> End
Cache2 --> End
Default --> End多層降級策略測試
大家可以跑跑看,是真的可以跑起來的,我覺得滿有成就感的。雖然大部分程式都是 AI 寫的🤣。
// 創建測試場景的降級函數
const createTestScenario = (apiConfig: MockConfig, redisConfig: MockConfig, localStorageConfig: MockConfig) => {
const testApiCall = createMockFunction(apiConfig)
const testGetFromRedis = createMockFunction(redisConfig)
const testGetFromLocalStorage = createMockFunction(localStorageConfig)
const testFetchUserFromAPI = (userId: string) =>
Effect.retryOrElse(
Effect.tryPromise({
try: () => testApiCall(userId).pipe(Effect.runPromise),
catch: (error) => new Error(`Failed to fetch user profile: ${error}`)
}),
retryPolicy,
() => testGetCachedUserData(userId)
)
const testGetCachedUserData = (userId: string) =>
Effect.retryOrElse(
testGetFromRedis(userId),
retryPolicy,
() => testGetLocalStorageData(userId)
)
const testGetLocalStorageData = (userId: string) =>
Effect.retryOrElse(
testGetFromLocalStorage(userId),
retryPolicy,
() => getDefaultUserData(userId)
)
return testFetchUserFromAPI
}
// 測試程序
const program = Effect.gen(function*() {
console.log("🚀 開始測試多層降級策略...")
console.log("📝 降級策略:API → Redis → LocalStorage → 預設資料")
console.log("⏱️ 每層都有重試機制,最多重試 3 次")
console.log("🛡️ 完整降級流程測試")
console.log("=".repeat(60))
// 測試場景 1:API 成功(第 3 次重試成功)
console.log("📋 測試場景 1:API 重試成功")
const scenario1 = createTestScenario(
mockConfigs.api, // API 正常
{ ...mockConfigs.redis, failureThreshold: 10 }, // Redis 永遠失敗
{ ...mockConfigs.localStorage, failureThreshold: 10 } // LocalStorage 永遠失敗
)
const userProfile1 = yield* scenario1("1")
console.log("✅ 場景 1 結果:", userProfile1)
console.log("=".repeat(40))
// 測試場景 2:API 失敗,Redis 成功
console.log("📋 測試場景 2:API 失敗,Redis 重試成功")
const scenario2 = createTestScenario(
{ ...mockConfigs.api, failureThreshold: 10 }, // API 永遠失敗
mockConfigs.redis, // Redis 正常
{ ...mockConfigs.localStorage, failureThreshold: 10 } // LocalStorage 永遠失敗
)
const userProfile2 = yield* scenario2("2")
console.log("✅ 場景 2 結果:", userProfile2)
console.log("=".repeat(40))
// 測試場景 3:API 和 Redis 都失敗,LocalStorage 成功
console.log("📋 測試場景 3:API 和 Redis 都失敗,LocalStorage 重試成功")
const scenario3 = createTestScenario(
{ ...mockConfigs.api, failureThreshold: 10 }, // API 永遠失敗
{ ...mockConfigs.redis, failureThreshold: 10 }, // Redis 永遠失敗
mockConfigs.localStorage // LocalStorage 正常
)
const userProfile3 = yield* scenario3("3")
console.log("✅ 場景 3 結果:", userProfile3)
console.log("=".repeat(40))
// 測試場景 4:所有層級都失敗,降級到預設資料
console.log("📋 測試場景 4:所有層級都失敗,降級到預設資料")
const scenario4 = createTestScenario(
{ ...mockConfigs.api, failureThreshold: 10 }, // API 永遠失敗
{ ...mockConfigs.redis, failureThreshold: 10 }, // Redis 永遠失敗
{ ...mockConfigs.localStorage, failureThreshold: 10 } // LocalStorage 永遠失敗
)
const userProfile4 = yield* scenario4("4")
console.log("✅ 場景 4 結果:", userProfile4)
console.log("=".repeat(40))
return {
scenario1: userProfile1,
scenario2: userProfile2,
scenario3: userProfile3,
scenario4: userProfile4
}
})
// 執行測試
Effect.runPromise(program).then((res) => {
console.log("🔍 最終結果:", res)
})總結
降級策略是 Effect 錯誤管理中的重要組成部分,它確保系統在面對持續性錯誤時仍能提供基本功能。通過 retryOrElse 方法,我們可以:
- 結合重試和降級:先嘗試重試,失敗時自動降級
- 保證系統可用性:即使主要功能失敗,仍能提供基本功能
- 提升用戶體驗:避免完全失敗,保持系統可用性
- 實現優雅降級:提供有意義的替代方案
降級策略讓我們的應用在面對各種不可預期的情況時,仍保持服務的穩定性和可用性。
參考資料
- Retrying | Effect Docs
- GPT-5
Last updated on