Idempotency Keys in a Rust API for Safe Retries Step by Step
An idempotency key lets clients retry a POST without charging twice. The server stores the first response and replays it for the same key.

Networks drop responses. A client that times out cannot know whether the server did the work, so it retries, and a naive server does the work twice. Idempotency keys let a retry be harmless. The pattern is well established, and implementing it correctly comes down to one unique index and careful state handling. Versions: Rust 1.90, Axum 0.8, SQLx 0.8, Postgres.
The protocol
- The client generates a unique value (a UUID) and sends
Idempotency-Key: <value>on a POST. - The server processes the request once and stores the result under that key.
- A repeat with the same key and the same body returns the stored result with the same status.
- The same key with a different body is a client bug and gets
422.
The table
CREATE TABLE idempotency_keys (
user_id uuid NOT NULL,
key text NOT NULL,
request_hash bytea NOT NULL,
status smallint,
response jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, key)
);A null status means "in progress". The primary key on (user_id, key) scopes keys per user.
Claiming the key
use sha2::{Digest, Sha256};
use sqlx::PgPool;
use uuid::Uuid;
enum Claim {
New,
Replay { status: i16, body: serde_json::Value },
InProgress,
Mismatch,
}
async fn claim(pool: &PgPool, user: Uuid, key: &str, body: &[u8]) -> Result<Claim, sqlx::Error> {
let hash = Sha256::digest(body).to_vec();
let inserted = sqlx::query(
"INSERT INTO idempotency_keys (user_id, key, request_hash)
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
)
.bind(user)
.bind(key)
.bind(&hash)
.execute(pool)
.await?
.rows_affected();
if inserted == 1 {
return Ok(Claim::New);
}
let row: (Vec<u8>, Option<i16>, Option<serde_json::Value>) = sqlx::query_as(
"SELECT request_hash, status, response FROM idempotency_keys WHERE user_id = $1 AND key = $2",
)
.bind(user)
.bind(key)
.fetch_one(pool)
.await?;
Ok(match row {
(h, _, _) if h != hash => Claim::Mismatch,
(_, Some(status), Some(body)) => Claim::Replay { status, body },
_ => Claim::InProgress,
})
}ON CONFLICT DO NOTHING plus rows_affected is an atomic test and set. Two concurrent requests with the same key race on the insert, one wins and the other sees zero rows affected. No application lock is needed.
The handler
use axum::{body::Bytes, http::{HeaderMap, StatusCode}, response::IntoResponse, Json};
async fn create_payment(
user: Uuid,
pool: PgPool,
headers: HeaderMap,
body: Bytes,
) -> impl IntoResponse {
let Some(key) = headers.get("idempotency-key").and_then(|v| v.to_str().ok()) else {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "Idempotency-Key required"})));
};
match claim(&pool, user, key, &body).await {
Ok(Claim::Replay { status, body }) => {
(StatusCode::from_u16(status as u16).unwrap_or(StatusCode::OK), Json(body))
}
Ok(Claim::InProgress) => (StatusCode::CONFLICT, Json(serde_json::json!({"error": "request in progress"}))),
Ok(Claim::Mismatch) => (StatusCode::UNPROCESSABLE_ENTITY, Json(serde_json::json!({"error": "key reused with different body"}))),
Ok(Claim::New) => {
let result = serde_json::json!({ "payment": "created" });
let _ = sqlx::query(
"UPDATE idempotency_keys SET status = 201, response = $3 WHERE user_id = $1 AND key = $2",
)
.bind(user)
.bind(key)
.bind(&result)
.execute(&pool)
.await;
(StatusCode::CREATED, Json(result))
}
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "internal"}))),
}
}This sketch shows the state machine. Real code does the business write and the key update in one transaction, so a crash cannot leave the payment created with the key still in progress (SQLx transactions). If the work fails with an error that is safe to retry, delete the key row so the client can try again.
Operational notes
- Expiry. Delete rows older than 24 hours with a scheduled job (scheduled tasks).
- Stuck keys. If a process dies mid request, the key stays "in progress". Treat rows older than a timeout as retryable.
- Hash the raw body, not the parsed value, or whitespace differences look like mismatches.
- Client side. Reuse the same key across retries of one logical operation and generate a new one for the next.
The pattern also protects queue consumers (idempotent consumers), and pairs with retries in clients (async resilience patterns). The broader design appears in idempotency keys as an architecture pattern.


