We have an eCommerce webapp with the front-end server and the API server being in separate EC2 Ubuntus. The API server exposes 168 API RESTFul endpoints using Spring MVC. Each class has the @RestController annotation. Now, 166 endpoints are called and work correctly except for 2 of them. This question is for one of these 2 which we added to the system 2 weeks ago.
When this endpoint (which is a POST) is called by the front-end, we see in the Chrome DevTools (network tab) that first the OPTIONS call occur and right after the POST call. That call is supposed to place a shopping order. The problem is that this POST call is invoked twice within a second of each other. We see the results of both of them, because we see 2 new orders in the database with their own OrderID and we see the 2 calls in the tomcat access logs. The front-end makes only 1 API call.
We use OpenJDK 11.0.11+9, tomcat 9.0.59, Spring Framework 5.3.16, Spring Security 5.6.2, javaee-api 8.0.1 and servlet 4.0. This webapp has been working for 14 months, but when we added a new endpoint for the user to place orders, our clients see the duplicate orders and they have to cancel one of them which is very annoying to them. Here is the endpoint method:
@RestController
@RequestMapping(value = "/api/v1")
public class DeliveryCourierMultidropAPIService extends OrionWebService
{
@PostMapping(value = "/orders", produces = "application/json")
public ResponseEntity<OrderResponseBean> placeOrder(@RequestBody OrderRequestBean requestBean, HttpServletRequest request, HttpServletResponse response, Model model)
{
try
{
return OrderService.placeOrder(requestBean, request, response);
}
catch(Throwable e)
{
return ResponseEntity.badRequest().body(OrderResponseBean.builder()
.hasErrors(true)
.build());
}
}
}
The tomcat server.xml (for the connectors) has:
<Executor name="tomcatThreadPool" namePrefix="tomcat9-thread-" maxIdleTime="60000"
maxThreads="50" minSpareThreads="5"/>
<Connector executor="tomcatThreadPool" port="8080" protocol="HTTP/1.1" redirectPort="8443" maxConnections="100" acceptCount="300"/>
<Connector executor="tomcatThreadPool" SSLEnabled="true" maxPostSize="5000000" port="8443" protocol="org.apache.coyote.http11.Http11Nio2Protocol" scheme="https"
secure="true" compression="on" maxKeepAliveRequests="10" connectionTimeout="60000"
maxConnections="100" acceptCount="300" enableLookups="false" connectionUploadTimeout="120000" disableUploadTimeout="false">
<SSLHostConfig>
<Certificate certificateFile="conf/cert.pem" certificateKeyFile="conf/privkey.pem" certificateChainFile="conf/chain.pem" />
</SSLHostConfig>
<UpgradeProtocol className="org.apache.coyote.http2.Http2Protocol" compression="on" keepAliveTimeout="360000"/>
</Connector>
This is the AJAX call (redacted), but it is a reusable piece of code. I included the endpoint and the method name manually here. This piece of code works for all the other 167 endpoints. It is the same reusable AJAX code:
setTimeout(function()
{
$.ajax(
{
type : "POST",
url : "https://awsec2.com/api/v1/orders",
contentType : "application/json; charset=UTF-8",
data : parameters,
dataType : "json",
async : true,
crossDomain: true,
headers: headers,
success : function(ajaxResponseData)
{
navigation.hidePreloader();
if(typeof ajaxResponseData === "object")
{
alert("order placed");
}
},
error : function(jqXHR, text, errorThrown)
{
navigation.hidePreloader();
alert("error");
},
complete : function(jqXHR, textStatus)
{
navigation.hidePreloader();
}
});
}, 300);
This is the Spring Security config (redacted):
@Configuration
@EnableWebSecurity
@ComponentScan(basePackages = {"package1", "package2", "package3"})
public class OurCustomSpringSecurityConfiguration extends WebSecurityConfigurerAdapter
{
..........................
..........................
@Override
protected void configure(HttpSecurity http) throws Exception
{
http = http.csrf().ignoringAntMatchers(URLPatternsThatDoNotNeedCSRFToken).and();
http = http
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and();
http = http
.exceptionHandling()
.accessDeniedPage("/error")
.and();
http = http.authorizeRequests()
.requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
.antMatchers("/api/v1/drivers/**").hasAuthority(UserAuthority.Driver.get())
.antMatchers("/api/v1/orders/**").hasAuthority(UserAuthority.Business.get())
.antMatchers("/api/v1/**").authenticated()
.anyRequest().denyAll()
.and();
http = http.addFilterBefore(new JWTTokenFilter(), UsernamePasswordAuthenticationFilter.class);
http = http.addFilterBefore(new JavaThreadIDGeneratorFilter(), JWTTokenFilter.class);
http = http.headers().cacheControl().disable().and();
http = http.headers().contentSecurityPolicy("frame-src 'none'").and().and();
http = http.headers().contentTypeOptions().and().and();
http = http.headers().frameOptions().sameOrigin().and();
http = http.headers().httpStrictTransportSecurity().includeSubDomains(true).preload(true).and().and();
http = http.headers().referrerPolicy(ReferrerPolicy.STRICT_ORIGIN)
.policy(ReferrerPolicy.STRICT_ORIGIN).and().and();
http = http.headers().xssProtection().block(true).xssProtectionEnabled(true).and().and();
}
}
UPDATE I "solved" the problem by implementing a GUID for that endpoint (generated by the front-end) and used HazelCast to handle a lockable cache of these GUIDs making sure that a duplicate GUID will not go through