⏱️ Lectura: 7 min

Picture paying online, the page hangs, you click again… and the familiar fear kicks in: “did they just charge me twice?”. That problem —operations running twice when the client retries— is one of the most expensive in payment systems and financial APIs. The open source extension quarkus-http-idempotency, published on Quarkiverse and available on Maven Central, solves it at the server level without you writing any special logic: it makes a POST or PATCH happen exactly once, even when it arrives more than once.

📑 En este artículo
  1. The problem: retries are not free
  2. The solution: the Idempotency-Key header
  3. What quarkus-http-idempotency is
  4. Installation
  5. Quick start
  6. How it behaves in each case
  7. Safe for multi-user and multi-tenant
  8. Stores: local memory or distributed Redis
  9. Configuration
  10. Best practices
  11. Security and robustness
  12. Frequently Asked Questions
  13. References

Data servers and infrastructure

The problem: retries are not free

On the web, retries are unavoidable: a dropped connection, a timeout, an impatient user double-clicking, an HTTP client configured to retry automatically. When the request is read-only (GET), retrying is harmless. But when the request changes something —creating an order, charging a card, transferring money— running it twice can mean two charges, two orders, or two transfers.

The hard part is that the client often doesn’t know whether the first request made it through. If the server processed the charge but the response was lost on the way back, the client thinks it failed and retries. The damage is already done.

The solution: the Idempotency-Key header

The industry solved this with a simple, elegant pattern, popularized by Stripe and formalized in an IETF draft: the Idempotency-Key header.

The idea: the client generates a unique key (for example, a UUID) and sends it with the request. The server remembers that key. If a second request arrives with the same key, the server does not run the operation again: it simply returns the stored response from the first time. The side effect happens exactly once; the client always gets the same answer.

We’ve already covered the concept and its edge cases in depth in previous articles. This one focuses on the tool that implements it in Quarkus in a production-ready way.

What quarkus-http-idempotency is

It’s a Quarkus extension that implements the Idempotency-Key pattern transparently. Its biggest strength: once it’s on the classpath, no code changes are required. Any POST or PATCH carrying an Idempotency-Key header is handled idempotently, automatically.

Key features:

  • Spec-aligned. Follows the IETF draft: 409/422/400 error semantics, a request-body fingerprint, a documented expiry policy, and RFC 9457 (application/problem+json) error bodies.
  • Safe for multi-user and multi-tenant APIs. The store key is a per-caller composite: SHA-256(principal ⊕ scope ⊕ raw-key). That way, one caller can never be served another caller’s stored response. Optional per-tenant isolation via a trusted scope header.
  • Pluggable stores. Bounded in-memory (Caffeine) for a single node, or distributed Redis for clusters, behind a small SPI.
  • Hardened. Bounded memory, capped fingerprint/stored-body sizes, and a deny-list that keeps credential headers (Set-Cookie, Authorization, *-token…) out of stored responses.
  • Reactive-ready. Works with Uni/async return types.

Installation

Add the dependency to your pom.xml:

<dependency>
    <groupId>io.quarkiverse.idempotency</groupId>
    <artifactId>quarkus-http-idempotency</artifactId>
    <version>${quarkus-http-idempotency.version}</version>
</dependency>

With the extension on the classpath, any POST/PATCH carrying an Idempotency-Key header is handled idempotently. You don’t need to touch your endpoints.

Quick start

Let’s see the behavior with two identical calls:

# First call — runs the operation
curl -i -H "Idempotency-Key: 8e039f93" -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: 8e039f93" -H "Content-Type: application/json" \
     -d '{"item":"widget"}' https://api.example.com/orders
# HTTP/1.1 201 Created · Idempotent-Replayed: true

The second response carries the Idempotent-Replayed: true header, signaling it was a replay and not a fresh execution.

Lab: same key runs once, retry replays
Real lab output: same Idempotency-Key runs once; the retry replays the stored response (Idempotent-Replayed: true).

How it behaves in each case

The extension covers the edge cases that naive logic tends to ignore:

