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
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);
}
}
}
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);
}
}
Top comments (0)