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.4.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.
On Quarkus 4, also add the Quarkus 4 line of quarkus-http-problem (4.x) to your application. The
extension brings in the 3.x line, which registers its serializer with Jackson 2 only, so on Quarkus 4
the error responses would not be rendered as problem details:
<dependency>
<groupId>io.quarkiverse.httpproblem</groupId>
<artifactId>quarkus-http-problem</artifactId>
<version>4.0.0.Beta1</version>
</dependency>
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).
When a single endpoint needs different behavior, use the optional @Idempotent annotation
(see Per-endpoint control).
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.
Per-endpoint control
The global configuration applies to every endpoint. To change the behavior of one resource method
(or every method of one resource class), annotate it with @Idempotent:
import java.time.temporal.ChronoUnit;
import io.quarkiverse.idempotency.runtime.Idempotent;
@Path("/orders")
public class OrderResource {
// Key mandatory here, and the response kept for 24 hours instead of response-ttl
@POST
@Idempotent(requireKey = Idempotent.Require.REQUIRED, ttl = 24, ttlUnit = ChronoUnit.HOURS)
public Response create(Order order) { ... }
// Excluded even though POST is a guarded method
@POST
@Path("/search")
@Idempotent(enabled = false)
public List<Order> search(Query query) { ... }
// Opted in even though PUT is not a guarded method
@PUT
@Path("/{id}")
@Idempotent
public Response replace(@PathParam("id") String id, Order order) { ... }
}
| Attribute | Default | Meaning |
|---|---|---|
|
|
|
|
|
|
|
|
Response retention for this endpoint; a value |
|
|
Unit of |
A method-level annotation takes precedence over a class-level one. To guard only annotated
endpoints instead of every POST/PATCH, set:
quarkus.idempotency.strategy=annotated
The annotation is discovered at build time, so it works in native images. It only tunes whether and how the filter guards the endpoint; the key is still read from the header and per-caller scoping always applies. Keep these points in mind:
-
It is not inherited. Put it on the concrete resource class or method that JAX-RS dispatches to, not on a superclass or a resource interface. A class-level annotation applies only to methods declared on that class.
-
Opting in a
GETreplays one stored representation regardless of the retry’sAcceptorAccept-Language, because those headers are not part of the fingerprint. Use it only where the endpoint serves a single representation. -
requireKey = OPTIONALoverrides a globalrequire-key=true, so review annotations together with that setting. -
ttlcan exceed the globalresponse-ttl, which keeps stored responses (and any data in them) for longer. Keep it within your retention policy. -
Replays skip in-method authorization (see Security model). Opting in a
PUTorDELETEwhose access check is time- or state-sensitive and lives inside the method is the riskiest case.
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+.
|
Every guarded request takes a connection from the Redis client pool for its store round-trips, so
|
JDBC (any relational database)
For multi-instance deployments that already run a relational database, use the JDBC store. It works with any JDBC driver through the application’s default datasource. Add Agroal and your driver, and select the store:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-agroal</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-postgresql</artifactId> <!-- or -mysql, -mariadb, -oracle, -mssql, ... -->
</dependency>
quarkus.idempotency.store=jdbc
# Optional, the default is idempotency_entry
quarkus.idempotency.jdbc.table=idempotency_entry
A key is reserved atomically through the primary key (the INSERT either wins or fails with an
integrity violation), and an expired key is reclaimed with a single conditional UPDATE. JDBC is
blocking, so each store call runs on a worker thread; prefer the Redis store for fully reactive,
high-throughput workloads.
The extension never issues DDL: create the table once before enabling the store. The response
column holds the serialized response as binary, and the two *_expires_at columns hold
epoch-millis.
CREATE TABLE idempotency_entry (
id VARCHAR(255) PRIMARY KEY,
fingerprint VARCHAR(255),
in_flight SMALLINT NOT NULL,
response BYTEA,
lock_expires_at BIGINT NOT NULL,
response_expires_at BIGINT
);
CREATE TABLE idempotency_entry (
id VARCHAR(255) PRIMARY KEY,
fingerprint VARCHAR(255),
in_flight SMALLINT NOT NULL,
response LONGBLOB,
lock_expires_at BIGINT NOT NULL,
response_expires_at BIGINT
);
CREATE TABLE idempotency_entry (
id VARCHAR2(255) PRIMARY KEY,
fingerprint VARCHAR2(255),
in_flight NUMBER(1) NOT NULL,
response BLOB,
lock_expires_at NUMBER(19) NOT NULL,
response_expires_at NUMBER(19)
);
CREATE TABLE idempotency_entry (
id VARCHAR(255) PRIMARY KEY,
fingerprint VARCHAR(255),
in_flight SMALLINT NOT NULL,
response VARBINARY(MAX),
lock_expires_at BIGINT NOT NULL,
response_expires_at BIGINT
);
Expiry is enforced lazily on read: an expired entry is never replayed, it is reclaimed in place as a
new reservation, so the store stays correct with no background job. A row whose key never comes back
is only reclaimed if that key is seen again, so to bound table growth run a periodic purge (a cron
job, a scheduled task, or quarkus-scheduler if you already depend on it), binding the current
epoch-millis to both parameters:
DELETE FROM idempotency_entry
WHERE (in_flight = 1 AND lock_expires_at < ?)
OR (in_flight = 0 AND response_expires_at IS NOT NULL AND response_expires_at < ?);
Metrics
With quarkus-micrometer on the classpath (for example through
quarkus-micrometer-registry-prometheus), the extension registers these counters. Without
Micrometer, nothing is recorded and no extra dependency is pulled in.
| Counter | Counts |
|---|---|
|
Keyed requests that ran the handler for the first time. |
|
Retries answered from the stored response. |
|
Retries rejected with |
|
Keys reused with a different payload, rejected with |
|
Responses captured and persisted for replay. |
|
Reserved keys released without storing a response ( |
|
Failed store operations: reserving a key (the request fails with |
In Prometheus they appear as idempotency_requests_total{outcome="…"},
idempotency_entries_stored_total, idempotency_entries_released_total and
idempotency_store_errors_total. Alert on any increase of idempotency_store_errors_total: the
extension fails closed, so a store that cannot reserve keys turns into 5xx responses for your
clients.
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 or the database they are
visible to anyone with access to that store. 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 (or restrict access to the idempotency table).
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 or JDBC 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 |
|
Name of the table holding idempotency entries. Must be a plain SQL identifier. The schema is owned by the application; the extension never issues DDL. See the documentation for the per-database 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. |