Workflows steps 深入:重試、sleep 與等待事件 | Cloudflare 完整教學
上一篇《Workflows 持久化執行入門》,我們寫出了第一個 Workflow,學會用
step.do(名稱, 函式)把每個有副作用的動作包成一個持久化步驟。但那只是最基本的兩參數形式。真正讓 Cloudflare Workflows 強大的,是step的深水區:每一步要怎麼設定重試與指數退避?怎麼讓流程睡上七天卻完全不計費?怎麼暫停下來等待一個外部的人工審核事件? 這一篇,我們就把step.do、step.sleep、step.waitForEvent這三支 API 一次拆透,並釐清背後那條所有人都會踩的紅線——決定性(determinism)。
前言
先把這一篇的定位講清楚:上一篇解決的是「Workflow 是什麼、骨架長怎樣、怎麼觸發」;這一篇解決的是「每一個步驟(step)內部能怎麼精細控制」。同樣一個 step.do,你可以只丟名稱和函式草草了事,也可以替它配上重試次數、退避策略、逾時上限,讓它在上游 API 抖動時自動重試、在業務錯誤時立刻放棄。除此之外,step 家族還有兩支讓 Workflows 真正與眾不同的 API:能睡上數天而不計費的 step.sleep,以及能暫停等待外部事件的 step.waitForEvent。
打個比方。如果說上一篇的 step.do 是教你「在流程卡上打勾」,那這一篇就是教你認識這張卡上更精密的欄位:每個勾格旁邊其實還有「失敗了要重試幾次、每次隔多久」的小字(retries),有「這一格先擱著,七天後再回來」的便利貼(sleep),還有「這一格要等主廚來簽名才能繼續」的待簽章(waitForEvent)。同一張卡、同一套持久化保護,但這些欄位讓你能表達遠比「一步接一步」更真實的業務流程——會失敗、要等待、要仰賴外部決定的流程。
要特別強調的是,step.sleep 不是 setTimeout。setTimeout 是讓當前程序原地空等、活活佔著資源;而 step.sleep 是把整個實例真正卸下、進入不計費也不佔並行額度的 waiting 狀態,時間到了平台再喚醒它。這個差別是理解 Workflows 成本模型的關鍵,我們會反覆回到它。
讀完你會掌握:
step.do的完整簽名——step.do(name, { retries, timeout }, fn),以及retries的limit/delay/backoff三欄位與 constant / linear / exponential 三種退避的取捨step.sleep與step.sleepUntil——相對與絕對時間的睡眠,為什麼「睡眠不計費、不佔並行」,以及它與setTimeout的本質差異step.waitForEvent——如何讓 Workflow 暫停等待外部事件(webhook、人工審核),再用instance.sendEvent()從外部喚醒- 決定性(determinism)要求——為什麼步驟外不能用
Date.now()、Math.random(),step 名稱為何必須是確定性字串 NonRetryableError——如何在業務邏輯錯誤(認證失敗、餘額不足)時立即終止重試,不做無謂的重試浪費
核心概念
在動手寫程式前,先把三件事的「運作原理」講透:一個 step 的完整生命週期(持久化 + 重試 + 睡眠是怎麼交織的)、決定性要求為什麼是所有規則的根源,以及貫穿全文的關鍵術語。
一、一個 step 的生命週期:持久化、重試、睡眠如何交織
回顧上一篇的核心機制:Workflows 靠重播(replay)實現崩潰恢復——每次恢復都從頭再跑 run(),已完成的步驟直接讀回持久化的回傳值、不重跑。現在我們把鏡頭拉近到單一一個 step.do 內部,看看重試與睡眠是怎麼疊加上去的:
step.do('呼叫外部 API', { retries: { limit: 3, delay: '2s', backoff: 'exponential' } }, fn)
│
├─ 第 1 次嘗試(ctx.attempt = 1)──► fn() 拋錯 ✗
│ │
│ └─ 等 2 秒(exponential 首次間隔)
│
├─ 第 2 次嘗試(ctx.attempt = 2)──► fn() 拋錯 ✗
│ │
│ └─ 等 4 秒(2 → 4,間隔倍增)
│
├─ 第 3 次嘗試(ctx.attempt = 3)──► fn() 成功 ✓
│ │
│ └─ 回傳值持久化保存 ✓ ──► 這一步從此不再重跑
│
▼
下一步 step.sleep('等 7 天', '7 days')
│
└─ 實例進入 waiting 狀態:不執行、不計費、不佔並行額度
⋯⋯ 7 天後平台喚醒 ⋯⋯
│
▼
繼續往下一步
有三個層次要分清楚:
- 步驟之間的持久化(上一篇):已完成的整個步驟不重跑,靠讀回快取。
- 步驟之內的重試(這一篇新增):一個步驟第一次執行時,若
fn()拋錯,step.do會在該步驟內部依retries設定自動重試,直到成功或達到limit。這些重試發生在「這一步還沒被標記為完成」的期間,只有最終成功的那次回傳值才會被持久化。 - 步驟形態的睡眠(這一篇新增):
step.sleep本身就是一種特殊的步驟,它不執行你的程式碼,只是登記一個「醒來時間」,讓實例卸下進入waiting。
理解這三層,你就能精準地控制「哪些失敗該重試、重試幾次、隔多久」以及「流程該在哪裡停下來等」。
二、決定性(determinism):所有規則的根源
Workflows 幾乎所有「怪規則」(不能用 Date.now()、step 名稱要固定、副作用要包進步驟)都源自同一個底層前提:你的 run() 主體必須是決定性(deterministic)的。
什麼叫決定性?就是「給同樣的輸入,每次重播都走出一模一樣的控制流路徑、產生一模一樣的步驟序列」。為什麼非得如此?因為崩潰恢復是靠重播實現的——平台重跑 run(),靠「依序遇到的 step.do 名稱」來對應「這一步上次做過沒」。如果重播時你的程式因為某個非決定性的值而走上不同分支、或產生了不同的步驟名稱,那麼「上次做到哪」的帳就對不上了,已完成的步驟會被誤判、狀態會錯亂。
第一次執行: 崩潰後重播:
now = Date.now() → 1000(偶數) now = Date.now() → 1001(奇數)
if (now % 2 === 0) 成立 if (now % 2 === 0) 不成立
→ step.do('走 A 路徑') → step.do('走 B 路徑') ✗ 對不上!
化解之道只有一條核心原則:把所有非決定性的東西(當下時間、隨機數、外部即時狀態)都關進 step.do() 裡。因為步驟的回傳值會被持久化,第一次算出來就定格了,之後每次重播都讀回同一個值,決定性自然被保住。這條原則會貫穿本篇的每一段程式碼。
三、關鍵術語
先記下這一篇會反覆出現的名詞:
| 術語 | 說明 |
|---|---|
retries | step.do 設定物件中的重試設定,含 limit、delay、backoff 三欄位 |
backoff(退避) | 重試間隔的增長策略:constant(固定)/ linear(線性)/ exponential(指數) |
timeout(逾時) | 單次嘗試的執行上限,超過即視為該次失敗 |
ctx.attempt | 步驟函式收到的當前嘗試次數(1 = 首次,2 = 第一次重試…) |
step.sleep | 相對時間睡眠,期間實例進入 waiting,不計費、不佔並行 |
step.sleepUntil | 絕對時間睡眠,睡到指定的 Date 為止 |
step.waitForEvent | 暫停實例,等待外部以 sendEvent() 送入指定 type 的事件 |
| 決定性(determinism) | run() 主體「同輸入每次重播走同路徑」的性質,是崩潰恢復正確的前提 |
NonRetryableError | 從 cloudflare:workflows 匯入,拋出後立即終止重試 |
實作範例
我們用一個貼近真實業務的訂閱扣款流程走一遍所有 API。這個流程包含:驗證與扣款(需要重試 + 業務錯誤要立即放棄)→ 等待對帳系統確認(等外部事件)→ 若未確認則睡一段時間後重查 → 寄送收據。它剛好把 retries、NonRetryableError、waitForEvent、sleep 全部串起來。
所有匯入都來自
cloudflare:workers,唯獨NonRetryableError來自cloudflare:workflows,別匯錯。
1. step.do 的完整簽名:name、設定物件、函式
上一篇只用了 step.do(name, fn) 兩參數形式。完整簽名其實是三參數:在名稱與函式之間,可以插入一個設定物件,用來指定 retries 與 timeout。
// step.do 的完整形態
const result = await step.do(
"呼叫外部 API", // ① 步驟名稱:必須是「確定性」的靜態字串
{
retries: {
limit: 5, // 最大重試次數(0 = 不重試,Infinity = 無限重試)
delay: "10 seconds", // 基準間隔(可寫數字毫秒,或 '10 seconds' 字串)
backoff: "exponential", // 退避策略:constant | linear | exponential
},
timeout: "30 seconds", // ② 單次嘗試逾時上限,超過即算這次失敗
},
async () => { // ③ 實際要跑的函式,回傳值會被持久化
const res = await fetch("https://api.example.com/data");
if (!res.ok) throw new Error(`API 回應 ${res.status}`); // 拋錯 → 觸發重試
return res.json();
}
);
三種 backoff 的實際間隔差異(以 delay: '2 seconds' 為例):
// constant(常數):每次間隔固定不變
// 2s → 2s → 2s → 2s ... 適合上游恢復時間穩定的情境
await step.do("固定重試", { retries: { limit: 5, delay: "2 seconds", backoff: "constant" } }, fn);
// linear(線性):間隔線性遞增
// 2s → 4s → 6s → 8s ... 溫和的折衷
await step.do("線性重試", { retries: { limit: 5, delay: "2 seconds", backoff: "linear" } }, fn);
// exponential(指數):間隔倍增 —— 呼叫外部相依的預設首選
// 2s → 4s → 8s → 16s ... 上游抖動時快速重試,上游真掛時不狂轟濫炸
await step.do("指數重試", { retries: { limit: 5, delay: "2 seconds", backoff: "exponential" } }, fn);
實務準則:對所有外部相依(API、資料庫、第三方服務)一律用 exponential,limit 設 3 到 5,delay 從 '2 seconds' 起跳。指數退避是分散式系統的標準做法,既能在上游短暫抖動時快速恢復,又能在上游真的掛掉時避免用密集請求把它壓得更慘。
函式還能收到一個 ctx,拿到當前是第幾次嘗試:
await step.do(
"帶嘗試資訊的步驟",
{ retries: { limit: 3, delay: "5 seconds", backoff: "exponential" } },
async (ctx) => {
// ctx.attempt:1 = 首次,2 = 第一次重試,以此類推
if (ctx.attempt > 1) {
console.log(`這是第 ${ctx.attempt - 1} 次重試`);
}
return await doWork();
}
);
2. NonRetryableError:業務錯誤就別重試了
不是所有錯誤都值得重試。網路逾時、上游 500 這類暫時性錯誤,重試很可能會成功;但「餘額不足」「付款方式無效」「認證憑證錯誤」這類業務性錯誤,你重試一百次結果還是一樣,只是白白浪費時間與配額。
NonRetryableError 就是用來表達「這個錯不要重試,立刻放棄這一步」。它從 cloudflare:workflows 匯入,一旦在步驟內拋出,step.do 會跳過剩餘的重試次數、直接讓該步驟失敗。
import { NonRetryableError } from "cloudflare:workflows";
const charge = await step.do(
"驗證並扣款",
{ retries: { limit: 3, delay: "5 seconds", backoff: "exponential" } },
async () => {
const { paymentMethod, amount } = event.payload;
// 業務前置條件不滿足 → 重試也沒用,立即放棄
if (!paymentMethod) {
throw new NonRetryableError("缺少付款方式,無法扣款");
}
const res = await fetch("https://payment.example.com/charge", {
method: "POST",
body: JSON.stringify({ paymentMethod, amount }),
});
if (res.status === 401) {
throw new NonRetryableError("API 憑證無效"); // 認證錯誤:重試也不會過
}
if (res.status === 402) {
throw new NonRetryableError("餘額不足"); // 業務狀態:不該重試
}
if (!res.ok) {
// 500 / 503 等:可能是暫時性,交給 retries 機制重試
throw new Error(`扣款 API 錯誤 ${res.status}`);
}
return res.json<{ transactionId: string }>();
}
);
心法一句話:暫時性錯誤丟普通 Error(讓它重試),業務性/永久性錯誤丟 NonRetryableError(立刻放棄)。
3. step.waitForEvent:暫停等待外部事件
有些流程走到一半必須停下來等外面的世界——等對帳系統回報、等使用者點擊確認信、等主管在後台按下核准。step.waitForEvent 讓實例暫停(進入 waiting,同樣不計費),直到外部用 sendEvent() 送入一個符合指定 type 的事件,或等到逾時。
// 等待對帳系統確認扣款(最多等 6 小時)
try {
const reconcile = await step.waitForEvent<{ confirmed: boolean; ref: string }>(
"等待對帳確認",
{
type: "reconcile-result", // 只接受此 type 的事件(命名限字母數字與 - _)
timeout: "6 hours", // 逾時後拋出錯誤
}
);
if (!reconcile.confirmed) {
throw new NonRetryableError(`對帳未通過:${reconcile.ref}`);
}
} catch {
// 逾時或未確認 → 進入下方的重查補救流程(見下一節)
}
從外部喚醒等待中的實例,用的是 instance.sendEvent(),type 必須與 waitForEvent 對得上:
// 另一個 Worker / handler:對帳系統回呼進來時,喚醒對應實例
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const { instanceId, confirmed, ref } = await request.json<{
instanceId: string;
confirmed: boolean;
ref: string;
}>();
const instance = await env.BILLING_WORKFLOW.get(instanceId);
await instance.sendEvent({
type: "reconcile-result", // ★ 必須與 waitForEvent 的 type 完全一致
payload: { confirmed, ref },
});
return Response.json({ status: "event delivered" });
},
} satisfies ExportedHandler<Env>;
4. step.sleep 與 step.sleepUntil:睡而不費
若對帳沒能即時確認,我們不急著失敗——先睡一段時間,讓對帳系統有機會補上,再回頭重查。這就是 step.sleep 的舞台:它讓實例真正卸下、進入 waiting,期間不執行程式碼、不計 CPU 費用、不佔用同時執行實例的並行額度,時間一到平台自動喚醒。
// 相對時間:從現在起睡多久(可寫字串或數字毫秒,最大 365 天)
await step.sleep("等對帳補上", "30 minutes");
await step.sleep("試用期", "7 days");
await step.sleep("短暫等待", 5000); // 5000ms = 5 秒
// 絕對時間:睡到某個明確的時間點
await step.sleepUntil("延到隔日凌晨對帳", new Date("2026-09-02T02:00:00Z"));
這與 setTimeout 的差別是本質性的,務必記牢:
| 面向 | setTimeout(fn, ms) | step.sleep(name, duration) |
|---|---|---|
| 等待期間 | 程序原地空等,計時器活著佔資源 | 實例卸下,進入 waiting |
| 計費 | 佔用執行資源(且 Worker 撐不了那麼久) | 不計 CPU 費用 |
| 並行額度 | 佔用中 | 不計入同時執行實例上限 |
| 崩潰後 | 計時器丟失,等待歸零 | 醒來時間已持久化,恢復後照樣醒來 |
| 最大時長 | 受 Worker 生命週期限制(極短) | 最大 365 天 |
正因如此,「試用 7 天後提醒」「延到某個絕對時間再動作」在 Workflows 裡就是一行 step.sleep/step.sleepUntil,不需要 cron 輪詢、不需要自建計時基礎設施。
5. 串起來:完整的訂閱扣款長時任務
現在把上面所有片段組成一個可運作的 Workflow。它示範了一條真實流程如何橫跨「重試扣款 → 等待對帳 → 睡眠後重查 → 寄收據」,而每一步都享有持久化保護。
import { WorkflowEntrypoint, WorkflowEvent, WorkflowStep } from "cloudflare:workers";
import { NonRetryableError } from "cloudflare:workflows";
interface BillingParams {
userId: string;
paymentMethod: string;
amount: number;
email: string;
}
interface Env {
BILLING_WORKFLOW: Workflow<BillingParams>;
DB: D1Database;
}
export class BillingWorkflow extends WorkflowEntrypoint<Env, BillingParams> {
async run(event: WorkflowEvent<BillingParams>, step: WorkflowStep): Promise<void> {
const { userId, amount, email } = event.payload;
// ── 步驟 1:扣款(暫時性錯誤重試,業務錯誤立即放棄)──
const charge = await step.do(
"驗證並扣款",
{ retries: { limit: 3, delay: "5 seconds", backoff: "exponential" }, timeout: "30 seconds" },
async () => {
const { paymentMethod, amount } = event.payload;
if (!paymentMethod) throw new NonRetryableError("缺少付款方式");
const res = await fetch("https://payment.example.com/charge", {
method: "POST",
body: JSON.stringify({ userId, paymentMethod, amount }),
});
if (res.status === 402) throw new NonRetryableError("餘額不足");
if (!res.ok) throw new Error(`扣款 API 錯誤 ${res.status}`); // 交給重試
return res.json<{ transactionId: string }>();
}
);
// ── 步驟 2:等待對帳確認(最多 6 小時),逾時則睡 30 分鐘後重查一次 ──
let confirmed = false;
try {
const evt = await step.waitForEvent<{ confirmed: boolean }>(
"等待對帳確認",
{ type: "reconcile-result", timeout: "6 hours" }
);
confirmed = evt.confirmed;
} catch {
// 逾時:先睡一段時間,給對帳系統補上的機會
await step.sleep("等對帳補上", "30 minutes");
// 主動向對帳 API 重查一次(這一步同樣有持久化與重試保護)
confirmed = await step.do(
"主動重查對帳",
{ retries: { limit: 3, delay: "10 seconds", backoff: "exponential" } },
async () => {
const res = await fetch(
`https://payment.example.com/reconcile/${charge.transactionId}`
);
const data = await res.json<{ confirmed: boolean }>();
return data.confirmed;
}
);
}
if (!confirmed) {
// 仍未確認 → 標記並結束(此為業務決定,不重試)
await step.do("標記對帳失敗", async () => {
await this.env.DB.prepare(
"UPDATE billings SET status = ? WHERE txn = ?"
).bind("reconcile-failed", charge.transactionId).run();
});
return;
}
// ── 步驟 3:寄送收據(副作用包進步驟,保證只寄一次)──
await step.do("寄送收據", async () => {
await fetch("https://mail.example.com/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
to: email,
subject: "扣款成功收據",
body: `已成功扣款 ${amount} 元,交易編號 ${charge.transactionId}。`,
}),
});
});
// ── 步驟 4:更新最終狀態 ──
await step.do("標記完成", async () => {
await this.env.DB.prepare(
"UPDATE billings SET status = ? WHERE txn = ?"
).bind("completed", charge.transactionId).run();
});
}
}
這個流程的精妙之處在於:扣款只重試暫時性錯誤、餘額不足立刻放棄;對帳可以等 6 小時、逾時還能睡半小時再重查;而寄收據與更新狀態全包在步驟裡,任何環節崩潰都不會重複扣款、不會重寄收據。這就是把 step 的深水區用起來的樣子。
常見錯誤與最佳實踐
step 的細節多,踩坑也集中在幾個固定位置。以下是最高頻的幾個。
坑一:把 step.sleep 當成 setTimeout 用。
新手常想「我在步驟裡呼叫 setTimeout 或 await new Promise(r => setTimeout(r, ...)) 來等待不就好了?」——大錯。setTimeout 讓程序原地空等,不但佔資源,Worker 也根本活不了幾天;更糟的是它不會被持久化,崩潰後計時器直接消失。要「等一段時間」,永遠用 step.sleep / step.sleepUntil,它才享有不計費、不佔並行、可恢復的待遇。
// ❌ 錯誤:用 setTimeout 空等 —— 佔資源、撐不久、崩潰即失效
await step.do("等 7 天", async () => {
await new Promise((r) => setTimeout(r, 7 * 24 * 3600 * 1000));
});
// ✅ 正確:用 step.sleep —— 卸下實例、不計費、可恢復
await step.sleep("等 7 天", "7 days");
坑二:step 名稱重複或用了非確定性字串。
Workflows 靠 step 名稱來對應「這步做過沒」。同一次執行裡若兩個步驟撞名,或名稱含 Date.now() 這類每次都變的值,重播時就對不上帳。名稱要嘛靜態、要嘛基於確定性的資料(如來自前一步回傳值的穩定 key)。
// ❌ 錯誤:名稱含非確定性值,重播對不上
await step.do(`process-${Date.now()}`, fn);
// ❌ 錯誤:迴圈裡步驟撞名(每圈都叫 "process item")
for (const item of items) await step.do("process item", () => handle(item));
// ✅ 正確:用確定性的、彼此唯一的名稱
for (const item of items) await step.do(`process item ${item.id}`, () => handle(item));
坑三:把有副作用或非決定性的邏輯放在步驟外。
這是決定性要求的直接推論。任何 Date.now()、Math.random()、對外呼叫,只要寫在 step.do() 外面,就會在每次重播時重新執行,導致重複副作用或分支翻轉。全部關進步驟,結果才會被持久化固定。
// ❌ 錯誤:步驟外取當下時間做判斷,重播時值會變
const now = Date.now();
if (now % 2 === 0) await step.do("A", fnA); else await step.do("B", fnB);
// ✅ 正確:非決定性取值包進步驟,固定成持久化值
const now = await step.do("capture now", async () => Date.now());
if (now % 2 === 0) await step.do("A", fnA); else await step.do("B", fnB);
坑四:對業務錯誤還一直重試。
沒用 NonRetryableError,「餘額不足」也會被 retries 反覆重試,白等一輪指數退避才失敗,浪費時間與配額。凡是重試不可能改變結果的錯誤(認證失敗、資料格式錯誤、業務狀態拒絕),一律拋 NonRetryableError。
坑五:忘記 await 步驟,造成 dangling promise。
step.do(...) 沒有 await,步驟可能沒真正完成就往下走,持久化與重試都會失準。每一個 step.* 呼叫前都要有 await。
最佳實踐小結:等待一律用 step.sleep/step.sleepUntil(絕不用 setTimeout);step 名稱用確定性且唯一的字串;非決定性取值與所有副作用一律包進步驟;暫時性錯誤丟普通 Error 交給 retries、業務錯誤丟 NonRetryableError 立即放棄;對外部相依用 exponential 退避、limit 設 3–5;每個 step.* 前都要 await。守住這幾條,你的 Workflow 才能在崩潰、重播、長時等待下都保持正確。
小結
上一篇《Workflows 持久化執行入門》,我們搭好了 Workflow 的骨架:WorkflowEntrypoint + run(event, step),用 create() 觸發實例,理解了「崩潰也不重來」的持久化魔法。這一篇,我們鑽進 step 的深水區,把每一步都練成生產級:
step.do(name, { retries, timeout }, fn)——完整三參數形態。retries由limit/delay/backoff組成,對外部相依一律用exponential指數退避,limit設 3–5。NonRetryableError——從cloudflare:workflows匯入,業務性/永久性錯誤(餘額不足、認證失敗)拋它立即終止重試,暫時性錯誤才丟普通Error交給重試。step.sleep/step.sleepUntil——相對與絕對時間睡眠,睡眠期間實例進入waiting,不計費、不佔並行額度,最大 365 天。它與setTimeout是本質不同的東西。step.waitForEvent——暫停等待外部以sendEvent()送入的指定type事件(人工審核、對帳回呼),可設timeout。- 決定性(determinism)——所有規則的根源。步驟外不可用
Date.now()、Math.random(),step 名稱必須確定且唯一,非決定性取值與副作用一律關進步驟。
至此,你已經能寫出會重試、會等待、會睡眠、能被外部事件喚醒的成熟 Workflow。但一個更上層的問題浮現了:面對一個非同步需求,我到底該選 Queues、Workflows,還是 Cron?三者的邊界在哪、又該怎麼搭配? 這個「選型」的全局視角,下一篇《非同步編排選型》,我們會用一張決策地圖把它講清楚。
想深入查閱官方對 step API 各參數的完整定義,可以隨時參考 Cloudflare Workflows Workers API 官方文件。把 step 的深水區踩熟了,你就真正握住了 Workflows 最鋒利的那把刀,我們下一篇《非同步編排選型》見。