[
https://issues.apache.org/jira/browse/HTTPCLIENT-2405?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18035000#comment-18035000
]
Henrik Dohlmann commented on HTTPCLIENT-2405:
---------------------------------------------
Attached.
> ConnectionReuse uses connectTimeout as readTimeout on reused connection
> -----------------------------------------------------------------------
>
> Key: HTTPCLIENT-2405
> URL: https://issues.apache.org/jira/browse/HTTPCLIENT-2405
> Project: HttpComponents HttpClient
> Issue Type: Bug
> Components: HttpClient (classic)
> Affects Versions: 5.5.1
> Reporter: Henrik Dohlmann
> Priority: Major
> Attachments: timeout.zip
>
>
> I was asked to post here from
> [https://github.com/spring-projects/spring-boot/issues/47895]
> It looks like HTTPCLIENT-2386 introduced a weird bug.
> 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:
> {code:java}
> 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() } {code}
> application.properties:
> {code:java}
> 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 {code}
> TimeoutApplication.java:
> {code:java}
> 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";
> }
> } {code}
> TimeoutApplicationTest.java:
> {code:java}
> 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();
> }
> } {code}
--
This message was sent by Atlassian Jira
(v8.20.10#820010)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]