How to Achieve Automatic API Idempotency with Spring Boot, Redis, and Custom Annotations
This article explains the concept of idempotency, outlines common strategies, and provides a complete Spring Boot implementation that uses Redis, a custom @AutoIdempotent annotation, token generation, and a web interceptor to ensure each API request affects the database only once.
Introduction
In real‑world projects an exposed API may receive many repeated requests. Idempotency means that executing the operation any number of times has the same effect as executing it once, i.e., the database is modified only once.
Common Ways to Ensure Idempotency
Create a unique index in the database so duplicate inserts are rejected.
Use a token mechanism: obtain a token before each request and include it in the request header; the server validates and then deletes the token.
Apply pessimistic or optimistic locks to prevent concurrent updates.
Perform a pre‑check: query the database first; if the record exists, reject the request.
Redis‑Based Automatic Idempotency
The following diagram shows how Redis is used to store and validate tokens, guaranteeing that each request is processed only once.
Redis Service Implementation
package com.example.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Component;
import java.io.Serializable;
import java.util.concurrent.TimeUnit;
@Component
public class RedisService {
@Autowired
private RedisTemplate redisTemplate;
/** Write cache */
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;
}
/** Write cache with expiration */
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;
}
/** Check existence */
public boolean exists(final String key) {
return redisTemplate.hasKey(key);
}
/** Read cache */
public Object get(final String key) {
ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue();
return operations.get(key);
}
/** Delete cache */
public boolean remove(final String key) {
if (exists(key)) {
Boolean delete = redisTemplate.delete(key);
return delete;
}
return false;
}
}Custom Annotation
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoIdempotent {
}Token Service Interface
public interface TokenService {
/** Create token */
String createToken();
/** Verify token */
boolean checkToken(HttpServletRequest request) throws Exception;
}Token Service Implementation
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@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);
}
if (!redisService.remove(token)) {
throw new ServiceException(Constant.ResponseCode.REPETITIVE_OPERATION, 200);
}
return true;
}
}Web Configuration and Interceptor
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
import javax.annotation.Resource;
@Configuration
public class WebConfiguration extends WebMvcConfigurerAdapter {
@Resource
private AutoIdempotentInterceptor autoIdempotentInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(autoIdempotentInterceptor);
super.addInterceptors(registry);
}
} import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.lang.reflect.Method;
@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 hm = (HandlerMethod) handler;
Method method = hm.getMethod();
AutoIdempotent anno = method.getAnnotation(AutoIdempotent.class);
if (anno != null) {
try {
return tokenService.checkToken(request);
} catch (Exception ex) {
ResultVo failed = ResultVo.getFailedResult(101, ex.getMessage());
writeReturnJson(response, JSONUtil.toJsonStr(failed));
throw ex;
}
}
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, org.springframework.web.servlet.ModelAndView modelAndView) throws Exception {}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {}
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) {
// ignore
} finally {
if (writer != null) writer.close();
}
}
}Testing the Idempotent API
A controller provides /get/token to obtain a token and /test/Idempotence (annotated with @AutoIdempotent) to perform a business operation. Using Postman, the first request succeeds; subsequent requests with the same token are rejected as duplicate operations, demonstrating automatic idempotency.
Conclusion
The blog shows a clean, Spring Boot‑based solution that combines Redis, a custom annotation, token management, and a web interceptor to guarantee that each API call modifies the backend exactly once, preventing dirty data, reducing unnecessary concurrency, and improving 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.
Java High-Performance Architecture
Sharing Java development articles and resources, including SSM architecture and the Spring ecosystem (Spring Boot, Spring Cloud, MyBatis, Dubbo, Docker), Zookeeper, Redis, architecture design, microservices, message queues, Git, etc.
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.
