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::pathreport the full path including the mount, so underRouter::nest("/api", ..)every lookup misses and@no_idempotencysilently does nothing. Usebuild_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, modellist/get). “Read op” is not “GETrequest”: undertransport rpcreads are dispatched byPOST /rpc/{op_id}, so they do reach this layer and are exempted; under REST they areGETand never reach it, so there the exemption is a no-op. Nothing stops aquery procedurefrom writing; if one does, installing a resolver removes its protection, and on RPC that is a live path.
Structs§
- Idempotency
Layer - Tower layer that wires an
IdempotencyStoreinto the request pipeline. - Idempotency
Record - 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.
- Idempotency
Service - OpAdmission
- The participation facts a schema declares about one op, lifted off whichever descriptor the schema’s transport emitted.
Enums§
- Reservation
Outcome - Outcome of an atomic
reserve_or_fetchcall.
Constants§
- IDEMPOTENCY_
TABLE_ DDL - SQL DDL for the idempotency table. Banks typically run migrations through
their own tooling —
cratestackcurrently ships migrations as raw DDL since the migration engine is deferred to Phase 3.
Traits§
Functions§
- build_
rest_ op_ resolver - Build an op resolver for REST schemas, over the generated
ROUTE_TRANSPORTSslice, for a router mounted at the root. - build_
rest_ op_ resolver_ with_ prefix build_rest_op_resolverfor a router mounted underprefix, e.g.build_rest_op_resolver_with_prefix("/api", ROUTE_TRANSPORTS)to matchRouter::nest("/api", router).- build_
rpc_ op_ resolver - Build an op resolver for
transport rpcschemas, over the generatedOPSslice, for a router mounted at the root. - build_
rpc_ op_ resolver_ with_ prefix build_rpc_op_resolverfor a router mounted underprefix, e.g.build_rpc_op_resolver_with_prefix("/api", OPS)to matchRouter::nest("/api", router).- decode_
headers - Decode a blob produced by
encode_headersback into aHeaderMap. 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 opaqueETagtokens 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
pathargument should include the query string so modifier-style flags (?dry_run=true,?confirm=true) don’t collide — the middleware passesUri::path_and_queryfor 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-Keyrequest header. ReturnsOk(None)if absent. The key must be ASCII and reasonably short to avoid storage abuse.