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.