Implementing API Idempotency with Redis Token Mechanism in Spring Boot
This article explains the concept of API idempotency, reviews common solutions, and provides a complete Spring Boot implementation using a Redis‑based token mechanism, custom @ApiIdempotent annotation, interceptor, and supporting services, along with testing and important concurrency considerations.
Concept Idempotency means that multiple identical requests to an interface must result in only one successful operation, such as preventing duplicate order creation, duplicate payments, or repeated form submissions.
Common Solutions
Unique index to avoid dirty data
Token mechanism to prevent duplicate submissions
Pessimistic lock (table or row lock)
Optimistic lock based on version number
Distributed lock using Redis (Jedis, Redisson) or Zookeeper
State machine to control status transitions
Implementation Idea Generate a unique token for each request, store it in Redis, and require the client to send the token in the request header or parameter. The interceptor checks Redis for the token: if present, the request proceeds and the token is deleted; if absent, the request is rejected as a duplicate.
Project Overview
Spring Boot
Redis (Jedis client)
Custom @ApiIdempotent annotation + interceptor
Global exception handling with @ControllerAdvice Performance testing with JMeter
Code Implementation
pom.xml dependencies
<!-- Redis-Jedis -->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>2.9.0</version>
</dependency>
<!-- Lombok for @Slf4j -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.16.10</version>
</dependency>JedisUtil
package com.wangzaiplus.test.util;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
@Component
@Slf4j
public class JedisUtil {
@Autowired
private JedisPool jedisPool;
private Jedis getJedis() {
return jedisPool.getResource();
}
/**
* Set value
*/
public String set(String key, String value) {
Jedis jedis = null;
try {
jedis = getJedis();
return jedis.set(key, value);
} catch (Exception e) {
log.error("set key:{} value:{} error", key, value, e);
return null;
} finally {
close(jedis);
}
}
// other set/get/del/exists/expire/ttl methods omitted for brevity
private void close(Jedis jedis) {
if (jedis != null) {
jedis.close();
}
}
}Custom Annotation @ApiIdempotent
package com.wangzaiplus.test.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Apply on controller methods that require idempotency
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiIdempotent {
}ApiIdempotentInterceptor
package com.wangzaiplus.test.interceptor;
import com.wangzaiplus.test.annotation.ApiIdempotent;
import com.wangzaiplus.test.service.TokenService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public class ApiIdempotentInterceptor implements HandlerInterceptor {
@Autowired
private TokenService tokenService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (!(handler instanceof HandlerMethod)) {
return true;
}
HandlerMethod hm = (HandlerMethod) handler;
ApiIdempotent anno = hm.getMethod().getAnnotation(ApiIdempotent.class);
if (anno != null) {
tokenService.checkToken(request); // throws exception if invalid
}
return true;
}
// postHandle and afterCompletion omitted
}TokenServiceImpl
package com.wangzaiplus.test.service.impl;
import com.wangzaiplus.test.common.Constant;
import com.wangzaiplus.test.common.ResponseCode;
import com.wangzaiplus.test.common.ServerResponse;
import com.wangzaiplus.test.exception.ServiceException;
import com.wangzaiplus.test.service.TokenService;
import com.wangzaiplus.test.util.JedisUtil;
import com.wangzaiplus.test.util.RandomUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.servlet.http.HttpServletRequest;
@Service
@Slf4j
public class TokenServiceImpl implements TokenService {
private static final String TOKEN_NAME = "token";
@Autowired
private JedisUtil jedisUtil;
@Override
public ServerResponse createToken() {
String uuid = RandomUtil.UUID32();
String token = Constant.Redis.TOKEN_PREFIX + uuid;
jedisUtil.set(token, token, Constant.Redis.EXPIRE_TIME_MINUTE);
return ServerResponse.success(token);
}
@Override
public void checkToken(HttpServletRequest request) {
String token = request.getHeader(TOKEN_NAME);
if (StringUtils.isBlank(token)) {
token = request.getParameter(TOKEN_NAME);
if (StringUtils.isBlank(token)) {
throw new ServiceException(ResponseCode.ILLEGAL_ARGUMENT.getMsg());
}
}
if (!jedisUtil.exists(token)) {
throw new ServiceException(ResponseCode.REPETITIVE_OPERATION.getMsg());
}
Long del = jedisUtil.del(token);
if (del == null || del <= 0) {
throw new ServiceException(ResponseCode.REPETITIVE_OPERATION.getMsg());
}
}
}TestApplication (Spring Boot entry)
package com.wangzaiplus.test;
import com.wangzaiplus.test.interceptor.ApiIdempotentInterceptor;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
@SpringBootApplication
@MapperScan("com.wangzaiplus.test.mapper")
public class TestApplication extends WebMvcConfigurerAdapter {
public static void main(String[] args) {
SpringApplication.run(TestApplication.class, args);
}
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.addAllowedOrigin("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(apiIdempotentInterceptor());
super.addInterceptors(registry);
}
@Bean
public ApiIdempotentInterceptor apiIdempotentInterceptor() {
return new ApiIdempotentInterceptor();
}
}Controllers for Testing
package com.wangzaiplus.test.controller;
import com.wangzaiplus.test.common.ServerResponse;
import com.wangzaiplus.test.service.TokenService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/token")
public class TokenController {
@Autowired
private TokenService tokenService;
@GetMapping
public ServerResponse token() {
return tokenService.createToken();
}
} package com.wangzaiplus.test.controller;
import com.wangzaiplus.test.annotation.ApiIdempotent;
import com.wangzaiplus.test.common.ServerResponse;
import com.wangzaiplus.test.service.TestService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/test")
public class TestController {
@Autowired
private TestService testService;
@ApiIdempotent
@PostMapping("testIdempotence")
public ServerResponse testIdempotence() {
return testService.testIdempotence();
}
}Testing and Verification
Obtain a token via /token endpoint.
Use JMeter to send 50 concurrent requests to /test/testIdempotence with the token; only the first succeeds.
Requests without a token, with an empty token, or with an invalid token are rejected.
Important Note When deleting the token, always check the result of jedisUtil.del(token). Without verifying the deletion count, concurrent threads may both see the token as present and allow duplicate processing, leading to a race condition.
Conclusion By generating a unique token per request and validating it through a Redis‑backed interceptor, API idempotency can be achieved cleanly without scattering duplicate‑check code throughout controllers. The same pattern can also be implemented with Spring AOP if preferred.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Architect's Tech Stack
Java backend, microservices, distributed systems, containerized programming, and more.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
