Skip to content
rusthacks.dev
Architecture

Idempotency Keys in Rust APIs and Safe Request Retries

Make POST requests safe to retry: clients send an Idempotency-Key, the server stores the first result keyed on it and replays it. An Axum and SQLx implementation with edge cases.

4 min read
Summarize in AI
Flat illustration for the post “Idempotency Keys in Rust APIs and Safe Request Retries”, composed around web.

A client sends POST /payments, the connection drops, and the client cannot know whether the server charged the card. If it retries, it may charge twice. If it does not, it may never charge. An idempotency key resolves the doubt: the retry carries the same key, and the server returns the original outcome. Everything compiles on Rust 1.90 with edition 2024, axum 0.8 and sqlx 0.8 on PostgreSQL 17.

The contract

Text
POST /payments
Idempotency-Key: 6f1c3b0e-3b6e-4d7a-9a41-0a5c1e9e6a11
Content-Type: application/json

{"amount_cents": 1999, "currency": "EUR"}
  • First request: runs, stores the response under the key, returns it.
  • Repeat with the same key and the same body: returns the stored response, with no side effects.
  • Same key, different body: error, because the client reused a key by mistake.
  • Same key while the first is still running: wait or return 409.

The table

migrations/0004_idempotency.sqlSQL
CREATE TABLE idempotency_keys (
    key          TEXT PRIMARY KEY,
    request_hash TEXT NOT NULL,
    status       TEXT NOT NULL CHECK (status IN ('started', 'completed')),
    response_code INT,
    response_body JSONB,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

The primary key on key is the lock. The request_hash is a hash of method, path and body so a changed request is detected. (Scope keys per account by making the primary key (account_id, key), since keys from different users must not collide.)

Claiming the key

rust
use sqlx::PgPool;

pub enum Claim {
    New,
    Replay { code: i32, body: serde_json::Value },
    InProgress,
    Mismatch,
}

pub async fn claim(pool: &PgPool, key: &str, hash: &str) -> sqlx::Result<Claim> {
    let inserted = sqlx::query(
        "INSERT INTO idempotency_keys (key, request_hash, status)
         VALUES ($1, $2, 'started')
         ON CONFLICT (key) DO NOTHING",
    )
    .bind(key)
    .bind(hash)
    .execute(pool)
    .await?
    .rows_affected();

    if inserted == 1 {
        return Ok(Claim::New);
    }

    let row: (String, String, Option<i32>, Option<serde_json::Value>) = sqlx::query_as(
        "SELECT request_hash, status, response_code, response_body FROM idempotency_keys WHERE key = $1",
    )
    .bind(key)
    .fetch_one(pool)
    .await?;

    if row.0 != hash {
        return Ok(Claim::Mismatch);
    }
    match (row.1.as_str(), row.2, row.3) {
        ("completed", Some(code), Some(body)) => Ok(Claim::Replay { code, body }),
        _ => Ok(Claim::InProgress),
    }
}

ON CONFLICT DO NOTHING plus rows_affected is an atomic claim: exactly one concurrent caller gets 1. Everyone else reads the row and decides. A single statement avoids the check-then-insert race that a separate SELECT would create.

The handler flow

rust
use axum::{http::{HeaderMap, StatusCode}, response::IntoResponse, Json};

pub async fn create_payment(
    pool: sqlx::PgPool,
    headers: HeaderMap,
    body: axum::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"}))).into_response();
    };
    let hash = hex_sha256(&body);

    match claim(&pool, key, &hash).await {
        Ok(Claim::New) => {
            let result = serde_json::json!({ "payment_id": 42, "status": "succeeded" });
            let _ = sqlx::query(
                "UPDATE idempotency_keys SET status = 'completed', response_code = 201, response_body = $2 WHERE key = $1",
            )
            .bind(key)
            .bind(&result)
            .execute(&pool)
            .await;
            (StatusCode::CREATED, Json(result)).into_response()
        }
        Ok(Claim::Replay { code, body }) => {
            (StatusCode::from_u16(code as u16).unwrap_or(StatusCode::OK), Json(body)).into_response()
        }
        Ok(Claim::InProgress) => (StatusCode::CONFLICT, "request in progress, retry shortly").into_response(),
        Ok(Claim::Mismatch) => (StatusCode::UNPROCESSABLE_ENTITY, "key reused with a different request").into_response(),
        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
    }
}

fn hex_sha256(data: &[u8]) -> String {
    use sha2::{Digest, Sha256};
    hex::encode(Sha256::digest(data))
}

(Parameter extraction is simplified to show the flow; in real Axum code the pool comes from State.)

The hard part: the work and the record

Storing the result and doing the work must succeed or fail together. If the process dies after charging the card and before writing the result, the key stays started forever and the retry sees "in progress". Ways to handle it:

  • **Same transaction:** if the work is local database writes, do the work and the UPDATE ... completed in one transaction. Then crash means nothing happened, and a retry runs again safely.
  • **External side effects:** pass your own key downstream (most payment APIs accept their own idempotency key) so the downstream also dedupes, and recover started keys with a timeout that re-runs with the same downstream key.
  • **Stale started rows:** treat a started row older than a limit as abandoned and allow reclaiming it.

Details to get right

  • Only store successful and deterministic failures; do not replay a 500, let the client retry.
  • Scope by user so one customer cannot replay another's response.
  • Expire rows with a scheduled delete after the documented window.
  • Do not log keys together with secrets; they are not secrets but are identifiers.
  • Return the stored status code and body exactly, plus a header such as Idempotent-Replayed: true.

Webhook receivers have the same problem from the other side; dedupe on the event id (webhook verification). The retry side of the client is in timeouts and retries for databases; for outbound events see the outbox pattern.

Also inWeb
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.