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

oscerd 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 92f6f789ff0c CAMEL-24756: camel-base-engine - bound http resource 
resolution with a connect and read timeout (#26734)
92f6f789ff0c is described below

commit 92f6f789ff0cac3e05da3da710b8e26fb9d69325
Author: Andrea Cosentino <[email protected]>
AuthorDate: Mon Sep 28 19:28:19 2026 +0200

    CAMEL-24756: camel-base-engine - bound http resource resolution with a 
connect and read timeout (#26734)
    
    DefaultResourceResolvers.HttpResource opened a connection with neither
    setConnectTimeout nor setReadTimeout, and the JDK default for both is 0,
    meaning wait forever. Resource resolution usually happens in doStart() on
    the bootstrap thread, so a server that accepted the connection and then
    stopped answering stalled CamelContext startup.
    
    Both timeouts are now applied in exists() and getInputStream() alike, for
    http: and https: resources, with defaults of 10s connect and 30s read,
    overridable through camel.resource.http.connect-timeout and
    camel.resource.http.read-timeout (0 restores the previous behaviour). A
    value that does not parse fails with an IllegalArgumentException naming the
    property. The read timeout is per read, so a large resource arriving slowly
    is unaffected. Upgrade-guide entry and camel-opa doc caveat updated.
    
    Co-Authored-By: Claude Opus 5 <[email protected]>
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Andrea Cosentino <[email protected]>
---
 .../apache/camel/catalog/docs/opa-component.adoc   |   7 +-
 .../camel-opa/src/main/docs/opa-component.adoc     |   7 +-
 .../impl/engine/DefaultResourceResolvers.java      |  74 +++++++-
 .../apache/camel/util/HttpResourceTimeoutTest.java | 200 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc    |  19 ++
 5 files changed, 296 insertions(+), 11 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/opa-component.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/opa-component.adoc
index 9af5c30402db..262750c8a7a8 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/opa-component.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/opa-component.adoc
@@ -293,9 +293,10 @@ Data that is *not* part of the bundle — what a server 
would receive through it
 equivalent in `wasm` mode. A policy depending on it needs 
`evaluationMode=rest`.
 
 Prefer `classpath:` or `file:` for a bundle shipped with the application, 
which is what a build-time artefact
-usually is. The bundle is fetched when the endpoint starts, and Camel resolves 
an `http:` resource with no
-connect or read timeout (CAMEL-24756), so a policy server that accepts the 
connection and then does not answer
-stalls `CamelContext` startup rather than failing the one route.
+usually is. The bundle is fetched when the endpoint starts, so an `http:` 
location makes startup depend on a
+server being reachable; since Camel 4.23 that fetch is bounded by 
`camel.resource.http.connect-timeout` and
+`camel.resource.http.read-timeout`, so an unresponsive server fails the route 
rather than stalling
+`CamelContext` startup indefinitely.
 
 Which to choose:
 
diff --git a/components/camel-opa/src/main/docs/opa-component.adoc 
b/components/camel-opa/src/main/docs/opa-component.adoc
index 9af5c30402db..262750c8a7a8 100644
--- a/components/camel-opa/src/main/docs/opa-component.adoc
+++ b/components/camel-opa/src/main/docs/opa-component.adoc
@@ -293,9 +293,10 @@ Data that is *not* part of the bundle — what a server 
would receive through it
 equivalent in `wasm` mode. A policy depending on it needs 
`evaluationMode=rest`.
 
 Prefer `classpath:` or `file:` for a bundle shipped with the application, 
which is what a build-time artefact
-usually is. The bundle is fetched when the endpoint starts, and Camel resolves 
an `http:` resource with no
-connect or read timeout (CAMEL-24756), so a policy server that accepts the 
connection and then does not answer
-stalls `CamelContext` startup rather than failing the one route.
+usually is. The bundle is fetched when the endpoint starts, so an `http:` 
location makes startup depend on a
+server being reachable; since Camel 4.23 that fetch is bounded by 
`camel.resource.http.connect-timeout` and
+`camel.resource.http.read-timeout`, so an unresponsive server fails the route 
rather than stalling
+`CamelContext` startup indefinitely.
 
 Which to choose:
 
diff --git 
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultResourceResolvers.java
 
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultResourceResolvers.java
index f72e9d0ad935..1a93b89e102a 100644
--- 
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultResourceResolvers.java
+++ 
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultResourceResolvers.java
@@ -32,6 +32,7 @@ import java.nio.charset.StandardCharsets;
 import java.util.Base64;
 import java.util.zip.GZIPInputStream;
 
+import org.apache.camel.CamelContext;
 import org.apache.camel.spi.ContentTypeAware;
 import org.apache.camel.spi.Resource;
 import org.apache.camel.spi.annotations.ResourceResolver;
@@ -42,9 +43,54 @@ import org.apache.camel.util.FileUtil;
 
 public final class DefaultResourceResolvers {
 
+    /**
+     * Property key for how long to wait for the connection to an {@code 
http:} or {@code https:} resource to be
+     * established, in milliseconds. {@code 0} means wait indefinitely.
+     */
+    public static final String HTTP_CONNECT_TIMEOUT_PROPERTY = 
"camel.resource.http.connect-timeout";
+
+    /**
+     * Property key for how long to wait for data when reading an {@code 
http:} or {@code https:} resource, in
+     * milliseconds. {@code 0} means wait indefinitely.
+     */
+    public static final String HTTP_READ_TIMEOUT_PROPERTY = 
"camel.resource.http.read-timeout";
+
+    static final int DEFAULT_HTTP_CONNECT_TIMEOUT = 10000;
+    static final int DEFAULT_HTTP_READ_TIMEOUT = 30000;
+
     private DefaultResourceResolvers() {
     }
 
+    /**
+     * Resolves a timeout from the properties component, falling back to the 
given default.
+     * <p/>
+     * Resolution happens per resource rather than once per resolver because a 
resolver is a long-lived service while
+     * the properties it reads can be reloaded underneath it.
+     */
+    private static int resolveTimeout(CamelContext camelContext, String key, 
int defaultValue) {
+        if (camelContext == null) {
+            return defaultValue;
+        }
+        String value = 
camelContext.getPropertiesComponent().resolveProperty(key).orElse(null);
+        if (value == null) {
+            return defaultValue;
+        }
+        try {
+            return Integer.parseInt(value.trim());
+        } catch (NumberFormatException e) {
+            // this runs during startup, so a bare "For input string" says 
nothing about which property is wrong
+            throw new IllegalArgumentException(
+                    "Property " + key + " must be a number of milliseconds, 
was: " + value, e);
+        }
+    }
+
+    private static Resource createHttpResource(CamelContext camelContext, 
String scheme, String location) {
+        return new HttpResource(
+                scheme, location,
+                resolveTimeout(camelContext, HTTP_CONNECT_TIMEOUT_PROPERTY, 
DEFAULT_HTTP_CONNECT_TIMEOUT),
+                resolveTimeout(camelContext, HTTP_READ_TIMEOUT_PROPERTY, 
DEFAULT_HTTP_READ_TIMEOUT));
+    }
+
     /**
      * An implementation of the {@link ResourceResolver} that resolves a 
{@link Resource} from a file.
      */
@@ -97,7 +143,7 @@ public final class DefaultResourceResolvers {
 
         @Override
         public Resource createResource(String location, String remaining) {
-            return new HttpResource(SCHEME, location);
+            return createHttpResource(getCamelContext(), SCHEME, location);
         }
     }
 
@@ -114,7 +160,7 @@ public final class DefaultResourceResolvers {
 
         @Override
         public Resource createResource(String location, String remaining) {
-            return new HttpResource(SCHEME, location);
+            return createHttpResource(getCamelContext(), SCHEME, location);
         }
     }
 
@@ -335,17 +381,21 @@ public final class DefaultResourceResolvers {
     }
 
     static final class HttpResource extends ResourceSupport implements 
ContentTypeAware {
+        private final int connectTimeout;
+        private final int readTimeout;
         private String contentType;
 
-        HttpResource(String scheme, String location) {
+        HttpResource(String scheme, String location, int connectTimeout, int 
readTimeout) {
             super(scheme, location);
+            this.connectTimeout = connectTimeout;
+            this.readTimeout = readTimeout;
         }
 
         @Override
         public boolean exists() {
             URLConnection connection = null;
             try {
-                connection = 
URI.create(getLocation()).toURL().openConnection();
+                connection = openConnection();
                 if (connection instanceof HttpURLConnection httpURLConnection) 
{
                     return httpURLConnection.getResponseCode() == 
HttpURLConnection.HTTP_OK;
                 }
@@ -363,7 +413,7 @@ public final class DefaultResourceResolvers {
 
         @Override
         public InputStream getInputStream() throws IOException {
-            URLConnection con = 
URI.create(getLocation()).toURL().openConnection();
+            URLConnection con = openConnection();
             con.setUseCaches(false);
             try {
                 setContentType(con.getContentType());
@@ -378,6 +428,20 @@ public final class DefaultResourceResolvers {
             }
         }
 
+        /**
+         * Opens the connection with both timeouts applied.
+         * <p/>
+         * The read timeout is per read rather than for the transfer as a 
whole, so a large resource arriving slowly is
+         * not cut off as long as bytes keep coming; it bounds the wait on a 
server that accepts the connection and then
+         * says nothing, which is the case that otherwise stalls the caller 
forever.
+         */
+        private URLConnection openConnection() throws IOException {
+            URLConnection connection = 
URI.create(getLocation()).toURL().openConnection();
+            connection.setConnectTimeout(connectTimeout);
+            connection.setReadTimeout(readTimeout);
+            return connection;
+        }
+
         @Override
         public String getContentType() {
             return this.contentType;
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/util/HttpResourceTimeoutTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/util/HttpResourceTimeoutTest.java
new file mode 100644
index 000000000000..7617ee432d6c
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/util/HttpResourceTimeoutTest.java
@@ -0,0 +1,200 @@
+/*
+ * 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.apache.camel.util;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.net.SocketTimeoutException;
+import java.nio.charset.StandardCharsets;
+import java.util.Properties;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.impl.engine.DefaultResourceResolvers;
+import org.apache.camel.spi.Resource;
+import org.apache.camel.support.ResourceHelper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * An {@code http:} resource must not be able to stall the caller forever.
+ * <p/>
+ * The server here accepts the connection and then never answers, which is the 
only shape that reproduces the problem: a
+ * refused connection fails fast on its own, so nothing is proven by pointing 
at a closed port.
+ * <p/>
+ * The timeouts below are deliberately {@code SEPARATE_THREAD}: the default 
mode only measures elapsed time once the
+ * test method returns, so a regression that restores the indefinite wait 
would hang the build instead of failing it.
+ */
+class HttpResourceTimeoutTest {
+
+    private ServerSocket server;
+    private Thread acceptor;
+    private CountDownLatch accepted;
+    private volatile boolean stopped;
+
+    @BeforeEach
+    void startMuteServer() throws Exception {
+        server = new ServerSocket(0);
+        accepted = new CountDownLatch(1);
+        acceptor = new Thread(() -> {
+            while (!stopped) {
+                try (Socket socket = server.accept()) {
+                    accepted.countDown();
+                    // hold the connection open and write nothing at all, so 
the client is left waiting on a read;
+                    // draining the request blocks until the client gives up 
and closes
+                    
socket.getInputStream().transferTo(OutputStream.nullOutputStream());
+                } catch (IOException e) {
+                    return;
+                }
+            }
+        }, "mute-http-server");
+        acceptor.setDaemon(true);
+        // the loop guard is set before the thread that reads it. Nothing 
depends on that order today: this class
+        // uses the default PER_METHOD lifecycle, so every test gets a fresh 
instance with the field already false.
+        // It would matter under PER_CLASS, where @AfterEach leaves it true 
for the next test
+        stopped = false;
+        acceptor.start();
+    }
+
+    @AfterEach
+    void stopMuteServer() throws Exception {
+        stopped = true;
+        server.close();
+        acceptor.join(TimeUnit.SECONDS.toMillis(5));
+    }
+
+    private CamelContext contextWithReadTimeout(String millis) {
+        CamelContext context = new DefaultCamelContext();
+        Properties properties = new Properties();
+        
properties.setProperty(DefaultResourceResolvers.HTTP_READ_TIMEOUT_PROPERTY, 
millis);
+        context.getPropertiesComponent().setInitialProperties(properties);
+        context.start();
+        return context;
+    }
+
+    private String muteUrl() {
+        return "http://localhost:"; + server.getLocalPort() + "/policy.rego";
+    }
+
+    @Test
+    @Timeout(value = 30, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
+    void getInputStreamGivesUpOnAServerThatNeverAnswers() throws Exception {
+        CamelContext context = contextWithReadTimeout("500");
+        try {
+            Resource resource = ResourceHelper.resolveResource(context, 
muteUrl());
+
+            assertThrows(SocketTimeoutException.class, 
resource::getInputStream);
+            assertTrue(accepted.await(5, TimeUnit.SECONDS),
+                    "the server should have accepted the connection - a 
refused connect proves nothing");
+        } finally {
+            context.stop();
+        }
+    }
+
+    @Test
+    @Timeout(value = 30, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
+    void existsGivesUpOnAServerThatNeverAnswers() throws Exception {
+        CamelContext context = contextWithReadTimeout("500");
+        try {
+            Resource resource = ResourceHelper.resolveResource(context, 
muteUrl());
+
+            // exists() wraps the IOException rather than declaring it, so the 
timeout surfaces as the cause
+            IllegalArgumentException e = 
assertThrows(IllegalArgumentException.class, resource::exists);
+            assertEquals(SocketTimeoutException.class, 
e.getCause().getClass());
+        } finally {
+            context.stop();
+        }
+    }
+
+    @Test
+    @Timeout(value = 30, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
+    void httpsResourcesAreBoundedToo() throws Exception {
+        CamelContext context = contextWithReadTimeout("500");
+        try {
+            // the https resolver hands back the same HttpResource, so it must 
inherit the same bound; the handshake
+            // against a mute plain-text server is what fails here, and it 
must fail rather than hang
+            Resource resource
+                    = ResourceHelper.resolveResource(context, 
"https://localhost:"; + server.getLocalPort() + "/x");
+
+            assertThrows(IOException.class, resource::getInputStream);
+        } finally {
+            context.stop();
+        }
+    }
+
+    @Test
+    @Timeout(value = 30, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
+    void aTimeoutThatIsNotANumberSaysWhichPropertyIsWrong() throws Exception {
+        CamelContext context = new DefaultCamelContext();
+        Properties properties = new Properties();
+        
properties.setProperty(DefaultResourceResolvers.HTTP_CONNECT_TIMEOUT_PROPERTY, 
"10s");
+        context.getPropertiesComponent().setInitialProperties(properties);
+        context.start();
+        try {
+            IllegalArgumentException e
+                    = assertThrows(IllegalArgumentException.class, () -> 
ResourceHelper.resolveResource(context, muteUrl()));
+
+            // the failure happens during startup, so the message has to carry 
the key; "For input string" alone
+            // leaves an operator grepping their whole configuration
+            
assertTrue(e.getMessage().contains(DefaultResourceResolvers.HTTP_CONNECT_TIMEOUT_PROPERTY),
+                    "message should name the property, was: " + 
e.getMessage());
+            assertTrue(e.getMessage().contains("10s"),
+                    "message should quote the bad value, was: " + 
e.getMessage());
+        } finally {
+            context.stop();
+        }
+    }
+
+    @Test
+    @Timeout(value = 30, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
+    void aResourceThatIsNotThereStillReportsAbsent() throws Exception {
+        // guards against the timeouts turning an ordinary 404 into a failure
+        CamelContext context = contextWithReadTimeout("5000");
+        try (ServerSocket notFound = new ServerSocket(0)) {
+            Thread responder = new Thread(() -> {
+                try (Socket socket = notFound.accept()) {
+                    socket.getOutputStream()
+                            .write("HTTP/1.1 404 Not Found\r\nContent-Length: 
0\r\nConnection: close\r\n\r\n"
+                                    .getBytes(StandardCharsets.US_ASCII));
+                    socket.getOutputStream().flush();
+                } catch (IOException e) {
+                    // the assertion below is what reports the failure
+                }
+            }, "not-found-server");
+            responder.setDaemon(true);
+            responder.start();
+
+            Resource resource
+                    = ResourceHelper.resolveResource(context, 
"http://localhost:"; + notFound.getLocalPort() + "/x");
+
+            assertFalse(resource.exists());
+        } finally {
+            context.stop();
+        }
+    }
+}
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index 13a5ab6b8749..b60be9b4bb0f 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -45,6 +45,25 @@ To migrate, remove the `camel.main.exchange-factory=pooled` 
property (and any re
 properties) from your `application.properties`. Camel will use the default 
prototype mode which creates a
 new exchange per message. A `WARN` is logged at startup if pooled mode is 
still configured.
 
+=== http and https resources are now resolved with a timeout
+
+Loading a resource from an `http:` or `https:` location - a Groovy script, an 
XSLT stylesheet, a Velocity template,
+or anything else resolved through `ResourceHelper` - previously used the JDK 
defaults, which are to wait indefinitely
+for both the connection and every read. A server that accepted the connection 
and then stopped answering would stall
+`CamelContext` startup with no way to bound the wait.
+
+Such a resource is now read with a 10 second connect timeout and a 30 second 
read timeout, and fails with a
+`java.net.SocketTimeoutException` instead of hanging. The read timeout applies 
per read rather than to the transfer as
+a whole, so a large resource arriving slowly is unaffected as long as data 
keeps coming.
+
+Both values can be changed, in milliseconds, and `0` restores the previous 
wait-forever behaviour:
+
+[source,properties]
+----
+camel.resource.http.connect-timeout = 10000
+camel.resource.http.read-timeout = 30000
+----
+
 === camel-oauth
 
 OAuth client credentials token caching now distinguishes profiles by client 
secret and requested scope, in addition to token endpoint and client ID. 
Profiles with different credentials or scopes request separate tokens instead 
of reusing the same cached token. Applications using such profiles may make 
additional token requests after upgrading.

Reply via email to