iT邦幫忙

2025 iThome 鐵人賽

DAY 15
0
Modern Web

用 Effect 實現產品級軟體系列 第 15

[學習 Effect Day15] Effect 進階錯誤管理 (一)

  • 分享至 

  • xImage
  •  

在前面的文章中,我們已經學會了 Effect 的基本錯誤處理 combinators,像是 catchAllcatchTagorElse 等。這些工具讓我們能夠優雅地處理各種錯誤情況。然而,在真實世界的應用中,我們經常會遇到更複雜的錯誤處理需求。例如:

  • 網路請求偶爾會失敗,但重試幾次就能成功
  • 某些操作需要設定時間限制,避免無限等待
  • 批量操作時,我們希望收集所有錯誤而不是遇到第一個錯誤就停止

針對這些場景,Effect 提供了多種進階的錯誤處理策略,我們這裡舉出三個比較常見的策略來說明,分別是:

  • 重試機制(Retrying):使用 Schedule 智能地重複執行 Effect 直到成功
  • 超時設定(Timeouts):為 Effect 設定時間限制,避免無限等待
  • 錯誤累積(Error Accumulation):收集多個錯誤而不是短路

這篇文章我們將先深入探討 Effect 的重試機制,學習如何使用 Schedule API 來設計靈活且強大的重試策略。

重試機制(Retrying)

你一定遇過這種狀況:程式在你本機跑得好好的,上線之後偶爾就會出問題——API 逾時、伺服器過載、第三方服務節流。這些多半是暫時性(transient)錯誤,通常重試幾次就能成功。

對這類錯誤,正確的作法不是「瘋狂重試」,而是「有策略地再試幾次」。好的重試策略需要考慮:

  • 哪些錯誤值得重試?
  • 要重試幾次?間隔多久?
  • 如何避免加重系統負擔?
  • 何時放棄並採用降級方案?

Effect 的重試設計哲學

Effect 將重試邏輯分為兩個部分:

  1. Effect:要執行的任務(可能失敗的計算)
  2. Schedule:重試策略(何時重試、重試幾次、間隔多久)

這種分離設計讓我們可以:

  • 用同一套重試策略處理不同的任務
  • 組合不同的重試策略(固定延遲 + 指數回退 + 抖動)
  • 根據錯誤類型動態調整重試策略
  • 提供優雅的降級方案

基本重試語法

Effect 提供了兩個主要的重試函數:

  • Effect.retry(effect, schedule):重試失敗就拋出錯誤
  • Effect.retryOrElse(effect, schedule, fallback):重試失敗後執行降級方案

1. 基本重試:固定延遲

固定延遲重試是最簡單的重試策略,每次重試之間都等待相同的時間間隔。這種策略適合:

  • 簡單的錯誤恢復場景
  • 不需要複雜重試邏輯的應用
  • 對系統負載要求不高的情況

Effect 套件有提供 Schedule.fixed 的方法來幫助我們輕鬆實現固定延遲的重試策略,並搭配 Effect.retry 來實現重試:

基本語法

import { Effect, Schedule } from "effect"

let count = 0
// 模擬一個可能失敗的任務
const task = Effect.async<string, Error>((resume) => {
  if (count <= 2) {
    count++
    console.log("failure")
    resume(Effect.fail(new Error()))
  } else {
    console.log("success")
    resume(Effect.succeed("yay!"))
  }
})

// 定義重試策略:固定延遲 100 毫秒
const policy = Schedule.fixed("100 millis")

// 重試任務
const repeated = Effect.retry(task, policy)

Effect.runPromise(repeated).then(console.log)
/*
輸出:
failure
failure
failure
success
yay!
*/

固定延遲的優缺點

優點 缺點
簡單易懂,邏輯直觀 可能加重系統負擔
可預測,便於規劃資源 無法根據錯誤類型調整
適合輕量級場景 缺乏抖動,可能造成雷群效應

固定延遲重試特別適合以下場景:

實際案例:健康檢查(healthCheck) API

const task = Effect.tryPromise({
  try: async (): Promise<Response> => {
    console.log("嘗試健康檢查...")
    const response = await fetch("https://httpbin.org/status/500")
    console.log(`收到回應: ${response.status}`)
    // 檢查 HTTP 狀態碼,500 應該觸發錯誤
    if (response.status >= 400) {
      throw new Error(`Health check failed: ${response.status}`)
    }
    return response.json()
  },
  catch: (error) => Effect.fail(`Health check failed: ${error}`)
})

