Skip to content

Idempotency keys only dedupe task.trigger() calls — the docs list "avoiding double-charging customers" as a use case, but a payment call made directly inside run() gets no protection #4627

Description

@aurumflux20

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:

  1. 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().
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions