Skip to content

Commit 00b99f2

Browse files
committed
Fix Eureka HTTP client shutdown
Signed-off-by: Prahlad Bhakat <prahladbhakat05@gmail.com>
1 parent 6d10206 commit 00b99f2

5 files changed

Lines changed: 195 additions & 7 deletions

File tree

spring-cloud-netflix-eureka-client/src/main/java/org/springframework/cloud/netflix/eureka/http/DefaultEurekaClientHttpRequestFactorySupplier.java

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package org.springframework.cloud.netflix.eureka.http;
1818

19+
import java.io.IOException;
1920
import java.util.Set;
2021
import java.util.concurrent.TimeUnit;
2122

@@ -34,6 +35,7 @@
3435
import org.apache.hc.core5.util.Timeout;
3536

3637
import org.springframework.cloud.netflix.eureka.TimeoutProperties;
38+
import org.springframework.cloud.netflix.eureka.http.EurekaClientHttpRequestFactorySupplier.RequestConfigCustomizer;
3739
import org.springframework.http.client.ClientHttpRequestFactory;
3840
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
3941
import org.springframework.lang.Nullable;
@@ -53,6 +55,10 @@ public class DefaultEurekaClientHttpRequestFactorySupplier implements EurekaClie
5355

5456
private final Set<RequestConfigCustomizer> requestConfigCustomizers;
5557

