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.
Files changed (86) hide show
  1. package/README.md +4 -4
  2. package/dist/api-workflow.d.ts +1 -3
  3. package/dist/api-workflow.d.ts.map +1 -1
  4. package/dist/api-workflow.js +2 -6
  5. package/dist/api.js +1 -1
  6. package/dist/astro.js +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/internal/builtins.js +1 -1
  9. package/dist/internal/class-serialization.js +1 -1
  10. package/dist/internal/errors.js +1 -1
  11. package/dist/nest.js +1 -1
  12. package/dist/next.cjs +4 -2
  13. package/dist/next.d.cts +1 -1
  14. package/dist/next.d.cts.map +1 -1
  15. package/dist/nitro.js +1 -1
  16. package/dist/nuxt.js +1 -1
  17. package/dist/observability.d.ts +1 -1
  18. package/dist/observability.js +2 -2
  19. package/dist/runtime.js +1 -1
  20. package/dist/stdlib.js +1 -1
  21. package/dist/sveltekit.js +1 -1
  22. package/dist/typescript-plugin.cjs +1 -1
  23. package/dist/vite.js +1 -1
  24. package/dist/workflow.js +1 -1
  25. package/docs/ai/resumable-streams.mdx +1 -1
  26. package/docs/api-reference/workflow/create-webhook.mdx +37 -18
  27. package/docs/api-reference/workflow/get-workflow-metadata.mdx +61 -0
  28. package/docs/api-reference/workflow-ai/durable-agent.mdx +0 -4
  29. package/docs/api-reference/workflow-ai/index.mdx +0 -5
  30. package/docs/api-reference/workflow-ai/workflow-chat-transport.mdx +0 -4
  31. package/docs/api-reference/workflow-api/get-world.mdx +6 -6
  32. package/docs/api-reference/workflow-api/index.mdx +1 -1
  33. package/docs/api-reference/workflow-api/world/index.mdx +2 -2
  34. package/docs/api-reference/workflow-api/world/observability.mdx +1 -1
  35. package/docs/api-reference/workflow-api/world/queue.mdx +1 -1
  36. package/docs/api-reference/workflow-api/world/storage.mdx +8 -8
  37. package/docs/api-reference/workflow-api/world/streams.mdx +38 -36
  38. package/docs/cookbook/advanced/custom-serialization.mdx +168 -0
  39. package/docs/cookbook/advanced/durable-objects.mdx +148 -0
  40. package/docs/cookbook/advanced/isomorphic-packages.mdx +145 -0
  41. package/docs/cookbook/advanced/meta.json +10 -0
  42. package/docs/cookbook/advanced/publishing-libraries.mdx +279 -0
  43. package/docs/cookbook/advanced/serializable-steps.mdx +135 -0
  44. package/docs/cookbook/agent-patterns/durable-agent.mdx +191 -0
  45. package/docs/cookbook/agent-patterns/human-in-the-loop.mdx +278 -0
  46. package/docs/cookbook/agent-patterns/meta.json +10 -0
  47. package/docs/cookbook/agent-patterns/stop-workflow.mdx +216 -0
  48. package/docs/cookbook/agent-patterns/tool-orchestration.mdx +255 -0
  49. package/docs/cookbook/agent-patterns/tool-streaming.mdx +181 -0
  50. package/docs/cookbook/common-patterns/batching.mdx +179 -0
  51. package/docs/cookbook/common-patterns/child-workflows.mdx +372 -0
  52. package/docs/cookbook/common-patterns/content-router.mdx +207 -0
  53. package/docs/cookbook/common-patterns/fan-out.mdx +208 -0
  54. package/docs/cookbook/common-patterns/idempotency.mdx +107 -0
  55. package/docs/cookbook/common-patterns/meta.json +15 -0
  56. package/docs/cookbook/common-patterns/rate-limiting.mdx +228 -0
  57. package/docs/cookbook/common-patterns/saga.mdx +152 -0
  58. package/docs/cookbook/common-patterns/scheduling.mdx +249 -0
  59. package/docs/cookbook/common-patterns/webhooks.mdx +185 -0
  60. package/docs/cookbook/index.mdx +41 -0
  61. package/docs/cookbook/integrations/ai-sdk.mdx +204 -0
  62. package/docs/cookbook/integrations/chat-sdk.mdx +203 -0
  63. package/docs/cookbook/integrations/meta.json +4 -0
  64. package/docs/cookbook/integrations/sandbox.mdx +128 -0
  65. package/docs/cookbook/meta.json +5 -0
  66. package/docs/deploying/building-a-world.mdx +45 -43
  67. package/docs/deploying/world/local-world.mdx +1 -1
  68. package/docs/deploying/world/postgres-world.mdx +10 -5
  69. package/docs/deploying/world/vercel-world.mdx +1 -1
  70. package/docs/errors/start-invalid-workflow-function.mdx +1 -1
  71. package/docs/getting-started/index.mdx +8 -1
  72. package/docs/getting-started/meta.json +2 -1
  73. package/docs/getting-started/next.mdx +24 -0
  74. package/docs/getting-started/python.mdx +165 -0
  75. package/docs/how-it-works/code-transform.mdx +6 -5
  76. package/docs/meta.json +1 -0
  77. package/docs/migration-guides/index.mdx +34 -0
  78. package/docs/migration-guides/meta.json +9 -0
  79. package/docs/migration-guides/migrating-from-aws-step-functions.mdx +311 -0
  80. package/docs/migration-guides/migrating-from-inngest.mdx +282 -0
  81. package/docs/migration-guides/migrating-from-temporal.mdx +284 -0
  82. package/docs/migration-guides/migrating-from-trigger-dev.mdx +296 -0
  83. package/package.json +13 -14
  84. package/dist/internal/private.d.ts +0 -6
  85. package/dist/internal/private.d.ts.map +0 -1
  86. 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,15 @@
1
+ {
2
+ "title": "Common Patterns",
3
+ "defaultOpen": true,
4
+ "pages": [
5
+ "saga",
6
+ "batching",
7
+ "rate-limiting",
8
+ "fan-out",
9
+ "scheduling",
10
+ "idempotency",
11
+ "webhooks",
12
+ "content-router",
13
+ "child-workflows"
14
+ ]
15
+ }
@@ -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