Skip to main content

Tutorial: Enforcing Idempotency in Mutations

In this tutorial, you will learn how to safely perform state-mutating operations (like creating an invoice or processing a payment) in Framework M without risking duplicate operations if network timeouts or retries occur.

Prerequisites

  • A working Framework M backend setup.
  • framework-m-desk UI library configured for your frontend.

The Problem

When users click "Submit" on a payment form on a slow network, the request might time out on the client, but still succeed on the server. If the user (or the browser) retries the request, the server might process the payment twice!

Step 1: Enable Idempotency in Configuration

Idempotency is enabled by default for standard framework routes, but you can configure its behavior in framework_config.toml:

[web.idempotency]
enabled = true
default_ttl = 86400 # Cache idempotency keys for 24 hours

Step 2: Using the UI (Metadata-Driven Forms)

If you are using the standard AutoForm or ListView components from @framework-m/desk, you do not need to do anything!

Framework M's data provider automatically intercepts any POST, PUT, PATCH, or DELETE request, generates a unique UUIDv4 Idempotency-Key, and sends it via HTTP headers. If the request fails due to a network timeout and the data provider retries it, the same key is reused, ensuring the backend safely returns the cached response.

Step 3: Calling Custom RPCs with useCall

When you step outside of standard forms and need to call a custom RPC method (e.g., triggering a background job or confirming an order), use the useCall hook.

import { useCall } from "@framework-m/desk";
import { Button } from "../components/ui";

export function ConfirmOrderButton({ orderId }) {
const { execute, isLoading } = useCall();

const handleConfirm = async () => {
// The useCall hook automatically generates and manages
// the Idempotency-Key for this specific mutation.
await execute({
doctype: "Order",
method: "confirm",
payload: { id: orderId }
});
};

return (
<Button onClick={handleConfirm} disabled={isLoading}>
{isLoading ? "Confirming..." : "Confirm Order"}
</Button>
);
}

Step 4: Accessing the Key in Backend Controllers

Sometimes, your backend controller needs to pass the idempotency key further downstream (for example, to a background job worker like Celery or Arq).

The framework's middleware injects the resolved key into the Litestar Request state.

from litestar import post, Request
from framework_m_core.services.job_queue import enqueue_job

@post("/api/v1/custom-webhook")
async def handle_webhook(request: Request, data: dict) -> dict:
# Retrieve the idempotency key set by the middleware
idempotency_key = request.state.idempotency_key

# Pass it to your job queue to ensure the worker is also idempotent!
await enqueue_job(
"process_webhook",
data=data,
job_id=idempotency_key
)

return {"status": "accepted"}

Summary

By relying on AutoForm and useCall on the frontend, and the built-in middleware on the backend, your applications are protected from the "double-charge" problem right out of the box.