Adding an Idempotency Filter to Spring Boot

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.

Where the caching filter sits in the chain The servlet filter chain runs in order: Spring Security, then the content-caching filter, then the dispatcher servlet. Placing the caching filter before security wastes buffering on unauthenticated requests and leaves no credential to scope the key. Placing it after the dispatcher is impossible, because by then the request stream has already been read. Spring Security resolves the credential ContentCachingFilter wraps request + response HIGHEST_PRECEDENCE + 50 DispatcherServlet interceptor runs inside Too early and there is no credential; too late and the stream is already consumed.
@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.

Filter and interceptor responsibilities in Spring MVC An outer box labelled ContentCachingFilter wraps an inner box labelled DispatcherServlet. Inside the dispatcher, the interceptor's preHandle runs first, performing key extraction, fingerprinting and atomic registration, and may short-circuit with 409, 422 or a replay. The controller runs next. Finally afterCompletion reads the cached response bytes and either completes or releases the key. A note marks that copyBodyToResponse must run in the filter's finally block. ContentCachingFilter — wraps request + response DispatcherServlet preHandle scope key + fingerprint registerOrGet (atomic) may short-circuit controller @Transactional service side effect commits afterCompletion reads cached bytes complete / release runs on throw too copyBodyToResponse() must run in the filter's finally block, or every response ships empty.

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
Two arrangements, two very different state machines With the key in the same database as the charge, the state machine has only two states — absent and completed — because both writes commit together. With the key in Redis and the charge in PostgreSQL, a pending state is unavoidable, along with a lease to bound it and a reconciliation job to resolve orphans. key in the same database absent completed no pending, no lease, no reconciler key in Redis, charge in Postgres absent pending completed lease + reconciler are now mandatory

SRE / observability checklist

  1. idempotency.finalise.error — Micrometer counter of failed completion writes. Page above 0.05% of wrapped requests.
  2. idempotency.registration — Timer around registerOrGet. Alert if p99 exceeds 15 ms.
  3. idempotency.replay — Counter of short-circuited replays, tagged by status. Baseline 0.5%–2%; a spike means a client retry loop.
  4. idempotency.pending.age — Gauge of the oldest pending key from a scheduled query. Alert above 120 s.
  5. idem.key MDC field — Bound in the filter so every log line for the request carries the scoped key.
  6. idempotency_keys table size — Gauge from pg_total_relation_size. Growth outpacing the reaper predicts the vacuum problem before it bites.