DEV Community

Vishesh
Vishesh

Posted on

API Idempotency: Why Your System Needs It?

API Idempotency: Why Your System Needs It?

In any system, there are processes that cannot be duplicated and the system must be resilient to duplicate requests. It's better to prevent issues rather than correct them later down the line.

Clients retry failed requests. Your system processes them multiple times. Data gets corrupted.

Example:

  • Client: "Add 100 units to inventory"
  • Server: Adds it
  • Client: Network fails, hence retries
  • Server: Adds 100 units... twice
  • Result: 200 units instead of 100 ❌

Solution: Idempotency header

Idempotency flow

Client responsibilities:

  • Create unique idempotency key for each request
  • Re-use same idempotency key for any retry attempts

Server responsibilities:

  • If idempotency key is given, save the successful responses. Only success 2XX responses because they are the only ones that update the main DB data. Other 4XX, 5XX do not change the DB.
  • If idempotency key is not given*,* process as usual as it's the client*'s* responsibility to add idempotency for critical operations
  • Do not save idempotency data for GET HTTP APIs as they only retrieve data and do not update any data.
  • Based on the need*,* add a scheduled job to remove the idempotency data. E.g.: Delete records older than 7 days

Sample DB (Postgres) design to store the response:

Column Type Nullable Default
id int8 (Primary Key) False Auto Increment
idempotency_key VARCHAR(50) False NA
http_status_code VARCHAR(50) True NA
http_response TEXT True NA
created_at TIMESTAMP False CURRENT_TIMESTAMP

Sample implementation (API):

Interceptor

@Component
class SimpleIdempotencyInterceptor implements HandlerInterceptor {

    @Autowired
    private final IdempotencyDataRepositoryImpl idempotencyRepository;

    // Before sending request to the controller we can check the headers here
    @Override
    public boolean preHandle(
        HttpServletRequest request,
        HttpServletResponse response,
        Object handler
    ) throws Exception {
        // Skip GET requests and non-configured paths
        if (request.getMethod().equals(HttpMethod.GET.name()) || !isPathEnabled(request)) {
            return true;
        }

        String idempotencyKey = request.getHeader("myapp-idempotency-key");
        if (idempotencyKey != null) {
            return handleIdempotency(idempotencyKey, response);
        }

        return true;
    }

    // Save the response after receiving form the controller
    @Override
    public void afterCompletion(
        HttpServletRequest request,
        HttpServletResponse response,
        Object handler,
        Exception ex
    ) throws Exception {
        String idempotencyKey = request.getHeader("myapp-idempotency-key");
        if (idempotencyKey != null && response.getStatus() >= 200 && response.getStatus() <= 299) {
            // Cache successful response
            cacheResponse(idempotencyKey, response);
        }
    }

    private boolean handleIdempotency(String idempotencyKey, HttpServletResponse response) {
        IdempotencyDataEntity existing = idempotencyRepository.findByIdempotencyKey(idempotencyKey);

        switch (existing == null ? "null" : "exists") {
            case "null":
                // First time - create placeholder record
                idempotencyRepository.save(
                    new IdempotencyDataEntity(
                        idempotencyKey,
                        0,
                        ""
                    )
                );
                return true;
            default:
                // Return cached response
                sendCachedResponse(response, existing);
                return false;
        }
    }

    private void cacheResponse(String idempotencyKey, HttpServletResponse response) {
        try {
            IdempotencyDataEntity existing = idempotencyRepository.findByIdempotencyKey(idempotencyKey);
            if (existing != null) {
                // ... response body reading logic would go here ...
                String responseBody = "/* extracted response body */";

                idempotencyRepository.save(
                    new IdempotencyDataEntity(
                        idempotencyKey,
                        response.getStatus(),
                        responseBody
                    )
                );
            }
        } catch (Exception e) {
            log.warn("Failed to cache idempotency response for key: " + idempotencyKey, e);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Scheduler for cleaning up old Idempotency data

/**
     * Cleanup idempotency records older than 7 days
     * Runs daily at 2 AM
     */
    @Scheduled(cron = "0 0 2 * * *")
    public void cleanupOldIdempotencyRecords() {
        try {
            LocalDateTime cutoffDate = LocalDateTime.now().minusDays(7);
            int deletedCount = idempotencyRepository.deleteByCreatedDateBefore(cutoffDate);
            logger.info("Cleaned up {} old idempotency records (older than {})", deletedCount, cutoffDate);
        } catch (Exception e) {
            logger.error("Failed to cleanup old idempotency records", e);
        }
    }
Enter fullscreen mode Exit fullscreen mode

Top comments (0)