Effect.runPromise(
  Effect.retry(task, Schedule.fixed("500 millis"))
)
/**
 * 輸出:
 * 嘗試健康檢查...
 * 收到回應: 500
 * 嘗試健康檢查...
 * 收到回應: 500
 * 嘗試健康檢查...
 * 收到回應: 500
 * 嘗試健康檢查...
 * 收到回應: 500
 * .....
 */

從上面的輸出結果可以知道,每 500 millis 重試一次。但我們沒有設置重試次數限制,所以會一直重試下去。為了解決這個問題,讓我們來透過 Schedule.recurs 來限制重試次數。並且搭配 Schedule.compose 組合重試時間與次數的 Schedule條件。完成更精緻的重試策略。

重試次數限制

  const task = Effect.tryPromise({
    try: async (): Promise<Response> => {
      console.log("嘗試健康檢查...")
      const response = await fetch("https://httpbin.org/status/500")
      console.log(`收到回應: ${response.status}`)
      // 檢查 HTTP 狀態碼,500 應該觸發錯誤
      if (response.status >= 400) {
        throw new Error(`Health check failed: ${response.status}`)
      }
      return response.json()
    },
    catch: (error) => Effect.fail(`Health check failed: ${error}`)
  })

  // 定義重試策略:固定延遲 500 毫秒,最多重試 3 次
  const retryPolicy = Schedule.compose(
    Schedule.recurs(3), // 最多重試 3 次
    Schedule.fixed("500 millis") // 固定延遲 500 毫秒
  )

  Effect.runPromise(
    Effect.retry(task, retryPolicy)
  )

// 輸出:
// 嘗試健康檢查...
// 收到回應: 500
// 嘗試健康檢查...
// 收到回應: 500
// 嘗試健康檢查...
// 收到回應: 500
// 嘗試健康檢查...
// 收到回應: 500
// node:internal/process/promises:394
//     triggerUncaughtException(err, true /* fromPromise */);
//     ^
// [Error: {
//   "_id": "Exit",
//   "_tag": "Failure",
//   "cause": {
//     "_id": "Cause",
//     "_tag": "Fail",
//     "failure": "Health check failed: Error: Health check failed: 500"
//   }
// }] {
//   name: '(FiberFailure) Error',
//   [Symbol(effect/Runtime/FiberFailure)]: Symbol(effect/Runtime/FiberFailure),
//   [Symbol(effect/Runtime/FiberFailure/Cause)]: {
//     _tag: 'Fail',
//     error: EffectPrimitiveFailure {
//       _op: 'Failure',
//       effect_instruction_i0: {
//         _tag: 'Fail',
//         error: 'Health check failed: Error: Health check failed: 500'
//       },
//       effect_instruction_i1: undefined,
//       effect_instruction_i2: undefined,
//       trace: undefined,
//       _tag: 'Failure',
//       [Symbol(effect/Effect)]: {
//         _R: [Function: _R],
//         _E: [Function: _E],
//         _A: [Function: _A],
//         _V: '3.17.13'
//       }
//     }
//   }
// }

在寫這段程式碼時,我們使用了 Schedule.composeSchedule.recurs 兩個方法,這兩個方法都是 Schedule 的 API。

Schedule.compose 的作用:

  • 將兩個 Schedule 串聯組合,第一個 Schedule 的輸出會成為第二個 Schedule 的輸入
  • 在這個例子中,Schedule.fixed("500 millis") 設定重試間隔,Schedule.recurs(3) 限制重試次數
  • 兩者串聯後形成「每 500ms 重試一次,最多重試 3 次」的策略

Schedule.recurs(n) 的作用:

  • 建立一個最多重複執行 n 次的排程,用來限制重試/重做的次數上限
  • 通常會與間隔策略(固定間隔、指數退避、抖動)透過 compose 組合
  • 形成「有上限的重試」或「有限次輪詢」的完整策略

實際執行流程:

  1. 第一次嘗試失敗後,等待 500ms 再重試
  2. 總共嘗試了 4 次(1 次初始 + 3 次重試)
  3. 每次重試間隔都是固定的 500ms
  4. 達到最大重試次數後,最終拋出錯誤

