Steps to Reproduce
- Download rest-client-issue.zip. It uses Spring Boot 4.0.0-RC2 and Spring Framework Web 7.0.0-RC3.
- Run the tests in
RestClientIssueApplicationTests
Expected Outcome
All tests should pass.
Actual Outcome
Last test is blocked and is waiting for 3 minutes until it reaches the time-out which is set for Apache HTTP Client 5. If you just execute this one single test it works out fine.
Analysis
This behaviour seems to occur only with Apache HTTP Client 5. After 5 tests/HTTP connections to the same server (route) the client doesn't get a response anymore.
2025-11-08T23:05:48.258+01:00 DEBUG h.i.i.PoolingHttpClientConnectionManager : ex-0000000001 endpoint lease request (3 MINUTES) [route: {}->[http://localhost:56211]][total available: 0; route allocated: 0 of 5; total allocated: 0 of 25]
2025-11-08T23:05:48.259+01:00 DEBUG h.i.i.PoolingHttpClientConnectionManager : ex-0000000001 endpoint leased [route: {}->[http://localhost:56211]][total available: 0; route allocated: 1 of 5; total allocated: 1 of 25]
2025-11-08T23:05:48.324+01:00 DEBUG h.i.i.PoolingHttpClientConnectionManager : ex-0000000002 endpoint lease request (3 MINUTES) [route: {}->[http://localhost:56211]][total available: 0; route allocated: 1 of 5; total allocated: 1 of 25]
2025-11-08T23:05:48.324+01:00 DEBUG h.i.i.PoolingHttpClientConnectionManager : ex-0000000002 endpoint leased [route: {}->[http://localhost:56211]][total available: 0; route allocated: 2 of 5; total allocated: 2 of 25]
2025-11-08T23:05:48.351+01:00 DEBUG h.i.i.PoolingHttpClientConnectionManager : ex-0000000003 endpoint lease request (3 MINUTES) [route: {}->[http://localhost:56211]][total available: 0; route allocated: 2 of 5; total allocated: 2 of 25]
2025-11-08T23:05:48.351+01:00 DEBUG h.i.i.PoolingHttpClientConnectionManager : ex-0000000003 endpoint leased [route: {}->[http://localhost:56211]][total available: 0; route allocated: 3 of 5; total allocated: 3 of 25]
2025-11-08T23:05:48.375+01:00 DEBUG h.i.i.PoolingHttpClientConnectionManager : ex-0000000004 endpoint lease request (3 MINUTES) [route: {}->[http://localhost:56211]][total available: 0; route allocated: 3 of 5; total allocated: 3 of 25]
2025-11-08T23:05:48.375+01:00 DEBUG h.i.i.PoolingHttpClientConnectionManager : ex-0000000004 endpoint leased [route: {}->[http://localhost:56211]][total available: 0; route allocated: 4 of 5; total allocated: 4 of 25]
2025-11-08T23:05:48.381+01:00 DEBUG h.i.i.PoolingHttpClientConnectionManager : ex-0000000005 endpoint lease request (3 MINUTES) [route: {}->[http://localhost:56211]][total available: 0; route allocated: 4 of 5; total allocated: 4 of 25]
2025-11-08T23:05:48.381+01:00 DEBUG h.i.i.PoolingHttpClientConnectionManager : ex-0000000005 endpoint leased [route: {}->[http://localhost:56211]][total available: 0; route allocated: 5 of 5; total allocated: 5 of 25]
2025-11-08T23:05:48.387+01:00 DEBUG h.i.i.PoolingHttpClientConnectionManager : ex-0000000006 endpoint lease request (3 MINUTES) [route: {}->[http://localhost:56211]][total available: 0; route allocated: 5 of 5; total allocated: 5 of 25]
After all routes are allocated the client is blocked.
[ERROR] Tests run: 6, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 181.4 s <<< FAILURE! -- in at.aztec.spring.RestClientIssueApplicationTests
[ERROR] at.aztec.spring.RestClientIssueApplicationTests.shouldAllowInfoEndpoint -- Time elapsed: 180.0 s <<< ERROR!
org.springframework.web.client.ResourceAccessException: I/O error on GET request for "http://localhost: 56211/actuator/info": Timeout deadline: 180000 MILLISECONDS, actual: 180004 MILLISECONDS
at org.springframework.web.client.DefaultRestClient$DefaultRequestBodyUriSpec.createResourceAccessException(DefaultRestClient.java:763)
at org.springframework.web.client.DefaultRestClient$DefaultRequestBodyUriSpec.exchangeInternal(DefaultRestClient.java:615)
at org.springframework.web.client.DefaultRestClient$DefaultRequestBodyUriSpec.exchangeForRequiredValue(DefaultRestClient.java:572)
at org.springframework.test.web.servlet.client.DefaultRestTestClient$DefaultRequestBodyUriSpec.exchange(DefaultRestTestClient.java:289)
at at.aztec.spring.RestClientIssueApplicationTests.shouldAllowInfoEndpoint(RestClientIssueApplicationTests.java:42)
Caused by: org.apache.hc.core5.http.ConnectionRequestTimeoutException: Timeout deadline: 180000 MILLISECONDS, actual: 180004 MILLISECONDS
at org.apache.hc.client5.http.impl.classic.InternalExecRuntime.acquireEndpoint(InternalExecRuntime.java:122)
at org.apache.hc.client5.http.impl.classic.ConnectExec.execute(ConnectExec.java:127)
at org.apache.hc.client5.http.impl.classic.ExecChainElement.execute(ExecChainElement.java:51)
at org.apache.hc.client5.http.impl.classic.ProtocolExec.execute(ProtocolExec.java:195)
at org.apache.hc.client5.http.impl.classic.ExecChainElement.execute(ExecChainElement.java:51)
at org.apache.hc.client5.http.impl.classic.ContentCompressionExec.execute(ContentCompressionExec.java:151)
at org.apache.hc.client5.http.impl.classic.ExecChainElement.execute(ExecChainElement.java:51)
at org.apache.hc.client5.http.impl.classic.HttpRequestRetryExec.execute(HttpRequestRetryExec.java:112)
at org.apache.hc.client5.http.impl.classic.ExecChainElement.execute(ExecChainElement.java:51)
at org.apache.hc.client5.http.impl.classic.RedirectExec.execute(RedirectExec.java:110)
at org.apache.hc.client5.http.impl.classic.ExecChainElement.execute(ExecChainElement.java:51)
at org.apache.hc.client5.http.impl.classic.InternalHttpClient.doExecute(InternalHttpClient.java:185)
at org.apache.hc.client5.http.impl.classic.CloseableHttpClient.execute(CloseableHttpClient.java:87)
at org.apache.hc.client5.http.impl.classic.CloseableHttpClient.execute(CloseableHttpClient.java:55)
at org.apache.hc.client5.http.classic.HttpClient.executeOpen(HttpClient.java:183)
at org.springframework.http.client.HttpComponentsClientHttpRequest.executeInternal(HttpComponentsClientHttpRequest.java:99)
at org.springframework.http.client.AbstractStreamingClientHttpRequest.executeInternal(AbstractStreamingClientHttpRequest.java:70)
at org.springframework.http.client.AbstractClientHttpRequest.execute(AbstractClientHttpRequest.java:80)
at org.springframework.http.client.AbstractBufferingClientHttpRequest.executeWithRequest(AbstractBufferingClientHttpRequest.java:89)
at org.springframework.http.client.InterceptingClientHttpRequest$EndOfChainRequestExecution.execute(InterceptingClientHttpRequest.java:102)
at org.springframework.test.web.servlet.client.DefaultRestTestClient$WiretapInterceptor.intercept(DefaultRestTestClient.java:531)
at org.springframework.http.client.ClientHttpRequestInterceptor.lambda$apply$0(ClientHttpRequestInterceptor.java:89)
at org.springframework.http.client.InterceptingClientHttpRequest.executeInternal(InterceptingClientHttpRequest.java:73)
at org.springframework.http.client.AbstractBufferingClientHttpRequest.executeInternal(AbstractBufferingClientHttpRequest.java:50)
at org.springframework.http.client.AbstractClientHttpRequest.execute(AbstractClientHttpRequest.java:80)
at org.springframework.web.client.DefaultRestClient$DefaultRequestBodyUriSpec.exchangeInternal(DefaultRestClient.java:609)
... 3 more
If you set the parameter close of the method exchangeForRequiredValue in org.springframework.test.web.servlet.client.DefaultRestTestClient.DefaultRequestBodyUriSpec.exchange() to true instead of false (currently hardcoded) this issue doesn't occur.
But I doubt this would be the correct solution since HTTP connections should be reused from the connection pool.
Comment From: jeffbswope
I have some random observations here from working to debug the same issue with some tests of ours.
I could isolate this more directly by putting a breakpoint in the DefaultRestTestClient constructor and having the debugger evaluate the following to turn off the default connection pooling (there does not seem to be any official way to control the config of the underlying client):
builder.requestFactory(new HttpComponentsClientHttpRequestFactory(HttpClients.custom().setConnectionManager(new BasicHttpClientConnectionManager()).build()))
I found the connection hung generally for error cases -- these were mostly also cases where I had no assertions on the body, like:
restTestClient.get().uri("/protected")
.exchange()
.expectStatus().isUnauthorized();
The documentation isn't super-clear but it does seem to indicate that making some assertion about the body is expected/required... (this seems awkward API choice if that is a requirement) but I could get through many of the hanging tests just by adding a blank assertion like:
restTestClient.get().uri("/protected")
.exchange()
.expectStatus().isUnauthorized()
.expectBody();
I'm still stuck though because at least some of the tests that hit 404 Not Found are hanging, and no body assertion I can find will release the connection:
restTestClient.get().uri("/foo")
.exchange()
.expectStatus().isNotFound()
// .expectBody()
// .expectBody(Void.class)
.expectBody().consumeWith(response -> log.info("Length: {}", response.getResponseBody() != null ? response.getResponseBody().length : 0))
;
I haven't yet had time to make a minimal replication to see if this is more about something I'm doing or Spring Boot error handling or... ?
Comment From: DerKanzler
Nice obversation. Thank you @jeffbswope!
Adding .expectBody() in the tests was sufficient for me.
Also in the minimal reproduction scenario from my first post.
It also worked out for 404 in that setup.
Without .expectBody() it would hang.
restTestClient.get().uri("/foo1").exchange().expectStatus().isNotFound().expectBody();
restTestClient.get().uri("/foo2").exchange().expectStatus().isNotFound().expectBody();
restTestClient.get().uri("/foo3").exchange().expectStatus().isNotFound().expectBody();
restTestClient.get().uri("/foo4").exchange().expectStatus().isNotFound().expectBody();
restTestClient.get().uri("/foo5").exchange().expectStatus().isNotFound().expectBody();
restTestClient.get().uri("/foo6").exchange().expectStatus().isNotFound().expectBody();
The documentation isn't super-clear but it does seem to indicate that making some assertion about the body is expected/required... (this seems awkward API choice if that is a requirement)
I can't find the documentation you are refering to. https://docs.spring.io/spring-framework/reference/7.0/testing/resttestclient.html doesn't seem to indicate that you have to do an assertion about the body. There are even examples without any of those.
So I assume it is not intentional and just a wrong behaviour with Apache HTTP Client 5.
Comment From: jeffbswope
Down on that page it says:
If you want to ignore the response content, the following releases the content without any assertions:
and shows use of .expectBody(Void.class);
But as you said it seems like some of the examples do not "release the content" at all, so it's not clear. And it's not self-evident from the API that you are making any kind of mistake if you just want to assert the status and stop there.
I haven't been able to get back to this to look more at my remaining issue and dig into the internals of the RestTestClient or RestClient to see why my isNotFound() is still hanging even with the body assertion.
Comment From: jeffbswope
I got one step deeper today and noticed:
// releases connection if 404 response includes a body
restTestClient.get().uri("/foo").exchange().expectStatus().isNotFound().expectBody(Void.class);
// does not release connection if 404 response is actually empty and has Content-Length: 0
restTestClient.get().uri("/special/foo").exchange().expectStatus().isNotFound().expectBody(Void.class);
// does not release connection if 404 response is actually empty and has Content-Length: 0
restTestClient.get().uri("/special/foo").exchange().expectStatus().isNotFound().expectBody().isEmpty();
There's a short-circuit in org.springframework.web.client.DefaultRestClient#readWithMessageConverters where it calls org.springframework.web.client.IntrospectingClientHttpResponse#hasMessageBody -- which will return false if Content-Length: 0. So it will never actually call down into org.springframework.http.client.HttpComponentsClientHttpResponse#getBody, which appears to leave the connection open in this case.
Comment From: riccardo-masseroni-bluecube
Hello there, having a similar issue:
@RestController
@RequestMapping("/public")
public class HelloController {
@GetMapping(value = "/hello")
public ResponseEntity<@NonNull Void> hello() {
return ResponseEntity.ok().build();
}
}
@AutoConfigureRestTestClient
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class HelloControllerIT {
@Autowired
protected RestTestClient restTestClient;
@RepeatedTest(6)
void theSixthTimeWillThrowTimeout() {
restTestClient.get()
.uri("/public/hello")
.exchange()
.expectStatus().isOk();
}
}
On the sixth time, I'm getting the following error:
org.springframework.web.client.ResourceAccessException: I/O error on GET request for "http://localhost:57610/public/hello": Timeout deadline: 180000 MILLISECONDS, actual: 180002 MILLISECONDS
at org.springframework.web.client.DefaultRestClient$DefaultRequestBodyUriSpec.createResourceAccessException(DefaultRestClient.java:763)
at org.springframework.web.client.DefaultRestClient$DefaultRequestBodyUriSpec.exchangeInternal(DefaultRestClient.java:615)
at org.springframework.web.client.DefaultRestClient$DefaultRequestBodyUriSpec.exchangeForRequiredValue(DefaultRestClient.java:572)
at org.springframework.test.web.servlet.client.DefaultRestTestClient$DefaultRequestBodyUriSpec.exchange(DefaultRestTestClient.java:294)
at HelloControllerIT.theSixthTimeWillThrowTimeout(HelloControllerIT.java:26)
Caused by: org.apache.hc.core5.http.ConnectionRequestTimeoutException: Timeout deadline: 180000 MILLISECONDS, actual: 180002 MILLISECONDS
at org.apache.hc.client5.http.impl.classic.InternalExecRuntime.acquireEndpoint(InternalExecRuntime.java:122)
at org.apache.hc.client5.http.impl.classic.ConnectExec.execute(ConnectExec.java:127)
at org.apache.hc.client5.http.impl.classic.ExecChainElement.execute(ExecChainElement.java:51)
at org.apache.hc.client5.http.impl.classic.ProtocolExec.execute(ProtocolExec.java:195)
at org.apache.hc.client5.http.impl.classic.ExecChainElement.execute(ExecChainElement.java:51)
at org.apache.hc.client5.http.impl.classic.ContentCompressionExec.execute(ContentCompressionExec.java:151)
at org.apache.hc.client5.http.impl.classic.ExecChainElement.execute(ExecChainElement.java:51)
at org.apache.hc.client5.http.impl.classic.HttpRequestRetryExec.execute(HttpRequestRetryExec.java:112)
at org.apache.hc.client5.http.impl.classic.ExecChainElement.execute(ExecChainElement.java:51)
at org.apache.hc.client5.http.impl.classic.RedirectExec.execute(RedirectExec.java:110)
at org.apache.hc.client5.http.impl.classic.ExecChainElement.execute(ExecChainElement.java:51)
at org.apache.hc.client5.http.impl.classic.InternalHttpClient.doExecute(InternalHttpClient.java:185)
at org.apache.hc.client5.http.impl.classic.CloseableHttpClient.execute(CloseableHttpClient.java:87)
at org.apache.hc.client5.http.impl.classic.CloseableHttpClient.execute(CloseableHttpClient.java:55)
at org.apache.hc.client5.http.classic.HttpClient.executeOpen(HttpClient.java:183)
at org.springframework.http.client.HttpComponentsClientHttpRequest.executeInternal(HttpComponentsClientHttpRequest.java:99)
at org.springframework.http.client.AbstractStreamingClientHttpRequest.executeInternal(AbstractStreamingClientHttpRequest.java:87)
at org.springframework.http.client.AbstractClientHttpRequest.execute(AbstractClientHttpRequest.java:80)
at org.springframework.http.client.AbstractBufferingClientHttpRequest.executeWithRequest(AbstractBufferingClientHttpRequest.java:89)
at org.springframework.http.client.InterceptingClientHttpRequest$EndOfChainRequestExecution.execute(InterceptingClientHttpRequest.java:102)
at org.springframework.test.web.servlet.client.DefaultRestTestClient$WiretapInterceptor.intercept(DefaultRestTestClient.java:537)
at org.springframework.http.client.ClientHttpRequestInterceptor.lambda$apply$0(ClientHttpRequestInterceptor.java:89)
at org.springframework.http.client.InterceptingClientHttpRequest.executeInternal(InterceptingClientHttpRequest.java:73)
at org.springframework.http.client.AbstractBufferingClientHttpRequest.executeInternal(AbstractBufferingClientHttpRequest.java:50)
at org.springframework.http.client.AbstractClientHttpRequest.execute(AbstractClientHttpRequest.java:80)
at org.springframework.web.client.DefaultRestClient$DefaultRequestBodyUriSpec.exchangeInternal(DefaultRestClient.java:609)
... 3 more
At a first look, seems our ClientHttpResponse never gets properly closed. (org.springframework.web.client.DefaultRestClient.DefaultRequestBodyUriSpec#exchangeInternal)
Comment From: riccardo-masseroni-bluecube
A possible workaround on my side was to explicitly consume the response body returned by RestTestClient.
For now, forcing the body consumption withreturnResult().getResponseBodyContent();prevents the issue.
So the full workaround looks like:
restTestClient.get()
.uri("/public/hello")
.exchange()
.expectStatus().isOk()
.returnResult()
.getResponseBodyContent();
Comment From: bclozel
Hi there, I looked into this report and I consider this as a bug. I'm working on a fix for the next maintenance release.