Quarkus Idempotency
Make unsafe HTTP requests safe to retry. This extension implements the Idempotency-Key
header (the Stripe / IETF pattern): when a client retries a POST or PATCH with the same key,
the server replays the original response instead of executing the operation twice — so the side
effect happens exactly once.
Installation
Add the dependency to your pom.xml:
<dependency>
<groupId>io.quarkiverse.idempotency</groupId>
<artifactId>quarkus-http-idempotency</artifactId>
<version>{project-version}</version>
</dependency>
With the extension on the classpath, any POST or PATCH request that carries an Idempotency-Key
header is handled idempotently. The extension renders its error responses via quarkus-http-problem,
so your application also needs a JSON provider — add quarkus-rest-jackson (or quarkus-rest-jsonb)
if it does not have one already.
Getting started
The client generates a unique key (a UUID is recommended) and sends it on the request. Retrying the request with the same key returns the stored result:
# First call — runs the operation
curl -i -H "Idempotency-Key: 8e039...f932" \
-H "Content-Type: application/json" \
-d '{"item":"widget"}' https://api.example.com/orders
# HTTP/1.1 201 Created
# Location: /orders/order-1
# Retry with the same key — replays the stored response, the order is NOT created again
curl -i -H "Idempotency-Key: 8e039...f932" \
-H "Content-Type: application/json" \
-d '{"item":"widget"}' https://api.example.com/orders
# HTTP/1.1 201 Created
# Idempotent-Replayed: true
How it works
For a guarded request that carries a key, the extension applies this policy:
| Situation | Behavior | Status |
|---|---|---|
New key |
Reserve the key, run the handler, store and return the response |
the handler’s |
Same key, same payload, completed |
Replay the stored status, body and headers |
the stored one |
Same key, same payload, still in flight |
Reject — a concurrent retry is in progress |
|
Same key, different payload |
Reject — the key was reused for a different request |
|
Key required but missing or invalid |
Reject |
|
No key (and not required) |
Pass through — the request is not made idempotent |
— |
To detect a different payload, the extension computes a fingerprint of the request,
SHA-256(method + normalized-path + query + body), and stores it with the key. The body bytes fed
into the fingerprint are capped by max-fingerprint-body. A replayed response carries an
Idempotent-Replayed: true marker so clients and observability can tell replays apart.
The stored key is never the raw client header. It is derived as
SHA-256(principal + scope + raw-key), so the idempotency state of one caller is namespaced to that
caller and can never be served to another — see Security model.
By default only POST and PATCH are guarded — replaying a stored response for a safe or
naturally idempotent method (GET, PUT, DELETE) would mask fresh data.
Async and streaming endpoints
The store lookup is non-blocking, so guarded endpoints may return synchronous values, Uni, or
CompletionStage — including with the Redis store — without blocking the event loop. Streaming
responses (Multi, Server-Sent Events, StreamingOutput) cannot be buffered for replay, so they are
intentionally not made idempotent: the key is released when the response completes and a retry
re-executes the operation. Do not rely on Idempotency-Key for streaming endpoints.
This behavior follows the IETF
Idempotency-Key
header draft (the Stripe pattern): the 409/422/400 status codes, the payload fingerprint, the
composite (per-caller) cache key, and the documented expiry policy are all what the draft calls for.
Error responses
Rejections are rendered as RFC 9457 (application/problem+json) documents by the
quarkus-http-problem extension. The type
URI points to the relevant documentation, as the draft recommends:
{
"type": "https://docs.quarkiverse.io/quarkus-http-idempotency/dev/#idempotency-key-mismatch",
"title": "Idempotency-Key reused with a different payload",
"status": 422,
"detail": "The Idempotency-Key was already used for a request with a different method, path, query, or body.",
"instance": "/orders"
}
|
Rendering uses |
The type base is configurable with quarkus.idempotency.problem-base-uri. The problem types are:
| Status | Type fragment | When |
|---|---|---|
|
Key required ( |
|
|
Key is empty, too long, or has control characters. |
|
|
|
|
|
A request with the same key is still in flight. |
|
|
The key was reused with a different payload. |
Stores
The reservation/replay state lives in a pluggable store.
In-memory (default)
A single-node, in-memory store. No configuration needed. Ideal for a single instance or for development. State is lost on restart and is not shared across instances.
Redis (distributed)
For multi-instance deployments, use Redis. Add the Redis client and select the store:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-redis-client</artifactId>
</dependency>
quarkus.idempotency.store=redis
quarkus.redis.hosts=redis://localhost:6379
# Size the pool at or above your peak concurrent guarded requests (see note below)
quarkus.redis.max-pool-size=50
The Redis store reserves a key with a single atomic SET key value NX GET PX ttl round-trip and
requires Redis 7.0+.
|
The store path is blocking, so |
At startup the extension logs the active store, which makes a misconfiguration obvious:
Idempotency active: store=redis (RedisIdempotencyStore), methods=[POST], header=Idempotency-Key, response-ttl=PT10M
Configuration reference
All properties are runtime configuration under the quarkus.idempotency prefix.
| Property | Default | Description |
|---|---|---|
|
|
Master switch. |
|
|
Request header carrying the key. |
|
|
HTTP methods guarded when the header is present. |
|
|
Require the key on guarded methods (otherwise |
|
|
Reject keyed requests from anonymous callers with |
|
(unset) |
Trusted request header (e.g. a tenant id) added as a second isolation dimension. See Security model. |
|
|
Reject longer keys with |
|
|
In-memory store hard ceiling on entry count (LRU eviction). No effect on Redis. |
|
|
Responses larger than this are not cached (measured from |
|
|
Cap on request-body bytes hashed into the fingerprint. |
|
|
Allow-list of response headers replayed; credential headers are always denied. |
|
|
How long a completed response stays replayable. |
|
|
In-flight reservation timeout. Must exceed worst-case handler latency. |
|
|
Fingerprint the payload (needed for the |
|
|
Store and replay |
|
|
Header added on a replay (empty to disable). |
|
|
Store backend: |
|
Documentation base URI used in the |
|
|
|
(build time) Force app-wide request-body buffering so reactive bodies can be fingerprinted. See Security model. |
Security model
Idempotency replays a previously stored response. Getting the isolation and resource bounds right is what keeps that safe in a multi-user, internet-facing deployment.
Per-caller isolation
The storage key is SHA-256(principal + scope + raw-key), never the raw client header. The
principal is the authenticated identity (from the request SecurityContext); scope is the value
of scope-header when configured. Two different callers that reuse the same Idempotency-Key
therefore land in different namespaces and can never receive each other’s stored response.
|
Per-principal scoping requires the identity to be resolved before the idempotency filter runs,
which is the case with proactive authentication (the Quarkus default,
|
Anonymous traffic
When a keyed request has no authenticated principal, all anonymous callers share one namespace
(scoped only by scope-header, if set). For endpoints that are intentionally anonymous, either set
quarkus.idempotency.require-identity=true to reject keyed anonymous requests with 401, or ensure
those responses contain no per-caller data.
scope-header trust
scope-header is read verbatim from the request. It MUST be set by a trusted gateway and stripped
from inbound client traffic — otherwise a caller can spoof it to pre-claim or poison another tenant’s
key slot.
Replays and method-level authorization
A replay short-circuits before the resource method runs, so authorization performed inside the method body is not re-evaluated on a replay. This is safe for the intended "same caller, same operation" use case because of per-caller scoping; do not rely on in-method authorization as the only access control for idempotent endpoints.
Stored responses and secrets
Stored responses (entity, status, and the allow-listed headers) are persisted — in Redis they are
visible to anyone with Redis access. Credential headers (Set-Cookie, Authorization, and any
header whose name contains token/secret/api-key/password/credential) are always dropped,
regardless of captured-headers. Still, ensure stored response bodies carry no secrets, and run
Redis with AUTH and TLS.
Resource bounds
-
In-memory store memory ≈
max-entries× your largest cached response.max-entriesis a hard LRU ceiling that prevents unbounded growth; size it for your heap and response sizes. For untrusted, internet-facing traffic prefer the Redis store with amaxmemory/eviction policy. -
max-stored-bodyskips caching oversized responses (measured fromContent-Length/byte[]/String; object entities serialized later are bounded only bymax-entries). -
max-fingerprint-bodycaps the per-request hashing work and allocation. -
Set
lock-ttlabove your worst-case handler latency so a reservation is not reassigned while the original request is still running.
Limitations
-
With
buffer-request-body=true(the default) request bodies are buffered app-wide so they can be fingerprinted without a blocking read on reactive endpoints. Bound request size withquarkus.http.limits.max-body-size, or setquarkus.idempotency.buffer-request-body=falseif you do not need body fingerprinting. -
Responses are captured as the response entity, bounded by
max-stored-body. Streaming responses (Multi/SSE/StreamingOutput) are not cached — they pass through and re-execute on retry. -
The in-memory store is per-instance — use the Redis store for clustered deployments.