在前面的文章中,我們已經學會了 Effect 的基本錯誤處理 combinators,像是 catchAll
、catchTag
、orElse
等。這些工具讓我們能夠優雅地處理各種錯誤情況。然而,在真實世界的應用中,我們經常會遇到更複雜的錯誤處理需求。例如:
針對這些場景,Effect 提供了多種進階的錯誤處理策略,我們這裡舉出三個比較常見的策略來說明,分別是:
Schedule
智能地重複執行 Effect 直到成功這篇文章我們將先深入探討 Effect 的重試機制,學習如何使用 Schedule
API 來設計靈活且強大的重試策略。
你一定遇過這種狀況:程式在你本機跑得好好的,上線之後偶爾就會出問題——API 逾時、伺服器過載、第三方服務節流。這些多半是暫時性(transient)錯誤,通常重試幾次就能成功。
對這類錯誤,正確的作法不是「瘋狂重試」,而是「有策略地再試幾次」。好的重試策略需要考慮:
Effect 將重試邏輯分為兩個部分:
這種分離設計讓我們可以:
Effect 提供了兩個主要的重試函數:
Effect.retry(effect, schedule)
:重試失敗就拋出錯誤Effect.retryOrElse(effect, schedule, fallback)
:重試失敗後執行降級方案固定延遲重試是最簡單的重試策略,每次重試之間都等待相同的時間間隔。這種策略適合:
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!
*/
優點 | 缺點 |
---|---|
簡單易懂,邏輯直觀 | 可能加重系統負擔 |
可預測,便於規劃資源 | 無法根據錯誤類型調整 |
適合輕量級場景 | 缺乏抖動,可能造成雷群效應 |
固定延遲重試特別適合以下場景:
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.compose
和 Schedule.recurs
兩個方法,這兩個方法都是 Schedule
的 API。
Schedule.compose 的作用:
Schedule.fixed("500 millis")
設定重試間隔,Schedule.recurs(3)
限制重試次數Schedule.recurs(n) 的作用:
compose
組合實際執行流程:
recurs 和 retry 的差異:
recurs
是一個「排程策略」,描述「最多可再進行幾次」。它本身不會執行任何 Effect,而是提供給像 Effect.retry(policy)
這樣的運算子當作政策的一部分retry
是一個「在錯誤時重新執行 Effect」的運算子,只有當 Effect 失敗時才會依據提供的 Schedule 進行重試;成功即停止,不會因為條件未滿就繼續簡言之: recurs
定義「次數策略」,retry
在失敗時依「策略」重跑 Effect。
在實際應用中,我們需要根據錯誤類型來決定是否重試。使用 until
或 while
條件:
條件式重試讓我們能夠根據錯誤的性質來決定是否值得重試。這比盲目重試更智能,可以:
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
條件// 重試直到遇到認證錯誤就停止
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 錯誤的函數 isRetriableHttpError
。這個函數實現了智能的錯誤分類:
染後我們自定義了一些錯誤的class(ApiError
、ErrorNeedRetry
、ErrorNotRetry
),用來模擬不同錯誤情境。最後我們使用 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 的負擔過大。所以我們需要一個更智能的重試策略-指數回退。
指數回退是一種智能的重試策略,每次重試的間隔時間會呈指數增長。這種策略的優點:
實現的語法很簡單,把 Schedule.fixed("500 millis")
改成 Schedule.exponential("200 millis")
就可以了。
// 指數回退:200ms, 400ms, 800ms, 1600ms...
const retryPolicy = Schedule.compose(
Schedule.recurs(30),
Schedule.exponential("200 millis")
)
想像一下,當一個熱門網站突然當機時,所有用戶都會同時重新整理頁面。如果這些請求都使用相同的重試策略,它們會在完全相同的時間點重新發送請求,就像雷群一樣同時爆發,這就叫做「雷群效應」。
當多個請求同時重試時:
抖動就像在重試時間中加入「隨機性」,讓每個請求的重試時間都略有不同:
沒有抖動:所有請求都在相同時間重試
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,我們可以建立靈活、可觀測且易於測試的重試機制。在下一篇文章中,我們將探討降級方案,讓重試機制更加完整和實用。