Stop Duplicate Checkout Work at the HTTP Boundary
A practical Quarkus pattern for safer retries, with the storage, crash, and multi-replica limits made explicit.
The user clicks Place order. The button starts spinning. After 600 milliseconds, the client gives up and shows a timeout.
The server keeps working. At 750 milliseconds it commits the order and writes 201 Created to a connection nobody is listening to anymore. The client sees a failed request. The database contains a successful order. Both views are correct, and that is what makes this problem difficult.
A normal retry sends the same JSON again. The server sees a second valid POST, so it can create another order and another fulfillment request. In a real checkout, that can also mean a second payment or stock reservation. One missing response has turned into a duplicate business operation.
Disabling the button helps with double-clicks. Debouncing helps too. Neither tells the browser whether the server committed after the connection disappeared. Refreshes, offline queues, client libraries, gateways, and two open tabs can all send the command again.
Idempotency gives the retry a stable meaning. The client sends one key for one logical checkout and keeps that key while the result is uncertain. The server reserves it before the handler runs, stores the completed HTTP response, and replays that response when the same request comes back.
We will build this flow with Quarkus, PostgreSQL, and the Quarkus HTTP Idempotency extension. The endpoint waits long enough to make concurrent requests visible, sends one fake fulfillment request, and writes one order. Then we will check the first request, a completed replay, a concurrent retry, and accidental key reuse with a different payload.
What You Need
This example uses Quarkus 3.37.2, Java 21, Quarkus HTTP Idempotency 0.1.0, and PostgreSQL 18.4. Dev Services starts PostgreSQL through Podman, so the application does not need a checked-in development password or port.
Java 25 or newer
Quarkus CLI
Podman with a running machine on macOS or Windows
curlAbout two ☕️
The commands below use readable keys so the request flow is easy to follow. A real frontend should normally generate a UUID when the user starts checkout, keep it until the operation has a final result, and create a new UUID for the next checkout.
The extension also computes a request fingerprint: a SHA-256 hash over the method, normalized path, query, and body. The key says which operation this is. The fingerprint catches a client that accidentally uses the same key for different input.
Create the Project
The Quarkus CLI registry used for this walkthrough did not resolve the new Quarkiverse artifact by name yet. Generate the application with the platform-managed extensions, then add the idempotency dependency explicitly.
Create the project or start from my Github repository:
quarkus create app -B \
-P io.quarkus.platform:quarkus-bom:3.37.2 \
--maven \
--java=25 \
--no-code \
--extensions='rest-jackson,hibernate-validator,hibernate-orm-panache,jdbc-postgresql,flyway' \
com.themainthread:retry-safe-checkout
cd retry-safe-checkoutUse these extensions:
quarkus-rest-jacksonexposes the JSON API and supplies the JSON provider required by the extension’s RFC 9457 error responsesquarkus-hibernate-validatorrejects blank SKUs and non-positive quantities before they reach the servicequarkus-hibernate-orm-panachestores and queries ordersquarkus-jdbc-postgresqlconnects the application to PostgreSQLquarkus-flywayowns the schema migration
Add the extension version to the <properties> section of pom.xml:
<http-idempotency.version>0.1.0</http-idempotency.version>Then add its dependency:
<dependency>
<groupId>io.quarkiverse.idempotency</groupId>
<artifactId>quarkus-http-idempotency</artifactId>
<version>${http-idempotency.version}</version>
</dependency>The generated project includes the Quarkus JUnit integration. Add RestAssured for the HTTP assertions used later:
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>Version 0.1.0 is the current published release. The extension catalog marks it experimental, requires Java 21, and lists Quarkus 3.37.0 as its build version.
Create the Order Table
The idempotency store remembers HTTP results. PostgreSQL remains the source of truth for orders. Keeping those two jobs separate matters later when we discuss crashes and multiple replicas.
Create src/main/resources/db/migration/V1__create_purchase_orders.sql:
CREATE TABLE purchase_orders (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
sku VARCHAR(40) NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0),
status VARCHAR(20) NOT NULL,
fulfillment_reference VARCHAR(32) NOT NULL UNIQUE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL
);The unique constraint prevents one fulfillment reference from being attached to two rows. It cannot recognize two different references as the same logical checkout, so it is not a substitute for the HTTP key or a stable business identifier.
Model the Checkout
Start with the request and response records. Create src/main/java/com/themainthread/checkout/CheckoutRequest.java:
package com.themainthread.checkout;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
public record CheckoutRequest(
@NotBlank String sku,
@Positive int quantity) {
}Create OrderStatus.java in the same package:
package com.themainthread.checkout;
public enum OrderStatus {
ACCEPTED
}Create PurchaseOrder.java:
package com.themainthread.checkout;
import java.time.Instant;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
@Entity
@Table(name = "purchase_orders")
public class PurchaseOrder extends PanacheEntityBase {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
public Long id;
@Column(nullable = false, length = 40)
public String sku;
@Column(nullable = false)
public int quantity;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
public OrderStatus status;
@Column(name = "fulfillment_reference", nullable = false, unique = true, length = 32)
public String fulfillmentReference;
@Column(name = "created_at", nullable = false)
public Instant createdAt;
protected PurchaseOrder() {
}
}The API returns its own record instead of exposing the persistence entity. Create OrderView.java:
package com.themainthread.checkout;
import java.time.Instant;
public record OrderView(
long id,
String sku,
int quantity,
OrderStatus status,
String fulfillmentReference,
Instant createdAt) {
static OrderView from(PurchaseOrder order) {
return new OrderView(
order.id,
order.sku,
order.quantity,
order.status,
order.fulfillmentReference,
order.createdAt);
}
}The last response type makes the side effects visible while we test. Create CheckoutStats.java:
package com.themainthread.checkout;
public record CheckoutStats(
long orders,
int fulfillmentDispatches,
int processing) {
}The processing count lets the test wait until the first request has entered the business operation before it sends the concurrent retry. That removes a timing guess from the test.
Add the Panache repository in OrderRepository.java:
package com.themainthread.checkout;
import jakarta.enterprise.context.ApplicationScoped;
import io.quarkus.hibernate.orm.panache.PanacheRepository;
@ApplicationScoped
public class OrderRepository implements PanacheRepository<PurchaseOrder> {
}Make the Slow Side Effect Visible
A checkout that completes in two milliseconds is hard to race from a terminal. Our fake fulfillment gateway waits for a configurable delay and counts dispatches.
Create CheckoutConfig.java:
package com.themainthread.checkout;
import java.time.Duration;
import io.smallrye.config.ConfigMapping;
import io.smallrye.config.WithDefault;
@ConfigMapping(prefix = "checkout")
public interface CheckoutConfig {
@WithDefault("750ms")
Duration processingDelay();
}Create FulfillmentGateway.java:
package com.themainthread.checkout;
import java.util.concurrent.atomic.AtomicInteger;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.ServiceUnavailableException;
@ApplicationScoped
public class FulfillmentGateway {
private final CheckoutConfig config;
private final AtomicInteger dispatches = new AtomicInteger();
private final AtomicInteger processing = new AtomicInteger();
public FulfillmentGateway(CheckoutConfig config) {
this.config = config;
}
public String dispatch(CheckoutRequest request) {
processing.incrementAndGet();
try {
Thread.sleep(config.processingDelay().toMillis());
return "FUL-%04d".formatted(dispatches.incrementAndGet());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ServiceUnavailableException("Fulfillment dispatch was interrupted");
} finally {
processing.decrementAndGet();
}
}
public int dispatchCount() {
return dispatches.get();
}
public int processingCount() {
return processing.get();
}
}This is intentionally a fake gateway. The sleep creates the race window, and the counter stands in for an external side effect such as reserving stock or sending a fulfillment command. The production section deals with the part this simulation cannot make atomic.
Write the Order Once
The service dispatches fulfillment, persists the order, and returns the API record. Create OrderService.java:
package com.themainthread.checkout;
import java.time.Instant;
import java.util.Optional;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
@ApplicationScoped
public class OrderService {
private final OrderRepository orderRepository;
private final FulfillmentGateway fulfillmentGateway;
public OrderService(OrderRepository orderRepository, FulfillmentGateway fulfillmentGateway) {
this.orderRepository = orderRepository;
this.fulfillmentGateway = fulfillmentGateway;
}
@Transactional
public OrderView create(CheckoutRequest request) {
String fulfillmentReference = fulfillmentGateway.dispatch(request);
PurchaseOrder order = new PurchaseOrder();
order.sku = request.sku();
order.quantity = request.quantity();
order.status = OrderStatus.ACCEPTED;
order.fulfillmentReference = fulfillmentReference;
order.createdAt = Instant.now();
orderRepository.persistAndFlush(order);
return OrderView.from(order);
}
public Optional<OrderView> find(long id) {
return orderRepository.findByIdOptional(id).map(OrderView::from);
}
public CheckoutStats stats() {
return new CheckoutStats(
orderRepository.count(),
fulfillmentGateway.dispatchCount(),
fulfillmentGateway.processingCount());
}
}@Transactional covers the PostgreSQL write. It does not include the fake gateway. A real HTTP call, message publish, or third-party payment request would sit outside the database transaction in the same way.
Guard Only the Checkout Endpoint
The extension can guard every configured HTTP method, or it can use annotations. I prefer the annotated strategy here because the boundary is visible on the write endpoint and future POST methods do not become guarded by accident.
Create OrderResource.java:
package com.themainthread.checkout;
import java.net.URI;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder;
import io.quarkiverse.idempotency.runtime.Idempotent;
import io.smallrye.common.annotation.Blocking;
@Path("/orders")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class OrderResource {
private final OrderService orderService;
public OrderResource(OrderService orderService) {
this.orderService = orderService;
}
@POST
@Blocking
@Idempotent(requireKey = Idempotent.Require.REQUIRED)
public Response create(@Valid CheckoutRequest request) {
OrderView order = orderService.create(request);
URI location = UriBuilder.fromResource(OrderResource.class)
.path(OrderResource.class, "get")
.build(order.id());
return Response.created(location).entity(order).build();
}
@GET
@Path("/{id}")
@Blocking
public OrderView get(@PathParam("id") long id) {
return orderService.find(id).orElseThrow(NotFoundException::new);
}
@GET
@Path("/stats")
@Blocking
public CheckoutStats stats() {
return orderService.stats();
}
}@Idempotent(requireKey = REQUIRED) does two things. It opts this method into the filter, and it rejects a checkout without Idempotency-Key with 400 Bad Request. The GET endpoints remain normal reads.
The resource is blocking because it uses JDBC and the fake gateway sleeps. The idempotency store lookup itself can suspend and resume a request without blocking the event loop, but that does not make our handler reactive.
Configure the Boundary
Create src/main/resources/application.properties:
quarkus.application.name=retry-safe-checkout
quarkus.datasource.db-kind=postgresql
quarkus.datasource.jdbc.min-size=2
quarkus.datasource.jdbc.max-size=12
quarkus.datasource.jdbc.acquisition-timeout=3s
%dev,test.quarkus.datasource.devservices.image-name=docker.io/library/postgres:18.4-alpine3.24
%dev,test.quarkus.datasource.devservices.db-name=checkout
%dev,test.quarkus.datasource.devservices.username=checkout
%dev,test.quarkus.datasource.devservices.password=checkout
quarkus.flyway.migrate-at-start=true
quarkus.hibernate-orm.schema-management.strategy=validate
quarkus.hibernate-orm.log.sql=false
quarkus.idempotency.strategy=annotated
quarkus.idempotency.store=in-memory
quarkus.idempotency.fingerprint-enabled=true
quarkus.idempotency.lock-ttl=30s
quarkus.idempotency.response-ttl=24h
quarkus.idempotency.max-entries=5000
quarkus.idempotency.max-stored-body=8K
quarkus.idempotency.max-fingerprint-body=64K
quarkus.idempotency.captured-headers=Location
quarkus.idempotency.cache-error-responses=false
quarkus.http.limits.max-body-size=64K
checkout.processing-delay=750ms
%test.checkout.processing-delay=350msstrategy=annotated makes the annotation authoritative. store=in-memory is explicit because this article verifies one application instance. The store forgets everything on restart and cannot coordinate multiple replicas.
lock-ttl=30s keeps an in-flight reservation alive longer than the 750-millisecond handler. Set this above the worst valid handler latency. If the lock expires while the first handler is still running, another request can acquire the key and run concurrently.
Completed responses stay replayable for 24 hours. The client must retain the key for the same period. Reusing it after expiry creates a fresh operation.
The memory limits deserve arithmetic. Five thousand entries with an 8 KiB stored-body ceiling means about 40 MiB of response bodies at the configured maximum, plus keys, fingerprints, headers, and map overhead. Measure real response sizes before changing those values.
The extension buffers request bodies by default so it can fingerprint reactive requests. That applies across the application, so quarkus.http.limits.max-body-size=64K caps the allocation. max-fingerprint-body separately caps how many body bytes enter the hash.
captured-headers=Location preserves the order URL on replay. The extension always rejects credential-bearing headers from capture, even if they are added to this list.
Finally, cache-error-responses=false releases the key after a 5xx response. A client can retry a transient server failure instead of receiving the same stored failure for 24 hours.
Run the Checkout
Start Podman if your platform uses a Podman machine, then run Quarkus:
podman machine start
./mvnw quarkus:devLinux users with a running Podman socket can skip the machine command. Dev Services starts PostgreSQL, Flyway applies the migration, and the extension logs its active store:
Idempotency active: store=in-memory (InMemoryIdempotencyStore),
methods=[POST, PATCH], header=Idempotency-Key,
response-ttl=PT24H, max-entries=5000, require-identity=falseCreate the first order:
curl -i \
-H 'Idempotency-Key: checkout-demo-1' \
-H 'Content-Type: application/json' \
-d '{"sku":"keyboard-1","quantity":1}' \
http://localhost:8080/ordersThe verified response is:
HTTP/1.1 201 Created
Content-Type: application/json;charset=UTF-8
Location: http://localhost:8080/orders/1
{"createdAt":"2026-07-15T11:53:58.828540Z","fulfillmentReference":"FUL-0001","id":1,"quantity":1,"sku":"keyboard-1","status":"ACCEPTED"}Send the same request with the same key:
curl -i \
-H 'Idempotency-Key: checkout-demo-1' \
-H 'Content-Type: application/json' \
-d '{"sku":"keyboard-1","quantity":1}' \
http://localhost:8080/ordersThe status, Location header, and body are the same. The extra header tells us this response came from the idempotency store:
HTTP/1.1 201 Created
Content-Type: application/json;charset=UTF-8
Idempotent-Replayed: true
Location: http://localhost:8080/orders/1
{"createdAt":"2026-07-15T11:53:58.828540Z","fulfillmentReference":"FUL-0001","id":1,"quantity":1,"sku":"keyboard-1","status":"ACCEPTED"}Check the side effects:
curl -s http://localhost:8080/orders/stats{"fulfillmentDispatches":1,"orders":1,"processing":0}Two HTTP responses produced one fulfillment dispatch and one PostgreSQL row.
Reuse the Key Incorrectly
Keep the key and change the quantity:
curl -i \
-H 'Idempotency-Key: checkout-demo-1' \
-H 'Content-Type: application/json' \
-d '{"sku":"keyboard-1","quantity":2}' \
http://localhost:8080/ordersThe fingerprint differs, so the extension returns an RFC 9457 problem document:
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
{
"type": "https://docs.quarkiverse.io/quarkus-http-idempotency/dev/#idempotency-key-mismatch",
"status": 422,
"title": "Idempotency-Key reused with a different payload",
"detail": "The Idempotency-Key was already used for a request with a different method, path, query, or body.",
"instance": "/orders"
}This is a client bug. Retrying the second payload again will not help. The client needs a new key because it is starting a different logical operation.
Catch a Concurrent Retry
The handler waits 750 milliseconds, which gives us time to send another request while the first key is reserved. Run this from another shell while Quarkus is still running:
KEY=checkout-concurrent-1
BODY='{"sku":"monitor-1","quantity":1}'
curl -s \
-H "Idempotency-Key: $KEY" \
-H 'Content-Type: application/json' \
-d "$BODY" \
http://localhost:8080/orders > /tmp/first-order.json &
FIRST_PID=$!
sleep 0.15
curl -i \
-H "Idempotency-Key: $KEY" \
-H 'Content-Type: application/json' \
-d "$BODY" \
http://localhost:8080/orders
wait "$FIRST_PID"The second request arrives while the first one is still processing:
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
"type": "https://docs.quarkiverse.io/quarkus-http-idempotency/dev/#idempotency-key-conflict",
"status": 409,
"title": "Request already in progress",
"detail": "A request with this Idempotency-Key is still being processed.",
"instance": "/orders"
}A 409 here means “wait, then retry this same operation with the same key.” Once the first request completes, that retry becomes a normal replay. The extension rejects concurrent work instead of holding a second HTTP connection open for the full handler duration.
Prove the Behavior in Tests
Terminal commands are good for learning the state machine. The build needs assertions that stop a regression, especially around response serialization.
Create src/test/java/com/themainthread/checkout/OrderResourceTest.java:
package com.themainthread.checkout;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.nullValue;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.fail;
import java.time.Duration;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.response.Response;
@QuarkusTest
class OrderResourceTest {
private static final String BODY = """
{"sku":"keyboard-1","quantity":1}
""";
@Test
void sameKeyReplaysTheOriginalResponse() {
CheckoutStats before = stats();
String key = UUID.randomUUID().toString();
Response first = postOrder(key, BODY)
.then()
.statusCode(201)
.header("Idempotent-Replayed", nullValue())
.extract().response();
Response replay = postOrder(key, BODY)
.then()
.statusCode(201)
.header("Idempotent-Replayed", equalTo("true"))
.extract().response();
assertEquals(first.asString(), replay.asString());
assertEquals(first.header("Location"), replay.header("Location"));
CheckoutStats after = stats();
assertEquals(before.orders() + 1, after.orders());
assertEquals(before.fulfillmentDispatches() + 1, after.fulfillmentDispatches());
}
@Test
void sameKeyWithDifferentPayloadIsRejected() {
String key = UUID.randomUUID().toString();
postOrder(key, BODY).then().statusCode(201);
postOrder(key, """
{"sku":"keyboard-1","quantity":2}
""")
.then()
.statusCode(422)
.contentType("application/problem+json")
.body("status", equalTo(422))
.body("title", equalTo("Idempotency-Key reused with a different payload"));
}
@Test
void concurrentRetryGetsConflictThenCanReplay() throws Exception {
CheckoutStats before = stats();
String key = UUID.randomUUID().toString();
CompletableFuture<Response> firstCall = CompletableFuture.supplyAsync(() -> postOrder(key, BODY));
waitUntilProcessing(Duration.ofSeconds(5));
postOrder(key, BODY)
.then()
.statusCode(409)
.contentType("application/problem+json")
.body("status", equalTo(409));
firstCall.get(5, TimeUnit.SECONDS).then().statusCode(201);
postOrder(key, BODY)
.then()
.statusCode(201)
.header("Idempotent-Replayed", equalTo("true"));
CheckoutStats after = stats();
assertEquals(before.orders() + 1, after.orders());
assertEquals(before.fulfillmentDispatches() + 1, after.fulfillmentDispatches());
}
@Test
void missingKeyIsRejectedOnTheAnnotatedEndpoint() {
given()
.contentType("application/json")
.body(BODY)
.when().post("/orders")
.then()
.statusCode(400)
.contentType("application/problem+json")
.body("status", equalTo(400));
}
private Response postOrder(String key, String body) {
return given()
.header("Idempotency-Key", key)
.contentType("application/json")
.body(body)
.when().post("/orders");
}
private CheckoutStats stats() {
return given()
.when().get("/orders/stats")
.then().statusCode(200)
.extract().as(CheckoutStats.class);
}
private void waitUntilProcessing(Duration timeout) throws InterruptedException {
long deadline = System.nanoTime() + timeout.toNanos();
while (System.nanoTime() < deadline) {
if (stats().processing() > 0) {
return;
}
Thread.sleep(25);
}
fail("Timed out waiting for checkout processing to start");
}
}The first test compares the complete response body and Location header. Counting rows alone is too weak: a replay that returns corrupted JSON still leaves one row in PostgreSQL.
The concurrency test waits for processing > 0 before sending the second request. It asserts 409 during the reservation, then retries after completion and expects a replay. This tests the full state transition instead of depending on a lucky scheduler delay.
Run the test suite:
./mvnw testExpected result:
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESSWhere the Guarantee Stops
The happy path is solid for one running instance: one key reserves one handler execution, and a completed retry gets the stored HTTP response. Three boundaries still decide whether this belongs in production.
The in-memory store is one JVM
A load balancer can send two retries to different replicas. Two in-memory maps then reserve the same client key independently, and both handlers run. A restart also removes completed responses before the 24-hour client retry window ends.
The extension guide describes a Redis store for shared reservations and responses. It requires Redis 7.0 or newer and the quarkus-redis-client extension.
The published 0.1.0 release has a blocker for our typed JSON response. I enabled Redis, repeated the verified curl sequence, and received this replay body:
{createdAt=2026-07-15T11:51:30.101593Z, fulfillmentReference=FUL-0001,
id=1, quantity=1, sku=keyboard-1, status=ACCEPTED}That is a Java Map.toString() representation, not JSON. The current main branch contains a materializeBody() path in RedisIdempotencyStore that pre-renders replay bodies, but it is not part of the 0.1.0 artifact used here.
I would keep this version on one instance and treat Redis as blocked. When a newer release includes the fix, add quarkus-redis-client, configure Redis with authentication and TLS, and run sameKeyReplaysTheOriginalResponse() against that backend before adding replicas. The assertion on the complete body is the release gate.
The HTTP filter cannot create a distributed transaction
There is a small but serious crash window. The extension reserves the key, our gateway dispatches fulfillment, PostgreSQL commits, and then the response store records the completed result. If the process dies after the side effect but before the response is stored, the in-flight reservation eventually expires. A later retry can run the handler again.
A real checkout still needs business-level protection. Pass the logical operation ID to the payment or fulfillment provider when it supports idempotency. For a message broker, write an outbox row in the same PostgreSQL transaction and make the consumer deduplicate its command. Keep unique constraints on stable business identifiers.
The HTTP key removes the common duplicate-retry path. It does not make Redis, PostgreSQL, and an external service one atomic system.
Replays must stay inside the caller’s security boundary
The extension derives its storage key from the authenticated principal, an optional trusted scope header, and the raw client key. Anonymous requests share one namespace. This demo is anonymous and returns no per-user secrets, which keeps the example small.
A real checkout should authenticate callers and set:
quarkus.idempotency.require-identity=trueKeep Quarkus proactive authentication enabled so the identity exists before the idempotency filter runs. Put authorization in declarative security rules or annotations that execute before the resource method. A replay short-circuits the method body, so an authorization check written only inside create() is not evaluated again.
For tenant scoping, quarkus.idempotency.scope-header must name a header inserted and validated by a trusted gateway. Accepting a tenant header directly from internet clients lets them claim another tenant’s key namespace.
Streaming responses form another hard boundary. The extension cannot buffer Multi, Server-Sent Events, or StreamingOutput for replay. It releases those keys, and a retry runs the endpoint again.
Conclusion
We built a checkout API where a client can retry an ambiguous POST and receive the original 201 response without creating a second order. The tests prove replay, in-flight conflict, fingerprint mismatch, and required-key behavior, while the production boundary stays honest: version 0.1.0 is a verified single-instance path, and clustered Redis use needs a released serialization fix plus the same end-to-end assertions.


