Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@
*/
package org.eclipse.aether.transport.jetty;

import java.io.IOException;

import org.eclipse.aether.ConfigurationProperties;
import org.eclipse.jetty.client.Request;

Expand All @@ -39,7 +37,7 @@ final class InsecureRedirectGuard implements Request.QueuedListener {
@Override
public void onQueued(Request request) {
if (!"https".equalsIgnoreCase(request.getScheme())) {
request.abort(new IOException("Insecure redirect to '" + request.getURI()
request.abort(new RedirectRefusedException("Insecure redirect to '" + request.getURI()
+ "' not followed: protocol downgrade from https to " + request.getScheme()
+ " would expose credentials and artifact content; set the configuration property "
+ ConfigurationProperties.HTTP_FOLLOW_INSECURE_REDIRECT
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.eclipse.aether.transport.jetty;

import javax.net.ssl.SSLException;

import java.io.IOException;
import java.io.InterruptedIOException;
import java.net.ConnectException;
import java.net.NoRouteToHostException;
import java.net.UnknownHostException;
import java.util.Set;
import java.util.concurrent.TimeoutException;

import org.eclipse.aether.RepositorySystemSession;
import org.eclipse.aether.repository.RemoteRepository;
import org.eclipse.aether.transfer.TransferCancelledException;
import org.eclipse.aether.util.connector.transport.http.HttpTransporterUtils;
import org.eclipse.jetty.http.HttpFields;
import org.eclipse.jetty.http.HttpHeader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Decides whether a failed request is retried and how long to wait before doing so, based on the
* {@code aether.connector.http.retryHandler.*} configuration. The Jetty HTTP client has no retry hook of its own,
* therefore {@link JettyTransporter} drives the retries and consults this class.
*/
final class JettyRetryHandler {

private static final Logger LOGGER = LoggerFactory.getLogger(JettyRetryHandler.class);

/**
* Classes of IOExceptions that should not be retried (because they are permanent failures).
* 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">
* Apache HttpClient's DefaultHttpRequestRetryHandler</a>.
*/
private static final Set<Class<? extends IOException>> NON_RETRIABLE_IO_EXCEPTIONS = Set.of(
InterruptedIOException.class,
UnknownHostException.class,
ConnectException.class,
NoRouteToHostException.class,
SSLException.class);

/**
* The delay before a retry together with the reason for choosing it.
*/
record Delay(long millis, String reason) {}

private final int retryCount;

private final long retryInterval;

private final long retryIntervalMax;

private final Set<Integer> retriableStatusCodes;

JettyRetryHandler(RepositorySystemSession session, RemoteRepository repository) {
this.retryCount = HttpTransporterUtils.getHttpRetryHandlerCount(session, repository);
this.retryInterval = HttpTransporterUtils.getHttpRetryHandlerInterval(session, repository);
this.retryIntervalMax = HttpTransporterUtils.getHttpRetryHandlerIntervalMax(session, repository);
this.retriableStatusCodes = HttpTransporterUtils.getHttpServiceUnavailableCodes(session, repository);
}

/**
* The maximum number of retries, i.e. a request is attempted at most {@code getRetryCount() + 1} times.
*/
int getRetryCount() {
return retryCount;
}

/**
* Tells whether a request that failed with the given exception should be sent again.
*
* @param attempt the 0-based number of the attempt that failed
*/
boolean shouldRetry(int attempt, Throwable failure) {
return attempt < retryCount && isRetriable(failure);
}

/**
* Tells whether a request that got a response with the given status code should be sent again.
*
* @param attempt the 0-based number of the attempt that got the response
*/
boolean shouldRetry(int attempt, int statusCode) {
return attempt < retryCount && retriableStatusCodes.contains(statusCode);
}

private static boolean isRetriable(Throwable failure) {
if (failure instanceof TimeoutException) {
return true;
}
if (!(failure instanceof IOException)) {
return false;
}
for (Throwable t = failure; t != null; t = t.getCause()) {
if (t instanceof TransferCancelledException || t instanceof RedirectRefusedException) {
return false;
}
}
return NON_RETRIABLE_IO_EXCEPTIONS.stream().noneMatch(clazz -> clazz.isInstance(failure));
}

/**
* Computes the delay before the next attempt. A {@code Retry-After} response header is obeyed, otherwise the
* delay grows linearly with the number of attempts. It never exceeds the configured maximum.
*
* @param responseHeaders the headers of the response that triggered the retry
* @param attempt the 0-based number of the attempt that got the response
*/
Delay getDelay(HttpFields responseHeaders, int attempt) {
long delay = -1L;
String reason = "";
String retryAfter = responseHeaders.get(HttpHeader.RETRY_AFTER);
if (retryAfter != null) {
try {
if (retryAfter.contains(":")) { // is date when to retry
delay = Math.max(
responseHeaders.getDateField(HttpHeader.RETRY_AFTER) - System.currentTimeMillis(), 0L);
reason = "Retry-After header with date value " + retryAfter;
} else {
delay = Long.parseLong(retryAfter.trim()) * 1000L; // is in seconds
reason = "Retry-After header with seconds value " + retryAfter;
}
Comment on lines +134 to +141

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Retry-After heuristic: prefer try-parse-as-long over contains(':').

RFC 7231 defines Retry-After as either a delta-seconds value (all digits) or an HTTP-date (contains :). The current heuristic works for all well-formed values, but the more robust and self-documenting approach is to try parsing as a long first and fall back to date parsing — consistent with the outer catch (IllegalArgumentException) which already covers NumberFormatException.

Suggested change
if (retryAfter.contains(":")) { // is date when to retry
delay = Math.max(
responseHeaders.getDateField(HttpHeader.RETRY_AFTER) - System.currentTimeMillis(), 0L);
reason = "Retry-After header with date value " + retryAfter;
} else {
delay = Long.parseLong(retryAfter.trim()) * 1000L; // is in seconds
reason = "Retry-After header with seconds value " + retryAfter;
}
try {
long seconds = Long.parseLong(retryAfter.trim());
delay = seconds * 1000L; // is in seconds
reason = "Retry-After header with seconds value " + retryAfter;
} catch (NumberFormatException nfe) {
// not a delta-seconds value, try parsing as HTTP-date
delay = Math.max(
responseHeaders.getDateField(HttpHeader.RETRY_AFTER) - System.currentTimeMillis(), 0L);
reason = "Retry-After header with date value " + retryAfter;
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! I kept the contains(":") check so it behaves the same as ApacheTransporter, which tells a date from seconds the same way. Happy to switch to try-parse-first if you'd prefer.

} catch (IllegalArgumentException e) { // includes NumberFormatException
LOGGER.warn("Invalid Retry-After header value {}", retryAfter);
delay = -1L;
}
}
if (delay < 0L) {
long executionCount = attempt + 1L;
delay = executionCount * retryInterval;
reason = "retryInterval " + retryInterval + " ms multiplied by execution count " + executionCount;
}
if (delay > retryIntervalMax) {
delay = retryIntervalMax;
reason += ", capped at max value";
}
return new Delay(delay, reason);
}
}
Loading