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>0.1.0</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
On the server
There is nothing to write. The extension installs a request filter, so every POST and PATCH
endpoint in the application is guarded as soon as the dependency is on the classpath — no annotation,
no wrapper, no change to your resource methods. You only ever touch configuration: narrow or widen
the guarded methods, rename the header, require a key, or pick a store (see Configuration reference).
To make the key mandatory on guarded methods (recommended for write APIs), so a request without one
is rejected with 400 instead of silently passing through:
quarkus.idempotency.require-key=true
On the client
The client owns the key. Three rules keep it correct:
-
One key per logical operation. Generate a fresh value (a UUID v4 is ideal) when the user first triggers the action, before the first attempt.
-
Reuse the same key for every retry of that operation — that is what lets the server recognise the retry and replay instead of re-running it.
-
Never reuse a key for a different request. A key sent with a different method, path, query, or body is rejected with
422(see Error responses), which protects against an accidental key collision.
A replayed response carries the Idempotent-Replayed: true header, so the client (and your
observability) can tell a replay from a fresh execution.
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
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.
Configuration reference
All properties live under the quarkus.idempotency prefix and — apart from
buffer-request-body, which is fixed at build time — take effect at runtime. For the security
implications of require-identity, scope-header, max-entries, max-stored-body, and
captured-headers, see Security model; for problem-base-uri, see Error responses.
Configuration property fixed at build time - All other configuration properties are overridable at runtime
Configuration property |
Type |
Default |
|---|---|---|
Whether to force full request-body buffering on all routes. The body must be buffered for the filter to fingerprint it on reactive endpoints, but forcing it converts every streaming endpoint in the application to buffered (bounded by Environment variable: |
boolean |
|
Whether the idempotency filter is active. Environment variable: |
boolean |
|
Name of the request header carrying the idempotency key. Environment variable: |
string |
|
HTTP methods subject to idempotency when the key header is present. Replaying a stored response for a safe/naturally-idempotent method would mask fresh data, so only unsafe methods belong here. Environment variable: |
list of string |
|
How endpoints are selected for idempotency.
Environment variable: |
|
|
Whether a guarded request MUST carry the key header. When Environment variable: |
boolean |
|
Maximum accepted length of an idempotency key. Longer keys are rejected with HTTP 400. Environment variable: |
int |
|
Whether a guarded, keyed request MUST come from an authenticated principal. When Environment variable: |
boolean |
|
Optional request header whose value adds a second isolation dimension (e.g. a tenant id) to the storage namespace, on top of the authenticated principal. Use this for multi-tenant deployments where the tenant is carried in a trusted, gateway-validated header. When unset, scoping is by principal only. Environment variable: |
string |
|
Maximum number of entries the in-memory store retains. Acts as a hard memory ceiling: once reached, the least-recently-used entries are evicted. Has no effect on the Environment variable: |
int |
|
Maximum size of a response body that will be stored for replay. Responses larger than this are not cached (the request passes through and the key is released), bounding per-entry memory. Measured from Environment variable: |
|
|
Maximum number of request-body bytes fed into the payload fingerprint. Caps the CPU and allocation an attacker can drive with a large allowed body; bytes beyond this bound are not hashed (the true body length is still mixed in, so truncation does not cause collisions). Environment variable: |
|
|
Response headers captured and replayed, as an explicit allow-list. Security-sensitive headers ( Environment variable: |
list of string |
|
How long a completed response remains replayable. Environment variable: |
|
|
How long an in-flight reservation is held before it is considered stale (so a crashed request does not block retries forever). Environment variable: |
|
|
Whether to fingerprint the request payload. When enabled, reusing a key with a different payload is rejected with HTTP 422; when disabled, the key alone identifies the request. Environment variable: |
boolean |
|
Whether to store and replay 5xx responses. When Environment variable: |
boolean |
|
Header added to a replayed response so clients and observability can distinguish replays. Set to empty to disable. Environment variable: |
string |
|
Store backend: Environment variable: |
string |
|
Base documentation URI used to build the Environment variable: |
string |
|
|
About the Duration format
To write duration values, use the standard You can also use a simplified format, starting with a number:
In other cases, the simplified format is translated to the
|
|
About the MemorySize format
A size configuration option recognizes strings in this format (shown as a regular expression): If no suffix is given, assume bytes. |