Spring Boot 3.5.7 upgrades Apache HttpClient5 from 5.5 to 5.5.1.
We now see a strange error when using RestTemplates with SSL and with the default ConnectionReuseStrategy. On the second call (that re-uses the connection), the call fails with a SocketTimeOutException (Read timed out) matching the connectTimeout value. So, somehow the reused connection suddenly use connectTimeout for readTimeout.
If we do not use SSL, it works as expected. If we configure the RestTemplate without connection reuse, it works as expected. If we downgrade to httpclient5 version 5.5, it works as expected.
Here is a minimal test that recreates the problem:
build.gradle:
plugins {
id 'java'
id 'org.springframework.boot' version '3.5.7'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'com.test'
version = '0.0.1-SNAPSHOT'
description = 'timeout'
java { toolchain { languageVersion = JavaLanguageVersion.of(24) } }
repositories { mavenCentral() }
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-logging'
implementation "org.apache.httpcomponents.client5:httpclient5"
implementation 'com.google.guava:guava:33.5.0-jre'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') { useJUnitPlatform() }
TimeOutApplication:
package com.test.timeout;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Duration;
@RestController
@RequestMapping("/timeout")
@SpringBootApplication
public class TimeoutApplication {
public static final Duration CONTROLLER_SLEEP_TIME = Duration.ofSeconds(2);
public static void main(String[] args) {
SpringApplication.run(TimeoutApplication.class, args);
}
@GetMapping()
public String timeout() throws Exception {
Thread.sleep(CONTROLLER_SLEEP_TIME);
return "OK";
}
}
application.properties:
spring.application.name=timeout
# NOTE: Generate the keystore and add it to src/main/resource
# keytool -genkeypair -keystore keystore.p12 -storetype PKCS12 -storepass password -alias test -keyalg RSA -keysize 2048 -validity 365 -dname "CN=localhost, OU=tst, O=tst, L=tst, ST=tst, C=tst"
spring.ssl.bundle.jks.mybundle.keystore.location=classpath:keystore.p12
spring.ssl.bundle.jks.mybundle.keystore.password=password
spring.ssl.bundle.jks.mybundle.keystore.type=PKCS12
spring.ssl.bundle.jks.mybundle.key.alias=test
server.ssl.bundle=mybundle
spring.ssl.bundle.jks.clientbundle.truststore.location=classpath:keystore.p12
spring.ssl.bundle.jks.clientbundle.truststore.password=password
server.ssl.enabled=true
logging.level.org.apache.hc.client5.http=DEBUG
TimeoutApplicationTest:
package com.test.timeout;
import com.google.common.base.Stopwatch;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder;
import org.springframework.boot.http.client.ClientHttpRequestFactorySettings;
import org.springframework.boot.ssl.SslBundles;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
import static org.assertj.core.api.Assertions.*;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class TimeoutApplicationTests {
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(1);
private static final Duration READ_TIMEOUT = Duration.ofSeconds(10);
private static final Duration ALLOWED_DIFFERENCE = Duration.ofMillis(200);
@Autowired
private RestTemplateBuilder restTemplateBuilder;
@Autowired
private SslBundles sslBundles;
@LocalServerPort
private int port;
record Result(Duration runtime, boolean timeout) { }
@Test
void with_connection_reuse_throws_socket_timeout_exception_after_connect_timeout_at_second_call() {
boolean connectionReuse = true;
var restTemplate = getRestTemplate(connectionReuse);
Result call1 = callEndpoint(restTemplate);
Result call2 = callEndpoint(restTemplate);
assertThat(call1.runtime()).isCloseTo(TimeoutApplication.CONTROLLER_SLEEP_TIME, ALLOWED_DIFFERENCE);
assertThat(call1.timeout()).isFalse();
assertThat(call2.runtime()).isCloseTo(CONNECT_TIMEOUT, ALLOWED_DIFFERENCE);
assertThat(call2.timeout()).isTrue();
}
@Test
void without_connection_reuse() {
boolean connectionReuse = false;
var restTemplate = getRestTemplate(connectionReuse);
Result call1 = callEndpoint(restTemplate);
Result call2 = callEndpoint(restTemplate);
assertThat(call1.runtime()).isCloseTo(TimeoutApplication.CONTROLLER_SLEEP_TIME, ALLOWED_DIFFERENCE);
assertThat(call1.timeout()).isFalse();
assertThat(call2.runtime()).isCloseTo(TimeoutApplication.CONTROLLER_SLEEP_TIME, ALLOWED_DIFFERENCE);
assertThat(call2.timeout()).isFalse();
}
private Result callEndpoint(RestTemplate restTemplate) {
String url = "https://localhost:" + port + "/timeout";
Stopwatch stopwatch = Stopwatch.createStarted();
try {
restTemplate.getForObject(url, String.class);
return new Result(stopwatch.elapsed(), false);
} catch (Exception e) {
return new Result(stopwatch.elapsed(), true);
}
}
private RestTemplate getRestTemplate(boolean connectionReuse) {
var factorySettings = ClientHttpRequestFactorySettings
.ofSslBundle(sslBundles.getBundle("clientbundle"))
.withConnectTimeout(CONNECT_TIMEOUT)
.withReadTimeout(READ_TIMEOUT);
var factoryBuilder = ClientHttpRequestFactoryBuilder.httpComponents();
if (!connectionReuse) {
factoryBuilder = factoryBuilder
.withHttpClientCustomizer(b -> b.setConnectionReuseStrategy((_, _, _) -> false));
}
return restTemplateBuilder
.requestFactoryBuilder(factoryBuilder)
.requestFactorySettings(factorySettings)
.build();
}
}
Comment From: wilkinsona
Thanks for the sample.
If we downgrade to httpclient5 version 5.5, it works as expected.
Given that this is the case, you appear to have identified that the Apache HTTP Client is the cause.
So, somehow the reused connection suddenly use connectTimeout for readTimeout.
I think that's due to https://github.com/apache/httpcomponents-client/commit/8094bac428ad61622530d216ee763724985f612f. The Apache HTTP Client now uses the connect timeout as a default timeout for the TLS handshake but it appears that it doesn't then re-apply the read timeout once the handshake has been completed. I've also observed that the problem only occurs when TLS is involved, another pointer towards https://github.com/apache/httpcomponents-client/commit/8094bac428ad61622530d216ee763724985f612f being the cause. I don't fully understand why this only happens when the connection is being re-used, but I think it'll have to be addressed in the Apache HTTP Client.
Comment From: HenrikPublic
I'll try to open a bug with them, then.
Comment From: HenrikPublic
Is there any way to set the TLS timeout on a RestTemplate?
Comment From: bclozel
@HenrikPublic no, this is specific to HTTP libraries.
Comment From: HenrikPublic
Ref: https://issues.apache.org/jira/browse/HTTPCLIENT-2405