Situation Behavior HTTP status
New key Reserve, 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 — key reused for a different request 422
Key required but missing/invalid Reject 400

The 422 case matters: if a client reuses an idempotency key for a different payload, it’s almost always a client bug. Rejecting it prevents silently corrupting data.

Spec-compliant 422 / 409 rejections
Spec-compliant rejections: 422 (key reused with a different body) and 409 (request still in flight).

Safe for multi-user and multi-tenant

A common mistake when rolling this by hand is storing responses in a global cache keyed only by the raw key. The problem: if two different users pick the same key (or an attacker guesses one), one could receive the other’s response —a serious data leak.

quarkus-http-idempotency prevents this by making the store key a per-caller composite: SHA-256(principal ⊕ scope ⊕ raw-key). The principal is the authenticated identity; the scope allows extra isolation. The result: one user’s key lives in a space separate from anyone else’s, and they never cross.

For multi-tenant scenarios, you can enable per-tenant isolation via a trusted scope header, so each tenant gets its own idempotency space.

Per-tenant isolation: same key in different tenants runs separately
Per-tenant isolation: the same key in different tenants runs separately; only the same tenant + key replays.

Stores: local memory or distributed Redis

The extension separates the idempotency logic from where responses are stored, behind an SPI:

  • in-memory (default) — a Caffeine cache bounded by max-entries. Ideal for a single node or development.
  • redis — for multi-node clusters. Add the Redis client and switch the store:
# application.properties
quarkus.idempotency.store=redis
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-redis-client</artifactId>
</dependency>

The Redis store reserves each key with a single atomic SET NX GET PX round-trip (requires Redis 7.0+). This guarantees that even with many nodes handling retries in parallel, only one wins the reservation and runs the operation.

Configuration

All properties live under the quarkus.idempotency.* prefix: header name, guarded methods, TTLs, store backend, per-tenant scope, and resource bounds. A typical example:

# Enable the distributed store
quarkus.idempotency.store=redis

# (Illustrative; check the official docs for the exact property
#  names and default values)
# - idempotency header name
# - guarded HTTP methods (POST, PATCH)
# - time-to-live of stored keys (TTL)
# - size limits for fingerprint and stored body

The full property reference and the security model are in the official documentation.

Best practices

  • Generate the key on the client, one per operation intent. A fresh UUID v4 per user action (not per retry). All retries of that action share the same key.
  • Guard only state-changing methods. POST and PATCH need it; GET, HEAD, and a naturally idempotent PUT don’t.
  • Set a TTL matching your retry window. Keys shouldn’t live forever: long enough to cover realistic retries (minutes to hours, depending on the case).
  • Use Redis in production with more than one node. The in-memory cache isn’t shared across instances; in a cluster, two nodes could run the same operation. Redis with the atomic reservation prevents that.
  • Never store credentials in the response. The extension already excludes sensitive headers by default; don’t turn that off.
  • Don’t reuse a key for different payloads. If the operation changes, change the key. The 422 is there to catch you if you slip.

Security and robustness

The extension is hardened for production:

  • Bounded memory. The in-memory store has an entry cap; it won’t grow unbounded under a flood of keys.
  • Capped sizes. The body fingerprint and the stored response have limits, preventing a giant body from exhausting memory.
  • Unconditional deny-list. Headers like Set-Cookie, Authorization, and anything ending in -token are never stored in the replayed response, avoiding credential leakage across retries.
  • Standard errors. Rejections use RFC 9457 (application/problem+json) with a Link to the docs, easy for clients to consume.

Frequently Asked Questions

Do I have to change my code to use it? No. With the extension on the classpath, any POST/PATCH with the Idempotency-Key header is handled automatically.

What happens if the client doesn’t send the header? It depends on your configuration: the request can be processed normally (with no idempotency guarantee) or rejected with 400 if you require the key.

Is it useful for microservices? Yes, especially. In distributed architectures with automatic retries between services, idempotency is essential. Use the Redis store to share state across instances.

Does it work with reactive code? Yes, it supports Uni/async return types.

References


0 Comments

Leave a Reply

Avatar placeholder

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.