Idempotency keys let clients safely retry mutating HTTP requests (POST, PUT, PATCH, DELETE) without causing duplicate side-effects. The client picks a unique key, attaches it as an Idempotency-Key request header, and replays the identical request if it suspects the first attempt was lost in transit. Autumn intercepts subsequent requests with the same key and replays the cached response instead of re-executing the handler.

This follows the IETF draft draft-ietf-httpapi-idempotency-key-header.


Quick start

Enable the middleware with a single builder call:

Rust
#[autumn_web::main]
async fn main() {
    autumn_web::app()
        .routes(routes![create_order])
        .idempotent()   // ← opt-in
        .run()
        .await;
}

.idempotent() activates the middleware with the defaults from autumn.toml (or the built-in defaults if no [idempotency] section is present).

Clients send the header with any UUID or opaque string:

Code
POST /orders HTTP/1.1
Content-Type: application/json
Idempotency-Key: 01926b3e-dead-beef-0000-aabbccddeeff

{"item": "widget", "qty": 2}

The first call executes the handler and caches the response. Every subsequent call with the same key and identical body receives the cached response immediately, with an extra header:

Code
HTTP/1.1 200 OK
X-Idempotent-Replayed: true

Configuration (autumn.toml)

TOML
[idempotency]
enabled   = true
backend   = "memory"   # "memory" | "redis"
ttl_secs  = 86400      # how long to cache responses (default: 24 h)
in_flight_ttl_secs = 86400 # Redis safety expiry for active in-flight locks

# Memory backend: allow in production (off by default, see below)
allow_memory_in_production = false

[idempotency.redis]
# Required when backend = "redis"
url        = "redis://redis:6379/0"   # or set AUTUMN_IDEMPOTENCY__REDIS__URL
key_prefix = "autumn:idempotency"     # Redis key namespace

Environment overrides:

VariableOverrides
AUTUMN_IDEMPOTENCY__ENABLEDidempotency.enabled
AUTUMN_IDEMPOTENCY__BACKENDidempotency.backend
AUTUMN_IDEMPOTENCY__TTL_SECSidempotency.ttl_secs
AUTUMN_IDEMPOTENCY__IN_FLIGHT_TTL_SECSidempotency.in_flight_ttl_secs
AUTUMN_IDEMPOTENCY__REDIS__URLidempotency.redis.url
AUTUMN_IDEMPOTENCY__REDIS__KEY_PREFIXidempotency.redis.key_prefix

Defaults

SettingDefault
enabledfalse (opt-in)
backend"memory"
ttl_secs86400 (24 hours)
in_flight_ttl_secs86400 (24 hours)
allow_memory_in_productionfalse
redis.key_prefix"autumn:idempotency"

Backends

Memory

The in-process store is zero-config and great for development and testing. It does not share state across replicas, so retries routed to a different instance will re-execute the handler.

Autumn refuses to start with backend = "memory" in a production profile unless you explicitly set allow_memory_in_production = true. This is a deliberate safety check — if you omit the flag you get a clear startup error rather than silent duplicate processing in production.

Redis

The Redis backend uses SET EX for cached responses and SET NX EX for distributed in-flight locks, so it coordinates correctly across multiple replicas.

TOML
[idempotency]
enabled = true
backend = "redis"

[idempotency.redis]
url = "redis://redis:6379/0"

Requires the redis Cargo feature on autumn-web.


Response behaviour

ConditionStatusExtra header
First request for a keyhandler's status
Repeat with same bodycached statusX-Idempotent-Replayed: true
Repeat with different body422 Unprocessable Entity
Concurrent duplicate (first still in-flight)409 ConflictRetry-After: 1
No Idempotency-Key headerhandler's status
Non-mutating method (GET, HEAD)handler's status

Only successful 2xx and 3xx responses are cached. Redirect-after-post responses such as 303 See Other are treated as successful mutation outcomes. If a handler returns an error (5xx, 4xx), the entry is not stored and the next attempt re-executes the handler — allowing transient failures to be retried freely.

Responses that modify the Autumn Session are cached after the outer SessionLayer saves the session and appends Set-Cookie. A retry after a lost login, checkout flash, or session rotation receives the finalized cached response instead of re-entering the mutating handler.

Routes mounted through AppBuilder::merge() or AppBuilder::nest() are raw Axum escape hatches and are opaque to Autumn. .idempotent() records the first successful raw-router mutation, but cache hits fail closed with 409 Conflict instead of rerunning the raw handler or replaying a stale success around route-local auth, tenant, or audit layers. If a raw router needs successful replay, apply IdempotencyLayer::replay_through_inner() inside the raw router and place IdempotencyReplayLayer in the route stack after the checks that must still run on replay.

