View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
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  * JDK Transport using {@link HttpClient}.
113  * <p>
114  * Known issues:
115  * <ul>
116  *     <li>Does not properly support {@link ConfigurationProperties#REQUEST_TIMEOUT} prior Java 26, see <a href="https://bugs.openjdk.org/browse/JDK-8208693">JDK-8208693</a></li>
117  * </ul>
118  * <p>
119  * Related: <a href="https://dev.to/kdrakon/httpclient-can-t-connect-to-a-tls-proxy-118a">No TLS proxy supported</a>.
120  *
121  * @since 2.0.0
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      * Classes of IOExceptions that should not be retried (because they are permanent failures).
134      * Same as in <a href="https://github.com/apache/httpcomponents-client/blob/54900db4653d7f207477e6ee40135b88e9bcf832/httpclient/src/main/java/org/apache/http/impl/client/DefaultHttpRequestRetryHandler.java#L102">
135      * Apache HttpClient's DefaultHttpRequestRetryHandler</a>.
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); // note: Wagon also does first not last
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                         // fall through
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         if (task.getDataLength() == 0L) {
401             request.PUT(HttpRequest.BodyPublishers.noBody());
402         } else {
403             request.PUT(HttpRequest.BodyPublishers.fromPublisher(
404                     HttpRequest.BodyPublishers.ofInputStream(() -> {
405                         try {
406                             return new TransportListenerNotifyingInputStream(
407                                     task.newInputStream(), task.getListener(), task.getDataLength());
408                         } catch (IOException e) {
409                             throw new UncheckedIOException(e);
410                         }
411                     }),
412                     // this adds a content-length request header
413                     task.getDataLength()));
414         }
415         prepare(request);
416         try {
417             HttpResponse<Void> response = send(request.build(), HttpResponse.BodyHandlers.discarding());
418             if (response.statusCode() >= MULTIPLE_CHOICES) {
419                 throw new HttpTransporterException(response.statusCode());
420             }
421         } catch (ConnectException e) {
422             throw enhance(e);
423         } catch (IOException e) {
424             // unwrap possible underlying exception from body supplier
425             Throwable rootCause = getRootCause(e);
426             if (rootCause instanceof TransferCancelledException) {
427                 throw (TransferCancelledException) rootCause;
428             }
429             throw e;
430         }
431     }
432 
433     private void prepare(HttpRequest.Builder requestBuilder) {
434         if (preemptiveAuth
435                 || (preemptivePutAuth && requestBuilder.build().method().equals("PUT"))) {
436             if (serverAuthentication != null) {
437                 // https://stackoverflow.com/a/58612586
438                 requestBuilder.setHeader(
439                         "Authorization",
440                         getBasicAuthValue(serverAuthentication.getUserName(), serverAuthentication.getPassword()));
441             }
442             if (proxyAuthentication != null) {
443                 requestBuilder.setHeader(
444                         "Proxy-Authorization",
445                         getBasicAuthValue(proxyAuthentication.getUserName(), proxyAuthentication.getPassword()));
446             }
447         }
448     }
449 
450     static String getBasicAuthValue(String username, char[] password) {
451         // Java's HTTP client uses ISO-8859-1 for Basic auth encoding
452         return "Basic "
453                 + Base64.getEncoder().encodeToString((username + ':' + String.valueOf(password)).getBytes(ISO_8859_1));
454     }
455 
456     private <T> HttpResponse<T> send(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler)
457             throws Exception {
458         maxConcurrentRequests.acquire();
459         try {
460             return client.send(request, responseBodyHandler);
461         } finally {
462             maxConcurrentRequests.release();
463         }
464     }
465 
466     @Override
467     protected void implClose() {
468         if (client != null) {
469             JdkTransporterCloser.closer(client).run();
470         }
471     }
472 
473     private HttpClient createClient(RepositorySystemSession session, RemoteRepository repository, boolean insecure)
474             throws RuntimeException {
475 
476         HashMap<Authenticator.RequestorType, PasswordAuthentication> authentications = new HashMap<>();
477         SSLContext sslContext = null;
478         try (AuthenticationContext repoAuthContext = AuthenticationContext.forRepository(session, repository)) {
479             if (repoAuthContext != null) {
480                 sslContext = repoAuthContext.get(AuthenticationContext.SSL_CONTEXT, SSLContext.class);
481 
482                 String username = repoAuthContext.get(AuthenticationContext.USERNAME);
483                 String password = repoAuthContext.get(AuthenticationContext.PASSWORD);
484                 serverAuthentication = new PasswordAuthentication(username, password.toCharArray());
485                 authentications.put(Authenticator.RequestorType.SERVER, serverAuthentication);
486             }
487         }
488 
489         if (sslContext == null) {
490             try {
491                 if (insecure) {
492                     sslContext = SSLContext.getInstance("TLS");
493                     X509ExtendedTrustManager tm = new X509ExtendedTrustManager() {
494                         @Override
495                         public void checkClientTrusted(X509Certificate[] chain, String authType) {}
496 
497                         @Override
498                         public void checkServerTrusted(X509Certificate[] chain, String authType) {}
499 
500                         @Override
501                         public void checkClientTrusted(X509Certificate[] chain, String authType, Socket socket) {}
502 
503                         @Override
504                         public void checkServerTrusted(X509Certificate[] chain, String authType, Socket socket) {}
505 
506                         @Override
507                         public void checkClientTrusted(X509Certificate[] chain, String authType, SSLEngine engine) {}
508 
509                         @Override
510                         public void checkServerTrusted(X509Certificate[] chain, String authType, SSLEngine engine) {}
511 
512                         @Override
513                         public X509Certificate[] getAcceptedIssuers() {
514                             return null;
515                         }
516                     };
517                     sslContext.init(null, new X509TrustManager[] {tm}, null);
518                 } else {
519                     sslContext = SSLContext.getDefault();
520                 }
521             } catch (Exception e) {
522                 if (e instanceof RuntimeException) {
523                     throw (RuntimeException) e;
524                 } else {
525                     throw new IllegalStateException("SSL Context setup failure", e);
526                 }
527             }
528         }
529 
530         Methanol.Builder builder = Methanol.newBuilder()
531                 .version(HttpClient.Version.valueOf(ConfigUtils.getString(
532                         session,
533                         DEFAULT_HTTP_VERSION,
534                         CONFIG_PROP_HTTP_VERSION + "." + repository.getId(),
535                         CONFIG_PROP_HTTP_VERSION)))
536                 .followRedirects(HttpClient.Redirect.NORMAL)
537                 .connectTimeout(Duration.ofMillis(connectTimeout))
538                 // this only considers the time until the response header is received, see
539                 // https://bugs.openjdk.org/browse/JDK-8208693
540                 // but better than nothing
541                 .requestTimeout(Duration.ofMillis(requestTimeout))
542                 .sslContext(sslContext);
543 
544         if (insecure) {
545             SSLParameters sslParameters = sslContext.getDefaultSSLParameters();
546             sslParameters.setEndpointIdentificationAlgorithm(null);
547             builder.sslParameters(sslParameters);
548         }
549 
550         setLocalAddress(
551                 builder,
552                 HttpTransporterUtils.getHttpLocalAddress(session, repository).orElse(null));
553 
554         if (repository.getProxy() != null) {
555             InetSocketAddress proxyAddress = new InetSocketAddress(
556                     repository.getProxy().getHost(), repository.getProxy().getPort());
557             if (proxyAddress.isUnresolved()) {
558                 throw new IllegalStateException(
559                         "Proxy host " + repository.getProxy().getHost() + " could not be resolved");
560             }
561             builder.proxy(ProxySelector.of(proxyAddress));
562             try (AuthenticationContext proxyAuthContext = AuthenticationContext.forProxy(session, repository)) {
563                 if (proxyAuthContext != null) {
564                     String username = proxyAuthContext.get(AuthenticationContext.USERNAME);
565                     String password = proxyAuthContext.get(AuthenticationContext.PASSWORD);
566 
567                     proxyAuthentication = new PasswordAuthentication(username, password.toCharArray());
568                     authentications.put(Authenticator.RequestorType.PROXY, proxyAuthentication);
569                 }
570             }
571         }
572 
573         if (!authentications.isEmpty()) {
574             builder.authenticator(new Authenticator() {
575                 @Override
576                 protected PasswordAuthentication getPasswordAuthentication() {
577                     return authentications.get(getRequestorType());
578                 }
579             });
580         }
581 
582         configureRetryHandler(session, repository, builder);
583 
584         return builder.build();
585     }
586 
587     private static class RetryLoggingListener implements RetryInterceptor.Listener {
588         private final int maxNumRetries;
589 
590         RetryLoggingListener(int maxNumRetries) {
591             this.maxNumRetries = maxNumRetries;
592         }
593 
594         @Override
595         public void onRetry(Context<?> context, HttpRequest nextRequest, Duration delay) {
596             LOGGER.warn(
597                     "{} request to {} failed (attempt {} of {}) due to {}. Retrying in {} ms...",
598                     context.request().method(),
599                     context.request().uri(),
600                     context.retryCount() + 1,
601                     maxNumRetries + 1,
602                     getReason(context),
603                     delay.toMillis());
604         }
605 
606         String getReason(Context<?> context) {
607             if (context.exception().isPresent()) {
608                 return context.exception().get().getMessage();
609             } else if (context.response().isPresent()) {
610                 return "status " + context.response().get().statusCode();
611             }
612             // should not happen
613             throw new IllegalStateException("No exception or response present in retry context");
614         }
615     }
616 
617     private static void configureRetryHandler(
618             RepositorySystemSession session, RemoteRepository repository, Methanol.Builder builder) {
619         int retryCount = HttpTransporterUtils.getHttpRetryHandlerCount(session, repository);
620         long retryInterval = HttpTransporterUtils.getHttpRetryHandlerInterval(session, repository);
621         long retryIntervalMax = HttpTransporterUtils.getHttpRetryHandlerIntervalMax(session, repository);
622         if (retryCount > 0) {
623             Methanol.Interceptor rateLimitingRetryInterceptor = RetryInterceptor.newBuilder()
624                     .maxRetries(retryCount)
625                     .onStatus(HttpTransporterUtils.getHttpServiceUnavailableCodes(session, repository)::contains)
626                     .listener(new RetryLoggingListener(retryCount))
627                     .backoff(RetryInterceptor.BackoffStrategy.linear(
628                             Duration.ofMillis(retryInterval), Duration.ofMillis(retryIntervalMax)))
629                     .build();
630             builder.interceptor(rateLimitingRetryInterceptor);
631             Methanol.Interceptor retryIoExceptionsInterceptor = RetryInterceptor.newBuilder()
632                     // this is in addition to the JDK internal retries (https://github.com/mizosoft/methanol/issues/174)
633                     // e.g. for connection timeouts this is hardcoded to 2 attempts:
634                     // https://github.com/openjdk/jdk/blob/640343f7d94894b0378ea5b1768eeac203a9aaf8/src/java.net.http/share/classes/jdk/internal/net/http/MultiExchange.java#L665
635                     .maxRetries(retryCount)
636                     .onException(t -> {
637                         // exceptions from body publishers are wrapped inside IOExceptions
638                         // but hard to distinguish from others, so just exclude some we know are emitted from body
639                         // suppliers (https://github.com/mizosoft/methanol/issues/179)
640                         Throwable rootCause = getRootCause(t);
641                         return t instanceof IOException
642                                 && !NON_RETRIABLE_IO_EXCEPTIONS.contains(t.getClass())
643                                 && !(rootCause instanceof TransferCancelledException);
644                     })
645                     .listener(new RetryLoggingListener(retryCount))
646                     .build();
647             builder.interceptor(retryIoExceptionsInterceptor);
648         }
649     }
650 
651     private static void setLocalAddress(HttpClient.Builder builder, InetAddress address) {
652         if (address == null) {
653             return;
654         }
655         try {
656             final Method mtd = builder.getClass().getDeclaredMethod("localAddress", InetAddress.class);
657             if (!mtd.canAccess(builder)) {
658                 mtd.setAccessible(true);
659             }
660             mtd.invoke(builder, address);
661         } catch (final NoSuchMethodException ignore) {
662             // skip, not yet in the API
663         } catch (InvocationTargetException e) {
664             throw new IllegalStateException(e.getTargetException());
665         } catch (IllegalAccessException e) {
666             throw new IllegalStateException(e);
667         }
668     }
669 
670     private static Throwable getRootCause(Throwable throwable) {
671         Objects.requireNonNull(throwable);
672         Throwable rootCause = throwable;
673         while (rootCause.getCause() != null && rootCause.getCause() != rootCause) {
674             rootCause = rootCause.getCause();
675         }
676         return rootCause;
677     }
678 }