Spring gives you two extension points and you need both: a Filter to install the content-caching wrappers, and a HandlerInterceptor to run the deduplication logic. This runbook builds the JVM implementation of the obligations set out in idempotency middleware & framework integration, and then shows the stronger variant where the key write joins the service transaction.
Prerequisites. Spring Boot 3.x with Spring MVC, a DataSource or a Redis client, and a security filter that has already resolved the credential.
Step 1 — Install the caching wrappers
HttpServletRequest’s input stream is single-read, so the interceptor cannot fingerprint a body the controller also needs. Spring ships wrappers for exactly this, but they must be installed outside the dispatcher.
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 50) // after security, before the dispatcher
public class ContentCachingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
if (!isIdempotentRoute(req)) { chain.doFilter(req, res); return; }
var cachedReq = new ContentCachingRequestWrapper(req, 1_048_576); // 1 MB cap
var cachedRes = new ContentCachingResponseWrapper(res);
try {
chain.doFilter(cachedReq, cachedRes);
} finally {
cachedRes.copyBodyToResponse(); // MUST run, or the client gets nothing
}
}
}
copyBodyToResponse() in a finally block is not optional. The wrapper buffers the response instead of writing it through; forgetting the copy produces an empty body for every request on the route, and it is a spectacularly confusing bug because the status code is correct.
Step 2 — The interceptor check
@Component
@RequiredArgsConstructor
public class IdempotencyInterceptor implements HandlerInterceptor {
private static final Pattern KEY = Pattern.compile("[A-Za-z0-9_-]{16,255}");
private final IdempotencyStore store;
private final ObjectMapper mapper;
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler)
throws IOException {
String raw = req.getHeader("Idempotency-Key");
if (raw == null) return fail(res, 400, "idempotency_key_required");
if (!KEY.matcher(raw).matches()) return fail(res, 400, "idempotency_key_invalid");
byte[] body = ((ContentCachingRequestWrapper) req).getContentAsByteArray();
String fingerprint = sha256Hex(canonicalise(body));
String scoped = "idem:v1:%s:%s:%s".formatted(
CredentialHolder.currentId(), req.getRequestURI(), raw);
Optional<StoredResponse> hit;
try {
hit = store.registerOrGet(scoped, fingerprint, Duration.ofSeconds(60));
} catch (StoreUnavailableException e) {
res.setHeader("Retry-After", "2");
return fail(res, 503, "idempotency_store_unavailable"); // fail closed
}
if (hit.isEmpty()) {
req.setAttribute("idem.key", scoped);
return true; // run the controller
}
StoredResponse r = hit.get();
if (!r.fingerprint().equals(fingerprint)) {
return fail(res, 422, "idempotency_key_payload_mismatch");
}
if (r.pending()) {
res.setHeader("Retry-After", "1");
return fail(res, 409, "idempotency_key_in_use");
}
res.setHeader("Idempotent-Replay", "true");
res.setStatus(r.status());
res.setContentType(MediaType.APPLICATION_JSON_VALUE);
res.getOutputStream().write(r.body());
return false; // short-circuit
}
private boolean fail(HttpServletResponse res, int status, String code) throws IOException {
res.setStatus(status);
res.setContentType(MediaType.APPLICATION_JSON_VALUE);
mapper.writeValue(res.getOutputStream(), Map.of("code", code));
return false;
}
}
Step 3 — Finalise in afterCompletion
@Override
public void afterCompletion(HttpServletRequest req, HttpServletResponse res,
Object handler, Exception ex) {
String scoped = (String) req.getAttribute("idem.key");
if (scoped == null) return;
int status = res.getStatus();
byte[] out = ((ContentCachingResponseWrapper) res).getContentAsByteArray();
try {
if (status >= 500 || status == 429) {
store.release(scoped); // transient — let the retry run
} else {
store.complete(scoped, status, out, Duration.ofHours(24));
}
} catch (RuntimeException e) {
log.error("idempotency finalise failed key={} status={}", scoped, status, e);
meterRegistry.counter("idempotency.finalise.error").increment();
}
}
afterCompletion runs whether the controller returned normally or threw, which makes the release path reliable. postHandle does not, which is why it is the wrong hook here.
Step 4 — The stronger variant: key inside the transaction
When the key lives in the same database as the business data, you can eliminate the orphaned-pending state entirely by making the two writes one commit.
@Service
@RequiredArgsConstructor
public class ChargeService {
private final ChargeRepository charges;
private final JdbcTemplate jdbc;
@Transactional
public Charge create(String scopedKey, byte[] fingerprint, ChargeRequest req) {
// Same transaction as the side effect: both commit, or neither does.
int inserted = jdbc.update("""
INSERT INTO idempotency_keys (scoped_key, fingerprint, state, expires_at)
VALUES (?, ?, 'completed', now() + interval '24 hours')
ON CONFLICT (scoped_key) DO NOTHING
""", scopedKey, fingerprint);
if (inserted == 0) throw new DuplicateKeyRequest(scopedKey); // rolls back cleanly
return charges.save(Charge.from(req));
}
}
CREATE TABLE idempotency_keys (
scoped_key text PRIMARY KEY,
fingerprint bytea NOT NULL,
state text NOT NULL,
status_code smallint,
response jsonb,
expires_at timestamptz NOT NULL
);
CREATE INDEX idempotency_keys_expiry ON idempotency_keys (expires_at);
There is no window in which the charge exists without its key, because there is no window between the two writes. The interceptor still handles the replay path; it simply no longer owns the atomicity.
Step 5 — Verify under concurrency
@SpringBootTest(webEnvironment = RANDOM_PORT)
@Testcontainers
class IdempotencyConcurrencyTest {
@Container static PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres:16");
@RepeatedTest(50)
void sixtyFourConcurrentDuplicatesCreateOneCharge() throws Exception {
String key = Ulid.fast().toString();
var start = new CountDownLatch(1);
var pool = Executors.newFixedThreadPool(64);
var futures = IntStream.range(0, 64).mapToObj(i -> pool.submit(() -> {
start.await(); // barrier
return post("/v1/charges", key, PAYLOAD).getStatusCode().value();
})).toList();
start.countDown();
var codes = futures.stream().map(this::get).toList();
assertThat(codes.stream().filter(c -> c == 201)).hasSize(1);
assertThat(codes.stream().filter(c -> c == 409)).hasSize(63);
assertThat(chargeCount(key)).isEqualTo(1); // the effect, not the codes
}
}
./gradlew test --tests '*IdempotencyConcurrencyTest' -i
# expect: 50 repetitions, all green — a single failure is a real race, never a flake
Failure scenarios and debugging
| Failure Scenario | Remediation Steps | Observability Hooks |
|---|---|---|
copyBodyToResponse() omitted, so every response on the wrapped routes ships with a correct status and an empty body |
Call it in a finally block in the filter. Add a smoke test asserting a non-empty body on a 201. |
http_server_response_bytes collapsing to zero on wrapped routes while status codes stay healthy |
Wrappers installed by the interceptor instead of a filter, so getContentAsByteArray() returns an empty array |
Move the wrapping to a Filter with @Order above the dispatcher. Assert the request class in a test: assertThat(req).isInstanceOf(ContentCachingRequestWrapper.class). |
Fingerprint cardinality of one across all requests — every body hashes identically |
Finalisation placed in postHandle, so a controller that throws leaves the key pending for its whole lease |
Move it to afterCompletion, which runs on the exception path too. |
idempotency_pending_age_seconds climbing only for routes with a nonzero 5xx rate |
Key written to Redis while the charge is written to PostgreSQL, and a rollback leaves a completed key with no charge |
Adopt the transaction-scoped variant in step 4, or only complete the key after the transaction manager confirms commit via TransactionSynchronization.afterCommit. |
idempotency_phantom_completion_total from a reconciliation job comparing keys to charges |
ContentCachingRequestWrapper cap set below the largest legitimate payload, silently truncating the fingerprint input |
Set the cap to the route’s documented body limit and reject larger bodies with 413 before the fingerprint is computed. |
request_body_bytes p99 compared against the configured cap; a p99 at exactly the cap means truncation |
SRE / observability checklist
idempotency.finalise.error— Micrometer counter of failed completion writes. Page above0.05%of wrapped requests.idempotency.registration— Timer aroundregisterOrGet. Alert if p99 exceeds15 ms.idempotency.replay— Counter of short-circuited replays, tagged by status. Baseline 0.5%–2%; a spike means a client retry loop.idempotency.pending.age— Gauge of the oldest pending key from a scheduled query. Alert above120 s.idem.keyMDC field — Bound in the filter so every log line for the request carries the scoped key.idempotency_keystable size — Gauge frompg_total_relation_size. Growth outpacing the reaper predicts the vacuum problem before it bites.
Related
- Idempotency Middleware & Framework Integration — the parent page with the five obligations and the cross-framework comparison.
- Writing Idempotency Middleware for Express and Fastify — the Node equivalent and its response-writer patching.
- A FastAPI Idempotency Dependency with Redis — the async Python equivalent.
- Wrapping Database Transactions for Safe Retries — the transaction-scoping rules behind step 4.