recurs 和 retry 的差異:

  • recurs 是一個「排程策略」,描述「最多可再進行幾次」。它本身不會執行任何 Effect,而是提供給像 Effect.retry(policy) 這樣的運算子當作政策的一部分
  • retry 是一個「在錯誤時重新執行 Effect」的運算子,只有當 Effect 失敗時才會依據提供的 Schedule 進行重試;成功即停止,不會因為條件未滿就繼續

簡言之: recurs 定義「次數策略」,retry失敗時依「策略」重跑 Effect。

2. 條件式重試:根據錯誤類型決定是否重試

在實際應用中,我們需要根據錯誤類型來決定是否重試。使用 untilwhile 條件:

什麼是條件式重試?

條件式重試讓我們能夠根據錯誤的性質來決定是否值得重試。這比盲目重試更智能,可以:

  • 避免重試不可恢復的錯誤(如認證失敗)
  • 只對可能成功的錯誤進行重試(如網路抖動)
  • 節省系統資源,提高效率

基本語法

let count = 0

// 定義一個會產生不同錯誤的 Effect
const action = Effect.failSync(() => {
  console.log(`Action called ${++count} time(s)`)
  return `Error ${count}`
})

// 重試直到遇到特定錯誤
const program = Effect.retry(action, {
  until: (err) => err === "Error 3"
})

Effect.runPromiseExit(program).then(console.log)
/*
Output:
Action called 1 time(s)
Action called 2 time(s)
Action called 3 time(s)
{
  _id: 'Exit',
  _tag: 'Failure',
  cause: { _id: 'Cause', _tag: 'Fail', failure: 'Error 3' }
}
*/

until vs while 的差異

until 條件

  • 用途:重試直到遇到特定條件就停止
  • 適用場景:當你知道什麼時候應該停止重試
  • 範例:重試直到遇到特定錯誤類型
// 重試直到遇到認證錯誤就停止
const retryUntilAuthError = Effect.retry(httpRequest, {
  until: (error) => error.status === 401
})

while 條件

  • 用途:只要條件成立就繼續重試
  • 適用場景:當你知道什麼錯誤值得重試
  • 範例:只對可重試的錯誤進行重試
// 只對網路錯誤進行重試
const retryWhileNetworkError = Effect.retry(httpRequest, {
  while: (error) => error.type === "NetworkError"
})
條件類型 用途 適用場景
until 重試直到遇到特定條件就停止 知道什麼時候應該停止重試
while 只要條件成立就繼續重試 知道什麼錯誤值得重試

實際案例:根據 HTTP 錯誤類型重試

首先定義了 HTTP 錯誤的結構,包含狀態碼和可選的錯誤訊息。然後定義了判斷是否為可重試的 HTTP 錯誤的函數 isRetriableHttpError。這個函數實現了智能的錯誤分類:

  • 429 (Too Many Requests):暫時性錯誤,值得重試
  • 5xx 系列錯誤:伺服器內部錯誤,通常是暫時性的
  • 4xx 系列錯誤:客戶端錯誤,通常不應該重試

染後我們自定義了一些錯誤的class(ApiErrorErrorNeedRetryErrorNotRetry),用來模擬不同錯誤情境。最後我們使用 Effect.tryPromise 來模擬一個可能失敗的 HTTP 請求。並且使用 Effect.retry 來實現重試策略。

// HTTP 錯誤類型定義
type HttpError = {
  status: number
  message?: string
}

// 判斷是否為可重試的 HTTP 錯誤
function isRetriableHttpError(error: unknown): boolean {
  if (error && typeof error === "object" && "status" in error) {
    const httpError = error as HttpError
    return httpError.status === 429 || (httpError.status >= 500 && httpError.status < 600)
  }
  return false
}

class ApiError extends Error {
  constructor(message: string, cause: number) {
    super(message, { cause })
  }
}
class ErrorNeedRetry extends Error {}
class ErrorNotRetry extends Error {}