58+
private volatile CloseableHttpClient sharedHttpClient;
59+
60+
private final Object lock = new Object();
61+
5662
public DefaultEurekaClientHttpRequestFactorySupplier(TimeoutProperties timeoutProperties,
5763
Set<RequestConfigCustomizer> requestConfigCustomizers) {
5864
this.timeoutProperties = timeoutProperties;
@@ -61,19 +67,40 @@ public DefaultEurekaClientHttpRequestFactorySupplier(TimeoutProperties timeoutPr
6167

6268
@Override
6369
public ClientHttpRequestFactory get(SSLContext sslContext, @Nullable HostnameVerifier hostnameVerifier) {
64-
HttpClientBuilder httpClientBuilder = HttpClientBuilder.create();
65-
if (sslContext != null || hostnameVerifier != null || timeoutProperties != null) {
66-
httpClientBuilder
67-
.setConnectionManager(buildConnectionManager(sslContext, hostnameVerifier, timeoutProperties));
70+
CloseableHttpClient httpClient = this.sharedHttpClient;
71+
if (httpClient == null) {
72+
synchronized (this.lock) {
73+
httpClient = this.sharedHttpClient;
74+
if (httpClient == null) {
75+
HttpClientBuilder httpClientBuilder = HttpClientBuilder.create();
76+
if (sslContext != null || hostnameVerifier != null || timeoutProperties != null) {
77+
httpClientBuilder.setConnectionManager(
78+
buildConnectionManager(sslContext, hostnameVerifier, timeoutProperties));
79+
}
80+
httpClientBuilder.setDefaultRequestConfig(buildRequestConfig());
81+
httpClient = httpClientBuilder.build();
82+
this.sharedHttpClient = httpClient;
83+
}
84+
}
6885
}
69-
httpClientBuilder.setDefaultRequestConfig(buildRequestConfig());
70-
71-
CloseableHttpClient httpClient = httpClientBuilder.build();
7286
HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory();
7387
requestFactory.setHttpClient(httpClient);
7488
return requestFactory;
7589
}
7690

91+
@Override
92+
public void close() {
93+
CloseableHttpClient httpClient = this.sharedHttpClient;
94+
if (httpClient != null) {
95+
try {
96+
httpClient.close();
97+
}
98+
catch (IOException ex) {
99+
// best-effort close during shutdown; nothing actionable if it fails
100+
}
101+
}
102+
}
103+
77104
private HttpClientConnectionManager buildConnectionManager(SSLContext sslContext, HostnameVerifier hostnameVerifier,
78105
TimeoutProperties timeoutProperties) {
79106
PoolingHttpClientConnectionManagerBuilder connectionManagerBuilder = PoolingHttpClientConnectionManagerBuilder

spring-cloud-netflix-eureka-client/src/main/java/org/springframework/cloud/netflix/eureka/http/EurekaClientHttpRequestFactorySupplier.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,17 @@ public interface EurekaClientHttpRequestFactorySupplier {
4040
*/
4141
ClientHttpRequestFactory get(SSLContext sslContext, @Nullable HostnameVerifier hostnameVerifier);
4242

43+
/**
44+
* Closes any resources (e.g. a shared HTTP client / connection pool) held by this
45+
* supplier. Called by the owning
46+
* {@link com.netflix.discovery.shared.transport.TransportClientFactory} on
47+
* {@code shutdown()}, which Netflix's {@code DiscoveryClient} invokes synchronously,
48+
* right after the final {@code unregister()} call completes.
49+
* @since 4.3.0
50+
*/
51+
default void close() {
52+
}
53+
4354
/**
4455
* Allows customising the {@link RequestConfig} of the underlying Apache HC5 instance.
4556
*

spring-cloud-netflix-eureka-client/src/main/java/org/springframework/cloud/netflix/eureka/http/RestClientTransportClientFactory.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ public EurekaHttpClient newClient(EurekaEndpoint endpoint) {
107107

108108
@Override
109109
public void shutdown() {
110+
eurekaClientHttpRequestFactorySupplier.close();
110111
}
111112

112113
private static void setUrl(RestClient.Builder builder, String serviceUrl) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Copyright 2013-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.cloud.netflix.eureka.http;
18+
19+
import java.util.Collections;
20+
21+
import org.junit.jupiter.api.Test;
22+
23+
import org.springframework.beans.factory.DisposableBean;
24+
import org.springframework.cloud.netflix.eureka.TimeoutProperties;
25+
import org.springframework.http.client.ClientHttpRequestFactory;
26+
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
27+
28+
import static org.assertj.core.api.Assertions.assertThat;
29+
30+
/**
31+
* Tests for {@link DefaultEurekaClientHttpRequestFactorySupplier}.
32+
*
33+
* <p>
34+
* These specifically guard against regressing gh-4275: an earlier fix (gh-4258) made this
35+
* class a Spring {@code DisposableBean}, which raced with
36+
* {@code CloudEurekaClient#shutdown()} during context shutdown and broke
37+
* unregister-on-shutdown. That fix was reverted; this class must continue to be closed
38+
* only via {@link EurekaClientHttpRequestFactorySupplier#close()}, invoked synchronously
39+
* by {@code TransportClientFactory#shutdown()} - never via an independent Spring
40+
* bean-destroy callback.
41+
*/
42+
class DefaultEurekaClientHttpRequestFactorySupplierTests {
43+
44+
private final DefaultEurekaClientHttpRequestFactorySupplier supplier = new DefaultEurekaClientHttpRequestFactorySupplier(
45+
new TimeoutProperties(), Collections.emptySet());
46+
47+
@Test
48+
void shouldNotBeADisposableBean() {
49+
// Guard against reintroducing gh-4275: this class must not be destroyed via an
50+
// independent Spring bean-destroy callback.
51+
assertThat(supplier).isNotInstanceOf(DisposableBean.class);
52+
}
53+
54+
@Test
55+
void shouldReuseSameHttpClientAcrossMultipleGetCalls() {
56+
ClientHttpRequestFactory first = supplier.get(null, null);
57+
ClientHttpRequestFactory second = supplier.get(null, null);
58+
59+
Object firstHttpClient = ((HttpComponentsClientHttpRequestFactory) first).getHttpClient();
60+
Object secondHttpClient = ((HttpComponentsClientHttpRequestFactory) second).getHttpClient();
61+
62+
assertThat(firstHttpClient).isSameAs(secondHttpClient);
63+
}
64+
65+
@Test
66+
void closeShouldBeSafeToCallWithoutPriorGet() {
67+
// close() before get() (e.g. context shut down before any request was ever
68+
// made) must not throw.
69+
supplier.close();
70+
}
71+
72+
@Test
73+
void closeShouldBeSafeToCallTwice() {
74+
supplier.get(null, null);
75+
supplier.close();
76+
// Idempotent - shutdown paths may call close() more than once.
77+
supplier.close();
78+
}
79+
80+
@Test
81+
void getAfterCloseShouldStillReturnARequestFactory() {
82+
supplier.get(null, null);
83+
supplier.close();
84+
85+
// A get() call racing just after shutdown must not throw; the returned factory
86+
// wraps a closed client and will fail on actual use, which is expected during
87+
// shutdown, but construction itself must remain safe.
88+
ClientHttpRequestFactory afterClose = supplier.get(null, null);
89+
assertThat(afterClose).isNotNull();
90+
}
91+
92+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/*
2+
* Copyright 2013-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.cloud.netflix.eureka.http;
18+
19+
import org.junit.jupiter.api.Test;
20+
21+
import org.springframework.cloud.configuration.TlsProperties;
22+
23+
import static org.mockito.Mockito.mock;
24+
import static org.mockito.Mockito.times;
25+
import static org.mockito.Mockito.verify;
26+
27+
/**
28+
* Tests that {@link RestClientTransportClientFactory#shutdown()} deterministically
29+
* delegates to {@link EurekaClientHttpRequestFactorySupplier#close()}, closing the shared
30+
* HTTP client/pool synchronously - after the caller (Netflix's {@code DiscoveryClient})
31+
* has already completed its final {@code unregister()} call, and not via a separate,
32+
* unordered Spring bean-destroy path (gh-4569).
33+
*/
34+
class RestClientTransportClientFactoryShutdownTests {
35+
36+
@Test
37+
void shutdownShouldCloseTheHttpRequestFactorySupplier() {
38+
EurekaClientHttpRequestFactorySupplier supplier = mock(EurekaClientHttpRequestFactorySupplier.class);
39+
RestClientTransportClientFactory factory = new RestClientTransportClientFactory(new TlsProperties(), supplier);
40+
41+
factory.shutdown();
42+
43+
verify(supplier, times(1)).close();
44+
}
45+
46+
@Test
47+
void shutdownShouldBeIdempotent() {
48+
EurekaClientHttpRequestFactorySupplier supplier = mock(EurekaClientHttpRequestFactorySupplier.class);
49+
RestClientTransportClientFactory factory = new RestClientTransportClientFactory(new TlsProperties(), supplier);
50+
51+
factory.shutdown();
52+
factory.shutdown();
53+
54+
verify(supplier, times(2)).close();
55+
}
56+
57+
}

0 commit comments

Comments
 (0)