java 59 lines · 10 steps

Building an idempotency filter with Redis in Spring

A servlet filter that uses Redis to make POST requests safe to retry by replaying the stored response for duplicate keys.

Explained by highlit
1@Component
2public class IdempotencyFilter extends OncePerRequestFilter {
3 
4 private final StringRedisTemplate redis;
5 private final ObjectMapper objectMapper;
6 private static final Duration TTL = Duration.ofHours(24);
7 
8 public IdempotencyFilter(StringRedisTemplate redis, ObjectMapper objectMapper) {
9 this.redis = redis;
10 this.objectMapper = objectMapper;
11 }
12 
13 @Override
14 protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
15 FilterChain chain) throws ServletException, IOException {
16 if (!"POST".equalsIgnoreCase(request.getMethod())) {
17 chain.doFilter(request, response);
18 return;
19 }
20 
21 String key = request.getHeader("Idempotency-Key");
22 if (key == null || key.isBlank()) {
23 chain.doFilter(request, response);
24 return;
25 }
26 
27 String cacheKey = "idem:" + key;
28 Boolean claimed = redis.opsForValue().setIfAbsent(cacheKey, "IN_PROGRESS", TTL);
29 
30 if (Boolean.FALSE.equals(claimed)) {
31 String cached = redis.opsForValue().get(cacheKey);
32 if (cached == null || "IN_PROGRESS".equals(cached)) {
33 response.setStatus(HttpStatus.CONFLICT.value());
34 response.setContentType(MediaType.APPLICATION_JSON_VALUE);
35 response.getWriter().write("{\"error\":\"request already in progress\"}");
36 return;
37 }
38 StoredResponse stored = objectMapper.readValue(cached, StoredResponse.class);
39 response.setStatus(stored.status());
40 response.setContentType(MediaType.APPLICATION_JSON_VALUE);
41 response.getWriter().write(stored.body());
42 return;
43 }
44 
45 ContentCachingResponseWrapper wrapper = new ContentCachingResponseWrapper(response);
46 try {
47 chain.doFilter(request, wrapper);
48 String body = new String(wrapper.getContentAsByteArray(), StandardCharsets.UTF_8);
49 StoredResponse stored = new StoredResponse(wrapper.getStatus(), body);
50 redis.opsForValue().set(cacheKey, objectMapper.writeValueAsString(stored), TTL);
51 wrapper.copyBodyToResponse();
52 } catch (Exception e) {
53 redis.delete(cacheKey);
54 throw e;
55 }
56 }
57 
58 private record StoredResponse(int status, String body) {}
59}
01 / 01
STEP 01

Walkthrough

Space play step click any line
Three takeaways
  1. 1An atomic setIfAbsent turns a cache into a claim lock, letting only the first request execute while duplicates wait or replay.
  2. 2Persisting status and body lets a retried request receive the exact original response instead of running the handler twice.
  3. 3Deleting the claim on failure keeps a crashed request from permanently blocking retries of the same key.

Related explainers

Share this explainer

Here's the card — post it anywhere.

Building an idempotency filter with Redis in Spring — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code