Concurrency and idempotency
Retries and double clicks
Section titled “Retries and double clicks”Every command accepts an idempotencyKey. A repeat with the same key returns the first result
without doing anything again (EC-16):
// A client that retries a timed-out POST sends the same key; the request is created once.const input = { flow: "expense", requesterId: "u_req", subject: { type: "expense", id: "EXP-1" }, payload: { amount: 50 }, idempotencyKey: "form-7f3a",};const first = await rungs.submit(input);const retry = await rungs.submit(input);console.log(retry.id === first.id); // true
// The same key for a different command is refused, so a bug cannot hide behind it.const misuse = await rungs .submit({ ...input, payload: { amount: 60 } }) .catch((error: unknown) => error instanceof InvalidCommandError);Runs in CI:examples/docs-snippets/src/guides/concurrency.ts
Generate the key when you render the form or start the client request, not when you handle it.
Two people, one request
Section titled “Two people, one request”Every request has a version that each change increments. Send the version the user saw as
expectedVersion; if the request has moved on, the command is refused with ConflictError
(EC-15):
// Both approvers opened the request at the same version. The first decision wins;// the second gets ConflictError instead of deciding something that already changed.const seen = first.version;await rungs.approve({ requestId: first.id, actorId: "u_a", expectedVersion: seen });try { await rungs.reject({ requestId: first.id, actorId: "u_b", expectedVersion: seen });} catch (error) { if (error instanceof ConflictError) { console.log("Someone decided first; showing the latest state."); }}Runs in CI:examples/docs-snippets/src/guides/concurrency.ts
Rungs never retries a refused command for you: the person may no longer be the one who should decide. Show them the latest state and let them act again.