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.

Architect's Tech Stack
Architect's Tech Stack
Architect's Tech Stack
Implementing API Idempotency with Redis Token Mechanism in Spring Boot

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.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

redisSpring BootAPIInterceptorIdempotencyToken
Architect's Tech Stack
Written by

Architect's Tech Stack

Java backend, microservices, distributed systems, containerized programming, and more.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.