Manually constructed Route values passed to AppBuilder::routes() or AppBuilder::scoped() are also treated as unknown by default. Autumn records the first successful mutation, but cache hits fail closed with 409 Conflict unless the route explicitly opts into RouteIdempotency::ReplayThroughInner and installs IdempotencyReplayLayer after any route-local checks that must run again before replay.

Generated repositories with durable after_*_commit hooks also receive the framework-scoped idempotency key in MutationContext::idempotency_key when the repository is extracted from an idempotent HTTP request. Autumn uses that key to de-duplicate durable commit-hook queue rows for duplicate request attempts.

Payload mismatch (422)

If a client sends the same key with a different request body, it almost certainly indicates a client bug. The middleware rejects it with 422 Unprocessable Entity immediately — the stored response is never returned.

Concurrent duplicates (409)

When the first request is still being processed (in-flight), any duplicate arriving at the same time receives 409 Conflict with a Retry-After: 1 header. The client should retry after the suggested delay; once the first request completes it will find the cached response.


Observability

Metrics

The /actuator/metrics endpoint exposes three counters under the idempotency key:

Json
{
  "idempotency": {
    "hits":      12,
    "misses":    48,
    "conflicts": 0
  }
}
  • hits — requests served from cache (replayed).
  • misses — first-time requests (handler executed).
  • conflicts — concurrent duplicates rejected with 409.

Tracing

The middleware emits tracing::debug! events with structured fields:

Code
idempotency.key   = "01926b3e-dead-beef-0000-aabbccddeeff"
idempotency.replayed = true

Pipe your log subscriber into a structured exporter (OTLP, JSON) to query these fields in your observability backend.


Startup validation

Autumn validates the idempotency configuration at startup and exits with a clear error message if the config is invalid:

  • Memory backend in production without allow_memory_in_production = true → startup aborts.
  • Redis backend with no URL configured (and no AUTUMN_IDEMPOTENCY__REDIS__URL environment variable) → startup aborts.

Testing

Use the TestApp builder in tests — it exposes an .idempotent() method that enables the middleware with an in-process memory store:

Rust
use autumn_web::test::TestApp;
use autumn_web::{post, routes};

#[tokio::test]
async fn duplicate_post_replays() {
    #[post("/orders")]
    async fn create() -> &'static str { "created" }

    let client = TestApp::new()
        .routes(routes![create])
        .idempotent()
        .build();

    let r1 = client
        .post("/orders")
        .header("idempotency-key", "test-key-1")
        .send()
        .await;
    r1.assert_ok();
    assert_eq!(r1.header("x-idempotent-replayed"), None);

    let r2 = client
        .post("/orders")
        .header("idempotency-key", "test-key-1")
        .send()
        .await;
    r2.assert_ok();
    assert_eq!(r2.header("x-idempotent-replayed"), Some("true"));
}

You can also instantiate the layer directly against a raw axum Router for lower-level tests that need finer-grained control over the store. The direct form below should only wrap simple routes where no route-local middleware must run again before replay:

Rust
use std::{sync::Arc, time::Duration};
use autumn_web::idempotency::{IdempotencyLayer, MemoryIdempotencyStore};

let store = Arc::new(MemoryIdempotencyStore::new(Duration::from_secs(3600)));
let layer = IdempotencyLayer::new(store.clone() as Arc<_>);

let app = axum::Router::new()
    .route("/echo", axum::routing::post(handler))
    .layer(layer);

Low-level API

When you need a custom backend (e.g. DynamoDB, Postgres advisory locks), implement the IdempotencyStore trait:

Rust
use autumn_web::idempotency::{
    IdempotencyEntry, IdempotencyRecord, IdempotencyStore, IdempotencyStoreError,
};
use std::time::Duration;

struct MyStore { /* ... */ }

impl IdempotencyStore for MyStore {
    fn get(&self, key: &str) -> Option<IdempotencyEntry> { /* ... */ }

    fn set(&self, key: &str, record: IdempotencyRecord, body_hash: Vec<u8>, ttl: Duration) {
        /* ... */
    }

    fn try_set(
        &self,
        key: &str,
        record: IdempotencyRecord,
        body_hash: Vec<u8>,
        ttl: Duration,
    ) -> Result<(), IdempotencyStoreError> {
        self.set(key, record, body_hash, ttl);
        Ok(())
    }

    fn try_lock(&self, key: &str, lock_ttl: Duration) -> bool { /* true = lock acquired */ }

    fn unlock(&self, key: &str) { /* ... */ }
}

Wire it into the layer and apply it to your router:

Rust
let store = Arc::new(MyStore::new()) as Arc<dyn IdempotencyStore>;
let layer = IdempotencyLayer::new(store).with_ttl(Duration::from_secs(3600));

autumn_web::app()
    .routes(routes![handler])
    .layer(layer)
    .run()
    .await;

See also