Implementing API Idempotency in Spring Boot Using Redis and Token Mechanism
This article explains how to achieve API idempotency in a Spring Boot application by generating unique tokens, storing them in Redis, and using a custom @ApiIdempotent annotation with an interceptor to prevent duplicate requests, including code examples, configuration, and testing procedures.
Idempotency means that multiple identical requests to an interface must result in the operation being performed only once, which is essential for order creation, payment processing, callback handling, and form submissions.
Common solutions include using a unique index, token mechanisms, pessimistic or optimistic locks, distributed locks (Redis or Zookeeper), and state machines.
This article adopts the second approach: combining Redis with a token mechanism to verify API idempotency.
The implementation creates a unique token for each request, stores it in Redis, and checks the token in an interceptor. If the token exists, the business logic proceeds and the token is deleted; if the token is missing, the request is rejected as a duplicate.
<!-- Redis-Jedis -->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>2.9.0</version>
</dependency>
<!-- lombok, used for @Slf4j -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.16.10</version>
</dependency> 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();
}
public String set(String key, String value) { ... }
public String set(String key, String value, int expireTime) { ... }
public String get(String key) { ... }
public Long del(String key) { ... }
public Boolean exists(String key) { ... }
public Long expire(String key, int expireTime) { ... }
public Long ttl(String key) { ... }
private void close(Jedis jedis) { if (jedis != null) jedis.close(); }
} 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 this annotation to controller methods that require idempotency.
*/
@Target({ ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiIdempotent { } 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;
if (hm.getMethod().isAnnotationPresent(ApiIdempotent.class)) {
tokenService.checkToken(request);
}
return true;
}
// postHandle and afterCompletion omitted for brevity
} 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 org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class TokenServiceImpl implements TokenService {
private static final String TOKEN_NAME = "token";
@Autowired
private JedisUtil jedisUtil;
@Override
public ServerResponse createToken() {
String str = RandomUtil.UUID32();
String token = Constant.Redis.TOKEN_PREFIX + str;
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 <= 0) throw new ServiceException(ResponseCode.REPETITIVE_OPERATION.getMsg());
}
} package com.wangzaiplus.test;
import com.wangzaiplus.test.interceptor.ApiIdempotentInterceptor;
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
public class TestApplication extends WebMvcConfigurerAdapter {
public static void main(String[] args) {
SpringApplication.run(TestApplication.class, args);
}
@Bean
public CorsFilter corsFilter() { /* CORS configuration omitted */ }
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(apiIdempotentInterceptor());
super.addInterceptors(registry);
}
@Bean
public ApiIdempotentInterceptor apiIdempotentInterceptor() {
return new ApiIdempotentInterceptor();
}
} 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();
}
}The testing process includes obtaining a token via /token, sending concurrent requests with JMeter using the token as a header or parameter, and verifying that only the first request succeeds while duplicates are rejected. Images in the original article illustrate the token retrieval, Redis state, and JMeter results.
Finally, the article emphasizes that the token deletion result must be checked; otherwise, concurrent threads may still see the token and cause duplicate processing, even if only one delete actually occurs.
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.
Top Architect
Top Architect focuses on sharing practical architecture knowledge, covering enterprise, system, website, large‑scale distributed, and high‑availability architectures, plus architecture adjustments using internet technologies. We welcome idea‑driven, sharing‑oriented architects to exchange and learn together.
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.
