DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

Idempotency keys: making a POST safe to retry

Day 16 of building OrderHub in the open, and the finale of Phase 2. Yesterday I gave the app retries, timeouts and a bulkhead — so a flaky dependency degrades gracefully instead of taking the API down. But retries have a dark side on writes. The network only ever promises at-least-once delivery. A client fires POST /api/orders, the order is created, and then the 201 is lost on the way back. The client sees a timeout and does the only sane thing: it retries. Now there are two orders and a double-charged customer. Double-clicks and resumed mobile requests cause the same thing. Idempotency is the contract that makes "send it again" safe.

The contract

An idempotent operation has the same effect whether you apply it once or ten times. GET, PUT and DELETE are naturally idempotent; a POST that creates a row is not. The fix is an Idempotency-Key: the client generates one key per logical action (a UUID) and sends it in a header. The server records it, and on any repeat of that key it returns the original result instead of doing the work again. That's the de-facto industry pattern — Stripe, PayPal, an IETF draft — and it's what lets a client-side retry, a server-side retry, or a double-click all collapse down to a single order.

I didn't want this logic copy-pasted into every controller, so it's a cross-cutting concern: a tiny marker annotation, woven by an aspect.

@PostMapping
@Idempotent
public ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest req) {
    Order order = service.placeOrder(req.customer(), req.item(), req.quantity());
    return ResponseEntity.created(URI.create("/api/orders/" + order.getId()))
                         .body(OrderResponse.from(order));
}
Enter fullscreen mode Exit fullscreen mode

A request with no key is processed normally. The annotation only enables idempotency; the client opts in by sending a key.

Reserve the key atomically

The whole thing hinges on one atomic operation: set-the-key-only-if-it-doesn't-exist. Redis SETNX (here setIfAbsent with a TTL, one round trip) guarantees that when two identical requests race, exactly one gets true back — it owns the key and proceeds; the other gets false. There's no read-then-write gap, and it holds even across app instances, because the check-and-set happens inside Redis, not in my JVM. I reuse the very same Redis that Day 11 caching and Day 13 rate limiting already run on.

public boolean reserve(String key, String requestHash) {
    Boolean won = redis.opsForValue().setIfAbsent(
        "idempotency:" + key,
        write(StoredResponse.inProgress(requestHash)),
        props.lockTtl());
    return Boolean.TRUE.equals(won);   // true == I own it
}
Enter fullscreen mode Exit fullscreen mode

Replay, or 409, or 422

The request that wins the reservation runs the handler exactly once, then overwrites the reservation with a COMPLETED envelope carrying the status, the serialized body and the Location header — at the full replay TTL (24h). Every later repeat with the same key finds COMPLETED and gets that response rebuilt verbatim: same 201, same order, same Location, plus an Idempotency-Replayed: true header so it's observable. The handler never runs twice.

Two edge cases matter. If a second identical request arrives while the first is still running, there's no result to replay yet — so it gets a 409 Conflict ("already being processed, retry shortly") rather than being allowed to create a second order. And if a client reuses a key for a different payload, that's a bug: I fingerprint the request body with SHA-256 on first use and compare on every repeat, returning 422 Unprocessable Entity on a mismatch rather than mis-serving a stale answer.

One more: if the handler throws, no order was created, so I DEL the reservation and rethrow — a legitimate retry mustn't be stuck getting 409. The short lock TTL is the backstop: even if the process is killed mid-request, the reservation self-expires instead of wedging the key for a full day.

Where it lives

Why an aspect and not a servlet filter, like the rate limiter? Because the point is to capture and later replay the handler's result. An @Around aspect sits at exactly the right seam: it reads the header, decides whether to invoke the real method at all, captures the ResponseEntity, and on a repeat hands back a rebuilt one without ever calling the handler. spring-boot-starter-aop was already on the classpath from the Day 14 circuit breaker, so it just works. The TTL, lock TTL and header name are all externalised onto @ConfigurationProperties.

The full-stack test runs the whole app over a real Postgres and Redis via Testcontainers: two POSTs with the same key return one order (the second replayed), a different key creates a distinct order, a reused key with a different body is 422, and eight concurrent requests with one key still create exactly one order — proving the atomic reservation holds under a real race. That's Phase 2 done: caching, cache strategies, rate limiting, circuit breaker, retry/timeout/bulkhead, and now idempotency. Next phase, I start splitting the monolith into services.

The interactive version — flip the key off and watch duplicates pile up, flip it on and watch the store dedupe them — is here: https://dev48v.infy.uk/orderhub.php

Code: https://github.com/dev48v/order-hub-from-zero

Top comments (0)