1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 package org.eclipse.aether.transport.jdk;
20
21 import javax.net.ssl.SSLContext;
22 import javax.net.ssl.SSLEngine;
23 import javax.net.ssl.SSLException;
24 import javax.net.ssl.SSLParameters;
25 import javax.net.ssl.X509ExtendedTrustManager;
26 import javax.net.ssl.X509TrustManager;
27
28 import java.io.BufferedInputStream;
29 import java.io.IOException;
30 import java.io.InputStream;
31 import java.io.InterruptedIOException;
32 import java.io.UncheckedIOException;
33 import java.lang.reflect.InvocationTargetException;
34 import java.lang.reflect.Method;
35 import java.net.Authenticator;
36 import java.net.ConnectException;
37 import java.net.InetAddress;
38 import java.net.InetSocketAddress;
39 import java.net.NoRouteToHostException;
40 import java.net.PasswordAuthentication;
41 import java.net.ProxySelector;
42 import java.net.Socket;
43 import java.net.URI;
44 import java.net.URISyntaxException;
45 import java.net.UnknownHostException;
46 import java.net.http.HttpClient;
47 import java.net.http.HttpRequest;
48 import java.net.http.HttpResponse;
49 import java.nio.file.Files;
50 import java.nio.file.Path;
51 import java.nio.file.StandardCopyOption;
52 import java.security.cert.X509Certificate;
53 import java.time.Duration;
54 import java.time.Instant;
55 import java.time.ZoneId;
56 import java.time.ZonedDateTime;
57 import java.time.format.DateTimeFormatter;
58 import java.time.format.DateTimeParseException;
59 import java.util.Base64;
60 import java.util.HashMap;
61 import java.util.Locale;
62 import java.util.Map;
63 import java.util.Objects;
64 import java.util.Optional;
65 import java.util.Set;
66 import java.util.concurrent.Semaphore;
67 import java.util.function.Function;
68 import java.util.regex.Matcher;
69
70 import com.github.mizosoft.methanol.Methanol;
71 import com.github.mizosoft.methanol.RetryInterceptor;
72 import com.github.mizosoft.methanol.RetryInterceptor.Context;
73 import org.eclipse.aether.ConfigurationProperties;
74 import org.eclipse.aether.RepositorySystemSession;
75 import org.eclipse.aether.repository.AuthenticationContext;
76 import org.eclipse.aether.repository.RemoteRepository;
77 import org.eclipse.aether.spi.connector.transport.AbstractTransporter;
78 import org.eclipse.aether.spi.connector.transport.GetTask;
79 import org.eclipse.aether.spi.connector.transport.PeekTask;
80 import org.eclipse.aether.spi.connector.transport.PutTask;
81 import org.eclipse.aether.spi.connector.transport.TransportListenerNotifyingInputStream;
82 import org.eclipse.aether.spi.connector.transport.TransportTask;
83 import org.eclipse.aether.spi.connector.transport.http.ChecksumExtractor;
84 import org.eclipse.aether.spi.connector.transport.http.HttpTransporter;
85 import org.eclipse.aether.spi.connector.transport.http.HttpTransporterException;
86 import org.eclipse.aether.spi.io.PathProcessor;
87 import org.eclipse.aether.transfer.NoTransporterException;
88 import org.eclipse.aether.transfer.TransferCancelledException;
89 import org.eclipse.aether.util.ConfigUtils;
90 import org.eclipse.aether.util.connector.transport.http.HttpTransporterUtils;
91 import org.slf4j.Logger;
92 import org.slf4j.LoggerFactory;
93
94 import static java.nio.charset.StandardCharsets.ISO_8859_1;
95 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.ACCEPT_ENCODING;
96 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.CACHE_CONTROL;
97 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.CONTENT_LENGTH;
98 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.CONTENT_RANGE;
99 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.CONTENT_RANGE_PATTERN;
100 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.IF_UNMODIFIED_SINCE;
101 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.LAST_MODIFIED;
102 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.MULTIPLE_CHOICES;
103 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.PRECONDITION_FAILED;
104 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.RANGE;
105 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.USER_AGENT;
106 import static org.eclipse.aether.transport.jdk.JdkTransporterConfigurationKeys.CONFIG_PROP_HTTP_VERSION;
107 import static org.eclipse.aether.transport.jdk.JdkTransporterConfigurationKeys.CONFIG_PROP_MAX_CONCURRENT_REQUESTS;
108 import static org.eclipse.aether.transport.jdk.JdkTransporterConfigurationKeys.DEFAULT_HTTP_VERSION;
109 import static org.eclipse.aether.transport.jdk.JdkTransporterConfigurationKeys.DEFAULT_MAX_CONCURRENT_REQUESTS;
110
111
112
113
114
115
116
117
118
119
120
121
122
123 final class JdkTransporter extends AbstractTransporter implements HttpTransporter {
124 private static final Logger LOGGER = LoggerFactory.getLogger(JdkTransporter.class);
125
126 private static final DateTimeFormatter RFC7231 = DateTimeFormatter.ofPattern(
127 "EEE, dd MMM yyyy HH:mm:ss z", Locale.ENGLISH)
128 .withZone(ZoneId.of("GMT"));
129
130 private static final long MODIFICATION_THRESHOLD = 60L * 1000L;
131
132
133
134
135
136
137 private static final Set<Class<? extends IOException>> NON_RETRIABLE_IO_EXCEPTIONS = Set.of(
138 InterruptedIOException.class,
139 UnknownHostException.class,
140 ConnectException.class,
141 NoRouteToHostException.class,
142 SSLException.class);
143
144 private final ChecksumExtractor checksumExtractor;
145
146 private final PathProcessor pathProcessor;
147
148 private final URI baseUri;
149
150 private final HttpClient client;
151
152 private final Map<String, String> headers;
153
154 private final int connectTimeout;
155
156 private final int requestTimeout;
157
158 private final Boolean expectContinue;
159
160 private final Semaphore maxConcurrentRequests;
161
162 private final boolean preemptivePutAuth;
163
164 private final boolean preemptiveAuth;
165
166 private PasswordAuthentication serverAuthentication;
167
168 private PasswordAuthentication proxyAuthentication;
169
170 JdkTransporter(
171 RepositorySystemSession session,
172 RemoteRepository repository,
173 int javaVersion,
174 ChecksumExtractor checksumExtractor,
175 PathProcessor pathProcessor)
176 throws NoTransporterException {
177 this.checksumExtractor = checksumExtractor;
178 this.pathProcessor = pathProcessor;
179 try {
180 URI uri = new URI(repository.getUrl()).parseServerAuthority();
181 if (uri.isOpaque()) {
182 throw new URISyntaxException(repository.getUrl(), "URL must not be opaque");
183 }
184 if (uri.getRawFragment() != null || uri.getRawQuery() != null) {
185 throw new URISyntaxException(repository.getUrl(), "URL must not have fragment or query");
186 }
187 String path = uri.getPath();
188 if (path == null) {
189 path = "/";
190 }
191 if (!path.startsWith("/")) {
192 path = "/" + path;
193 }
194 if (!path.endsWith("/")) {
195 path = path + "/";
196 }
197 this.baseUri = URI.create(uri.getScheme() + "://" + uri.getRawAuthority() + path);
198 } catch (URISyntaxException e) {
199 throw new NoTransporterException(repository, e.getMessage(), e);
200 }
201
202 HashMap<String, String> headers = new HashMap<>();
203 String userAgent = HttpTransporterUtils.getUserAgent(session, repository);
204 if (userAgent != null) {
205 headers.put(USER_AGENT, userAgent);
206 }
207 Map<String, String> configuredHeaders = HttpTransporterUtils.getHttpHeaders(session, repository);
208 if (configuredHeaders != null) {
209 headers.putAll(configuredHeaders);
210 }
211 headers.put(CACHE_CONTROL, "no-cache, no-store");
212
213 this.connectTimeout = HttpTransporterUtils.getHttpConnectTimeout(session, repository);
214 this.requestTimeout = HttpTransporterUtils.getHttpRequestTimeout(session, repository);
215 Optional<Boolean> expectContinue = HttpTransporterUtils.getHttpExpectContinue(session, repository);
216 if (javaVersion > 19) {
217 this.expectContinue = expectContinue.orElse(null);
218 } else {
219 this.expectContinue = null;
220 if (expectContinue.isPresent()) {
221 LOGGER.warn(
222 "Configuration for Expect-Continue set but is ignored on Java versions below 20 (current java version is {}) due https://bugs.openjdk.org/browse/JDK-8286171",
223 javaVersion);
224 }
225 }
226 final String httpsSecurityMode = HttpTransporterUtils.getHttpsSecurityMode(session, repository);
227 final boolean insecure = ConfigurationProperties.HTTPS_SECURITY_MODE_INSECURE.equals(httpsSecurityMode);
228
229 this.maxConcurrentRequests = new Semaphore(ConfigUtils.getInteger(
230 session,
231 DEFAULT_MAX_CONCURRENT_REQUESTS,
232 CONFIG_PROP_MAX_CONCURRENT_REQUESTS + "." + repository.getId(),
233 CONFIG_PROP_MAX_CONCURRENT_REQUESTS));
234
235 this.preemptiveAuth = HttpTransporterUtils.isHttpPreemptiveAuth(session, repository);
236 this.preemptivePutAuth = HttpTransporterUtils.isHttpPreemptivePutAuth(session, repository);
237
238 this.headers = headers;
239 this.client = createClient(session, repository, insecure);
240 }
241
242 private URI resolve(TransportTask task) {
243 return baseUri.resolve(task.getLocation());
244 }
245
246 private ConnectException enhance(ConnectException connectException) {
247 ConnectException result = new ConnectException("Connection to " + baseUri.toASCIIString() + " refused");
248 result.initCause(connectException);
249 return result;
250 }
251
252 @Override
253 protected void implPeek(PeekTask task) throws Exception {
254 HttpRequest.Builder request =
255 HttpRequest.newBuilder().uri(resolve(task)).method("HEAD", HttpRequest.BodyPublishers.noBody());
256 headers.forEach(request::setHeader);
257
258 prepare(request);
259 try {
260 HttpResponse<Void> response = send(request.build(), HttpResponse.BodyHandlers.discarding());
261 if (response.statusCode() >= MULTIPLE_CHOICES) {
262 throw new HttpTransporterException(response.statusCode());
263 }
264 } catch (ConnectException e) {
265 throw enhance(e);
266 }
267 }
268
269 @Override
270 protected void implGet(GetTask task) throws Exception {
271 boolean resume = task.getResumeOffset() > 0L && task.getDataPath() != null;
272 HttpResponse<InputStream> response = null;
273
274 try {
275 while (true) {
276 HttpRequest.Builder request =
277 HttpRequest.newBuilder().uri(resolve(task)).GET();
278 headers.forEach(request::setHeader);
279
280 if (resume) {
281 long resumeOffset = task.getResumeOffset();
282 long lastModified = pathProcessor.lastModified(task.getDataPath(), 0L);
283 request.header(RANGE, "bytes=" + resumeOffset + '-');
284 request.header(
285 IF_UNMODIFIED_SINCE,
286 RFC7231.format(Instant.ofEpochMilli(lastModified - MODIFICATION_THRESHOLD)));
287 request.header(ACCEPT_ENCODING, "identity");
288 }
289
290 prepare(request);
291 try {
292 response = send(request.build(), HttpResponse.BodyHandlers.ofInputStream());
293 if (response.statusCode() >= MULTIPLE_CHOICES) {
294 if (resume && response.statusCode() == PRECONDITION_FAILED) {
295 closeBody(response);
296 resume = false;
297 continue;
298 }
299 try {
300 JdkRFC9457Reporter.INSTANCE.generateException(response, (statusCode, reasonPhrase) -> {
301 throw new HttpTransporterException(statusCode);
302 });
303 } finally {
304 closeBody(response);
305 }
306 }
307 } catch (ConnectException e) {
308 closeBody(response);
309 throw enhance(e);
310 }
311 break;
312 }
313
314 long offset = 0L,
315 length = response.headers().firstValueAsLong(CONTENT_LENGTH).orElse(-1L);
316 if (resume) {
317 String range = response.headers().firstValue(CONTENT_RANGE).orElse(null);
318 if (range != null) {
319 Matcher m = CONTENT_RANGE_PATTERN.matcher(range);
320 if (!m.matches()) {
321 throw new IOException("Invalid Content-Range header for partial download: " + range);
322 }
323 offset = Long.parseLong(m.group(1));
324 length = Long.parseLong(m.group(2)) + 1L;
325 if (offset < 0L || offset >= length || (offset > 0L && offset != task.getResumeOffset())) {
326 throw new IOException("Invalid Content-Range header for partial download from offset "
327 + task.getResumeOffset() + ": " + range);
328 }
329 }
330 }
331
332 final boolean downloadResumed = offset > 0L;
333 final Path dataFile = task.getDataPath();
334 if (dataFile == null) {
335 try (InputStream is = response.body()) {
336 utilGet(task, is, true, length, downloadResumed);
337 }
338 } else {
339 try (PathProcessor.CollocatedTempFile tempFile = pathProcessor.newTempFile(dataFile)) {
340 task.setDataPath(tempFile.getPath(), downloadResumed);
341 if (downloadResumed && Files.isRegularFile(dataFile)) {
342 try (InputStream inputStream = new BufferedInputStream(Files.newInputStream(dataFile))) {
343 Files.copy(inputStream, tempFile.getPath(), StandardCopyOption.REPLACE_EXISTING);
344 }
345 }
346 try (InputStream is = response.body()) {
347 utilGet(task, is, true, length, downloadResumed);
348 }
349 tempFile.move();
350 } finally {
351 task.setDataPath(dataFile);
352 }
353 }
354 if (task.getDataPath() != null) {
355 String lastModifiedHeader = response.headers()
356 .firstValue(LAST_MODIFIED)
357 .orElse(null);
358 if (lastModifiedHeader != null) {
359 try {
360 pathProcessor.setLastModified(
361 task.getDataPath(),
362 ZonedDateTime.parse(lastModifiedHeader, RFC7231)
363 .toInstant()
364 .toEpochMilli());
365 } catch (DateTimeParseException e) {
366
367 }
368 }
369 }
370 Map<String, String> checksums = checksumExtractor.extractChecksums(headerGetter(response));
371 if (checksums != null && !checksums.isEmpty()) {
372 checksums.forEach(task::setChecksum);
373 }
374 } finally {
375 closeBody(response);
376 }
377 }
378
379 private static Function<String, String> headerGetter(HttpResponse<?> response) {
380 return s -> response.headers().firstValue(s).orElse(null);
381 }
382
383 private void closeBody(HttpResponse<InputStream> streamHttpResponse) throws IOException {
384 if (streamHttpResponse != null) {
385 InputStream body = streamHttpResponse.body();
386 if (body != null) {
387 body.close();
388 }
389 }
390 }
391
392 @Override
393 protected void implPut(PutTask task) throws Exception {
394 HttpRequest.Builder request = HttpRequest.newBuilder().uri(resolve(task));
395 if (expectContinue != null) {
396 request = request.expectContinue(expectContinue);
397 }
398 headers.forEach(request::setHeader);
399
400 request.PUT(HttpRequest.BodyPublishers.ofInputStream(() -> {
401 try {
402 return new TransportListenerNotifyingInputStream(
403 task.newInputStream(), task.getListener(), task.getDataLength());
404 } catch (IOException e) {
405 throw new UncheckedIOException(e);
406 }
407 }));
408 prepare(request);
409 try {
410 HttpResponse<Void> response = send(request.build(), HttpResponse.BodyHandlers.discarding());
411 if (response.statusCode() >= MULTIPLE_CHOICES) {
412 throw new HttpTransporterException(response.statusCode());
413 }
414 } catch (ConnectException e) {
415 throw enhance(e);
416 } catch (IOException e) {
417
418 Throwable rootCause = getRootCause(e);
419 if (rootCause instanceof TransferCancelledException) {
420 throw (TransferCancelledException) rootCause;
421 }
422 throw e;
423 }
424 }
425
426 private void prepare(HttpRequest.Builder requestBuilder) {
427 if (preemptiveAuth
428 || (preemptivePutAuth && requestBuilder.build().method().equals("PUT"))) {
429 if (serverAuthentication != null) {
430
431 requestBuilder.setHeader(
432 "Authorization",
433 getBasicAuthValue(serverAuthentication.getUserName(), serverAuthentication.getPassword()));
434 }
435 if (proxyAuthentication != null) {
436 requestBuilder.setHeader(
437 "Proxy-Authorization",
438 getBasicAuthValue(proxyAuthentication.getUserName(), proxyAuthentication.getPassword()));
439 }
440 }
441 }
442
443 static String getBasicAuthValue(String username, char[] password) {
444
445 return "Basic "
446 + Base64.getEncoder().encodeToString((username + ':' + String.valueOf(password)).getBytes(ISO_8859_1));
447 }
448
449 private <T> HttpResponse<T> send(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler)
450 throws Exception {
451 maxConcurrentRequests.acquire();
452 try {
453 return client.send(request, responseBodyHandler);
454 } finally {
455 maxConcurrentRequests.release();
456 }
457 }
458
459 @Override
460 protected void implClose() {
461 if (client != null) {
462 JdkTransporterCloser.closer(client).run();
463 }
464 }
465
466 private HttpClient createClient(RepositorySystemSession session, RemoteRepository repository, boolean insecure)
467 throws RuntimeException {
468
469 HashMap<Authenticator.RequestorType, PasswordAuthentication> authentications = new HashMap<>();
470 SSLContext sslContext = null;
471 try (AuthenticationContext repoAuthContext = AuthenticationContext.forRepository(session, repository)) {
472 if (repoAuthContext != null) {
473 sslContext = repoAuthContext.get(AuthenticationContext.SSL_CONTEXT, SSLContext.class);
474
475 String username = repoAuthContext.get(AuthenticationContext.USERNAME);
476 String password = repoAuthContext.get(AuthenticationContext.PASSWORD);
477 serverAuthentication = new PasswordAuthentication(username, password.toCharArray());
478 authentications.put(Authenticator.RequestorType.SERVER, serverAuthentication);
479 }
480 }
481
482 if (sslContext == null) {
483 try {
484 if (insecure) {
485 sslContext = SSLContext.getInstance("TLS");
486 X509ExtendedTrustManager tm = new X509ExtendedTrustManager() {
487 @Override
488 public void checkClientTrusted(X509Certificate[] chain, String authType) {}
489
490 @Override
491 public void checkServerTrusted(X509Certificate[] chain, String authType) {}
492
493 @Override
494 public void checkClientTrusted(X509Certificate[] chain, String authType, Socket socket) {}
495
496 @Override
497 public void checkServerTrusted(X509Certificate[] chain, String authType, Socket socket) {}
498
499 @Override
500 public void checkClientTrusted(X509Certificate[] chain, String authType, SSLEngine engine) {}
501
502 @Override
503 public void checkServerTrusted(X509Certificate[] chain, String authType, SSLEngine engine) {}
504
505 @Override
506 public X509Certificate[] getAcceptedIssuers() {
507 return null;
508 }
509 };
510 sslContext.init(null, new X509TrustManager[] {tm}, null);
511 } else {
512 sslContext = SSLContext.getDefault();
513 }
514 } catch (Exception e) {
515 if (e instanceof RuntimeException) {
516 throw (RuntimeException) e;
517 } else {
518 throw new IllegalStateException("SSL Context setup failure", e);
519 }
520 }
521 }
522
523 Methanol.Builder builder = Methanol.newBuilder()
524 .version(HttpClient.Version.valueOf(ConfigUtils.getString(
525 session,
526 DEFAULT_HTTP_VERSION,
527 CONFIG_PROP_HTTP_VERSION + "." + repository.getId(),
528 CONFIG_PROP_HTTP_VERSION)))
529 .followRedirects(HttpClient.Redirect.NORMAL)
530 .connectTimeout(Duration.ofMillis(connectTimeout))
531
532
533
534 .requestTimeout(Duration.ofMillis(requestTimeout))
535 .sslContext(sslContext);
536
537 if (insecure) {
538 SSLParameters sslParameters = sslContext.getDefaultSSLParameters();
539 sslParameters.setEndpointIdentificationAlgorithm(null);
540 builder.sslParameters(sslParameters);
541 }
542
543 setLocalAddress(
544 builder,
545 HttpTransporterUtils.getHttpLocalAddress(session, repository).orElse(null));
546
547 if (repository.getProxy() != null) {
548 InetSocketAddress proxyAddress = new InetSocketAddress(
549 repository.getProxy().getHost(), repository.getProxy().getPort());
550 if (proxyAddress.isUnresolved()) {
551 throw new IllegalStateException(
552 "Proxy host " + repository.getProxy().getHost() + " could not be resolved");
553 }
554 builder.proxy(ProxySelector.of(proxyAddress));
555 try (AuthenticationContext proxyAuthContext = AuthenticationContext.forProxy(session, repository)) {
556 if (proxyAuthContext != null) {
557 String username = proxyAuthContext.get(AuthenticationContext.USERNAME);
558 String password = proxyAuthContext.get(AuthenticationContext.PASSWORD);
559
560 proxyAuthentication = new PasswordAuthentication(username, password.toCharArray());
561 authentications.put(Authenticator.RequestorType.PROXY, proxyAuthentication);
562 }
563 }
564 }
565
566 if (!authentications.isEmpty()) {
567 builder.authenticator(new Authenticator() {
568 @Override
569 protected PasswordAuthentication getPasswordAuthentication() {
570 return authentications.get(getRequestorType());
571 }
572 });
573 }
574
575 configureRetryHandler(session, repository, builder);
576
577 return builder.build();
578 }
579
580 private static class RetryLoggingListener implements RetryInterceptor.Listener {
581 private final int maxNumRetries;
582
583 RetryLoggingListener(int maxNumRetries) {
584 this.maxNumRetries = maxNumRetries;
585 }
586
587 @Override
588 public void onRetry(Context<?> context, HttpRequest nextRequest, Duration delay) {
589 LOGGER.warn(
590 "{} request to {} failed (attempt {} of {}) due to {}. Retrying in {} ms...",
591 context.request().method(),
592 context.request().uri(),
593 context.retryCount() + 1,
594 maxNumRetries + 1,
595 getReason(context),
596 delay.toMillis());
597 }
598
599 String getReason(Context<?> context) {
600 if (context.exception().isPresent()) {
601 return context.exception().get().getMessage();
602 } else if (context.response().isPresent()) {
603 return "status " + context.response().get().statusCode();
604 }
605
606 throw new IllegalStateException("No exception or response present in retry context");
607 }
608 }
609
610 private static void configureRetryHandler(
611 RepositorySystemSession session, RemoteRepository repository, Methanol.Builder builder) {
612 int retryCount = HttpTransporterUtils.getHttpRetryHandlerCount(session, repository);
613 long retryInterval = HttpTransporterUtils.getHttpRetryHandlerInterval(session, repository);
614 long retryIntervalMax = HttpTransporterUtils.getHttpRetryHandlerIntervalMax(session, repository);
615 if (retryCount > 0) {
616 Methanol.Interceptor rateLimitingRetryInterceptor = RetryInterceptor.newBuilder()
617 .maxRetries(retryCount)
618 .onStatus(HttpTransporterUtils.getHttpServiceUnavailableCodes(session, repository)::contains)
619 .listener(new RetryLoggingListener(retryCount))
620 .backoff(RetryInterceptor.BackoffStrategy.linear(
621 Duration.ofMillis(retryInterval), Duration.ofMillis(retryIntervalMax)))
622 .build();
623 builder.interceptor(rateLimitingRetryInterceptor);
624 Methanol.Interceptor retryIoExceptionsInterceptor = RetryInterceptor.newBuilder()
625
626
627
628 .maxRetries(retryCount)
629 .onException(t -> {
630
631
632
633 Throwable rootCause = getRootCause(t);
634 return t instanceof IOException
635 && !NON_RETRIABLE_IO_EXCEPTIONS.contains(t.getClass())
636 && !(rootCause instanceof TransferCancelledException);
637 })
638 .listener(new RetryLoggingListener(retryCount))
639 .build();
640 builder.interceptor(retryIoExceptionsInterceptor);
641 }
642 }
643
644 private static void setLocalAddress(HttpClient.Builder builder, InetAddress address) {
645 if (address == null) {
646 return;
647 }
648 try {
649 final Method mtd = builder.getClass().getDeclaredMethod("localAddress", InetAddress.class);
650 if (!mtd.canAccess(builder)) {
651 mtd.setAccessible(true);
652 }
653 mtd.invoke(builder, address);
654 } catch (final NoSuchMethodException ignore) {
655
656 } catch (InvocationTargetException e) {
657 throw new IllegalStateException(e.getTargetException());
658 } catch (IllegalAccessException e) {
659 throw new IllegalStateException(e);
660 }
661 }
662
663 private static Throwable getRootCause(Throwable throwable) {
664 Objects.requireNonNull(throwable);
665 Throwable rootCause = throwable;
666 while (rootCause.getCause() != null && rootCause.getCause() != rootCause) {
667 rootCause = rootCause.getCause();
668 }
669 return rootCause;
670 }
671 }