const task = Effect.tryPromise({
  try: async (): Promise<Response> => {
    console.log("嘗試健康檢查...")
    // 200 來測試成功情況;4xx,5xx 來測試重試情況
    const response = await fetch("https://httpbin.org/status/500")
    console.log(`收到回應: ${response.status}`)

    if (response.status !== 200) {
      throw new ApiError(`Health check failed: ${response.status}`, response.status)
    }
    return response
  },
  catch: (error) => {
    if (error instanceof ApiError) {
      // 使用 isRetriableHttpError 函數來判斷是否應該重試
      if (isRetriableHttpError({ status: error.cause as number })) {
        console.log(`HTTP 錯誤 ${error.cause} 是可重試的`)
        return new ErrorNeedRetry()
      } else {
        console.log(`HTTP 錯誤 ${error.cause} 不可重試`)
        return new ErrorNotRetry()
      }
    }
    return error
  }
})

// 定義重試策略:最多重試 3 次,每次重試間隔 500 毫秒
const retryPolicy = Schedule.compose(
  Schedule.recurs(3), // 最多重試 3 次
  Schedule.fixed("500 millis") // 固定延遲 500 毫秒
)

Effect.runPromise(
  Effect.retry(task, {
    while: (error) => error instanceof ErrorNeedRetry,
    schedule: retryPolicy
  })
).catch((error) => {
  console.log(`最終失敗: ${error.message}`)
})

最終的重試策略為:最多重試 3 次,每次重試間隔 500 毫秒,且只對可重試的錯誤(ErrorNeedRetry)進行重試。但我們仔細思考,一但我們 API server 突然出了問題,這樣的策略似乎會讓突然讓 server 短時間內接受大量的請求,造成 server 的負擔過大。所以我們需要一個更智能的重試策略-指數回退。

3. 指數回退(Exponential Backoff)

指數回退是一種智能的重試策略,每次重試的間隔時間會呈指數增長。這種策略的優點:

  • 減少系統負擔:隨著時間推移,重試頻率逐漸降低
  • 提高成功率:給系統更多時間來恢復

實現的語法很簡單,把 Schedule.fixed("500 millis") 改成 Schedule.exponential("200 millis") 就可以了。

// 指數回退:200ms, 400ms, 800ms, 1600ms...
const retryPolicy = Schedule.compose(
  Schedule.recurs(30),
  Schedule.exponential("200 millis")
)

4.抖動(Jitter)的重要性

想像一下,當一個熱門網站突然當機時,所有用戶都會同時重新整理頁面。如果這些請求都使用相同的重試策略,它們會在完全相同的時間點重新發送請求,就像雷群一樣同時爆發,這就叫做「雷群效應」。

為什麼雷群效應很危險?

當多個請求同時重試時:

  • 伺服器會再次被大量請求淹沒
  • 可能導致系統再次崩潰
  • 形成惡性循環:失敗 → 同時重試 → 再次失敗

抖動(Jitter)如何解決問題?

抖動就像在重試時間中加入「隨機性」,讓每個請求的重試時間都略有不同:

沒有抖動:所有請求都在相同時間重試
1秒後重試 → 2秒後重試 → 4秒後重試

有抖動:每個請求的重試時間都不同
請求A:1.2秒後重試 → 2.3秒後重試 → 4.1秒後重試
請求B:0.8秒後重試 → 1.7秒後重試 → 3.9秒後重試
請求C:1.5秒後重試 → 2.8秒後重試 → 4.5秒後重試

實際效果

  • 分散重試時間:避免所有請求同時重試
  • 降低系統負擔:重試請求分散在不同時間點
  • 提高成功率:給系統更多恢復時間

根據 AWS 的研究,加入抖動是處理高並發重試的最佳實踐。

在 Effect 中實現的語法很簡單,把 Schedule.exponential("200 millis") 外面套上 Schedule.jittered 就可以了。

const retryPolicy = Schedule.compose(
  Schedule.recurs(30),
  Schedule.jittered(Schedule.exponential("200 millis"))
)

總結

Effect 的 Schedule API 讓我們能夠以宣告式的方式設計複雜的重試策略。通過組合不同的 Schedule,我們可以建立靈活、可觀測且易於測試的重試機制。在下一篇文章中,我們將探討降級方案,讓重試機制更加完整和實用。

參考資料


上一篇
[學習 Effect Day14] Effect 錯誤管理 (二)
系列文
用 Effect 實現產品級軟體15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言