This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new f485348ae0b1 CAMEL-26901: camel-tooling-maven - a Maven repository
that does not answer fails after 60 seconds, and one that cannot be connected
to is not asked again (#29781)
f485348ae0b1 is described below
commit f485348ae0b14475ab18e50764dbc53d9a2c4ce0
Author: Claus Ibsen <[email protected]>
AuthorDate: Sat Nov 14 09:42:59 2026 +0100
CAMEL-26901: camel-tooling-maven - a Maven repository that does not answer
fails after 60 seconds, and one that cannot be connected to is not asked again
(#29781)
- The request timeout is 60 seconds (Maven Resolver's default is 30
minutes), so a repository that stops sending
no longer hangs camel run for that long. The
aether.connector.requestTimeout system property still wins.
- A repository that cannot be connected to (unknown host, connection
refused, connect timeout of HttpClient 4,
HttpClient 5 or the JDK HTTP client) is logged once with a WARN and left
out of the next resolutions, instead of
costing the connect timeout for every POM and JAR. A resolution failure
lists it as "(not reachable)".
camel run with two missing dependencies and an unreachable repository: 23s
instead of 44s.
Signed-off-by: Claus Ibsen <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 8 ++
.../modules/ROOT/pages/camel-jbang-running.adoc | 11 +++
.../camel/tooling/maven/MavenDownloaderImpl.java | 110 ++++++++++++++++++++-
.../tooling/maven/MavenDownloaderImplTest.java | 105 ++++++++++++++++++++
4 files changed, 232 insertions(+), 2 deletions(-)
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index f2f9f06296cf..87f0af8119a7 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -2666,6 +2666,14 @@ All commands find running integrations the same way:
* The `--timeout` of `camel cmd send`, `camel cmd receive`, `camel eval
expression`, `camel transform message` and the
`camel semantic` commands also takes a duration such as `30s` (a number is
still millis).
+=== camel-jbang - a Maven repository that does not answer fails the download
after 60 seconds
+
+The Camel CLI (`camel run`, `camel export`, the CLI plugins, and other tools
that download with `camel-tooling-maven`) now
+waits 60 seconds for data from a Maven repository, instead of the 30 minutes
of Maven Resolver, so a repository that
+stops sending no longer hangs the download for that long. Set the
`aether.connector.requestTimeout` system property
+(in millis) for a longer timeout. A repository that cannot be connected to is
now logged with a WARN and not used
+for the rest of the run.
+
=== camel-pqc - KeyLifecycleManager behaviour aligned across the
implementations
Four differences between the `KeyLifecycleManager` implementations are
resolved. All four are corrections, but each
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-running.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-running.adoc
index 9f8d664c80e3..c299857ed441 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-running.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-running.adoc
@@ -359,6 +359,17 @@ To disable automatic downloading:
camel run foo.java --download=false
----
+A repository that cannot be connected to (the host does not resolve, refuses
the connection, or does not answer
+within the connect timeout of 10 seconds) is logged once with a WARN and not
used for the rest of the run, so each
+download does not wait for it again. A repository that stops sending data
fails the download after 60 seconds.
+Set the `aether.connector.connectTimeout` and
`aether.connector.requestTimeout` system properties (in millis) to
+change the timeouts:
+
+[source,bash]
+----
+export JAVA_TOOL_OPTIONS="-Daether.connector.requestTimeout=180000"
+----
+
[[advanced-class-to-dependency]]
=== Advanced: how a class becomes a dependency
diff --git
a/tooling/camel-tooling-maven/src/main/java/org/apache/camel/tooling/maven/MavenDownloaderImpl.java
b/tooling/camel-tooling-maven/src/main/java/org/apache/camel/tooling/maven/MavenDownloaderImpl.java
index 10cc259193c8..13e0141c04ee 100644
---
a/tooling/camel-tooling-maven/src/main/java/org/apache/camel/tooling/maven/MavenDownloaderImpl.java
+++
b/tooling/camel-tooling-maven/src/main/java/org/apache/camel/tooling/maven/MavenDownloaderImpl.java
@@ -19,9 +19,12 @@ package org.apache.camel.tooling.maven;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.InputStream;
+import java.net.ConnectException;
import java.net.MalformedURLException;
+import java.net.SocketTimeoutException;
import java.net.URI;
import java.net.URL;
+import java.net.UnknownHostException;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Arrays;
@@ -34,6 +37,7 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Predicate;
import java.util.stream.Collectors;
@@ -84,9 +88,12 @@ import
org.eclipse.aether.resolution.DependencyResolutionException;
import org.eclipse.aether.resolution.DependencyResult;
import org.eclipse.aether.resolution.MetadataRequest;
import org.eclipse.aether.resolution.MetadataResult;
+import org.eclipse.aether.transfer.AbstractTransferListener;
import org.eclipse.aether.transfer.MetadataNotFoundException;
import org.eclipse.aether.transfer.RepositoryOfflineException;
+import org.eclipse.aether.transfer.TransferEvent;
import org.eclipse.aether.util.graph.selector.AndDependencySelector;
+import org.eclipse.aether.util.listener.ChainedTransferListener;
import org.eclipse.aether.util.repository.SimpleArtifactDescriptorPolicy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -116,6 +123,7 @@ public class MavenDownloaderImpl extends ServiceSupport
implements MavenDownload
// see org.eclipse.aether.internal.impl.DefaultUpdateCheckManager
private static final String UPDATE_CHECK_SESSION_STATE =
"aether.updateCheckManager.sessionState";
private static final DependencyFilter ACCEPT_ALL = (node, parents) -> true;
+ private static final int DEFAULT_REQUEST_TIMEOUT = 60000;
// Configuration
private boolean mavenCentralEnabled = true;
@@ -127,6 +135,8 @@ public class MavenDownloaderImpl extends ServiceSupport
implements MavenDownload
private boolean fresh;
private boolean offline;
private boolean preferLocal = true;
+ // the resolver's default is 30 minutes, so a repository that stops
sending would hang the download for that long
+ private int requestTimeout = DEFAULT_REQUEST_TIMEOUT;
private RemoteArtifactDownloadListener remoteArtifactDownloadListener;
private RepositoryResolver repositoryResolver;
@@ -151,6 +161,8 @@ public class MavenDownloaderImpl extends ServiceSupport
implements MavenDownload
private RepositoryPolicy defaultPolicy;
private boolean apacheSnapshotsIncluded;
private AtomicInteger customRepositoryCounter = new AtomicInteger(1);
+ // the repositories that could not be connected to (by URL): they are not
used for the rest of the run
+ private Set<String> unreachableRepositories =
ConcurrentHashMap.newKeySet();
/**
* Default constructor for standalone mode. MIMA will be used to create
RepositorySystem and
@@ -361,7 +373,7 @@ public class MavenDownloaderImpl extends ServiceSupport
implements MavenDownload
// Get RepositorySystem and RepositorySystemSession from MIMA
repositorySystem = mimaContext.repositorySystem();
- repositorySystemSession =
withTrackingKeyCompat(mimaContext.repositorySystemSession());
+ repositorySystemSession =
withTimeoutsAndReachability(withTrackingKeyCompat(mimaContext.repositorySystemSession()));
// Configure repositories (same as embedded mode)
List<RemoteRepository> originalRepositories = new ArrayList<>();
@@ -817,7 +829,12 @@ public class MavenDownloaderImpl extends ServiceSupport
implements MavenDownload
} catch (DependencyResolutionException | ArtifactResolutionException
e) {
MavenResolutionException mre = new
MavenResolutionException(e.getMessage(), e);
- repositories.forEach(r -> mre.getRepositories().add(r.getUrl()));
+ Set<String> unreachable = new
LinkedHashSet<>(unreachableRepositories);
+ for (RemoteRepository r : repositories) {
+ boolean reachable =
!unreachable.remove(repositoryUrl(r.getUrl()));
+ mre.getRepositories().add(reachable ? r.getUrl() : r.getUrl()
+ " (not reachable)");
+ }
+ unreachable.forEach(url -> mre.getRepositories().add(url + " (not
reachable)"));
throw mre;
}
}
@@ -840,9 +857,97 @@ public class MavenDownloaderImpl extends ServiceSupport
implements MavenDownload
session, extraRepos);
repositories.addAll(resolvedExtraRepos);
}
+ if (!unreachableRepositories.isEmpty()) {
+ // asking them again would only wait for the connect timeout again
+ repositories.removeIf(r ->
unreachableRepositories.contains(repositoryUrl(r.getUrl())));
+ }
return repositories;
}
+ /**
+ * Sets the request timeout of Camel (unless configured, such as with the
aether.connector.requestTimeout system
+ * property), and stops using a repository that cannot be connected to.
+ */
+ private RepositorySystemSession
withTimeoutsAndReachability(RepositorySystemSession session) {
+ DefaultRepositorySystemSession answer = new
DefaultRepositorySystemSession(session);
+ if
(session.getConfigProperties().get(ConfigurationProperties.REQUEST_TIMEOUT) ==
null) {
+ answer.setConfigProperty(ConfigurationProperties.REQUEST_TIMEOUT,
requestTimeout);
+ }
+
answer.setTransferListener(ChainedTransferListener.newInstance(session.getTransferListener(),
+ new UnreachableRepositoryListener(unreachableRepositories)));
+ return answer;
+ }
+
+ /**
+ * Notes the repositories that cannot be connected to: a host that does
not resolve, refuses the connection, or does
+ * not answer within the connect timeout. A repository that is slow to
send an artifact is still used.
+ */
+ static final class UnreachableRepositoryListener extends
AbstractTransferListener {
+
+ private final Set<String> unreachable;
+
+ UnreachableRepositoryListener(Set<String> unreachable) {
+ this.unreachable = unreachable;
+ }
+
+ @Override
+ public void transferFailed(TransferEvent event) {
+ Throwable cause = connectFailure(event.getException());
+ if (cause != null && event.getResource() != null &&
event.getResource().getRepositoryUrl() != null) {
+ String url =
repositoryUrl(event.getResource().getRepositoryUrl());
+ if (unreachable.add(url)) {
+ LOG.warn("Cannot connect to Maven repository: {} due to:
{}. The repository is not used anymore.",
+ url, cause.getMessage() != null ?
cause.getMessage() : cause.getClass().getName());
+ }
+ }
+ }
+
+ static Throwable connectFailure(Throwable e) {
+ // guard against a cause that points back to an exception in the
chain
+ int depth = 0;
+ for (Throwable t = e; t != null && depth < 20; t = t.getCause(),
depth++) {
+ if (t instanceof ConnectException || t instanceof
UnknownHostException) {
+ return t;
+ }
+ // the connect timeout of the JDK socket
+ if (t instanceof SocketTimeoutException && "Connect timed
out".equalsIgnoreCase(t.getMessage())) {
+ return t;
+ }
+ // the connect timeout of an HTTP client, by name to need none
of them on the classpath:
+ // org.apache.http.conn.ConnectTimeoutException (HttpClient 4,
the transport of Maven Resolver 1.x),
+ // org.apache.hc.client5.http.ConnectTimeoutException
(HttpClient 5), whose message is not
+ // "Connect timed out", and
java.net.http.HttpConnectTimeoutException (the JDK HTTP client)
+ if
(t.getClass().getSimpleName().endsWith("ConnectTimeoutException")) {
+ return t;
+ }
+ }
+ return null;
+ }
+ }
+
+ private static String repositoryUrl(String url) {
+ return url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
+ }
+
+ /**
+ * The request timeout in millis: how long to wait for data from a Maven
repository before failing (default 60
+ * seconds). The aether.connector.requestTimeout system property overrides
it.
+ */
+ public void setRequestTimeout(int requestTimeout) {
+ this.requestTimeout = requestTimeout;
+ }
+
+ public int getRequestTimeout() {
+ return requestTimeout;
+ }
+
+ /**
+ * The repositories that could not be connected to (by URL), which are not
used anymore.
+ */
+ Set<String> getUnreachableRepositories() {
+ return unreachableRepositories;
+ }
+
@Override
public MavenArtifactDescriptor resolveArtifactDescriptor(String gav,
Set<String> extraRepositories)
throws MavenResolutionException {
@@ -1127,6 +1232,7 @@ public class MavenDownloaderImpl extends ServiceSupport
implements MavenDownload
copy.fresh = fresh;
copy.apacheSnapshotsIncluded = apacheSnapshotsIncluded;
copy.customRepositoryCounter = customRepositoryCounter;
+ copy.unreachableRepositories = unreachableRepositories;
copy.repositoryResolver = repositoryResolver;
copy.offline = offline;
diff --git
a/tooling/camel-tooling-maven/src/test/java/org/apache/camel/tooling/maven/MavenDownloaderImplTest.java
b/tooling/camel-tooling-maven/src/test/java/org/apache/camel/tooling/maven/MavenDownloaderImplTest.java
index 512aef05d888..11e6e486c946 100644
---
a/tooling/camel-tooling-maven/src/test/java/org/apache/camel/tooling/maven/MavenDownloaderImplTest.java
+++
b/tooling/camel-tooling-maven/src/test/java/org/apache/camel/tooling/maven/MavenDownloaderImplTest.java
@@ -17,6 +17,13 @@
package org.apache.camel.tooling.maven;
import java.io.File;
+import java.io.IOException;
+import java.net.ConnectException;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.net.SocketTimeoutException;
+import java.net.UnknownHostException;
+import java.net.http.HttpConnectTimeoutException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.security.MessageDigest;
@@ -24,11 +31,13 @@ import java.util.Base64;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Callable;
+import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.TimeUnit;
import org.apache.camel.util.FileUtil;
import org.apache.commons.codec.binary.Hex;
import org.apache.http.Header;
+import org.apache.http.conn.ConnectTimeoutException;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.bootstrap.HttpServer;
import org.apache.http.impl.bootstrap.ServerBootstrap;
@@ -47,6 +56,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
@@ -1146,4 +1156,99 @@ class MavenDownloaderImplTest {
""".formatted(artifactId, body);
Files.writeString(new File(dir, artifactId + "-1.0.0.pom").toPath(),
pom);
}
+
+ @Test
+ void testUnreachableRepositoryIsNotUsedAnymore() throws Exception {
+ File repo = createDescriptorFixtureRepository();
+ File localRepo = new File(tempDir, "unreachable-local-repo");
+ // nothing listens on this port, so the connection is refused
+ String dead = "http://localhost:" +
AvailablePortFinder.getNextAvailable(20000, 30000) + "/maven";
+
+ try (MavenDownloaderImpl downloader = new MavenDownloaderImpl()) {
+ downloader.setMavenCentralEnabled(false);
+ downloader.setMavenApacheSnapshotEnabled(false);
+ downloader.setMavenSettingsLocation("false");
+ downloader.setRepos(dead + "," + repo.toURI());
+ downloader.build();
+ MavenDownloader customDownloader =
downloader.customize(localRepo.getAbsolutePath(), 5000, 5000);
+
+ // an artifact no repository has: both are asked
+ assertThrows(MavenResolutionException.class,
+ () ->
customDownloader.resolveArtifacts(List.of("org.example.fixture:missing:1.0.0"),
null, false,
+ false));
+ assertEquals(Set.of(dead),
downloader.getUnreachableRepositories());
+
+ // the other repository is still used
+ List<MavenArtifact> artifacts = customDownloader.resolveArtifacts(
+ List.of("org.example.fixture:fixture-bom:pom:1.0.0"),
null, false, false);
+ assertEquals(1, artifacts.size());
+
+ // the repository is not asked again, and the failure says so
+ MavenResolutionException e =
assertThrows(MavenResolutionException.class,
+ () ->
customDownloader.resolveArtifacts(List.of("org.example.fixture:missing2:1.0.0"),
null, false,
+ false));
+ assertTrue(e.getRepositories().contains(dead + " (not
reachable)"), e.getRepositories().toString());
+ assertFalse(e.getRepositories().contains(dead),
e.getRepositories().toString());
+ }
+ }
+
+ @Test
+ void testRepositoryThatDoesNotAnswerFailsOnTheRequestTimeout() throws
Exception {
+ File localRepo = new File(tempDir, "stalled-local-repo");
+ // accepts the connection, and never answers
+ try (ServerSocket server = new ServerSocket(0);
+ MavenDownloaderImpl downloader = new MavenDownloaderImpl()) {
+ List<Socket> accepted = new CopyOnWriteArrayList<>();
+ Thread acceptor = new Thread(() -> {
+ try {
+ while (!server.isClosed()) {
+ accepted.add(server.accept());
+ }
+ } catch (Exception e) {
+ // closed
+ }
+ });
+ acceptor.setDaemon(true);
+ acceptor.start();
+ String stalled = "http://localhost:" + server.getLocalPort() +
"/maven";
+
+ downloader.setMavenCentralEnabled(false);
+ downloader.setMavenApacheSnapshotEnabled(false);
+ downloader.setMavenSettingsLocation("false");
+ downloader.setRepos(stalled);
+ downloader.build();
+ assertEquals(60000, downloader.getRequestTimeout());
+ MavenDownloader customDownloader =
downloader.customize(localRepo.getAbsolutePath(), 5000, 1000);
+
+ long start = System.nanoTime();
+ assertThrows(MavenResolutionException.class,
+ () ->
customDownloader.resolveArtifacts(List.of("org.example.fixture:stalled:1.0.0"),
null, false,
+ false));
+ assertTrue(TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() -
start) < 30);
+ // a slow repository is still used
+ assertTrue(downloader.getUnreachableRepositories().isEmpty());
+ for (Socket socket : accepted) {
+ socket.close();
+ }
+ }
+ }
+
+ @Test
+ void testConnectFailures() {
+
assertNotNull(MavenDownloaderImpl.UnreachableRepositoryListener.connectFailure(
+ new RuntimeException(new ConnectException("Connection
refused"))));
+
assertNotNull(MavenDownloaderImpl.UnreachableRepositoryListener.connectFailure(new
UnknownHostException("nope")));
+
assertNotNull(MavenDownloaderImpl.UnreachableRepositoryListener.connectFailure(
+ new SocketTimeoutException("Connect timed out")));
+ // Apache HttpClient 4 (the HTTP transport of Maven Resolver) has its
own exception for a connect timeout
+
assertNotNull(MavenDownloaderImpl.UnreachableRepositoryListener.connectFailure(
+ new RuntimeException(new ConnectTimeoutException("Connect to
localhost:1234 timed out"))));
+
assertNotNull(MavenDownloaderImpl.UnreachableRepositoryListener.connectFailure(
+ new HttpConnectTimeoutException("HTTP connect timed out")));
+ // a read timeout is a slow repository, not an unreachable one
+
assertNull(MavenDownloaderImpl.UnreachableRepositoryListener.connectFailure(
+ new SocketTimeoutException("Read timed out")));
+
assertNull(MavenDownloaderImpl.UnreachableRepositoryListener.connectFailure(new
IOException("Not found")));
+
assertNull(MavenDownloaderImpl.UnreachableRepositoryListener.connectFailure(null));
+ }
}