This is an automated email from the ASF dual-hosted git repository.

cstamas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/maven-resolver.git


The following commit(s) were added to refs/heads/master by this push:
     new 3c073bcc [MRESOLVER-396] Back off on too many requests (#326)
3c073bcc is described below

commit 3c073bccc73ebe4265631eb8e2ca9616c89afbd0
Author: Tamas Cservenak <[email protected]>
AuthorDate: Mon Sep 4 15:17:33 2023 +0200

    [MRESOLVER-396] Back off on too many requests (#326)
    
    Provide a way to "back off", and lower the request pace if remote claims 
"too many requests". Also, if server sends Retry-After header, obey that.
    
    ---
    
    https://issues.apache.org/jira/projects/MRESOLVER/issues/MRESOLVER-396
---
 .../eclipse/aether/ConfigurationProperties.java    |  54 +++++++++
 .../aether/transport/http/HttpTransporter.java     | 128 +++++++++++++++++++++
 src/site/markdown/configuration.md                 |   3 +
 3 files changed, 185 insertions(+)

diff --git 
a/maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java
 
b/maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java
index fc435d87..a5519514 100644
--- 
a/maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java
+++ 
b/maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java
@@ -144,6 +144,60 @@ public final class ConfigurationProperties {
      */
     public static final int DEFAULT_HTTP_RETRY_HANDLER_COUNT = 3;
 
+    /**
+     * The initial retry interval of request to a remote server should be 
waited in case of "too many requests"
+     * (HTTP codes 429 and 503). Accepts long as milliseconds. This value is 
used if remote server does not use
+     * {@code Retry-After} header, in which case Server value is obeyed.
+     *
+     * @see #DEFAULT_HTTP_RETRY_HANDLER_INTERVAL
+     * @since 1.9.16
+     */
+    public static final String HTTP_RETRY_HANDLER_INTERVAL = PREFIX_CONNECTOR 
+ "http.retryHandler.interval";
+
+    /**
+     * The default initial retry interval to use if {@link 
#HTTP_RETRY_HANDLER_INTERVAL} isn't set.
+     * Default value 5000ms.
+     *
+     * @since 1.9.16
+     */
+    public static final long DEFAULT_HTTP_RETRY_HANDLER_INTERVAL = 5000L;
+
+    /**
+     * The maximum retry interval of request to a remote server above which 
the request should be aborted instead.
+     * In theory, a malicious server could tell Maven "come back after 100 
years" that would stall the build for
+     * some. Using this parameter Maven will fail the request instead, if 
interval is above this value.
+     *
+     * @see #DEFAULT_HTTP_RETRY_HANDLER_INTERVAL_MAX
+     * @since 1.9.16
+     */
+    public static final String HTTP_RETRY_HANDLER_INTERVAL_MAX = 
PREFIX_CONNECTOR + "http.retryHandler.intervalMax";
+
+    /**
+     * The default retry interval maximum to use if {@link 
#HTTP_RETRY_HANDLER_INTERVAL_MAX} isn't set.
+     * Default value 5 minutes.
+     *
+     * @since 1.9.16
+     */
+    public static final long DEFAULT_HTTP_RETRY_HANDLER_INTERVAL_MAX = 
300_000L;
+
+    /**
+     * The HTTP codes of remote server responses that should be handled as 
"too many requests"
+     * (examples: HTTP codes 429 and 503). Accepts comma separated list of 
HTTP response codes.
+     *
+     * @see #DEFAULT_HTTP_RETRY_HANDLER_SERVICE_UNAVAILABLE
+     * @since 1.9.16
+     */
+    public static final String HTTP_RETRY_HANDLER_SERVICE_UNAVAILABLE =
+            PREFIX_CONNECTOR + "http.retryHandler.serviceUnavailable";
+
+    /**
+     * The default HTTP codes of remote server responses that should be 
handled as "too many requests".
+     * Default value: "429,503".
+     *
+     * @since 1.9.16
+     */
+    public static final String DEFAULT_HTTP_RETRY_HANDLER_SERVICE_UNAVAILABLE 
= "429,503";
+
     /**
      * Should HTTP client use preemptive auth (w/ BASIC) or not?
      *
diff --git 
a/maven-resolver-transport-http/src/main/java/org/eclipse/aether/transport/http/HttpTransporter.java
 
b/maven-resolver-transport-http/src/main/java/org/eclipse/aether/transport/http/HttpTransporter.java
index d1848cc9..87020474 100644
--- 
a/maven-resolver-transport-http/src/main/java/org/eclipse/aether/transport/http/HttpTransporter.java
+++ 
b/maven-resolver-transport-http/src/main/java/org/eclipse/aether/transport/http/HttpTransporter.java
@@ -34,8 +34,10 @@ import java.nio.file.StandardCopyOption;
 import java.nio.file.attribute.FileTime;
 import java.util.Collections;
 import java.util.Date;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
@@ -44,12 +46,14 @@ import org.apache.http.HttpEntity;
 import org.apache.http.HttpEntityEnclosingRequest;
 import org.apache.http.HttpHeaders;
 import org.apache.http.HttpHost;
+import org.apache.http.HttpResponse;
 import org.apache.http.HttpStatus;
 import org.apache.http.auth.AuthSchemeProvider;
 import org.apache.http.auth.AuthScope;
 import org.apache.http.client.CredentialsProvider;
 import org.apache.http.client.HttpRequestRetryHandler;
 import org.apache.http.client.HttpResponseException;
+import org.apache.http.client.ServiceUnavailableRetryStrategy;
 import org.apache.http.client.config.AuthSchemes;
 import org.apache.http.client.config.RequestConfig;
 import org.apache.http.client.methods.CloseableHttpResponse;
@@ -76,6 +80,7 @@ import org.apache.http.impl.client.CloseableHttpClient;
 import org.apache.http.impl.client.DefaultHttpRequestRetryHandler;
 import org.apache.http.impl.client.HttpClientBuilder;
 import org.apache.http.impl.client.StandardHttpRequestRetryHandler;
+import org.apache.http.protocol.HttpContext;
 import org.apache.http.util.EntityUtils;
 import org.eclipse.aether.ConfigurationProperties;
 import org.eclipse.aether.RepositorySystemSession;
@@ -147,6 +152,7 @@ final class HttpTransporter extends AbstractTransporter {
 
     private final boolean supportWebDav;
 
+    @SuppressWarnings("checkstyle:methodlength")
     HttpTransporter(
             Map<String, ChecksumExtractor> checksumExtractors,
             RemoteRepository repository,
@@ -230,6 +236,21 @@ final class HttpTransporter extends AbstractTransporter {
                 ConfigurationProperties.DEFAULT_HTTP_RETRY_HANDLER_COUNT,
                 ConfigurationProperties.HTTP_RETRY_HANDLER_COUNT + "." + 
repository.getId(),
                 ConfigurationProperties.HTTP_RETRY_HANDLER_COUNT);
+        long retryInterval = ConfigUtils.getLong(
+                session,
+                ConfigurationProperties.DEFAULT_HTTP_RETRY_HANDLER_INTERVAL,
+                ConfigurationProperties.HTTP_RETRY_HANDLER_INTERVAL + "." + 
repository.getId(),
+                ConfigurationProperties.HTTP_RETRY_HANDLER_INTERVAL);
+        long retryIntervalMax = ConfigUtils.getLong(
+                session,
+                
ConfigurationProperties.DEFAULT_HTTP_RETRY_HANDLER_INTERVAL_MAX,
+                ConfigurationProperties.HTTP_RETRY_HANDLER_INTERVAL_MAX + "." 
+ repository.getId(),
+                ConfigurationProperties.HTTP_RETRY_HANDLER_INTERVAL_MAX);
+        String serviceUnavailableCodesString = ConfigUtils.getString(
+                session,
+                
ConfigurationProperties.DEFAULT_HTTP_RETRY_HANDLER_SERVICE_UNAVAILABLE,
+                ConfigurationProperties.HTTP_RETRY_HANDLER_SERVICE_UNAVAILABLE 
+ "." + repository.getId(),
+                
ConfigurationProperties.HTTP_RETRY_HANDLER_SERVICE_UNAVAILABLE);
         String retryHandlerName = ConfigUtils.getString(
                 session,
                 HTTP_RETRY_HANDLER_NAME_STANDARD,
@@ -269,11 +290,24 @@ final class HttpTransporter extends AbstractTransporter {
             throw new IllegalArgumentException(
                     "Unsupported parameter " + HTTP_RETRY_HANDLER_NAME + " 
value: " + retryHandlerName);
         }
+        Set<Integer> serviceUnavailableCodes = new HashSet<>();
+        try {
+            for (String code : 
ConfigUtils.parseCommaSeparatedUniqueNames(serviceUnavailableCodesString)) {
+                serviceUnavailableCodes.add(Integer.parseInt(code));
+            }
+        } catch (NumberFormatException e) {
+            throw new IllegalArgumentException(
+                    "Illegal HTTP codes for " + 
ConfigurationProperties.HTTP_RETRY_HANDLER_SERVICE_UNAVAILABLE
+                            + " (list of integers): " + 
serviceUnavailableCodesString);
+        }
+        ServiceUnavailableRetryStrategy serviceUnavailableRetryStrategy = new 
ResolverServiceUnavailableRetryStrategy(
+                retryCount, retryInterval, retryIntervalMax, 
serviceUnavailableCodes);
 
         HttpClientBuilder builder = HttpClientBuilder.create()
                 .setUserAgent(userAgent)
                 .setDefaultSocketConfig(socketConfig)
                 .setDefaultRequestConfig(requestConfig)
+                
.setServiceUnavailableRetryStrategy(serviceUnavailableRetryStrategy)
                 .setRetryHandler(retryHandler)
                 .setDefaultAuthSchemeRegistry(authSchemeRegistry)
                 .setConnectionManager(state.getConnectionManager())
@@ -704,4 +738,98 @@ final class HttpTransporter extends AbstractTransporter {
             }
         }
     }
+
+    private static class ResolverServiceUnavailableRetryStrategy implements 
ServiceUnavailableRetryStrategy {
+        private final int retryCount;
+
+        private final long retryInterval;
+
+        private final long retryIntervalMax;
+
+        private final Set<Integer> serviceUnavailableHttpCodes;
+
+        /**
+         * Ugly, but forced by HttpClient API {@link 
ServiceUnavailableRetryStrategy}: the calls for
+         * {@link #retryRequest(HttpResponse, int, HttpContext)} and {@link 
#getRetryInterval()} are done by same
+         * thread and are actually done from spot that are very close to each 
other (almost subsequent calls).
+         */
+        private static final ThreadLocal<Long> RETRY_INTERVAL_HOLDER = new 
ThreadLocal<>();
+
+        private ResolverServiceUnavailableRetryStrategy(
+                int retryCount, long retryInterval, long retryIntervalMax, 
Set<Integer> serviceUnavailableHttpCodes) {
+            if (retryCount < 0) {
+                throw new IllegalArgumentException("retryCount must be >= 0");
+            }
+            if (retryInterval < 0L) {
+                throw new IllegalArgumentException("retryInterval must be >= 
0");
+            }
+            if (retryIntervalMax < 0L) {
+                throw new IllegalArgumentException("retryIntervalMax must be 
>= 0");
+            }
+            this.retryCount = retryCount;
+            this.retryInterval = retryInterval;
+            this.retryIntervalMax = retryIntervalMax;
+            this.serviceUnavailableHttpCodes = 
requireNonNull(serviceUnavailableHttpCodes);
+        }
+
+        @Override
+        public boolean retryRequest(HttpResponse response, int executionCount, 
HttpContext context) {
+            final boolean retry = executionCount <= retryCount
+                    && (serviceUnavailableHttpCodes.contains(
+                            response.getStatusLine().getStatusCode()));
+            if (retry) {
+                Long retryInterval = retryInterval(response, executionCount, 
context);
+                if (retryInterval != null) {
+                    RETRY_INTERVAL_HOLDER.set(retryInterval);
+                    return true;
+                }
+            }
+            RETRY_INTERVAL_HOLDER.remove();
+            return false;
+        }
+
+        /**
+         * Calculates retry interval in milliseconds. If {@link 
HttpHeaders#RETRY_AFTER} header present, it obeys it.
+         * Otherwise, it returns {@link this#retryInterval} long value 
multiplied with {@code executionCount} (starts
+         * from 1 and goes 2, 3,...).
+         *
+         * @return Long representing the retry interval as millis, or {@code 
null} if the request should be failed.
+         */
+        private Long retryInterval(HttpResponse httpResponse, int 
executionCount, HttpContext httpContext) {
+            Long result = null;
+            Header header = 
httpResponse.getFirstHeader(HttpHeaders.RETRY_AFTER);
+            if (header != null && header.getValue() != null) {
+                String headerValue = header.getValue();
+                if (headerValue.contains(":")) { // is date when to retry
+                    Date when = DateUtils.parseDate(headerValue); // 
presumably future
+                    if (when != null) {
+                        result = Math.max(when.getTime() - 
System.currentTimeMillis(), 0L);
+                    }
+                } else {
+                    try {
+                        result = Long.parseLong(headerValue) * 1000L; // is in 
seconds
+                    } catch (NumberFormatException e) {
+                        // fall through
+                    }
+                }
+            }
+            if (result == null) {
+                result = executionCount * this.retryInterval;
+            }
+            if (result > retryIntervalMax) {
+                return null;
+            }
+            return result;
+        }
+
+        @Override
+        public long getRetryInterval() {
+            Long ri = RETRY_INTERVAL_HOLDER.get();
+            if (ri == null) {
+                return 0L;
+            }
+            RETRY_INTERVAL_HOLDER.remove();
+            return ri;
+        }
+    }
 }
diff --git a/src/site/markdown/configuration.md 
b/src/site/markdown/configuration.md
index 4f66f3f4..fbee0a98 100644
--- a/src/site/markdown/configuration.md
+++ b/src/site/markdown/configuration.md
@@ -43,8 +43,11 @@ Option | Type | Description | Default Value | Supports Repo 
ID Suffix
 `aether.connector.http.preemptiveAuth` | boolean | Should HTTP client use 
preemptive-authentication for all HTTP verbs (works only w/ BASIC). By default 
is disabled, as it is considered less secure. | `false` | yes
 `aether.connector.http.preemptivePutAuth` | boolean | Should HTTP client use 
preemptive-authentication for HTTP PUTs only (works only w/ BASIC). By default 
is enabled (same as Wagon). | `true` | yes
 `aether.connector.http.retryHandler.count` | int | The maximum number of times 
a request to a remote HTTP server should be retried in case of an error. | `3` 
| yes
+`aether.connector.http.retryHandler.interval` | long | The initial retry 
interval in milliseconds, if server responds with "too many requests". Is 
multiplied by 1, 2,.. on each try. If server emits `Retry-After` header, it is 
obeyed instead of this value. | `5000` | yes
+`aether.connector.http.retryHandler.intervalMax` | long | The retry interval 
maximum in milliseconds, after request should be given up (5 minutes). | 
`300000` | yes
 `aether.connector.http.retryHandler.name` | String | The name of retryHandler, 
supported values are "standard", that obeys RFC-2616, regarding idempotent 
methods, and "default" that considers requests w/o payload as idempotent. | 
`standard` | yes
 `aether.connector.http.retryHandler.requestSentEnabled` | boolean | Set to 
`true` if it is acceptable to retry non-idempotent requests, that have been 
sent. | `false` | yes
+`aether.connector.http.retryHandler.serviceUnavailable` | String | Comma 
separated list of HTTP codes that should be handled as "too many requests". | 
`"429,503"` | yes
 `aether.connector.http.reuseConnections` | boolean | Should HTTP client reuse 
connections (in other words, pool connections) or not? | `true` | yes
 `aether.connector.http.supportWebDav` | boolean | If enabled, transport makes 
best effort to deploy to WebDAV server. This mode is not recommended, better 
use real Maven Repository Manager instead. | `false` | yes
 `aether.connector.http.useSystemProperties` | boolean | If enabled, underlying 
Apache HttpClient will use system properties as well to configure itself 
(typically used to set up HTTP Proxy via Java system properties). See <a 
href="https://hc.apache.org/httpcomponents-client-4.5.x/current/httpclient/apidocs/org/apache/http/impl/client/HttpClientBuilder.html";>HttpClientBuilder</a>
 for used properties. This mode is **not recommended**, better use documented 
ways of configuration instead. |  [...]

Reply via email to