workflow 5.0.0-beta.0 → 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.d.ts +1 -3
- package/dist/api-workflow.d.ts.map +1 -1
- package/dist/api-workflow.js +2 -6
- 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 +4 -2
- package/dist/next.d.cts +1 -1
- package/dist/next.d.cts.map +1 -1
- package/dist/nitro.js +1 -1
- package/dist/nuxt.js +1 -1
- package/dist/observability.d.ts +1 -1
- package/dist/observability.js +2 -2
- 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 +61 -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/api-reference/workflow-api/get-world.mdx +6 -6
- package/docs/api-reference/workflow-api/index.mdx +1 -1
- package/docs/api-reference/workflow-api/world/index.mdx +2 -2
- package/docs/api-reference/workflow-api/world/observability.mdx +1 -1
- package/docs/api-reference/workflow-api/world/queue.mdx +1 -1
- package/docs/api-reference/workflow-api/world/storage.mdx +8 -8
- package/docs/api-reference/workflow-api/world/streams.mdx +38 -36
- 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/building-a-world.mdx +45 -43
- package/docs/deploying/world/local-world.mdx +1 -1
- package/docs/deploying/world/postgres-world.mdx +10 -5
- 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/next.mdx +24 -0
- package/docs/getting-started/python.mdx +165 -0
- package/docs/how-it-works/code-transform.mdx +6 -5
- 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 -14
- package/dist/internal/private.d.ts +0 -6
- package/dist/internal/private.d.ts.map +0 -1
- package/dist/internal/private.js +0 -6
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Fan-Out & Parallel Delivery
|
|
3
|
+
description: Send a message to multiple channels or recipients in parallel with independent failure handling.
|
|
4
|
+
type: guide
|
|
5
|
+
summary: Fan out an incident alert to Slack, email, SMS, and PagerDuty simultaneously using Promise.allSettled, so a failure in one channel does not block the others.
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Use fan-out when one event needs to trigger multiple independent actions in parallel. Each action runs as its own step, so failures are isolated -- a Slack outage doesn't prevent the email from sending.
|
|
9
|
+
|
|
10
|
+
## When to use this
|
|
11
|
+
|
|
12
|
+
- Incident alerting across multiple channels (Slack, email, SMS, PagerDuty)
|
|
13
|
+
- Notifying a list of recipients determined at runtime
|
|
14
|
+
- Any "broadcast" where each delivery is independent
|
|
15
|
+
|
|
16
|
+
## Pattern: Static fan-out
|
|
17
|
+
|
|
18
|
+
Define one step per channel and launch them all with `Promise.allSettled()`:
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
declare function sendSlackAlert(incidentId: string, message: string): Promise<any>; // @setup
|
|
22
|
+
declare function sendEmailAlert(incidentId: string, message: string): Promise<any>; // @setup
|
|
23
|
+
declare function sendSmsAlert(incidentId: string, message: string): Promise<any>; // @setup
|
|
24
|
+
declare function sendPagerDutyAlert(incidentId: string, message: string): Promise<any>; // @setup
|
|
25
|
+
|
|
26
|
+
export async function incidentFanOut(incidentId: string, message: string) {
|
|
27
|
+
"use workflow";
|
|
28
|
+
|
|
29
|
+
const settled = await Promise.allSettled([ // [!code highlight]
|
|
30
|
+
sendSlackAlert(incidentId, message),
|
|
31
|
+
sendEmailAlert(incidentId, message),
|
|
32
|
+
sendSmsAlert(incidentId, message),
|
|
33
|
+
sendPagerDutyAlert(incidentId, message),
|
|
34
|
+
]); // [!code highlight]
|
|
35
|
+
|
|
36
|
+
const ok = settled.filter((r) => r.status === "fulfilled").length;
|
|
37
|
+
return { incidentId, delivered: ok, failed: settled.length - ok };
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Step functions
|
|
42
|
+
|
|
43
|
+
Each channel is a separate `"use step"` function. Steps have full Node.js access and retry automatically on transient failures.
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
async function sendSlackAlert(incidentId: string, message: string) {
|
|
47
|
+
"use step";
|
|
48
|
+
await fetch("https://hooks.slack.com/services/T.../B.../xxx", {
|
|
49
|
+
method: "POST",
|
|
50
|
+
body: JSON.stringify({ text: `[${incidentId}] ${message}` }),
|
|
51
|
+
});
|
|
52
|
+
return { channel: "slack" };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function sendEmailAlert(incidentId: string, message: string) {
|
|
56
|
+
"use step";
|
|
57
|
+
await fetch("https://api.sendgrid.com/v3/mail/send", {
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers: { Authorization: `Bearer ${process.env.SENDGRID_KEY}` },
|
|
60
|
+
body: JSON.stringify({
|
|
61
|
+
to: [{ email: "oncall@example.com" }],
|
|
62
|
+
subject: `Incident ${incidentId}`,
|
|
63
|
+
content: [{ type: "text/plain", value: message }],
|
|
64
|
+
}),
|
|
65
|
+
});
|
|
66
|
+
return { channel: "email" };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function sendSmsAlert(incidentId: string, message: string) {
|
|
70
|
+
"use step";
|
|
71
|
+
// Call Twilio or similar SMS provider
|
|
72
|
+
return { channel: "sms" };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function sendPagerDutyAlert(incidentId: string, message: string) {
|
|
76
|
+
"use step";
|
|
77
|
+
// Call PagerDuty Events API
|
|
78
|
+
return { channel: "pagerduty" };
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Pattern: Dynamic recipient list
|
|
83
|
+
|
|
84
|
+
When recipients are determined at runtime (e.g., severity-based routing), build the list dynamically:
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
type Severity = "info" | "warning" | "critical";
|
|
88
|
+
|
|
89
|
+
const RULES = [
|
|
90
|
+
{ channel: "slack", match: () => true },
|
|
91
|
+
{ channel: "email", match: (s: Severity) => s === "warning" || s === "critical" },
|
|
92
|
+
{ channel: "pagerduty", match: (s: Severity) => s === "critical" },
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
export async function alertByRecipientList(
|
|
96
|
+
alertId: string,
|
|
97
|
+
message: string,
|
|
98
|
+
severity: Severity
|
|
99
|
+
) {
|
|
100
|
+
"use workflow";
|
|
101
|
+
|
|
102
|
+
const matched = RULES.filter((r) => r.match(severity)).map((r) => r.channel);
|
|
103
|
+
|
|
104
|
+
const settled = await Promise.allSettled( // [!code highlight]
|
|
105
|
+
matched.map((channel) => deliverToChannel(channel, alertId, message))
|
|
106
|
+
); // [!code highlight]
|
|
107
|
+
|
|
108
|
+
const delivered = settled.filter((r) => r.status === "fulfilled").length;
|
|
109
|
+
return { alertId, severity, matched, delivered, failed: matched.length - delivered };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function deliverToChannel(
|
|
113
|
+
channel: string,
|
|
114
|
+
alertId: string,
|
|
115
|
+
message: string
|
|
116
|
+
): Promise<void> {
|
|
117
|
+
"use step";
|
|
118
|
+
// Route to the appropriate API based on channel name
|
|
119
|
+
await fetch(`https://notifications.example.com/${channel}`, {
|
|
120
|
+
method: "POST",
|
|
121
|
+
body: JSON.stringify({ alertId, message }),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Pattern: Publish-subscribe
|
|
127
|
+
|
|
128
|
+
When subscribers are managed in a registry and filtered by topic:
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
type Subscriber = { id: string; name: string; topics: string[] };
|
|
132
|
+
|
|
133
|
+
export async function publishEvent(topic: string, payload: string) {
|
|
134
|
+
"use workflow";
|
|
135
|
+
|
|
136
|
+
const subscribers = await loadSubscribers();
|
|
137
|
+
const matched = subscribers.filter((sub) => sub.topics.includes(topic));
|
|
138
|
+
|
|
139
|
+
await Promise.allSettled( // [!code highlight]
|
|
140
|
+
matched.map((sub) => deliverToSubscriber(sub.id, topic, payload))
|
|
141
|
+
); // [!code highlight]
|
|
142
|
+
|
|
143
|
+
return { topic, delivered: matched.length, total: subscribers.length };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function loadSubscribers(): Promise<Subscriber[]> {
|
|
147
|
+
"use step";
|
|
148
|
+
// Load from database or configuration service
|
|
149
|
+
return [
|
|
150
|
+
{ id: "sub-1", name: "Order Service", topics: ["orders", "inventory"] },
|
|
151
|
+
{ id: "sub-2", name: "Email Notifier", topics: ["orders", "shipping"] },
|
|
152
|
+
{ id: "sub-3", name: "Analytics", topics: ["orders", "inventory", "shipping"] },
|
|
153
|
+
];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function deliverToSubscriber(
|
|
157
|
+
subscriberId: string,
|
|
158
|
+
topic: string,
|
|
159
|
+
payload: string
|
|
160
|
+
): Promise<void> {
|
|
161
|
+
"use step";
|
|
162
|
+
await fetch(`https://subscribers.example.com/${subscriberId}/deliver`, {
|
|
163
|
+
method: "POST",
|
|
164
|
+
body: JSON.stringify({ topic, payload }),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Deferred await (background steps)
|
|
170
|
+
|
|
171
|
+
You don't have to await a step immediately. Start a step, do other work, and collect the result later. This is different from `Promise.all` -- you interleave sequential and parallel work instead of waiting for everything at once.
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
declare function generateReport(data: Record<string, string>): Promise<any>; // @setup
|
|
175
|
+
declare function sendNotification(userId: string, message: string): Promise<void>; // @setup
|
|
176
|
+
declare function updateDashboard(userId: string): Promise<void>; // @setup
|
|
177
|
+
|
|
178
|
+
export async function onboardUser(userId: string, data: Record<string, string>) {
|
|
179
|
+
"use workflow";
|
|
180
|
+
|
|
181
|
+
// Start report generation in the background
|
|
182
|
+
const reportPromise = generateReport(data); // [!code highlight]
|
|
183
|
+
|
|
184
|
+
// Do other work while the report generates
|
|
185
|
+
await sendNotification(userId, "Processing started");
|
|
186
|
+
await updateDashboard(userId);
|
|
187
|
+
|
|
188
|
+
// Now await the report when we actually need it
|
|
189
|
+
const report = await reportPromise; // [!code highlight]
|
|
190
|
+
return { userId, report };
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
The workflow runtime tracks the background step like any other. If the workflow replays, the already-completed step returns its cached result instantly.
|
|
195
|
+
|
|
196
|
+
## Tips
|
|
197
|
+
|
|
198
|
+
- **Use `Promise.allSettled` over `Promise.all`.** `allSettled` lets you know which channels failed without aborting the others.
|
|
199
|
+
- **Each delivery is an independent step.** Transient failures (e.g., Slack 503) trigger automatic retries without affecting other channels.
|
|
200
|
+
- **Use `FatalError` for permanent failures** (e.g., PagerDuty not configured) to stop retries on that channel while letting others continue.
|
|
201
|
+
- **Dynamic recipient lists** decouple routing from delivery -- adding a new channel is a configuration change, not a code change.
|
|
202
|
+
|
|
203
|
+
## Key APIs
|
|
204
|
+
|
|
205
|
+
- [`"use workflow"`](/docs/foundations/workflows-and-steps) -- marks the orchestrator function
|
|
206
|
+
- [`"use step"`](/docs/foundations/workflows-and-steps) -- marks functions that run with full Node.js access
|
|
207
|
+
- [`Promise.allSettled()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled) -- fans out to all targets, isolating failures
|
|
208
|
+
- [`FatalError`](/docs/api-reference/workflow/fatal-error) -- prevents automatic retry for permanent failures
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Idempotency
|
|
3
|
+
description: Ensure external side effects happen exactly once, even when steps are retried or workflows are replayed.
|
|
4
|
+
type: guide
|
|
5
|
+
summary: Use step IDs as idempotency keys for external APIs like Stripe so that retries and replays don't create duplicate charges.
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Workflow steps can be retried (on failure) and replayed (on cold start). If a step calls an external API that isn't idempotent, retries could create duplicate charges, send duplicate emails, or double-process records. Use idempotency keys to make these operations safe.
|
|
9
|
+
|
|
10
|
+
## When to use this
|
|
11
|
+
|
|
12
|
+
- Charging a payment (Stripe, PayPal)
|
|
13
|
+
- Sending transactional emails or SMS
|
|
14
|
+
- Creating records in external systems where duplicates are harmful
|
|
15
|
+
- Any step that has side effects in systems you don't control
|
|
16
|
+
|
|
17
|
+
## Pattern: Step ID as idempotency key
|
|
18
|
+
|
|
19
|
+
Every step has a unique, deterministic `stepId` available via `getStepMetadata()`. Pass this as the idempotency key to external APIs:
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
import { getStepMetadata } from "workflow";
|
|
23
|
+
|
|
24
|
+
declare function createCharge(customerId: string, amount: number): Promise<{ id: string }>; // @setup
|
|
25
|
+
declare function sendReceipt(customerId: string, chargeId: string): Promise<void>; // @setup
|
|
26
|
+
|
|
27
|
+
export async function chargeCustomer(customerId: string, amount: number) {
|
|
28
|
+
"use workflow";
|
|
29
|
+
|
|
30
|
+
const charge = await createCharge(customerId, amount);
|
|
31
|
+
await sendReceipt(customerId, charge.id);
|
|
32
|
+
|
|
33
|
+
return { customerId, chargeId: charge.id, status: "completed" };
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Step function with idempotency key
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
import { getStepMetadata } from "workflow";
|
|
41
|
+
|
|
42
|
+
async function createCharge(
|
|
43
|
+
customerId: string,
|
|
44
|
+
amount: number
|
|
45
|
+
): Promise<{ id: string }> {
|
|
46
|
+
"use step";
|
|
47
|
+
|
|
48
|
+
const { stepId } = getStepMetadata(); // [!code highlight]
|
|
49
|
+
|
|
50
|
+
// Stripe uses the idempotency key to deduplicate requests.
|
|
51
|
+
// If this step is retried, Stripe returns the same charge.
|
|
52
|
+
const charge = await fetch("https://api.stripe.com/v1/charges", {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: {
|
|
55
|
+
Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,
|
|
56
|
+
"Idempotency-Key": stepId, // [!code highlight]
|
|
57
|
+
},
|
|
58
|
+
body: new URLSearchParams({
|
|
59
|
+
amount: String(amount),
|
|
60
|
+
currency: "usd",
|
|
61
|
+
customer: customerId,
|
|
62
|
+
}),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (!charge.ok) {
|
|
66
|
+
const error = await charge.json();
|
|
67
|
+
throw new Error(`Charge failed: ${error.message}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return charge.json();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function sendReceipt(customerId: string, chargeId: string): Promise<void> {
|
|
74
|
+
"use step";
|
|
75
|
+
|
|
76
|
+
const { stepId } = getStepMetadata();
|
|
77
|
+
|
|
78
|
+
await fetch("https://api.example.com/receipts", {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: { "Idempotency-Key": stepId },
|
|
81
|
+
body: JSON.stringify({ customerId, chargeId }),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Race condition caveats
|
|
87
|
+
|
|
88
|
+
Workflow does not currently provide distributed locking or true exactly-once delivery across concurrent runs. If two workflow runs could process the same entity concurrently:
|
|
89
|
+
|
|
90
|
+
- **Rely on the external API's idempotency** (like Stripe's `Idempotency-Key`) rather than checking a local flag.
|
|
91
|
+
- **Don't use check-then-act patterns** like "read a flag, then write if not set" -- another run could read the same flag between your read and write.
|
|
92
|
+
|
|
93
|
+
If your external API doesn't support idempotency keys natively, consider adding a deduplication layer (e.g., a database unique constraint on the operation ID).
|
|
94
|
+
|
|
95
|
+
## Tips
|
|
96
|
+
|
|
97
|
+
- **`stepId` is deterministic.** It's the same value across retries and replays of the same step, making it a reliable idempotency key.
|
|
98
|
+
- **Always provide idempotency keys for non-idempotent external calls.** Even if you think a step won't be retried, cold-start replay will re-execute it.
|
|
99
|
+
- **Handle 409/conflict as success.** If an external API returns "already processed," treat that as a successful result, not an error.
|
|
100
|
+
- **Make your own APIs idempotent** where possible. Accept an idempotency key and return the cached result on duplicate requests.
|
|
101
|
+
|
|
102
|
+
## Key APIs
|
|
103
|
+
|
|
104
|
+
- [`"use workflow"`](/docs/api-reference/workflow/use-workflow) -- declares the orchestrator function
|
|
105
|
+
- [`"use step"`](/docs/api-reference/workflow/use-step) -- declares step functions with full Node.js access
|
|
106
|
+
- [`getStepMetadata()`](/docs/api-reference/step/get-step-metadata) -- provides the deterministic `stepId` for idempotency keys
|
|
107
|
+
- [`start()`](/docs/api-reference/workflow-api/start) -- starts a new workflow run
|
|
@@ -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
|