Skip to content

Commit aa44eb5

Browse files
add idempotency docs (vercel#19)
* add idempotency docs * Update docs/content/docs/foundations/errors-and-retries.mdx Co-authored-by: Nathan Rajlich <n@n8.io> * Update docs/content/docs/foundations/errors-and-retries.mdx Co-authored-by: Nathan Rajlich <n@n8.io> * Update docs/content/docs/foundations/idempotency.mdx Co-authored-by: Nathan Rajlich <n@n8.io> * Apply suggestion from @TooTallNate --------- Co-authored-by: Nathan Rajlich <n@n8.io>
1 parent d6cac93 commit aa44eb5

File tree

4 files changed

+128
-27
lines changed

4 files changed

+128
-27
lines changed

docs/content/docs/api-reference/workflow/get-step-metadata.mdx

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,55 +2,83 @@
22
title: getStepMetadata
33
---
44

5-
import { generateDefinition } from "@/lib/tsdoc"
5+
import { generateDefinition } from "@/lib/tsdoc";
66

77
# `getStepMetadata`
88

99
Returns metadata available in the current step function.
1010

1111
You may want to use this function when you need to:
1212

13-
* Track retry attempts in error handling
14-
* Access timing information of a step and execution metadata
13+
- Track retry attempts in error handling
14+
- Access timing information of a step and execution metadata
15+
- Generate idempotency keys for external APIs
1516

1617
<Callout type="warn">
17-
This function can only be called inside a step function.
18+
This function can only be called inside a step function.
1819
</Callout>
1920

2021
```typescript lineNumbers
21-
import { getStepMetadata } from "workflow"
22+
import { getStepMetadata } from "workflow";
2223

2324
async function testWorkflow() {
24-
"use workflow"
25-
await logStepId()
25+
"use workflow";
26+
await logStepId();
2627
}
2728

2829
async function logStepId() {
29-
"use step"
30-
const ctx = getStepMetadata() // [!code highlight]
31-
console.log(ctx.stepId) // Grab the current step ID
30+
"use step";
31+
const ctx = getStepMetadata(); // [!code highlight]
32+
console.log(ctx.stepId); // Grab the current step ID
3233
}
3334
```
3435

36+
### Example: Use `stepId` as an idempotency key
37+
38+
```typescript lineNumbers
39+
import { getStepMetadata } from "workflow";
40+
41+
async function chargeUser(userId: string, amount: number) {
42+
"use step";
43+
const { stepId } = getStepMetadata();
44+
45+
await stripe.charges.create(
46+
{
47+
amount,
48+
currency: "usd",
49+
customer: userId,
50+
},
51+
{
52+
idempotencyKey: `charge:${stepId}`, // [!code highlight]
53+
}
54+
);
55+
}
56+
```
57+
58+
<Callout type="info">
59+
Learn more about patterns and caveats in the{" "}
60+
<a href="/docs/foundations/idempotency">Idempotency</a> guide.
61+
</Callout>
62+
3563
## API Signature
3664

3765
### Parameters
3866

3967
<TSDoc
40-
definition={generateDefinition({
41-
code: `
68+
definition={generateDefinition({
69+
code: `
4270
import { getStepMetadata } from "workflow";
43-
export default getStepMetadata;`
44-
})}
45-
showSections={['parameters']}
71+
export default getStepMetadata;`,
72+
})}
73+
showSections={["parameters"]}
4674
/>
4775

4876
### Returns
4977

5078
<TSDoc
51-
definition={generateDefinition({
52-
code: `
79+
definition={generateDefinition({
80+
code: `
5381
import type { StepMetadata } from "workflow";
54-
export default StepMetadata;`
55-
})}
82+
export default StepMetadata;`,
83+
})}
5684
/>

docs/content/docs/foundations/errors-and-retries.mdx

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,19 @@ callApi.maxRetries = 5; // Set a custom number of retries
3131

3232
Steps get enqueued immediately after the failure. Read on to see how this can be customized.
3333

34+
<Callout type="info">
35+
When a retried step performs external side effects (payments, emails, API
36+
writes), ensure those calls are <strong>idempotent</strong> to avoid duplicate
37+
side effects. See <a href="/docs/foundations/idempotency">Idempotency</a> for
38+
more information.
39+
</Callout>
40+
3441
## Intentional Errors
3542

3643
When your step needs to intentionally throw an error and skip retrying, simply throw a [`FatalError`](/docs/api-reference/workflow/fatal-error).
3744

3845
```typescript lineNumbers
39-
import { FatalError } from 'workflow';
46+
import { FatalError } from "workflow";
4047

4148
async function callApi(endpoint: string) {
4249
"use step";
@@ -61,7 +68,7 @@ async function callApi(endpoint: string) {
6168
When you need to customize the delay on the retry, use [`RetryableError`](/docs/api-reference/workflow/retryable-error)and set the retryAfter property.
6269

6370
```typescript lineNumbers
64-
import { FatalError, RetryableError } from 'workflow';
71+
import { FatalError, RetryableError } from "workflow";
6572

6673
async function callApi(endpoint: string) {
6774
"use step";
@@ -77,10 +84,10 @@ async function callApi(endpoint: string) {
7784
}
7885

7986
if (response.status === 429) {
80-
const retryAfter = response.headers.get('Retry-After');
87+
const retryAfter = response.headers.get("Retry-After");
8188
// Delay the retry until after a timeout
8289
throw new RetryableError("Too many requests. Retrying...", { // [!code highlight]
83-
retryAfter: parseInt(retryAfter) // [!code highlight]
90+
retryAfter: parseInt(retryAfter), // [!code highlight]
8491
}); // [!code highlight]
8592
}
8693

@@ -93,7 +100,7 @@ async function callApi(endpoint: string) {
93100
This final example combines everything we've learnt, along with [`getStepMetadata`](/docs/api-reference/workflow/get-step-metadata).
94101

95102
```typescript lineNumbers
96-
import { FatalError, RetryableError, getStepMetadata } from 'workflow';
103+
import { FatalError, RetryableError, getStepMetadata } from "workflow";
97104

98105
async function callApi(endpoint: string) {
99106
"use step";
@@ -104,18 +111,20 @@ async function callApi(endpoint: string) {
104111

105112
if (response.status >= 500) {
106113
// Exponential backoffs
107-
throw new RetryableError("Backing off...", { retryAfter: metadata.attempt ** 2 }); // [!code highlight]
114+
throw new RetryableError("Backing off...", {
115+
retryAfter: metadata.attempt ** 2, // [!code highlight]
116+
});
108117
}
109118

110119
if (response.status === 404) {
111120
throw new FatalError("Resource not found. Skipping retries.");
112121
}
113122

114123
if (response.status === 429) {
115-
const retryAfter = response.headers.get('Retry-After');
124+
const retryAfter = response.headers.get("Retry-After");
116125
// Delay the retry until after a timeout
117126
throw new RetryableError("Too many requests. Retrying...", {
118-
retryAfter: parseInt(retryAfter)
127+
retryAfter: parseInt(retryAfter),
119128
});
120129
}
121130

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
title: Idempotency
3+
---
4+
5+
# Idempotency
6+
7+
Idempotency is a property of an operation that ensures it can be safely retried without producing duplicate side effects.
8+
9+
In distributed systems (calling external APIs), it is not always possible to ensure an operation has only been performed once just by seeing if it succeeds.
10+
Consider a payment API that charges the user $10, but due to network failures, the confirmation response is lost. When the step retries (because the previous attempt was considered a failure), it will charge the user again.
11+
12+
To prevent this, many external APIs support idempotency keys. An idempotency key is a unique identifier for an operation that can be used to deduplicate requests.
13+
14+
---
15+
16+
## The core pattern: use the step ID as your idempotency key
17+
18+
Every step invocation has a stable `stepId` that stays the same across retries.
19+
Use it as the idempotency key when calling third-party APIs.
20+
21+
```typescript lineNumbers
22+
import { getStepMetadata } from "workflow";
23+
24+
async function chargeUser(userId: string, amount: number) {
25+
"use step";
26+
27+
const { stepId } = getStepMetadata();
28+
29+
// Example: Stripe-style idempotency key
30+
// This guarantees only one charge is created even if the step retries
31+
await stripe.charges.create(
32+
{
33+
amount,
34+
currency: "usd",
35+
customer: userId,
36+
},
37+
{
38+
idempotencyKey: stepId, // [!code highlight]
39+
}
40+
);
41+
}
42+
```
43+
44+
Why this works:
45+
46+
- **Stable across retries**: `stepId` does not change between attempts.
47+
- **Globally unique per step**: Fulfills the uniqueness requirement for an idempotency key.
48+
49+
---
50+
51+
## Best practices
52+
53+
- **Always provide idempotency keys to external side effects that are not idempotent** inside steps (payments, emails, SMS, queues).
54+
- **Prefer `stepId` as your key**; it is stable across retries and unique per step.
55+
- **Keep keys deterministic**; avoid including timestamps or attempt counters.
56+
- **Handle 409/conflict responses** gracefully; treat them as success if the prior attempt completed.
57+
58+
---
59+
60+
## Related docs
61+
62+
- Learn about retries in [Errors & Retrying](/docs/foundations/errors-and-retries)
63+
- API reference: [`getStepMetadata`](/docs/api-reference/workflow/get-step-metadata)

docs/content/docs/foundations/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"workflows-and-steps",
55
"control-flow-patterns",
66
"errors-and-retries",
7+
"idempotency",
78
"hooks",
89
"serialization"
910
],

0 commit comments

Comments
 (0)