Ensuring API Idempotency with Spring, Redis, and Custom Annotations

This article explains the concept of idempotency, identifies which HTTP methods are naturally idempotent, and demonstrates a complete Java Spring solution—including custom annotations, AOP handling, token generation, and Redis storage—to guarantee safe retry and duplicate‑request protection.

Su San Talks Tech
Su San Talks Tech
Su San Talks Tech
Ensuring API Idempotency with Spring, Redis, and Custom Annotations

1. What is Idempotency?

In simple terms, idempotency means that executing the same interface request multiple times yields the same result as a single execution. Achieving this in a production system requires careful design, and every API should be idempotent.

2. Which requests are naturally idempotent?

Read‑only (query) requests are inherently idempotent, and delete requests are usually idempotent except in ABA scenarios.

Simple example

If a delete‑A request times out and is automatically retried, an intervening insert of A could cause the second delete to remove a newly added record—an ABA problem, though it is often negligible.

Update operations are also idempotent in most cases, except when the update changes state based on current values, e.g., update table set a = a + 1 where v = 1, which is non‑idempotent. Insert operations are generally non‑idempotent unless a unique index prevents duplicates.

3. Why is idempotency needed?

1. Timeout retry

When an RPC request fails due to network instability, the client may retry. If the original request already reached the server, the server must ensure the operation is idempotent.

2. Asynchronous callbacks

Asynchronous callbacks increase throughput but must be idempotent to avoid duplicate processing.

3. Message queues

Frameworks such as Kafka, RocketMQ, and RabbitMQ follow an “at‑least‑once” delivery model, so consumers must implement idempotent handling.

4. Key factors for implementing idempotency

Factor 1

An idempotent token (or global ID) uniquely identifies a client request. It is usually generated by the client, though a third‑party service can also provide it.

Factor 2

With a unique token, the server must ensure the token is used only once, commonly by leveraging a database unique index.

5. Implementing idempotency with annotations

1. Custom annotation

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(value = ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
    /** Parameter name from which the token will be extracted */
    String name() default "";
    /** Field name inside the parameter that holds the token */
    String field() default "";
    /** Parameter type */
    Class type();
}

2. Unified request data object

@Data
public class RequestData<T> {
    private Header header;
    private T body;
}

@Data
public class Header {
    private String token;
}

@Data
public class Order {
    String orderNo;
}

3. AOP handling

import com.springboot.micrometer.annotation.Idempotent;
import com.springboot.micrometer.entity.RequestData;
import com.springboot.micrometer.idempotent.RedisIdempotentStorage;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.lang.reflect.Method;
import java.util.Map;

@Aspect
@Component
public class IdempotentAspect {
    @Resource
    private RedisIdempotentStorage redisIdempotentStorage;

    @Pointcut("@annotation(com.springboot.micrometer.annotation.Idempotent)")
    public void idempotent() {}

    @Around("idempotent()")
    public Object methodAround(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        Idempotent idempotent = method.getAnnotation(Idempotent.class);

        String field = idempotent.field();
        String name = idempotent.name();
        Class clazzType = idempotent.type();

        String token = "";
        Object object = clazzType.newInstance();
        Map<String, Object> paramValue = AopUtils.getParamValue(joinPoint);
        if (object instanceof RequestData) {
            RequestData idempotentEntity = (RequestData) paramValue.get(name);
            token = String.valueOf(AopUtils.getFieldValue(idempotentEntity.getHeader(), field));
        }

        if (redisIdempotentStorage.delete(token)) {
            return joinPoint.proceed();
        }
        return "重复请求";
    }
}
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.reflect.CodeSignature;

import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;

public class AopUtils {
    public static Object getFieldValue(Object obj, String name) throws Exception {
        Field[] fields = obj.getClass().getDeclaredFields();
        Object object = null;
        for (Field field : fields) {
            field.setAccessible(true);
            if (field.getName().toUpperCase().equals(name.toUpperCase())) {
                object = field.get(obj);
                break;
            }
        }
        return object;
    }

    public static Map<String, Object> getParamValue(ProceedingJoinPoint joinPoint) {
        Object[] paramValues = joinPoint.getArgs();
        String[] paramNames = ((CodeSignature) joinPoint.getSignature()).getParameterNames();
        Map<String, Object> param = new HashMap<>(paramNames.length);
        for (int i = 0; i < paramNames.length; i++) {
            param.put(paramNames[i], paramValues[i]);
        }
        return param;
    }
}

4. Token generation

import com.springboot.micrometer.idempotent.RedisIdempotentStorage;
import com.springboot.micrometer.util.IdGeneratorUtil;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;

@RestController
@RequestMapping("/idGenerator")
public class IdGeneratorController {
    @Resource
    private RedisIdempotentStorage redisIdempotentStorage;

    @RequestMapping("/getIdGeneratorToken")
    public String getIdGeneratorToken() {
        String generateId = IdGeneratorUtil.generateId();
        redisIdempotentStorage.save(generateId);
        return generateId;
    }
}
public interface IdempotentStorage {
    void save(String idempotentId);
    boolean delete(String idempotentId);
}
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.io.Serializable;
import java.util.concurrent.TimeUnit;

@Component
public class RedisIdempotentStorage implements IdempotentStorage {
    @Resource
    private RedisTemplate<String, Serializable> redisTemplate;

    @Override
    public void save(String idempotentId) {
        redisTemplate.opsForValue().set(idempotentId, idempotentId, 10, TimeUnit.MINUTES);
    }

    @Override
    public boolean delete(String idempotentId) {
        return redisTemplate.delete(idempotentId);
    }
}
import java.util.UUID;

public class IdGeneratorUtil {
    public static String generateId() {
        return UUID.randomUUID().toString();
    }
}

5. Request example

Before calling the business API, obtain a token, then include the token in the request.

import com.springboot.micrometer.annotation.Idempotent;
import com.springboot.micrometer.entity.Order;
import com.springboot.micrometer.entity.RequestData;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/order")
public class OrderController {
    @RequestMapping("/saveOrder")
    @Idempotent(name = "requestData", type = RequestData.class, field = "token")
    public String saveOrder(@RequestBody RequestData<Order> requestData) {
        return "success";
    }
}

First request succeeds, second request fails as the token has been consumed.

Token request
Token request
First request success
First request success
Second request rejected
Second request rejected
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.

BackendJavaaopspringIdempotencyannotation
Su San Talks Tech
Written by

Su San Talks Tech

Su San, former staff at several leading tech companies, is a top creator on Juejin and a premium creator on CSDN, and runs the free coding practice site www.susan.net.cn.

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.