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.
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");
}
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
409withRetry-After, so two attempts never run at once. - Per caller. Keys are hashed with the caller's
AuthorizationorX-API-Key(orCookie), 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
5xxreleases 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:
// 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
POSTandPATCHby 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_bodyroutes: the body cannot be fingerprinted before the handler reads it, so the key is ignored there.