Hi — really like how deliberately this project has thought about retries (per-task config, exponential backoff, staging/prod on by default with dev opt-out documented plainly). This is about a gap in the idempotency system itself, from reading docs/idempotency.mdx and packages/trigger-sdk/src/v3/idempotencyKeys.ts against 69f396f. No runtime testing — happy to be corrected if this is handled somewhere I didn't find.
The shape
docs/idempotency.mdx lists this as a use case (line 35):
Avoiding double-charging customers - Prevent duplicate payment processing during retries
But every code example in the same doc — including the one directly under that bullet — only shows idempotency protecting childTask.trigger():
const idempotencyKey = await idempotencyKeys.create("my-task-key");
await childTask.trigger({ foo: "bar" }, { idempotencyKey });
And idempotencyKeys.ts confirms this is the entire surface:
export const idempotencyKeys = {
create: createIdempotencyKey,
reset: resetIdempotencyKey,
};
There is no primitive that makes an arbitrary side effect idempotent — only task.trigger()/triggerAndWait() calls consume an idempotency key. A payment call made directly inside run(), rather than moved into a child task, gets no protection from this system at all.
Why that's a real gap, not just a docs nit
Retries are on by default outside dev (docs/errors-retrying.mdx: "By default when you create your project using the CLI init command we disabled retrying in the DEV environment" — implying prod/staging retry by default), and the docs' own canonical retry example is:
export const myTask = task({
id: "my-task",
retry: { maxAttempts: 4 },
run: async (payload) => {
const idempotencyKey = await idempotencyKeys.create("my-task-key");
await childTask.trigger({ foo: "bar" }, { idempotencyKey });
throw new Error("Something went wrong"); // triggers a retry of THIS task
},
});
A developer who read "avoiding double-charging customers" as a supported use case, and naturally reached for the pattern shown immediately below it, would write:
run: async (payload) => {
await stripe.refunds.create({ payment_intent: payload.piId, amount: payload.amount });
await sendConfirmationEmail(payload); // throws for an unrelated reason
}
The email step failing retries the whole task (per the configured maxAttempts), and the refund call — having no idempotency key of its own — runs again on the retry. The task-level idempotency key never touches it, because nothing in this task calls trigger().
Suggested fix
Two independent options, not mutually exclusive:
- Narrow the doc claim precisely: state that idempotency keys dedupe task triggers, not arbitrary code inside
run(), and that the correct pattern for "avoid double-charging" is to always wrap the payment call in a child task triggered with a key — never call a payment API directly inside a retryable run().
- Or close the actual gap: a general-purpose helper — something like
idempotencyKeys.run(key, fn) — that persists the result of an arbitrary async operation keyed the same way trigger() already is, so a direct API call inside run() can opt into the same protection without being restructured into a child task.
Either would stop the doc's own payments use case from silently not applying to the code shown right next to it.
Hi — really like how deliberately this project has thought about retries (per-task config, exponential backoff, staging/prod on by default with dev opt-out documented plainly). This is about a gap in the idempotency system itself, from reading
docs/idempotency.mdxandpackages/trigger-sdk/src/v3/idempotencyKeys.tsagainst69f396f. No runtime testing — happy to be corrected if this is handled somewhere I didn't find.The shape
docs/idempotency.mdxlists this as a use case (line 35):But every code example in the same doc — including the one directly under that bullet — only shows idempotency protecting
childTask.trigger():And
idempotencyKeys.tsconfirms this is the entire surface:There is no primitive that makes an arbitrary side effect idempotent — only
task.trigger()/triggerAndWait()calls consume an idempotency key. A payment call made directly insiderun(), rather than moved into a child task, gets no protection from this system at all.Why that's a real gap, not just a docs nit
Retries are on by default outside dev (
docs/errors-retrying.mdx: "By default when you create your project using the CLI init command we disabled retrying in the DEV environment" — implying prod/staging retry by default), and the docs' own canonical retry example is:A developer who read "avoiding double-charging customers" as a supported use case, and naturally reached for the pattern shown immediately below it, would write:
The email step failing retries the whole task (per the configured
maxAttempts), and the refund call — having no idempotency key of its own — runs again on the retry. The task-level idempotency key never touches it, because nothing in this task callstrigger().Suggested fix
Two independent options, not mutually exclusive:
run(), and that the correct pattern for "avoid double-charging" is to always wrap the payment call in a child task triggered with a key — never call a payment API directly inside a retryablerun().idempotencyKeys.run(key, fn)— that persists the result of an arbitrary async operation keyed the same waytrigger()already is, so a direct API call insiderun()can opt into the same protection without being restructured into a child task.Either would stop the doc's own payments use case from silently not applying to the code shown right next to it.