Achieve Automatic API Idempotency with Spring Boot, Redis, and Custom Annotations
This article explains how to implement automatic idempotency for Spring Boot APIs using Redis, a custom @AutoIdempotent annotation, token generation and verification, and an interceptor, providing step‑by‑step code examples and a testing workflow to prevent duplicate operations.
Preface
In real development, an exposed API may receive many requests. Idempotency means that executing the operation any number of times has the same effect as executing it once, ensuring that database changes occur only once.
Create a unique index in the database to guarantee a single row insertion.
Use a token mechanism: obtain a token before each request and include it in the request header; the server validates and deletes the token after use.
Apply pessimistic or optimistic locks to prevent concurrent updates.
Perform a check‑then‑act pattern: query first, if data exists reject the request; otherwise allow it.
Redis implementation diagram:
Set up Redis service API
Install and start a Redis server.
Import Spring Boot's Redis starter (or Jedis) and use RedisTemplate for set and exists operations.
@Component
public class RedisService {
@Autowired
private RedisTemplate redisTemplate;
public boolean set(final String key, Object value) {
boolean result = false;
try {
ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue();
operations.set(key, value);
result = true;
} catch (Exception e) {
e.printStackTrace();
}
return result;
}
public boolean setEx(final String key, Object value, Long expireTime) {
boolean result = false;
try {
ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue();
operations.set(key, value);
redisTemplate.expire(key, expireTime, TimeUnit.SECONDS);
result = true;
} catch (Exception e) {
e.printStackTrace();
}
return result;
}
public boolean exists(final String key) {
return redisTemplate.hasKey(key);
}
public Object get(final String key) {
ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue();
return operations.get(key);
}
public boolean remove(final String key) {
if (exists(key)) {
Boolean delete = redisTemplate.delete(key);
return delete;
}
return false;
}
}Custom annotation AutoIdempotent
Define an annotation that can be placed on methods to enable automatic idempotency. It is retained at runtime and applicable only to methods.
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoIdempotent {}Token creation and verification
Token service interface with methods to create a token and to check a token.
public interface TokenService {
/**
* Create token
*/
public String createToken();
/**
* Verify token
*/
public boolean checkToken(HttpServletRequest request) throws Exception;
}Implementation stores a UUID token in Redis with an expiration time, retrieves the token from request header or parameter, throws exceptions for missing or repeated tokens, and removes the token after successful verification.
@Service
public class TokenServiceImpl implements TokenService {
@Autowired
private RedisService redisService;
@Override
public String createToken() {
String str = RandomUtil.randomUUID();
StrBuilder token = new StrBuilder();
try {
token.append(Constant.Redis.TOKEN_PREFIX).append(str);
redisService.setEx(token.toString(), token.toString(), 10000L);
if (StrUtil.isNotEmpty(token.toString())) {
return token.toString();
}
} catch (Exception ex) {
ex.printStackTrace();
}
return null;
}
@Override
public boolean checkToken(HttpServletRequest request) throws Exception {
String token = request.getHeader(Constant.TOKEN_NAME);
if (StrUtil.isBlank(token)) {
token = request.getParameter(Constant.TOKEN_NAME);
if (StrUtil.isBlank(token)) {
throw new ServiceException(Constant.ResponseCode.ILLEGAL_ARGUMENT, 100);
}
}
if (!redisService.exists(token)) {
throw new ServiceException(Constant.ResponseCode.REPETITIVE_OPERATION, 200);
}
boolean remove = redisService.remove(token);
if (!remove) {
throw new ServiceException(Constant.ResponseCode.REPETITIVE_OPERATION, 200);
}
return true;
}
}Interceptor configuration
Web configuration class adds AutoIdempotentInterceptor to the MVC interceptor registry.
@Configuration
public class WebConfiguration extends WebMvcConfigurerAdapter {
@Resource
private AutoIdempotentInterceptor autoIdempotentInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(autoIdempotentInterceptor);
super.addInterceptors(registry);
}
}Interceptor scans for @AutoIdempotent on methods, invokes tokenService.checkToken, and returns a JSON error response when validation fails.
@Component
public class AutoIdempotentInterceptor implements HandlerInterceptor {
@Autowired
private TokenService tokenService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)) {
return true;
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
AutoIdempotent methodAnnotation = method.getAnnotation(AutoIdempotent.class);
if (methodAnnotation != null) {
try {
return tokenService.checkToken(request);
} catch (Exception ex) {
ResultVo failedResult = ResultVo.getFailedResult(101, ex.getMessage());
writeReturnJson(response, JSONUtil.toJsonStr(failedResult));
throw ex;
}
}
return true;
}
private void writeReturnJson(HttpServletResponse response, String json) throws Exception {
response.setCharacterEncoding("UTF-8");
response.setContentType("text/html; charset=utf-8");
PrintWriter writer = null;
try {
writer = response.getWriter();
writer.print(json);
} catch (IOException e) {
} finally {
if (writer != null) writer.close();
}
}
}Test case
BusinessController provides /get/token to obtain a token and /test/Idempotence annotated with @AutoIdempotent. Using Postman, the first call succeeds; subsequent calls are rejected as duplicate operations.
@RestController
public class BusinessController {
@Resource
private TokenService tokenService;
@Resource
private TestService testService;
@PostMapping("/get/token")
public String getToken(){
String token = tokenService.createToken();
if (StrUtil.isNotEmpty(token)) {
ResultVo resultVo = new ResultVo();
resultVo.setCode(Constant.code_success);
resultVo.setMessage(Constant.SUCCESS);
resultVo.setData(token);
return JSONUtil.toJsonStr(resultVo);
}
return StrUtil.EMPTY;
}
@AutoIdempotent
@PostMapping("/test/Idempotence")
public String testIdempotence() {
String businessResult = testService.testIdempotence();
if (StrUtil.isNotEmpty(businessResult)) {
ResultVo successResult = ResultVo.getSuccessResult(businessResult);
return JSONUtil.toJsonStr(successResult);
}
return StrUtil.EMPTY;
}
}Conclusion
This tutorial demonstrates an elegant way to achieve API idempotency using Spring Boot, Redis, a custom annotation, and an interceptor, which prevents duplicate data processing, reduces unnecessary concurrency, and improves system scalability.
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.
Programmer DD
A tinkering programmer and author of "Spring Cloud Microservices in Action"
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.
