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

409

Same key, different payload

Reject — the key was reused for a different request

422

Key required but missing or invalid

Reject

400

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 quarkus-http-problem, which requires a JSON provider on the classpath — add quarkus-rest-jackson (or quarkus-rest-jsonb) to your application.

The type base is configurable with quarkus.idempotency.problem-base-uri. The problem types are:

Status Type fragment When

400

#idempotency-key-required

Key required (require-key=true) but missing.

400

#idempotency-key-invalid

Key is empty, too long, or has control characters.

401

#authentication-required

require-identity=true and the caller is anonymous.

409

#idempotency-key-conflict

A request with the same key is still in flight.

422

#idempotency-key-mismatch

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 quarkus.redis.max-pool-size must be sized at or above the peak number of concurrent guarded requests. The default pool (6 connections + 24 wait queue) overflows under load and surfaces as HTTP 500 (ConnectionPoolTooBusyException).

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, quarkus.http.auth.proactive=true). With proactive auth disabled, an authenticated caller may read as anonymous in the filter and be scoped into the shared anonymous namespace. The extension logs a warning at startup in that case. Keep proactive auth on, or scope by a trusted scope-header.

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 memorymax-entries × your largest cached response. max-entries is 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 a maxmemory/eviction policy.

  • max-stored-body skips caching oversized responses (measured from Content-Length/byte[]/ String; object entities serialized later are bounded only by max-entries).

  • max-fingerprint-body caps the per-request hashing work and allocation.

  • Set lock-ttl above 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 with quarkus.http.limits.max-body-size, or set quarkus.idempotency.buffer-request-body=false if 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 quarkus.http.limits.max-body-size), not just idempotent ones. Disable this if you do not fingerprint request bodies, or if your application already buffers the bodies it needs, to avoid the app-wide blast radius.

Environment variable: QUARKUS_IDEMPOTENCY_BUFFER_REQUEST_BODY

boolean

true

Whether the idempotency filter is active.

Environment variable: QUARKUS_IDEMPOTENCY_ENABLED

boolean

true

Name of the request header carrying the idempotency key.

Environment variable: QUARKUS_IDEMPOTENCY_HEADER_NAME

string

Idempotency-Key

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: QUARKUS_IDEMPOTENCY_METHODS

list of string

POST, PATCH

How endpoints are selected for idempotency.

  • all-methods (default): every request using a guarded methods() is handled; an Idempotent annotation can opt individual endpoints in or out.

  • annotated: only endpoints carrying Idempotent are handled, regardless of HTTP method.

Environment variable: QUARKUS_IDEMPOTENCY_STRATEGY

all-methods, annotated

all-methods

Whether a guarded request MUST carry the key header. When true, a matching request without a valid key is rejected with HTTP 400.

Environment variable: QUARKUS_IDEMPOTENCY_REQUIRE_KEY

boolean

false

Maximum accepted length of an idempotency key. Longer keys are rejected with HTTP 400.

Environment variable: QUARKUS_IDEMPOTENCY_MAX_KEY_LENGTH

int

255

Whether a guarded, keyed request MUST come from an authenticated principal. When true, a request carrying the key header but no authenticated identity is rejected with HTTP 401. This closes the cross-caller replay window on anonymous traffic: with no identity to scope by, all anonymous callers would otherwise share one namespace. Leave false only when idempotent endpoints are intentionally anonymous and their responses carry no per-caller data.

Environment variable: QUARKUS_IDEMPOTENCY_REQUIRE_IDENTITY

boolean

false

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: QUARKUS_IDEMPOTENCY_SCOPE_HEADER

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 redis store.

Environment variable: QUARKUS_IDEMPOTENCY_MAX_ENTRIES

int

100000

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 Content-Length or a byte[]/String entity; entities whose size cannot be determined cheaply are stored regardless.

Environment variable: QUARKUS_IDEMPOTENCY_MAX_STORED_BODY

MemorySize 

256K

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: QUARKUS_IDEMPOTENCY_MAX_FINGERPRINT_BODY

MemorySize 

1M

Response headers captured and replayed, as an explicit allow-list. Security-sensitive headers (Set-Cookie, Authorization, WWW-Authenticate, and similar) are always denied even if listed here, so a stored response can never leak credentials on replay.

Environment variable: QUARKUS_IDEMPOTENCY_CAPTURED_HEADERS

list of string

Location

How long a completed response remains replayable.

Environment variable: QUARKUS_IDEMPOTENCY_RESPONSE_TTL

Duration 

24H

How long an in-flight reservation is held before it is considered stale (so a crashed request does not block retries forever).

Environment variable: QUARKUS_IDEMPOTENCY_LOCK_TTL

Duration 

60S

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: QUARKUS_IDEMPOTENCY_FINGERPRINT_ENABLED

boolean

true

Whether to store and replay 5xx responses. When false (the default), a server error releases the key so the client can genuinely retry a transient failure.

Environment variable: QUARKUS_IDEMPOTENCY_CACHE_ERROR_RESPONSES

boolean

false

Header added to a replayed response so clients and observability can distinguish replays. Set to empty to disable.

Environment variable: QUARKUS_IDEMPOTENCY_REPLAYED_HEADER

string

Idempotent-Replayed

Store backend: in-memory (default, single node) or redis (distributed, requires quarkus-redis-client on the classpath).

Environment variable: QUARKUS_IDEMPOTENCY_STORE

string

in-memory

Base documentation URI used to build the type field (and Link header) of the RFC 9457 application/problem+json error responses. A per-problem fragment is appended (e.g. #idempotency-key-conflict) so clients can follow the link to the relevant documentation, as the Idempotency-Key draft recommends.

Environment variable: QUARKUS_IDEMPOTENCY_PROBLEM_BASE_URI

string

https://docs.quarkiverse.io/quarkus-http-idempotency/dev/

About the Duration format

To write duration values, use the standard java.time.Duration format. See the Duration#parse() Java API documentation for more information.

You can also use a simplified format, starting with a number:

  • If the value is only a number, it represents time in seconds.

  • If the value is a number followed by ms, it represents time in milliseconds.

In other cases, the simplified format is translated to the java.time.Duration format for parsing:

  • If the value is a number followed by h, m, or s, it is prefixed with PT.

  • If the value is a number followed by d, it is prefixed with P.

About the MemorySize format

A size configuration option recognizes strings in this format (shown as a regular expression): [0-9]+[KkMmGgTtPpEeZzYy]?.

If no suffix is given, assume bytes.