Skip to content

    Guides10

    Idempotent commands

    Let a client send a command again without running it twice, with the idempotencyKey option, the Idempotency-Key header and a form's idempotencyKey field.

    A command can reach the app twice: the client retried after a timeout, the user clicked twice, a webhook was delivered again. The aggregate rejects what it must (placeOrder again is AlreadyPlaced), but a rejection is the wrong answer to a retry of a command that went through, and a command the aggregate takes twice, such as adding a line, does its work twice. An idempotency key names one dispatch, so a second arrival under it answers what the first did instead of running again. What the runtime already guarantees for the events and reactions inside the app is in delivery guarantees; this page is about commands that come from outside it.

    TypeScript
    const placed = await app.commands.placeOrder(payload, { idempotencyKey: key });

    The first dispatch under a key runs the command and remembers what it answered, in the same transaction as the events it stores. Every later dispatch with the same key, the same command, the same payload and the same delay answers the same without running the handler: the same StoredDispatch, its position included, so reading your own writes waits for the same read models; the same ScheduledDispatch for a command with delay, which is scheduled once; or the same rejection, thrown again as a DomainError with its code. A dispatch that failed, with a ConcurrencyError after its retries, a HANDLER_TIMEOUT or an error of the handler’s, is not remembered, so the client’s retry runs it.

    Two dispatches racing for one key both run; one records its outcome and the other loses on the key, rolls back whatever it stored, and answers what the key holds. The events land once.

    The key names one command with one payload and one delay. The same key with another command, with another payload, or scheduled when it ran at once (or the other way round, or with another delay), is a bug of the caller, not a retry: the dispatch rejects with IdempotencyKeyConflictError (IDEMPOTENCY_KEY_CONFLICT) and runs nothing. The payload is compared as JSON, keys in any order, with maps, sets and bigints compared by what they hold.

    A key is a string of 1 to 255 characters that the caller chooses, one per operation the client means to do once, such as a UUID made when the form is submitted and sent again on every retry of it. It is remembered for runtime.commands.idempotencyKeyLifetime, 24 hours by default; past it the key is free for another command, and the scheduled-command worker purges the record from bounda_idempotency_keys (configuration). A rejection decided on a state that held someone’s personal data is remembered only until that person is forgotten: forget removes it, and the same key is then decided again.

    This is not the idempotencyKey a command handler receives: that one is the command’s own id, for the provider the handler calls, the same across the runs a concurrency conflict causes (calling the outside world). The option is for whoever dispatches the command, and reaches the handler as command.metadata does not: a replay never runs it.

    The boundaMiddleware takes a request’s key from its Idempotency-Key header or, when there is none, from the idempotencyKey field of the form it posts, urlencoded or multipart. It gives every command the request dispatches an idempotencyKey of its own, idempotencyKeyFor(key, commandName), on Node and on Cloudflare alike, so an action has nothing to add, even one that dispatches two commands. The form is read from a copy of the request, so the action still reads it with request.formData(); a multipart form is therefore parsed twice, files included, so a form that declares a length over a mebibyte is not read for its key: a route that takes uploads keys its requests with the header. The host never makes keys up: a request with neither dispatches as before, and an empty key is refused like any other.

    A browser form sends no headers, so a form carries its key in a hidden field. @bounda-dev/react-router/form has both halves: createIdempotencyKey() makes one in the loader, so the page hydrates with the key it was rendered with, and <IdempotencyKeyField> renders it:

    app/routes/activate.tsxTypeScript
    import { bounda, failure } from "@bounda-dev/react-router/app";
    import { createIdempotencyKey, IdempotencyKeyField } from "@bounda-dev/react-router/form";
    import { Form, redirect } from "react-router";
    import type { Route } from "./+types/activate";
    export const loader = () => ({ idempotencyKey: createIdempotencyKey() });
    export const action = async ({ request, context }: Route.ActionArgs) => {
    const form = await request.formData();
    const userId = String(form.get("userId"));
    try {
    // Keyed with the form's idempotencyKey field.
    await context.get(bounda).commands.activateUser({ userId });
    } catch (error) {
    return failure(error);
    }
    return redirect(`/users/${userId}`);
    };
    export default function Activate({ loaderData }: Route.ComponentProps) {
    return (
    <Form method="post">
    <IdempotencyKeyField value={loaderData.idempotencyKey} />
    <input name="userId" required />
    <button type="submit">Activate</button>
    </Form>
    );
    }

    The same form submitted again unchanged, twice in a row or after a response that never came back, sends the same key and answers what the first submit did. Once the user edits the form, the field takes a fresh key, so a form corrected after a rejection is a new request rather than a conflict: React Router does not run the loader again after an action that answers 4xx, which would otherwise leave the old key in place. A new key from the loader, after a successful submit, replaces it too. A payload that comes from the page rather than the user, such as the id of what the form creates, belongs in the loader as well, so that a retry sends the same one:

    TypeScript
    export const loader = () => ({ userId: crypto.randomUUID(), idempotencyKey: createIdempotencyKey() });

    A client that calls the action itself, a script or another service, sends the header, made once per operation and sent again on every retry of it:

    TypeScript
    const key = crypto.randomUUID();
    await fetch("/activate", { method: "POST", body, headers: { "Idempotency-Key": key } });

    The header wins over the field when a request has both.

    A key names one command, so an action that dispatches the same command twice shares one key between them: the second answers what the first did with the same payload, and conflicts with another. Pass idempotencyKey yourself, which wins over the request’s key, derived with idempotencyKeyFor from @bounda-dev/core:

    TypeScript
    const form = await request.formData();
    const key = request.headers.get("Idempotency-Key") ?? form.get("idempotencyKey");
    for (const [index, seat] of seats.entries()) {
    const keyed =
    typeof key === "string" ? { idempotencyKey: idempotencyKeyFor(key, `seat-${index}`) } : {};
    await commands.reserveSeat(seat, keyed);
    }

    failure() answers a conflict with a 422 (errors from the domain).

    connect(stub) sends the option to the Durable Object with the command, where it works as above; React Router on Cloudflare reads the header and the field the same way. The JSON API of createWorker takes delay and correlationId from the query string and no key.

    The commands a policy or process dispatches get the same ids on every retry of the run, so a scheduled one is stored once and a provider sees one key (what the runtime promises); they rarely need an idempotencyKey of their own. One they pass is recorded with the run’s unit of work and answered from it on a later run.

    Stripe’s Idempotency-Key remembers the first response for 24 hours and refuses the key with other parameters, which is where the lifetime and the fingerprint come from; the IETF’s Idempotency-Key header draft names the header and the 422 for a mismatched payload.