Skip to main content

Module idempotency

Module idempotency 

Source
Expand description

Idempotency-key middleware.

Protects mutating routes against duplicate execution. On the first request with a given Idempotency-Key, the handler runs and the captured response is persisted. Subsequent requests with the same key replay the stored response if the request body hashes match, or return 422 with a idempotency_key_conflict code if a different body is sent under the same key (per the draft IETF spec).

Usage:

use cratestack_axum::idempotency::{IdempotencyLayer, SqlxIdempotencyStore};
use std::net::SocketAddr;
let store = std::sync::Arc::new(SqlxIdempotencyStore::new(pool.clone()));
let router = generated_router.layer(IdempotencyLayer::new(store, std::time::Duration::from_secs(24 * 3600)));

// The default principal fingerprint hashes `Authorization` when present
// and otherwise falls back to the verified TCP peer address, which
// axum only populates via `ConnectInfo<SocketAddr>` when the server is
// served through `into_make_service_with_connect_info`:
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, router.into_make_service_with_connect_info::<SocketAddr>()).await?;

This wiring matters. Nothing in this crate — and, as of this writing, no example shipped in this repository — serves through into_make_service_with_connect_info by default; every example uses plain into_make_service(). Without it, ConnectInfo<SocketAddr> is never present in request extensions, so every request without an Authorization header is refused with 412 Precondition Failed (cratestack#416 — the default used to silently collapse such requests onto a shared "anonymous" namespace instead; it now refuses rather than risk that collision). Consumers who authenticate via cookies/mTLS rather than an Authorization header — and who cannot serve through into_make_service_with_connect_info — must supply IdempotencyLayer::with_principal_fingerprint explicitly.

§Where the decision is made

Not here. Since ADR 0015 slice 1, everything in this module is the HTTP adapter around [cratestack_exec::OpExecutor] (L3): it derives the principal, parses the key, hashes the request, and renders the answer — but whether to reserve is OpExecutor::admit’s call. The split follows docs/design/layering.md §2’s L3 exclusions: a method, a HeaderMap and a Response are transport facts and stay at L4.

§Honouring @no_idempotency

The layer is opt-in at the consumer’s router, and by default it reserves for every keyed request on a mutating method — exactly as it always did. To let a schema’s @no_idempotency procedures (and its reads) skip reservation, install a resolver over the generated descriptors:

// transport rpc
IdempotencyLayer::new(store, ttl)
    .with_op_resolver(build_rpc_op_resolver(cratestack_schema::axum::OPS));
// REST
IdempotencyLayer::new(store, ttl)
    .with_op_resolver(build_rest_op_resolver(cratestack_schema::axum::ROUTE_TRANSPORTS));

Both resolvers fail closed toward reserving on any lookup miss — the opposite polarity from crate::ratelimit’s filters, and deliberately so; see build_rest_op_resolver’s module docs.

Two things to know before installing one:

  • A nested router needs its mount prefix. Descriptors record the path the schema declares, but MatchedPath/Uri::path report the full path including the mount, so under Router::nest("/api", ..) every lookup misses and @no_idempotency silently does nothing. Use build_rest_op_resolver_with_prefix / build_rpc_op_resolver_with_prefix.
  • The exemption is wider than the attribute. A resolver exempts everything marked idempotent_by_default, which is annotated procedures and every read op (query procedure, model list/get). “Read op” is not “GET request”: under transport rpc reads are dispatched by POST /rpc/{op_id}, so they do reach this layer and are exempted; under REST they are GET and never reach it, so there the exemption is a no-op. Nothing stops a query procedure from writing; if one does, installing a resolver removes its protection, and on RPC that is a live path.

Structs§

IdempotencyLayer
Tower layer that wires an IdempotencyStore into the request pipeline.
IdempotencyRecord
Persisted idempotency record returned on a replay. Banks need an invariant view of the captured response — the store rebuilds this from its persisted columns when the second caller asks to replay.
IdempotencyService
OpAdmission
The participation facts a schema declares about one op, lifted off whichever descriptor the schema’s transport emitted.

Enums§

ReservationOutcome
Outcome of an atomic reserve_or_fetch call.

Constants§

IDEMPOTENCY_TABLE_DDL
SQL DDL for the idempotency table. Banks typically run migrations through their own tooling — cratestack currently ships migrations as raw DDL since the migration engine is deferred to Phase 3.

Traits§

IdempotencyStore

Functions§

build_rest_op_resolver
Build an op resolver for REST schemas, over the generated ROUTE_TRANSPORTS slice, for a router mounted at the root.
build_rest_op_resolver_with_prefix
build_rest_op_resolver for a router mounted under prefix, e.g. build_rest_op_resolver_with_prefix("/api", ROUTE_TRANSPORTS) to match Router::nest("/api", router).
build_rpc_op_resolver
Build an op resolver for transport rpc schemas, over the generated OPS slice, for a router mounted at the root.
build_rpc_op_resolver_with_prefix
build_rpc_op_resolver for a router mounted under prefix, e.g. build_rpc_op_resolver_with_prefix("/api", OPS) to match Router::nest("/api", router).
decode_headers
Decode a blob produced by encode_headers back into a HeaderMap. Returns an empty map on malformed input rather than failing the replay — a corrupt headers blob is a recoverable curiosity, not a reason to drop the response status and body the caller is waiting for.
encode_headers
Encode a response’s headers into the opaque blob that the store persists. Format: little-endian length-prefixed (name, value) pairs. Header values can carry arbitrary bytes (per RFC 9110 they may include any opaque-data octet, with the exception of CR/LF), so a binary blob is the only correct representation — JSON would force lossy UTF-8 coercion on values like opaque ETag tokens that may already be quoted-string blobs.
hash_request
Stable fingerprint of a request: SHA-256 over method, path + query, content-type, and body bytes. Used to detect when a duplicate key is reused with a different payload (the conflict case the draft spec calls out). The path argument should include the query string so modifier-style flags (?dry_run=true, ?confirm=true) don’t collide — the middleware passes Uri::path_and_query for that reason.
is_idempotent_target_method
Returns true if the HTTP method is one we’d guard with idempotency. We apply only to mutating verbs — GETs are already safely repeatable.
parse_idempotency_key
Parse the Idempotency-Key request header. Returns Ok(None) if absent. The key must be ASCII and reasonably short to avoid storage abuse.