workflow 5.0.0-beta.1 → 5.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/api-workflow.js +1 -1
- package/dist/api.js +1 -1
- package/dist/astro.js +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/builtins.js +1 -1
- package/dist/internal/class-serialization.js +1 -1
- package/dist/internal/errors.js +1 -1
- package/dist/nest.js +1 -1
- package/dist/next.cjs +1 -1
- package/dist/nitro.js +1 -1
- package/dist/nuxt.js +1 -1
- package/dist/observability.js +1 -1
- package/dist/runtime.js +1 -1
- package/dist/stdlib.js +1 -1
- package/dist/sveltekit.js +1 -1
- package/dist/typescript-plugin.cjs +1 -1
- package/dist/vite.js +1 -1
- package/dist/workflow.js +1 -1
- package/docs/ai/resumable-streams.mdx +1 -1
- package/docs/api-reference/workflow/create-webhook.mdx +37 -18
- package/docs/api-reference/workflow/get-workflow-metadata.mdx +34 -0
- package/docs/api-reference/workflow-ai/durable-agent.mdx +0 -4
- package/docs/api-reference/workflow-ai/index.mdx +0 -5
- package/docs/api-reference/workflow-ai/workflow-chat-transport.mdx +0 -4
- package/docs/cookbook/advanced/custom-serialization.mdx +168 -0
- package/docs/cookbook/advanced/durable-objects.mdx +148 -0
- package/docs/cookbook/advanced/isomorphic-packages.mdx +145 -0
- package/docs/cookbook/advanced/meta.json +10 -0
- package/docs/cookbook/advanced/publishing-libraries.mdx +279 -0
- package/docs/cookbook/advanced/serializable-steps.mdx +135 -0
- package/docs/cookbook/agent-patterns/durable-agent.mdx +191 -0
- package/docs/cookbook/agent-patterns/human-in-the-loop.mdx +278 -0
- package/docs/cookbook/agent-patterns/meta.json +10 -0
- package/docs/cookbook/agent-patterns/stop-workflow.mdx +216 -0
- package/docs/cookbook/agent-patterns/tool-orchestration.mdx +255 -0
- package/docs/cookbook/agent-patterns/tool-streaming.mdx +181 -0
- package/docs/cookbook/common-patterns/batching.mdx +179 -0
- package/docs/cookbook/common-patterns/child-workflows.mdx +372 -0
- package/docs/cookbook/common-patterns/content-router.mdx +207 -0
- package/docs/cookbook/common-patterns/fan-out.mdx +208 -0
- package/docs/cookbook/common-patterns/idempotency.mdx +107 -0
- package/docs/cookbook/common-patterns/meta.json +15 -0
- package/docs/cookbook/common-patterns/rate-limiting.mdx +228 -0
- package/docs/cookbook/common-patterns/saga.mdx +152 -0
- package/docs/cookbook/common-patterns/scheduling.mdx +249 -0
- package/docs/cookbook/common-patterns/webhooks.mdx +185 -0
- package/docs/cookbook/index.mdx +41 -0
- package/docs/cookbook/integrations/ai-sdk.mdx +204 -0
- package/docs/cookbook/integrations/chat-sdk.mdx +203 -0
- package/docs/cookbook/integrations/meta.json +4 -0
- package/docs/cookbook/integrations/sandbox.mdx +128 -0
- package/docs/cookbook/meta.json +5 -0
- package/docs/deploying/world/local-world.mdx +1 -1
- package/docs/deploying/world/postgres-world.mdx +1 -1
- package/docs/deploying/world/vercel-world.mdx +1 -1
- package/docs/errors/start-invalid-workflow-function.mdx +1 -1
- package/docs/getting-started/index.mdx +8 -1
- package/docs/getting-started/meta.json +2 -1
- package/docs/getting-started/python.mdx +165 -0
- package/docs/meta.json +1 -0
- package/docs/migration-guides/index.mdx +34 -0
- package/docs/migration-guides/meta.json +9 -0
- package/docs/migration-guides/migrating-from-aws-step-functions.mdx +311 -0
- package/docs/migration-guides/migrating-from-inngest.mdx +282 -0
- package/docs/migration-guides/migrating-from-temporal.mdx +284 -0
- package/docs/migration-guides/migrating-from-trigger-dev.mdx +296 -0
- package/package.json +13 -13
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Rate Limiting & Retries
|
|
3
|
+
description: Handle 429 responses and transient failures with RetryableError and exponential backoff.
|
|
4
|
+
type: guide
|
|
5
|
+
summary: When an external API returns 429, throw RetryableError with the Retry-After value so the workflow runtime automatically reschedules the step after the specified delay.
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Use this pattern when calling external APIs that enforce rate limits. Instead of writing manual retry loops, throw `RetryableError` with a `retryAfter` value and let the workflow runtime handle rescheduling.
|
|
9
|
+
|
|
10
|
+
## When to use this
|
|
11
|
+
|
|
12
|
+
- Calling APIs that return 429 (Too Many Requests) with `Retry-After` headers
|
|
13
|
+
- Any step that hits transient failures and needs backoff
|
|
14
|
+
- Syncing data with third-party services (Stripe, CRMs, scrapers)
|
|
15
|
+
|
|
16
|
+
## Pattern: RetryableError with Retry-After
|
|
17
|
+
|
|
18
|
+
A step function calls an external API. On 429, it reads the `Retry-After` header and throws `RetryableError`. The runtime reschedules the step automatically.
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
import { RetryableError } from "workflow";
|
|
22
|
+
|
|
23
|
+
declare function fetchFromCrm(contactId: string): Promise<unknown>; // @setup
|
|
24
|
+
declare function upsertToWarehouse(contactId: string, contact: unknown): Promise<void>; // @setup
|
|
25
|
+
|
|
26
|
+
export async function syncContact(contactId: string) {
|
|
27
|
+
"use workflow";
|
|
28
|
+
|
|
29
|
+
const contact = await fetchFromCrm(contactId);
|
|
30
|
+
await upsertToWarehouse(contactId, contact);
|
|
31
|
+
|
|
32
|
+
return { contactId, status: "synced" };
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Step function with rate limit handling
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
import { RetryableError } from "workflow";
|
|
40
|
+
|
|
41
|
+
async function fetchFromCrm(contactId: string) {
|
|
42
|
+
"use step";
|
|
43
|
+
|
|
44
|
+
const res = await fetch(`https://crm.example.com/contacts/${contactId}`);
|
|
45
|
+
|
|
46
|
+
if (res.status === 429) { // [!code highlight]
|
|
47
|
+
const retryAfter = res.headers.get("Retry-After");
|
|
48
|
+
throw new RetryableError("Rate limited by CRM", { // [!code highlight]
|
|
49
|
+
retryAfter: retryAfter ? parseInt(retryAfter) * 1000 : "1m",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!res.ok) throw new Error(`CRM returned ${res.status}`);
|
|
54
|
+
return res.json();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function upsertToWarehouse(contactId: string, contact: unknown) {
|
|
58
|
+
"use step";
|
|
59
|
+
await fetch(`https://warehouse.example.com/contacts/${contactId}`, {
|
|
60
|
+
method: "PUT",
|
|
61
|
+
body: JSON.stringify(contact),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Pattern: Exponential backoff
|
|
67
|
+
|
|
68
|
+
Use `getStepMetadata()` to access the current attempt number and calculate increasing delays:
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
import { RetryableError, getStepMetadata } from "workflow";
|
|
72
|
+
|
|
73
|
+
async function callFlakeyApi(endpoint: string) {
|
|
74
|
+
"use step";
|
|
75
|
+
|
|
76
|
+
const { attempt } = getStepMetadata(); // [!code highlight]
|
|
77
|
+
const res = await fetch(endpoint);
|
|
78
|
+
|
|
79
|
+
if (res.status === 429 || res.status >= 500) {
|
|
80
|
+
throw new RetryableError(`Request failed (${res.status})`, { // [!code highlight]
|
|
81
|
+
retryAfter: (attempt ** 2) * 1000, // 1s, 4s, 9s... // [!code highlight]
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return res.json();
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Pattern: Circuit breaker with sleep
|
|
90
|
+
|
|
91
|
+
When a dependency is completely down, stop hitting it for a cooldown period using `sleep()`, then probe with a single test request:
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
import { sleep } from "workflow";
|
|
95
|
+
|
|
96
|
+
export async function circuitBreaker(maxRequests: number = 10) {
|
|
97
|
+
"use workflow";
|
|
98
|
+
|
|
99
|
+
let state: "closed" | "open" | "half-open" = "closed";
|
|
100
|
+
let consecutiveFailures = 0;
|
|
101
|
+
const FAILURE_THRESHOLD = 3;
|
|
102
|
+
|
|
103
|
+
for (let i = 1; i <= maxRequests; i++) {
|
|
104
|
+
if (state === "open") {
|
|
105
|
+
await sleep("30s"); // Durable cooldown // [!code highlight]
|
|
106
|
+
state = "half-open";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const success = await callService(i);
|
|
110
|
+
|
|
111
|
+
if (success) {
|
|
112
|
+
consecutiveFailures = 0;
|
|
113
|
+
if (state === "half-open") state = "closed";
|
|
114
|
+
} else {
|
|
115
|
+
consecutiveFailures++;
|
|
116
|
+
if (consecutiveFailures >= FAILURE_THRESHOLD) {
|
|
117
|
+
state = "open";
|
|
118
|
+
consecutiveFailures = 0;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { status: state === "closed" ? "recovered" : "failed" };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function callService(requestNum: number): Promise<boolean> {
|
|
127
|
+
"use step";
|
|
128
|
+
try {
|
|
129
|
+
const res = await fetch("https://payment-gateway.example.com/charge");
|
|
130
|
+
return res.ok;
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Pattern: Custom max retries
|
|
138
|
+
|
|
139
|
+
Override the default retry count (3) for steps that need more or fewer attempts:
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
async function fetchWithRetries(url: string) {
|
|
143
|
+
"use step";
|
|
144
|
+
const res = await fetch(url);
|
|
145
|
+
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
|
146
|
+
return res.json();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Allow up to 10 retry attempts
|
|
150
|
+
fetchWithRetries.maxRetries = 10; // [!code highlight]
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Application-level retry
|
|
154
|
+
|
|
155
|
+
Sometimes you need retry logic at the workflow level -- wrapping a step call with your own backoff instead of relying on the framework's built-in `RetryableError`. This is useful when you want full control over retry conditions, delays, and error filtering.
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
interface RetryOptions {
|
|
159
|
+
maxRetries?: number;
|
|
160
|
+
baseDelay?: number;
|
|
161
|
+
maxDelay?: number;
|
|
162
|
+
shouldRetry?: (error: Error, attempt: number) => boolean;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function withRetry<T>(
|
|
166
|
+
fn: () => Promise<T>,
|
|
167
|
+
options: RetryOptions = {},
|
|
168
|
+
): Promise<T> {
|
|
169
|
+
const { maxRetries = 3, baseDelay = 2000, maxDelay = 10000, shouldRetry } = options;
|
|
170
|
+
let lastError: Error | undefined;
|
|
171
|
+
|
|
172
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
173
|
+
try {
|
|
174
|
+
return await fn();
|
|
175
|
+
} catch (error) {
|
|
176
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
177
|
+
const isLastAttempt = attempt === maxRetries;
|
|
178
|
+
if (isLastAttempt || (shouldRetry && !shouldRetry(lastError, attempt + 1))) {
|
|
179
|
+
throw lastError;
|
|
180
|
+
}
|
|
181
|
+
// Exponential backoff with jitter
|
|
182
|
+
const delay = Math.min(baseDelay * 2 ** attempt * (0.5 + Math.random() * 0.5), maxDelay);
|
|
183
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
throw lastError;
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Use it in a workflow to wrap step calls:
|
|
192
|
+
|
|
193
|
+
```typescript
|
|
194
|
+
declare function withRetry<T>(fn: () => Promise<T>, options?: { maxRetries?: number; shouldRetry?: (error: Error) => boolean }): Promise<T>; // @setup
|
|
195
|
+
declare function downloadFile(url: string): Promise<any>; // @setup
|
|
196
|
+
|
|
197
|
+
export async function downloadWithRetry(url: string) {
|
|
198
|
+
"use workflow";
|
|
199
|
+
|
|
200
|
+
const result = await withRetry(() => downloadFile(url), { // [!code highlight]
|
|
201
|
+
maxRetries: 5,
|
|
202
|
+
shouldRetry: (error) => error.message.includes("Timeout"),
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**When to use this vs `RetryableError`/`FatalError`:**
|
|
210
|
+
- **`RetryableError`** runs inside a step -- the framework reschedules the step after the delay. Use it for transient HTTP errors (429, 503) where the runtime should handle backoff.
|
|
211
|
+
- **Application-level retry** wraps the step call from the workflow. Use it when you need custom retry conditions, want to retry across different steps, or when you're building a library and prefer not to depend on workflow-specific error classes.
|
|
212
|
+
|
|
213
|
+
## Tips
|
|
214
|
+
|
|
215
|
+
- **`RetryableError` is for transient failures.** Use it when the request might succeed on a later attempt (429, 503, network timeout).
|
|
216
|
+
- **`FatalError` is for permanent failures.** Use it when retrying won't help (404, 401, invalid input). This skips all remaining retries.
|
|
217
|
+
- **The `retryAfter` option accepts** a millisecond number, a duration string (`"1m"`, `"30s"`), or a `Date` object.
|
|
218
|
+
- **Steps retry up to 3 times by default.** Set `fn.maxRetries = N` to change this per step function.
|
|
219
|
+
- **Don't write manual sleep-retry loops.** The runtime handles scheduling natively with `RetryableError` -- it's more efficient and survives cold starts.
|
|
220
|
+
|
|
221
|
+
## Key APIs
|
|
222
|
+
|
|
223
|
+
- [`"use workflow"`](/docs/foundations/workflows-and-steps) -- marks the orchestrator function
|
|
224
|
+
- [`"use step"`](/docs/foundations/workflows-and-steps) -- marks functions that run with full Node.js access
|
|
225
|
+
- [`RetryableError`](/docs/api-reference/workflow/retryable-error) -- signals the runtime to retry after a delay
|
|
226
|
+
- [`FatalError`](/docs/api-reference/workflow/fatal-error) -- signals a permanent failure, skipping retries
|
|
227
|
+
- [`getStepMetadata()`](/docs/api-reference/step/get-step-metadata) -- provides the current attempt number and step ID
|
|
228
|
+
- [`sleep()`](/docs/api-reference/workflow/sleep) -- durable pause for circuit breaker cooldowns
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Transactions & Rollbacks (Saga)
|
|
3
|
+
description: Coordinate multi-step transactions with automatic rollback when a step fails.
|
|
4
|
+
type: guide
|
|
5
|
+
summary: Run a sequence of steps where each registers a compensation. If any step throws a FatalError, compensations execute in reverse order to restore consistency.
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Use the saga pattern when a business transaction spans multiple services and you need automatic rollback if any step fails. Each forward step registers a compensation, and on failure the workflow unwinds them in reverse order.
|
|
9
|
+
|
|
10
|
+
## When to use this
|
|
11
|
+
|
|
12
|
+
- Multi-service transactions (reserve inventory, charge payment, provision access)
|
|
13
|
+
- Any sequence where partial completion leaves the system in an inconsistent state
|
|
14
|
+
- Operations that need "all or nothing" semantics across external APIs
|
|
15
|
+
|
|
16
|
+
## Pattern
|
|
17
|
+
|
|
18
|
+
Each step returns a result and pushes a compensation handler onto a stack. If a later step throws a `FatalError`, the workflow catches it and executes compensations in LIFO order.
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
import { FatalError } from "workflow";
|
|
22
|
+
|
|
23
|
+
declare function reserveSeats(accountId: string, seats: number): Promise<string>; // @setup
|
|
24
|
+
declare function releaseSeats(accountId: string, reservationId: string): Promise<void>; // @setup
|
|
25
|
+
declare function captureInvoice(accountId: string, seats: number): Promise<string>; // @setup
|
|
26
|
+
declare function refundInvoice(accountId: string, invoiceId: string): Promise<void>; // @setup
|
|
27
|
+
declare function provisionSeats(accountId: string, seats: number): Promise<string>; // @setup
|
|
28
|
+
declare function deprovisionSeats(accountId: string, entitlementId: string): Promise<void>; // @setup
|
|
29
|
+
declare function sendConfirmation(accountId: string, invoiceId: string, entitlementId: string): Promise<void>; // @setup
|
|
30
|
+
|
|
31
|
+
export async function subscriptionUpgradeSaga(accountId: string, seats: number) {
|
|
32
|
+
"use workflow";
|
|
33
|
+
|
|
34
|
+
const compensations: Array<() => Promise<void>> = [];
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
// Step 1: Reserve seats
|
|
38
|
+
const reservationId = await reserveSeats(accountId, seats);
|
|
39
|
+
compensations.push(() => releaseSeats(accountId, reservationId)); // [!code highlight]
|
|
40
|
+
|
|
41
|
+
// Step 2: Capture payment
|
|
42
|
+
const invoiceId = await captureInvoice(accountId, seats);
|
|
43
|
+
compensations.push(() => refundInvoice(accountId, invoiceId)); // [!code highlight]
|
|
44
|
+
|
|
45
|
+
// Step 3: Provision access
|
|
46
|
+
const entitlementId = await provisionSeats(accountId, seats);
|
|
47
|
+
compensations.push(() => deprovisionSeats(accountId, entitlementId)); // [!code highlight]
|
|
48
|
+
|
|
49
|
+
// Step 4: Notify
|
|
50
|
+
await sendConfirmation(accountId, invoiceId, entitlementId);
|
|
51
|
+
return { status: "completed" };
|
|
52
|
+
} catch (error) {
|
|
53
|
+
// Unwind compensations in reverse order
|
|
54
|
+
for (const compensate of compensations.reverse()) { // [!code highlight]
|
|
55
|
+
await compensate(); // [!code highlight]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { status: "rolled_back" };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Step functions
|
|
64
|
+
|
|
65
|
+
Each step is a `"use step"` function with full Node.js access. Forward steps do the work; compensation steps undo it.
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
import { FatalError } from "workflow";
|
|
69
|
+
|
|
70
|
+
async function reserveSeats(accountId: string, seats: number): Promise<string> {
|
|
71
|
+
"use step";
|
|
72
|
+
const res = await fetch(`https://api.example.com/seats/reserve`, {
|
|
73
|
+
method: "POST",
|
|
74
|
+
body: JSON.stringify({ accountId, seats }),
|
|
75
|
+
});
|
|
76
|
+
if (!res.ok) throw new FatalError("Seat reservation failed"); // [!code highlight]
|
|
77
|
+
const { reservationId } = await res.json();
|
|
78
|
+
return reservationId;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function releaseSeats(accountId: string, reservationId: string): Promise<void> {
|
|
82
|
+
"use step";
|
|
83
|
+
// Compensations should be idempotent — safe to call twice
|
|
84
|
+
await fetch(`https://api.example.com/seats/release`, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
body: JSON.stringify({ accountId, reservationId }),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function captureInvoice(accountId: string, seats: number): Promise<string> {
|
|
91
|
+
"use step";
|
|
92
|
+
const res = await fetch(`https://api.example.com/invoices`, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
body: JSON.stringify({ accountId, seats }),
|
|
95
|
+
});
|
|
96
|
+
if (!res.ok) throw new FatalError("Invoice capture failed"); // [!code highlight]
|
|
97
|
+
const { invoiceId } = await res.json();
|
|
98
|
+
return invoiceId;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function refundInvoice(accountId: string, invoiceId: string): Promise<void> {
|
|
102
|
+
"use step";
|
|
103
|
+
await fetch(`https://api.example.com/invoices/${invoiceId}/refund`, {
|
|
104
|
+
method: "POST",
|
|
105
|
+
body: JSON.stringify({ accountId }),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function provisionSeats(accountId: string, seats: number): Promise<string> {
|
|
110
|
+
"use step";
|
|
111
|
+
const res = await fetch(`https://api.example.com/entitlements`, {
|
|
112
|
+
method: "POST",
|
|
113
|
+
body: JSON.stringify({ accountId, seats }),
|
|
114
|
+
});
|
|
115
|
+
if (!res.ok) throw new FatalError("Provisioning failed"); // [!code highlight]
|
|
116
|
+
const { entitlementId } = await res.json();
|
|
117
|
+
return entitlementId;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function deprovisionSeats(accountId: string, entitlementId: string): Promise<void> {
|
|
121
|
+
"use step";
|
|
122
|
+
await fetch(`https://api.example.com/entitlements/${entitlementId}`, {
|
|
123
|
+
method: "DELETE",
|
|
124
|
+
body: JSON.stringify({ accountId }),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function sendConfirmation(
|
|
129
|
+
accountId: string,
|
|
130
|
+
invoiceId: string,
|
|
131
|
+
entitlementId: string
|
|
132
|
+
): Promise<void> {
|
|
133
|
+
"use step";
|
|
134
|
+
await fetch(`https://api.example.com/notifications`, {
|
|
135
|
+
method: "POST",
|
|
136
|
+
body: JSON.stringify({ accountId, invoiceId, entitlementId, template: "upgrade-complete" }),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Tips
|
|
142
|
+
|
|
143
|
+
- **Use `FatalError` for permanent failures.** Regular errors trigger automatic retries (up to 3 by default). Throw `FatalError` when retrying won't help (e.g., insufficient funds, invalid input).
|
|
144
|
+
- **Make compensations idempotent.** If a compensation step is retried, it should produce the same result. Check whether the resource was already released before releasing it again.
|
|
145
|
+
- **Compensation steps are also `"use step"` functions.** This makes them durable — if the workflow restarts mid-rollback, it resumes where it left off.
|
|
146
|
+
- **Capture values in closures carefully.** Use block-scoped variables or copy values before pushing compensations to avoid referencing stale state.
|
|
147
|
+
|
|
148
|
+
## Key APIs
|
|
149
|
+
|
|
150
|
+
- [`"use workflow"`](/docs/api-reference/workflow/use-workflow) -- declares the orchestrator function
|
|
151
|
+
- [`"use step"`](/docs/api-reference/workflow/use-step) -- declares step functions with full Node.js access
|
|
152
|
+
- [`FatalError`](/docs/api-reference/workflow/fatal-error) -- non-retryable error that triggers compensation
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Sleep, Scheduling & Timed Workflows
|
|
3
|
+
description: Use durable sleep to schedule actions minutes, hours, days, or weeks into the future.
|
|
4
|
+
type: guide
|
|
5
|
+
summary: Schedule future actions with durable sleep that survives cold starts, and race sleeps against hooks to let external events wake the workflow early.
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Workflow's `sleep()` is durable -- it survives cold starts, restarts, and deployments. This makes it the foundation for scheduled actions, drip campaigns, reminders, and any pattern that needs to wait for real-world time to pass.
|
|
9
|
+
|
|
10
|
+
## When to use this
|
|
11
|
+
|
|
12
|
+
- Sending emails on a schedule (drip campaigns, reminders, digests)
|
|
13
|
+
- Waiting for a deadline before taking action
|
|
14
|
+
- Any pattern where "do X, wait N hours, then do Y" needs to be reliable
|
|
15
|
+
|
|
16
|
+
## Pattern: Drip campaign
|
|
17
|
+
|
|
18
|
+
Send emails at scheduled intervals using `sleep()` between steps. The workflow runs for days or weeks, sleeping between each email.
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
import { sleep } from "workflow";
|
|
22
|
+
|
|
23
|
+
export async function onboardingDrip(email: string) {
|
|
24
|
+
"use workflow";
|
|
25
|
+
|
|
26
|
+
await sendEmail(email, "welcome");
|
|
27
|
+
|
|
28
|
+
await sleep("1d"); // [!code highlight]
|
|
29
|
+
await sendEmail(email, "getting-started-tips");
|
|
30
|
+
|
|
31
|
+
await sleep("2d"); // [!code highlight]
|
|
32
|
+
await sendEmail(email, "feature-highlights");
|
|
33
|
+
|
|
34
|
+
await sleep("4d"); // [!code highlight]
|
|
35
|
+
await sendEmail(email, "follow-up");
|
|
36
|
+
|
|
37
|
+
return { email, status: "completed", totalDays: 7 };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function sendEmail(email: string, template: string): Promise<void> {
|
|
41
|
+
"use step";
|
|
42
|
+
await fetch("https://api.sendgrid.com/v3/mail/send", {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: { Authorization: `Bearer ${process.env.SENDGRID_KEY}` },
|
|
45
|
+
body: JSON.stringify({
|
|
46
|
+
to: [{ email }],
|
|
47
|
+
template_id: template,
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Pattern: Interruptible reminder (sleep vs hook)
|
|
54
|
+
|
|
55
|
+
Race a `sleep()` against a `defineHook` so external events can cancel, snooze, or send early:
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
import { defineHook, sleep } from "workflow";
|
|
59
|
+
|
|
60
|
+
type ReminderAction =
|
|
61
|
+
| { type: "cancel" }
|
|
62
|
+
| { type: "send_now" }
|
|
63
|
+
| { type: "snooze"; seconds: number };
|
|
64
|
+
|
|
65
|
+
export const reminderActionHook = defineHook<ReminderAction>();
|
|
66
|
+
|
|
67
|
+
export async function scheduleReminder(userId: string, delayMs: number) {
|
|
68
|
+
"use workflow";
|
|
69
|
+
|
|
70
|
+
let sendAt = new Date(Date.now() + delayMs);
|
|
71
|
+
const action = reminderActionHook.create({ token: `reminder:${userId}` });
|
|
72
|
+
|
|
73
|
+
const outcome = await Promise.race([ // [!code highlight]
|
|
74
|
+
sleep(sendAt).then(() => ({ kind: "time" as const })), // [!code highlight]
|
|
75
|
+
action.then((payload) => ({ kind: "action" as const, payload })), // [!code highlight]
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
if (outcome.kind === "action") {
|
|
79
|
+
if (outcome.payload.type === "cancel") {
|
|
80
|
+
return { userId, status: "cancelled" };
|
|
81
|
+
}
|
|
82
|
+
if (outcome.payload.type === "snooze") {
|
|
83
|
+
sendAt = new Date(Date.now() + outcome.payload.seconds * 1000);
|
|
84
|
+
await sleep(sendAt);
|
|
85
|
+
}
|
|
86
|
+
// "send_now" falls through to send immediately
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
await sendReminderEmail(userId);
|
|
90
|
+
return { userId, status: "sent" };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function sendReminderEmail(userId: string): Promise<void> {
|
|
94
|
+
"use step";
|
|
95
|
+
await fetch("https://api.example.com/reminders/send", {
|
|
96
|
+
method: "POST",
|
|
97
|
+
body: JSON.stringify({ userId }),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
To wake the reminder early from an API route:
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
import { resumeHook } from "workflow/api";
|
|
106
|
+
|
|
107
|
+
// POST /api/reminder/cancel
|
|
108
|
+
export async function POST(request: Request) {
|
|
109
|
+
const { userId } = await request.json();
|
|
110
|
+
await resumeHook(`reminder:${userId}`, { type: "cancel" }); // [!code highlight]
|
|
111
|
+
return Response.json({ ok: true });
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Pattern: Timed collection window (digest)
|
|
116
|
+
|
|
117
|
+
Open a collection window using `sleep()` and accumulate events from a hook until the window closes:
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
import { sleep, defineHook } from "workflow";
|
|
121
|
+
|
|
122
|
+
type EventPayload = { type: string; message: string };
|
|
123
|
+
|
|
124
|
+
export const digestEvent = defineHook<EventPayload>();
|
|
125
|
+
|
|
126
|
+
export async function collectAndSendDigest(
|
|
127
|
+
digestId: string,
|
|
128
|
+
userId: string,
|
|
129
|
+
windowMs: number = 3_600_000
|
|
130
|
+
) {
|
|
131
|
+
"use workflow";
|
|
132
|
+
|
|
133
|
+
const hook = digestEvent.create({ token: `digest:${digestId}` });
|
|
134
|
+
const windowClosed = sleep(`${windowMs}ms`).then(() => ({
|
|
135
|
+
kind: "window_closed" as const,
|
|
136
|
+
}));
|
|
137
|
+
const events: EventPayload[] = [];
|
|
138
|
+
|
|
139
|
+
while (true) {
|
|
140
|
+
const outcome = await Promise.race([ // [!code highlight]
|
|
141
|
+
hook.then((payload) => ({ kind: "event" as const, payload })),
|
|
142
|
+
windowClosed,
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
if (outcome.kind === "window_closed") break; // [!code highlight]
|
|
146
|
+
events.push(outcome.payload);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (events.length > 0) {
|
|
150
|
+
await sendDigestEmail(userId, events);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { digestId, status: events.length > 0 ? "sent" : "empty", eventCount: events.length };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function sendDigestEmail(userId: string, events: EventPayload[]): Promise<void> {
|
|
157
|
+
"use step";
|
|
158
|
+
await fetch("https://api.example.com/digest/send", {
|
|
159
|
+
method: "POST",
|
|
160
|
+
body: JSON.stringify({ userId, events }),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Pattern: Timeout
|
|
166
|
+
|
|
167
|
+
Add a timeout to any operation by racing it against `sleep()`:
|
|
168
|
+
|
|
169
|
+
```typescript
|
|
170
|
+
import { sleep, FatalError } from "workflow";
|
|
171
|
+
|
|
172
|
+
export async function processWithTimeout(jobId: string) {
|
|
173
|
+
"use workflow";
|
|
174
|
+
|
|
175
|
+
const result = await Promise.race([ // [!code highlight]
|
|
176
|
+
processData(jobId),
|
|
177
|
+
sleep("30s").then(() => "timeout" as const), // [!code highlight]
|
|
178
|
+
]);
|
|
179
|
+
|
|
180
|
+
if (result === "timeout") {
|
|
181
|
+
return { jobId, status: "timed_out" };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return { jobId, status: "completed", result };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function processData(jobId: string): Promise<string> {
|
|
188
|
+
"use step";
|
|
189
|
+
// Long-running computation
|
|
190
|
+
return `result-for-${jobId}`;
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Polling external services
|
|
195
|
+
|
|
196
|
+
When you need to poll an external service until a job completes, define your own `sleep` as a step function and use it in a polling loop. Each iteration becomes a separate step in the event log, making the entire loop durable.
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
async function sleep(ms: number): Promise<void> {
|
|
200
|
+
"use step";
|
|
201
|
+
await new Promise(resolve => setTimeout(resolve, ms));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function waitForTranscription(jobId: string) {
|
|
205
|
+
"use workflow";
|
|
206
|
+
|
|
207
|
+
let status = "processing";
|
|
208
|
+
let attempts = 0;
|
|
209
|
+
const maxAttempts = 36; // ~3 minutes at 5s intervals
|
|
210
|
+
|
|
211
|
+
while (status === "processing" && attempts < maxAttempts) {
|
|
212
|
+
await sleep(5000); // [!code highlight]
|
|
213
|
+
attempts++;
|
|
214
|
+
const result = await checkJobStatus(jobId); // [!code highlight]
|
|
215
|
+
status = result.status;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (status !== "completed") {
|
|
219
|
+
return { jobId, status: "timed_out", attempts };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return { jobId, status: "completed", attempts };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function checkJobStatus(jobId: string): Promise<{ status: string }> {
|
|
226
|
+
"use step";
|
|
227
|
+
const res = await fetch(`https://api.example.com/jobs/${jobId}`);
|
|
228
|
+
return res.json();
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
**When to use this vs `sleep()` from `workflow`:**
|
|
233
|
+
- Use `sleep()` from `workflow` for fixed, known delays (drip campaigns, reminders, cooldowns).
|
|
234
|
+
- Use a custom sleep-as-step for polling loops where you need to check a condition between sleeps. The custom step version also works in libraries that don't want to import from the `workflow` module directly.
|
|
235
|
+
|
|
236
|
+
## Tips
|
|
237
|
+
|
|
238
|
+
- **`sleep()` accepts** duration strings (`"1d"`, `"2h"`, `"30s"`), milliseconds, or `Date` objects for sleeping until a specific time.
|
|
239
|
+
- **Durable means durable.** A `sleep("7d")` workflow costs nothing while sleeping -- no compute, no memory. It resumes precisely when the timer fires.
|
|
240
|
+
- **Race `sleep` against `defineHook`** for interruptible waits. This is the standard pattern for reminders, approvals with deadlines, and timed collection windows.
|
|
241
|
+
- **Use `sleep()` in workflow context only.** Step functions cannot call `sleep()` directly. If a step needs a delay, use a standard `setTimeout` or return control to the workflow.
|
|
242
|
+
|
|
243
|
+
## Key APIs
|
|
244
|
+
|
|
245
|
+
- [`"use workflow"`](/docs/foundations/workflows-and-steps) -- marks the orchestrator function
|
|
246
|
+
- [`"use step"`](/docs/foundations/workflows-and-steps) -- marks functions that run with full Node.js access
|
|
247
|
+
- [`sleep()`](/docs/api-reference/workflow/sleep) -- durable wait (survives restarts, zero compute cost while sleeping)
|
|
248
|
+
- [`defineHook`](/docs/api-reference/workflow/define-hook) -- creates a hook that external systems can trigger
|
|
249
|
+
- [`Promise.race()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race) -- races sleep against hooks for interruptible waits
|