While I am not sure this bug is directly linked to spring framework.
If you use the test framework with WebTestClient and restTemplate connecting to a mockServer. It creates a blocking process at the restTemplate level.
I first wanted to check if you guys were aware of this issue?
It seems to be linked to the mock server as well. As hitting an external server directly will work.
So WebClient(async) used in the test + RestTemplate in the app + MockServer --> return a concurrent issue. So RestTemplate used in the test + RestTemplate in the app + MockServer --> works fine So WebClient(async) used in the test + RestTemplate in the app + Direct server --> work fine
@RestController
@RequestMapping("/test")
@Slf4j
public class TestController {
RestTemplate restTemplate;
TestController() {
restTemplate = new RestTemplateBuilder()
.build();
}
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<TestResponse> postTransaction(@Valid @RequestBody TestRequest testRequest,
@RequestHeader HttpHeaders headers) {
HttpEntity<testRequest> entity = new HttpEntity<>(testRequest, headers);
restTemplate.postForObject("http://localhost:6666/myTest", entity, TestResponse.class);
return null;
}
}
@ActiveProfiles("integration-test")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
@AutoConfigureWireMock(files = "file:./happy-path", port = 6666)
public class TestControllerIT {
@Autowired
private WebTestClient client;
@Test
public void testWithWebClient() throws Exception {
client.post().uri("/test")
.bodyValue(testRequest)
.header("Content-Type", "application/json")
.exchange()
.expectStatus().isEqualTo(200)
.expectBody();
}
}
Same test using RestTemplate will work fine.
@ActiveProfiles("integration-test")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
@AutoConfigureWireMock(files = "file:./happy-path", port = 6666)
public class TestControllerIT {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate testRestTemplate;
@Test
public void testWithRestTemplate() throws Exception {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
ResponseEntity<String> entity = testRestTemplate.exchange("http://localhost:" + port + "/test",
HttpMethod.POST,
new HttpEntity<>(testRequest, headers),
String.class);
assertEquals(HttpStatusCode.valueOf(200), entity.getStatusCode());
}
}
The error generated is :
10:24:54.004 [Test worker] WARN i.o.t.c.ExceptionAdviceHandler - Generic exception thrown: 500 Server Error: "{<EOL>"cause2":"java.util.concurrent.TimeoutException: Idle timeout expired: 30004/30000 ms",<EOL>"cause1":"java.io.IOException: java.util.concurrent.TimeoutException: Idle timeout expired: 30004/30000 ms",<EOL>"servlet":"com.github.tomakehurst.wiremock.servlet.WireMockHandlerDispatchingServlet-3b84359c",<EOL>"cause0":"java.lang.RuntimeException: java.io.IOException: java.util.concurrent.TimeoutException: Idle timeout expired: 30004/30000 ms",<EOL>"message":"java.lang.RuntimeException: java.io.IOException: java.util.concurrent.TimeoutException: Idle timeout expired: 30004/30000 ms",<EOL>"url":"/test",<EOL>"status":"500"<EOL>}"
org.springframework.web.client.HttpServerErrorException$InternalServerError: 500 Server Error: "{<EOL>"cause2":"java.util.concurrent.TimeoutException: Idle timeout expired: 30004/30000 ms",<EOL>"cause1":"java.io.IOException: java.util.concurrent.TimeoutException: Idle timeout expired: 30004/30000 ms",<EOL>"servlet":"com.github.tomakehurst.wiremock.servlet.WireMockHandlerDispatchingServlet-3b84359c",<EOL>"cause0":"java.lang.RuntimeException: java.io.IOException: java.util.concurrent.TimeoutException: Idle timeout expired: 30004/30000 ms",<EOL>"message":"java.lang.RuntimeException: java.io.IOException: java.util.concurrent.TimeoutException: Idle timeout expired: 30004/30000 ms",<EOL>"url":"/test",<EOL>"status":"500"<EOL>}"
at org.springframework.web.client.HttpServerErrorException.create(HttpServerErrorException.java:102)
at org.springframework.web.client.DefaultResponseErrorHandler.handleError(DefaultResponseErrorHandler.java:186)
at org.springframework.web.client.DefaultResponseErrorHandler.handleError(DefaultResponseErrorHandler.java:137)
at org.springframework.web.client.ResponseErrorHandler.handleError(ResponseErrorHandler.java:63)
at org.springframework.web.client.RestTemplate.handleResponse(RestTemplate.java:942)
at org.springframework.web.client.RestTemplate.doExecute(RestTemplate.java:891)
at org.springframework.web.client.RestTemplate.execute(RestTemplate.java:790)
at org.springframework.web.client.RestTemplate.postForObject(RestTemplate.java:507)
at xxxTestController.postTransaction(TestController.java:64)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:568)
at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:255)
at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:188)
at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:118)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:926)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:831)
at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1089)
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:979)
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014)
at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:914)
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:547)
at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:885)
at org.springframework.test.web.servlet.TestDispatcherServlet.service(TestDispatcherServlet.java:72)
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:614)
at org.springframework.mock.web.MockFilterChain$ServletFilterProxy.doFilter(MockFilterChain.java:165)
at org.springframework.mock.web.MockFilterChain.doFilter(MockFilterChain.java:132)
at org.springframework.test.web.servlet.setup.MockMvcFilterDecorator.doFilter(MockMvcFilterDecorator.java:151)
at org.springframework.mock.web.MockFilterChain.doFilter(MockFilterChain.java:132)
at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
at org.springframework.test.web.servlet.setup.MockMvcFilterDecorator.doFilter(MockMvcFilterDecorator.java:151)
at org.springframework.mock.web.MockFilterChain.doFilter(MockFilterChain.java:132)
at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
at org.springframework.test.web.servlet.setup.MockMvcFilterDecorator.doFilter(MockMvcFilterDecorator.java:151)
at org.springframework.mock.web.MockFilterChain.doFilter(MockFilterChain.java:132)
at org.springframework.web.filter.ServerHttpObservationFilter.doFilterInternal(ServerHttpObservationFilter.java:113)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
at org.springframework.test.web.servlet.setup.MockMvcFilterDecorator.doFilter(MockMvcFilterDecorator.java:151)
at org.springframework.mock.web.MockFilterChain.doFilter(MockFilterChain.java:132)
at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
at org.springframework.test.web.servlet.setup.MockMvcFilterDecorator.doFilter(MockMvcFilterDecorator.java:151)
at org.springframework.mock.web.MockFilterChain.doFilter(MockFilterChain.java:132)
at org.springframework.test.web.servlet.MockMvc.perform(MockMvc.java:201)
at org.springframework.test.web.servlet.client.MockMvcHttpConnector.connect(MockMvcHttpConnector.java:106)
at org.springframework.test.web.reactive.server.WiretapConnector.connect(WiretapConnector.java:71)
at org.springframework.web.reactive.function.client.ExchangeFunctions$DefaultExchangeFunction.exchange(ExchangeFunctions.java:102)
at org.springframework.test.web.reactive.server.DefaultWebTestClient$DefaultRequestBodyUriSpec.exchange(DefaultWebTestClient.java:359)
Comment From: bclozel
@alexisgayte did you close this by mistake? Did you find where the issue came from?
Comment From: alexisgayte
I did close it and I didn't find the issue yet. But I couldn't reproduce it in isolation with a clean gradle import.
It seems more complex than just this simple code probably link to lib interaction. I wasn't really sure this is linked to spring and I didn't want to bother you. I will open a new one if I have more evidence.
Comment From: bclozel
Please share your sample project as a zip. That should help us to reproduce the problem.
Comment From: alexisgayte
Is that enough?
Comment From: bclozel
Sorry I'm not seeing anything shared here.
Comment From: alexisgayte
Yes sorry here the zip. Let me know if you need help. As previously said, it seems to be linked to the serialisation part.
Additionally, it seems to be a regression as it works with spring boot 3.1.12
message Edited * I have updated the test case as Remove RestTemplate is not needed. Remove the jackson part as not needed.
I think it is clear now that there is an issue on the framework. Probably WebTestClient.
I just don't know how to work it around.
Comment From: bclozel
Thanks for the complete sample @alexisgayte , this is really useful.
I've modified a bit your sample to compare the behavior with RestTemplate
in one test and with WebTestClient
in another. It doesn't seem related to deserialization, since the RestTemplate
being used in the controller is configured the same way in both cases.
It seems that in the second case the Wiremock server is not responding, so the controller doesn't reply and so the WebTestClient
times out:
org.apache.hc.core5.http.NoHttpResponseException: localhost:6666 failed to respond
at org.apache.hc.core5.http.impl.io.DefaultBHttpClientConnection.receiveResponseHeader(DefaultBHttpClientConnection.java:306) ~[httpcore5-5.2.5.jar:5.2.5]
at org.apache.hc.core5.http.impl.io.HttpRequestExecutor.execute(HttpRequestExecutor.java:175) ~[httpcore5-5.2.5.jar:5.2.5]
at org.apache.hc.core5.http.impl.io.HttpRequestExecutor.execute(HttpRequestExecutor.java:218) ~[httpcore5-5.2.5.jar:5.2.5]
Could you create an issue with the Spring Cloud Contract project? I'm not saying the problem is definitely in Spring Cloud, but so far I'm only seeing that the Wiremock server does not respond. I dug into this and still don't get why WebTestClient
is different here - it does bind to the real "localhost:8080" server and only uses a different HTTP client. I am seeing the request being bound to the Controller in both cases.
I am not able to transfer this issue to a different organization, so could you open a new issue on the Spring Cloud Contract side with my modified sample please: spring-framework-34435.zip
Depending on the feedback from the Cloud Contract team, we could reopen this issue and fix any problem in Framework. Thanks!
Comment From: alexisgayte
Thanks for your time,
If you downgrade the spring boot version to 3.1.12 it works just fine. It is a regression in WebTestClient. It affects also "server mock" not only wiremock.
I use spring-cloud to match as much as possible to your set up. The same behaviour is affecting "server mock", wiremock from cloud and latest version of wiremock.
So definitely not linked to spring cloud contract.
Also switching to feign instead of restTemplate works just fine.
It seems to be linked to the "extends RequestDemo" class and .bodyValue(requestDemo)
My gut feeling is WebTestClient creates a concurrent issue and block either the http client or spring http client wrapper. If you switch http client for jkd, hc or netty it is the same. So it should be linked to spring http client wrapper.
Comment From: bclozel
Reopening to consider your comment. If we can reproduce this without Spring Cloud contract this would help. I'll have a look.
Comment From: bclozel
Thanks for bearing with me, I think I have found the root problem here.
I didn't notice at first two things that are critical to understand:
- this also fails when running the actual application;
WebTestClient
is completely irrevelant here and so is the concurrency model and the Jackson codecs - the
HelloController
is reusing the HTTP headers it received and sending those with the request it's issuing!
The core problem here is that because the RestTemplate
used by the controller reuses the inbound HTTP headers, those are polluted by the incoming request. Here, the "Content-Length" header is set for the length of the body we received, but the RestTemplate
is sending a different body, way shorter in this case.
This explains the stacktrace I'm seeing when running the application, because we closed the connection before writing the amount of data we declared in the "Content-Length" header:
java.io.IOException: insufficient data written
at java.base/sun.net.www.protocol.http.HttpURLConnection$StreamingOutputStream.close(HttpURLConnection.java:3906) ~[na:na]
In the controller, replacing part of the method with this fixes things:
RequestDemo requestDemoA = new RequestDemo();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<RequestDemo> entity = new HttpEntity<>(requestDemoA, headers);
I'm not sure exactly why this was working previously in tests/in production. I believe there are several changes at play here. One of the main changes is called out in the upgrade guide:
To reduce memory usage in RestClient and RestTemplate, most ClientHttpRequestFactory implementations no longer buffer request bodies before sending them to the server. As a result, for certain content types such as JSON, the contents size is no longer known, and a Content-Length header is no longer set. If you would like to buffer request bodies like before, simply wrap the ClientHttpRequestFactory you are using in a BufferingClientHttpRequestFactory.
This means that in some cases the "Content-Length" header would be overwritten. Also, in recent versions TestRestTemplate
sends the body with a chunked encoding while WebTestClient
sets the content length; if anything, WebTestClient
is actually uncovering the bug in the controller.
The main conclusion here is that the Controller is invalid and there are numerous issues with copy/pasting HTTP headers from a received request to an outbound request: security headers being copied, content length / transfer encoding, etc. I'm closing this issue as there's nothing to be fixed in Spring Framework as far as I can see.
Thanks!