Skip to content

Commit adc4979

Browse files
authored
docs: idempotency keys deduplicate triggers, not side effects inside run() (#4952)
## Summary Idempotency keys deduplicate task triggers (`trigger()`, `triggerAndWait()`, `batchTrigger()`). They do not make the code inside a task's `run()` idempotent: when a run retries, `run()` executes again from the top, and any API call inside it happens again unless that call is itself idempotent. The idempotency page listed "avoiding double-charging customers" as a use case, but every example on the page only ever deduplicated a child trigger, so a reader could reasonably assume a payment call made directly inside a retryable `run()` was protected. It is not, and no Trigger.dev-side mechanism can protect it: only the payment provider can deduplicate its own API call, so the provider's idempotency key has to be passed too. This is a docs-only change. No SDK changes. ## What changed - The intro now states the boundary up front: the key applies to the trigger call and nothing else. - The use-case bullets say "trigger the X task once" rather than implying the side effect itself is deduplicated, and the payments bullet points at the new section. - A new "Side effects inside `run()`" section explains why moving the call into a child task does not close the gap either, and shows a Stripe refund passing Stripe's `idempotencyKey` alongside a Trigger.dev key for the follow-up email trigger. Addresses the documentation side of [#4627](#4627). Supersedes [#4650](#4650), which was auto-closed by the vouch gate.
1 parent ea97581 commit adc4979

1 file changed

Lines changed: 41 additions & 5 deletions

File tree

docs/idempotency.mdx

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ title: "Idempotency"
33
description: "An API call or operation is idempotent if it has the same result when called more than once."
44
---
55

6-
We currently support idempotency at the task level, meaning that if you trigger a task with the same `idempotencyKey` twice, the second request will not create a new task run. Instead, the original run's handle is returned, allowing you to track the existing run's progress.
6+
**Idempotency keys deduplicate task triggers.** If you trigger a task with the same `idempotencyKey` twice, the second request does not create a new run. It returns the original run's handle, so you can track the existing run's progress.
7+
8+
The key applies to the trigger call and nothing else. It deduplicates `trigger()`, `triggerAndWait()`, and `batchTrigger()`; it does not make the code inside a task's `run()` function idempotent. See [Side effects inside `run()`](#side-effects-inside-run) for what that means when a task calls an external API.
79

810
## Why use idempotency keys?
911

@@ -31,10 +33,44 @@ sequenceDiagram
3133

3234
Other common use cases include:
3335

34-
- **Preventing duplicate emails** - Ensure a confirmation email is only sent once, even if the parent task retries
35-
- **Avoiding double-charging customers** - Prevent duplicate payment processing during retries
36-
- **One-time setup tasks** - Ensure initialization or migration tasks only run once
37-
- **Deduplicating webhook processing** - Handle the same webhook event only once, even if it's delivered multiple times
36+
- **Preventing duplicate emails** - Trigger the email task once, even if the parent task retries
37+
- **Avoiding double-charging customers** - Trigger the payment task once, even if the parent task retries. The payment API call itself still needs the provider's own idempotency key, see [Side effects inside `run()`](#side-effects-inside-run)
38+
- **One-time setup tasks** - Trigger initialization or migration tasks once
39+
- **Deduplicating webhook processing** - Trigger the handler task once per webhook event, even if the event is delivered multiple times
40+
41+
## Side effects inside `run()`
42+
43+
An idempotency key stops a task from being *triggered* twice. It does not stop the code inside that task from *running* twice. When a run retries, `run()` executes again from the top, and every API call, database write, or email inside it happens again unless that operation is itself idempotent.
44+
45+
This matters most for payments. A refund issued directly inside a retryable task is issued again on every retry, even when the retry is caused by an unrelated failure later in the same function. Moving the refund into a child task and triggering it with an idempotency key does not close the gap either: the child is triggered once, but its own `run()` can retry after the provider has accepted the request. Only the provider can deduplicate its own API call.
46+
47+
Pass the provider's idempotency key on every call that must happen at most once. Derive it from a value that stays the same across retries, such as an ID from your payload:
48+
49+
```ts /trigger/refund-order.ts
50+
import { idempotencyKeys, task } from "@trigger.dev/sdk";
51+
import Stripe from "stripe";
52+
import { sendRefundEmail } from "./send-refund-email";
53+
54+
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
55+
56+
export const refundOrder = task({
57+
id: "refund-order",
58+
retry: { maxAttempts: 3 },
59+
run: async (payload: { orderId: string; paymentIntentId: string; amount: number }) => {
60+
// Stripe returns the original refund for a repeated key instead of creating a second one
61+
await stripe.refunds.create(
62+
{ payment_intent: payload.paymentIntentId, amount: payload.amount },
63+
{ idempotencyKey: `refund-${payload.orderId}` }
64+
);
65+
66+
// The Trigger.dev key deduplicates the trigger, so a retry does not create a second email run
67+
const idempotencyKey = await idempotencyKeys.create(`refund-email-${payload.orderId}`);
68+
await sendRefundEmail.trigger({ orderId: payload.orderId }, { idempotencyKey });
69+
},
70+
});
71+
```
72+
73+
Use a Trigger.dev idempotency key to stop a retry triggering a task twice, and the provider's idempotency key to stop a retry calling the provider twice. For a side effect with no idempotency support, record that you performed it in your own database and check that record before performing it again.
3874

3975
## `idempotencyKey` option
4076

0 commit comments

Comments
 (0)