Cookbook / Public APIs

Idempotency keys

Make POST requests safe to retry: a repeated Idempotency-Key gets the first response instead of running twice.

A client that times out cannot know whether its POST /payments ran. If it retries, you may charge twice. With an Idempotency-Key header and this middleware, the second attempt gets the stored response from the first, and the handler does not run again.

src/main.rs
use axeon::middleware::idempotency::Idempotency;
use axeon::{json, Json, Server};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;

fn main() {
    let charges = Arc::new(AtomicU64::new(0));

    let mut app = Server::new();
    // After authentication, before Transactional: the response is stored once the
    // transaction has committed.
    app.middleware(Idempotency::new().required(true)); // 400 without the header

    app.post("/payments", move |_req| {
        let charges = charges.clone();
        async move {
            let n = charges.fetch_add(1, Ordering::SeqCst) + 1;
            (201, Json(json!({ "charge": n })))
        }
    });

    app.listen("127.0.0.1:3000").expect("server failed to start");
}
Terminal
curl -si -X POST http://127.0.0.1:3000/payments -H 'idempotency-key: order-42' -d '{"amount":10}'
# Again: the same {"charge":1}, with "idempotent-replayed: true". The handler did not run.
curl -si -X POST http://127.0.0.1:3000/payments -H 'idempotency-key: order-42' -d '{"amount":10}'
# Same key, different body: 422. The key belongs to the first request.
curl -si -X POST http://127.0.0.1:3000/payments -H 'idempotency-key: order-42' -d '{"amount":99}'

What it guarantees#

  • Replay. A repeated key with the same method, path, query and body gets the stored response (Idempotent-Replayed: true).
  • Mismatch. The same key with a different request gets 422.
  • In progress. A repeat while the first request is still running gets 409 with Retry-After, so two attempts never run at once.
  • Per caller. Keys are hashed with the caller's Authorization or X-API-Key (or Cookie), so one client never receives another's response. For cookie sessions use .scope_by(|req| ...) with the user ID.
  • Server errors retry. Responses below 500 are kept for 24 hours. A 5xx releases the key so the client can try again. Streamed responses are not stored.

Several instances#

The default store is in memory. Behind a load balancer, retries can land on another instance, so share the records. Redis and PostgreSQL stores are included:

Rust
// Redis
use axeon_redis::RedisIdempotencyStore;
app.middleware(Idempotency::new().store(RedisIdempotencyStore::new(redis.clone())));

// PostgreSQL (run `idempotency::install(&db)` or add `idempotency::migration()`)
use axeon_orm::idempotency::PgIdempotencyStore;
app.middleware(Idempotency::new().store(PgIdempotencyStore::new(db.clone())));

Notes#

  • Applies to POST and PATCH by default; change it with .methods([...]).
  • lock_ttl (60 seconds) is how long a claim lasts if the instance crashes mid-request. Set it above your longest handler timeout.
  • If the store fails, requests that carry a key get 503, because running them unguarded could do the work twice. .fail_open(true) chooses availability instead.
  • Not for stream_body routes: the body cannot be fingerprinted before the handler reads it, so the key is ignored there.