What I am trying to achieve
We have a REST API built with Spring Boot, JPA and Hibernate. The clients using the API has an unreliable access to network. To avoid having too many errors for the end user, we made the client retry unsuccessful requests (eg. after a timeout occurs).
As we cannot be sure that the request has not already been processed by the server when sending it again, we need to make the POST requests idempotent. That is, sending twice the same POST request must not create the same resource twice.
What I have done so far
To achieve this, here is what I did:
- The client is sending a UUID along with the request, in a custom HTTP header.
- When the client resends the same request, the same UUID is sent.
- The first time the server processes the request, the response for the request is stored in a database, along with the UUID.
- The second time the same request is received, the result is retrieved from the database and the response is made without processing the request again.
So far so good.
The issue
I have multiple instances of the server working on the same database, and requests are load balanced. As a result, any instance can process the requests.
With my current implementation, the following scenario can occur:
- The request is processed by instance 1 and takes a long time
- Because it takes too long, the client aborts the connection and resends the same request
- The 2nd request is processed by instance 2
- The 1st request processing finishes, and the result is saved in database by instance 1
- The 2nd request processing finishes. When instance 2 tries to store the result in the database, the result already exists in database.
In this scenario, the request has been processed twice, which is what I want to avoid.
I thought of two possible solutions:
- Rollback the request 2 when a result for the same request has already been stored, and sending the saved response to the client.
- Prevent the request 2 to be processed by saving the request id in the database as soon as instance 1 starts processing it. This solution wouldn't work as the connection between the client and instance 1 is closed by the timeout, making it impossible for the client to actually receive the response processed by instance 1.
Attempt on solution 1
I'm using a Filter
to retrieve and store a response. My filter looks roughly like this:
@Component
public class IdempotentRequestFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
String requestId = getRequestId(request);
if(requestId != null) {
ResponseCache existingResponse = getExistingResponse(requestId);
if(existingResponse != null) {
serveExistingResponse(response, existingResponse);
}
else {
filterChain.doFilter(request, response);
try {
saveResponse(requestId, response);
serve(response);
}
catch (DataIntegrityViolationException e) {
// Here perform rollback somehow
existingResponse = getExistingResponse(requestId);
serveExistingResponse(response, existingResponse);
}
}
}
else {
filterChain.doFilter(request, response);
}
}
...
My requests are then processed like this:
@Controller
public class UserController {
@Autowired
UserManager userManager;
@RequestMapping(value = "/user", method = RequestMethod.POST)
@ResponseBody
public User createUser(@RequestBody User newUser) {
return userManager.create(newUser);
}
}
@Component
@Lazy
public class UserManager {
@Transactional("transactionManager")
public User create(User user) {
userRepository.save(user);
return user;
}
}
Questions
- Can you think of any other solution to avoid the issue?
- Is there any other solution to make POST requests idempotent (entirely different perhaps)?
- How can I start a transaction, commit it or rollback it from the
Filter
shown above? Is it a good practice? - When processing the requests, the existing code already create transactions by calling multiple methods annotated with
@Transactional("transactionManager")
. What will happen when I start or rollback a transaction with the filter?
Note: I am rather new to spring, hibernate and JPA, and I have a limited understanding of the mechanism behind transactions and filters.