This is an automated email from the ASF dual-hosted git repository.
gnodet pushed a commit to branch camel-4.18.x
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/camel-4.18.x by this push:
new 389bf171e42a [backport camel-4.18.x] CAMEL-25694: camel-milo - fix
write/call service failures being reported as success (#28368)
389bf171e42a is described below
commit 389bf171e42ae9dfab6c23ec6803ebeba45c09ad
Author: Guillaume Nodet <[email protected]>
AuthorDate: Mon Nov 2 10:25:43 2026 +0100
[backport camel-4.18.x] CAMEL-25694: camel-milo - fix write/call service
failures being reported as success (#28368)
* [backport camel-4.18.x] CAMEL-25694: camel-milo - fix write/call service
failures being reported as success
* [Backport camel-4.18.x] CAMEL-25694: fix MiloClientConnection and
SubscriptionManager constructor calls in tests
* CAMEL-25694: move upgrade guide note to 4.18.5→4.18.6 section
---
.../milo/client/internal/SubscriptionManager.java | 20 +-
.../camel/component/milo/call/CallClientTest.java | 15 ++
.../client/MiloClientProducerWriteFailureTest.java | 221 +++++++++++++++++++++
...ubscriptionManagerExceptionPropagationTest.java | 203 +++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_18.adoc | 12 ++
5 files changed, 459 insertions(+), 12 deletions(-)
diff --git
a/components/camel-milo/src/main/java/org/apache/camel/component/milo/client/internal/SubscriptionManager.java
b/components/camel-milo/src/main/java/org/apache/camel/component/milo/client/internal/SubscriptionManager.java
index 89dd3dfd4010..37fa3c8727dc 100644
---
a/components/camel-milo/src/main/java/org/apache/camel/component/milo/client/internal/SubscriptionManager.java
+++
b/components/camel-milo/src/main/java/org/apache/camel/component/milo/client/internal/SubscriptionManager.java
@@ -957,13 +957,12 @@ public class SubscriptionManager {
return newNotConnectedResult();
}
- return this.connected.call(nodeId, methodId,
inputArguments).handleAsync((status, e) -> {
+ return this.connected.call(nodeId, methodId,
inputArguments).whenCompleteAsync((status, e) -> {
// handle outside the lock, running using
- // handleAsync
+ // whenCompleteAsync (preserves the original exceptional
completion)
if (e != null) {
handleConnectionFailure(e);
}
- return null;
}, this.executor);
}
}
@@ -974,13 +973,12 @@ public class SubscriptionManager {
return newNotConnectedResult();
}
- return this.connected.write(nodeId, value).handleAsync((status, e)
-> {
+ return this.connected.write(nodeId,
value).whenCompleteAsync((status, e) -> {
// handle outside the lock, running using
- // handleAsync
+ // whenCompleteAsync (preserves the original exceptional
completion)
if (e != null) {
handleConnectionFailure(e);
}
- return status;
}, this.executor);
}
}
@@ -991,13 +989,12 @@ public class SubscriptionManager {
return newNotConnectedResult();
}
- return this.connected.readValues(nodeIds).handleAsync((nodes, e)
-> {
+ return
this.connected.readValues(nodeIds).whenCompleteAsync((nodes, e) -> {
// handle outside the lock, running using
- // handleAsync
+ // whenCompleteAsync (preserves the original exceptional
completion)
if (e != null) {
handleConnectionFailure(e);
}
- return nodes;
}, this.executor);
}
}
@@ -1012,13 +1009,12 @@ public class SubscriptionManager {
return this.connected.browse(expandedNodeIds, direction,
nodeClasses, 1, maxDepth,
null != filter ? Pattern.compile(filter) : null,
includeSubTypes, maxNodesPerRequest)
- .handleAsync((browseResult, e) -> {
+ .whenCompleteAsync((browseResult, e) -> {
// handle outside the lock, running using
- // handleAsync
+ // whenCompleteAsync (preserves the original
exceptional completion)
if (e != null) {
handleConnectionFailure(e);
}
- return browseResult;
}, this.executor);
}
}
diff --git
a/components/camel-milo/src/test/java/org/apache/camel/component/milo/call/CallClientTest.java
b/components/camel-milo/src/test/java/org/apache/camel/component/milo/call/CallClientTest.java
index 5e5ff94b3916..3255db6dc935 100644
---
a/components/camel-milo/src/test/java/org/apache/camel/component/milo/call/CallClientTest.java
+++
b/components/camel-milo/src/test/java/org/apache/camel/component/milo/call/CallClientTest.java
@@ -37,6 +37,7 @@ import org.eclipse.milo.opcua.stack.core.security.*;
import org.eclipse.milo.opcua.stack.core.transport.TransportProfile;
import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText;
import org.eclipse.milo.opcua.stack.core.types.enumerated.MessageSecurityMode;
+import org.eclipse.milo.opcua.stack.core.types.structured.CallMethodResult;
import org.eclipse.milo.opcua.stack.core.types.structured.UserTokenPolicy;
import org.eclipse.milo.opcua.stack.transport.server.tcp.OpcTcpServerTransport;
import
org.eclipse.milo.opcua.stack.transport.server.tcp.OpcTcpServerTransportConfig;
@@ -48,7 +49,9 @@ import org.slf4j.LoggerFactory;
import static org.apache.camel.component.milo.NodeIds.nodeValue;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Unit tests for calling from the client side
@@ -194,6 +197,18 @@ public class CallClientTest extends AbstractMiloServerTest
{
.build();
}
+ @Test
+ public void testAwaitedCallReturnsResult() {
+ // Before CAMEL-25694, the call() handleAsync unconditionally returned
null even on success,
+ // so an awaited call always left the exchange body null. Now the body
is the CallMethodResult.
+ final CallMethodResult result = this.producer1.requestBodyAndHeader(
+ DIRECT_START_1, "foo", "CamelMiloAwait", true,
CallMethodResult.class);
+
+ assertNotNull(result);
+ assertTrue(result.getStatusCode().isGood());
+ assertEquals("out-foo", result.getOutputArguments()[0].getValue());
+ }
+
@Test
public void testCall1() {
// call
diff --git
a/components/camel-milo/src/test/java/org/apache/camel/component/milo/client/MiloClientProducerWriteFailureTest.java
b/components/camel-milo/src/test/java/org/apache/camel/component/milo/client/MiloClientProducerWriteFailureTest.java
new file mode 100644
index 000000000000..a82fe3a5dbea
--- /dev/null
+++
b/components/camel-milo/src/test/java/org/apache/camel/component/milo/client/MiloClientProducerWriteFailureTest.java
@@ -0,0 +1,221 @@
+/*
+ * 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.component.milo.client;
+
+import java.lang.reflect.Field;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.component.milo.MiloConstants;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.support.DefaultExchange;
+import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode;
+import org.eclipse.milo.opcua.stack.core.types.enumerated.BrowseDirection;
+import org.eclipse.milo.opcua.stack.core.types.structured.BrowseResult;
+import org.eclipse.milo.opcua.stack.core.types.structured.CallMethodResult;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Unit tests verifying that OPC-UA service failures from awaited write and
call operations are propagated as exchange
+ * exceptions rather than being silently swallowed and reported as success.
+ *
+ * <p>
+ * Regression test for CAMEL-25694: when {@code SubscriptionManager.write()}
or {@code call()} received an exceptional
+ * completion from the underlying future (e.g. a Bad_Timeout service fault)
and used {@code handleAsync()}, it converted
+ * the failure into a normal completion with a null value. {@code
MiloClientProducer} then reported success with a null
+ * body. The fix replaces {@code handleAsync()} with {@code
whenCompleteAsync()} to preserve the original exceptional
+ * completion while still triggering the reconnection side-effect.
+ */
+class MiloClientProducerWriteFailureTest {
+
+ /**
+ * Stub connection that returns a pre-arranged future from {@code
writeValue()} and {@code call()}, allowing tests
+ * to simulate service failures without a real OPC UA server.
+ */
+ static class StubMiloClientConnection extends MiloClientConnection {
+
+ private final CompletableFuture<?> writeResult;
+ private final CompletableFuture<CallMethodResult> callResult;
+
+ StubMiloClientConnection(CompletableFuture<?> writeResult,
CompletableFuture<CallMethodResult> callResult) {
+ super(new MiloClientConfiguration(), null);
+ this.writeResult = writeResult;
+ this.callResult = callResult;
+ }
+
+ @Override
+ public CompletableFuture<?> writeValue(ExpandedNodeId nodeId, Object
value) {
+ return writeResult;
+ }
+
+ @Override
+ public CompletableFuture<CallMethodResult> call(ExpandedNodeId nodeId,
ExpandedNodeId methodId, Object value) {
+ return callResult;
+ }
+
+ @Override
+ public CompletableFuture<?> readValues(List<ExpandedNodeId> nodeIds) {
+ return CompletableFuture.completedFuture(null);
+ }
+
+ @Override
+ public CompletableFuture<Map<ExpandedNodeId, BrowseResult>> browse(
+ List<ExpandedNodeId> expandedNodeIds, BrowseDirection
direction, int nodeClasses, int maxDepth,
+ String filter, boolean includeSubTypes, int
maxNodesPerRequest) {
+ return CompletableFuture.completedFuture(null);
+ }
+ }
+
+ private DefaultCamelContext camelContext;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ camelContext = new DefaultCamelContext();
+ camelContext.start();
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ camelContext.stop();
+ }
+
+ /**
+ * Verifies that when the awaited write future completes exceptionally,
the exchange receives the exception rather
+ * than reporting success with a null body.
+ */
+ @Test
+ void testAwaitedWriteServiceFailurePropagatesAsException() throws
Exception {
+ RuntimeException serviceError = new RuntimeException("Bad_Timeout
OPC-UA service fault");
+ CompletableFuture<StatusCode> failingWrite = new CompletableFuture<>();
+ failingWrite.completeExceptionally(serviceError);
+
+ Exchange exchange = runProducerWithStubConnection(
+ new StubMiloClientConnection(failingWrite,
CompletableFuture.completedFuture(null)),
+ null, true);
+
+ assertNotNull(exchange.getException(), "Exchange must carry the
service exception on failed awaited write");
+ assertInstanceOf(RuntimeException.class, exchange.getException());
+ }
+
+ /**
+ * Verifies that when the awaited call future completes exceptionally, the
exchange receives the exception rather
+ * than reporting success with a null body.
+ */
+ @Test
+ void testAwaitedCallServiceFailurePropagatesAsException() throws Exception
{
+ RuntimeException serviceError = new RuntimeException("Bad_Timeout
OPC-UA service fault");
+ CompletableFuture<CallMethodResult> failingCall = new
CompletableFuture<>();
+ failingCall.completeExceptionally(serviceError);
+
+ Exchange exchange = runProducerWithStubConnection(
+ new
StubMiloClientConnection(CompletableFuture.completedFuture(null), failingCall),
+ "ns=1;s=myMethod", true);
+
+ assertNotNull(exchange.getException(), "Exchange must carry the
service exception on failed awaited call");
+ assertInstanceOf(RuntimeException.class, exchange.getException());
+ }
+
+ /**
+ * Verifies that a successful awaited write does not set an exception on
the exchange.
+ */
+ @Test
+ void testAwaitedWriteSuccessDoesNotSetException() throws Exception {
+ CompletableFuture<StatusCode> successWrite =
CompletableFuture.completedFuture(StatusCode.GOOD);
+
+ Exchange exchange = runProducerWithStubConnection(
+ new StubMiloClientConnection(successWrite,
CompletableFuture.completedFuture(null)),
+ null, true);
+
+ assertNull(exchange.getException(), "Exchange must not carry an
exception on successful awaited write");
+ }
+
+ /**
+ * Verifies that non-awaited writes (fire-and-forget mode) do not
propagate the exception to the exchange.
+ */
+ @Test
+ void testNonAwaitedWriteServiceFailureDoesNotSetException() throws
Exception {
+ RuntimeException serviceError = new RuntimeException("Bad_Timeout
OPC-UA service fault");
+ CompletableFuture<StatusCode> failingWrite = new CompletableFuture<>();
+ failingWrite.completeExceptionally(serviceError);
+
+ Exchange exchange = runProducerWithStubConnection(
+ new StubMiloClientConnection(failingWrite,
CompletableFuture.completedFuture(null)),
+ null, false);
+
+ assertNull(exchange.getException(), "Non-awaited write must not
propagate exceptions to the exchange");
+ }
+
+ /**
+ * Creates a {@link MiloClientProducer} backed by the given stub
connection, runs process() synchronously, and
+ * returns the resulting exchange.
+ *
+ * @param stubConnection the stub connection returning pre-arranged futures
+ * @param methodIdStr if non-null, treated as a method-call producer;
otherwise a write producer
+ * @param await whether to set {@code CamelMiloAwait=true} on the
exchange
+ */
+ private Exchange runProducerWithStubConnection(
+ StubMiloClientConnection stubConnection,
+ String methodIdStr,
+ boolean await)
+ throws Exception {
+
+ MiloClientEndpoint endpoint = new MiloClientEndpoint(
+ "milo-client:opc.tcp://localhost:4840", new
MiloClientComponent(), "opc.tcp://localhost:4840",
+ new MiloClientCachingConnectionManager());
+ endpoint.setCamelContext(camelContext);
+ endpoint.setNode("ns=1;s=myNode");
+ if (methodIdStr != null) {
+ endpoint.setMethod(methodIdStr);
+ }
+
+ MiloClientProducer producer = new MiloClientProducer(endpoint, await);
+
+ // Inject the stub connection via reflection to bypass doStart() which
requires a real server
+ Field connectionField =
MiloClientProducer.class.getDeclaredField("connection");
+ connectionField.setAccessible(true);
+ connectionField.set(producer, stubConnection);
+
+ DefaultExchange exchange = new DefaultExchange(camelContext);
+ exchange.getIn().setBody("testValue");
+ exchange.getIn().setHeader(MiloConstants.HEADER_AWAIT, await);
+
+ CountDownLatch latch = new CountDownLatch(1);
+ AtomicReference<Exchange> resultRef = new AtomicReference<>();
+
+ producer.process(exchange, done -> {
+ resultRef.set(exchange);
+ latch.countDown();
+ });
+
+ latch.await(5, TimeUnit.SECONDS);
+ assertTrue(latch.getCount() == 0, "Producer callback must complete
within 5 seconds");
+ return resultRef.get();
+ }
+}
diff --git
a/components/camel-milo/src/test/java/org/apache/camel/component/milo/client/internal/SubscriptionManagerExceptionPropagationTest.java
b/components/camel-milo/src/test/java/org/apache/camel/component/milo/client/internal/SubscriptionManagerExceptionPropagationTest.java
new file mode 100644
index 000000000000..7a456f27fbec
--- /dev/null
+++
b/components/camel-milo/src/test/java/org/apache/camel/component/milo/client/internal/SubscriptionManagerExceptionPropagationTest.java
@@ -0,0 +1,203 @@
+/*
+ * 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.component.milo.client.internal;
+
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.component.milo.client.MiloClientConfiguration;
+import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue;
+import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.Variant;
+import org.eclipse.milo.opcua.stack.core.types.structured.CallMethodResult;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * Unit tests for {@link SubscriptionManager} exception propagation through
write() and call().
+ *
+ * <p>
+ * Regression tests for CAMEL-25694: {@code SubscriptionManager.write()} and
{@code call()} previously used
+ * {@code handleAsync()} which converted exceptional completions into normal
completions with null values. The fix
+ * replaces {@code handleAsync()} with {@code whenCompleteAsync()} to preserve
the original exceptional completion.
+ *
+ * <p>
+ * The {@code whenCompleteAsync}-vs-{@code handleAsync} contract is tested via
standalone helper methods (no real OPC UA
+ * connection needed). The disconnected-manager tests use a real {@link
SubscriptionManager} to verify that
+ * {@code write()}, {@code call()}, and {@code readValues()} all return
exceptional futures before any connection is
+ * established.
+ */
+class SubscriptionManagerExceptionPropagationTest {
+
+ private ScheduledExecutorService executor;
+
+ @BeforeEach
+ void setUp() {
+ executor = Executors.newSingleThreadScheduledExecutor();
+ }
+
+ @AfterEach
+ void tearDown() throws InterruptedException {
+ executor.shutdownNow();
+ assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS),
+ "Executor must shut down cleanly within 5 seconds");
+ }
+
+ //
-------------------------------------------------------------------------
+ // Contract tests: whenCompleteAsync vs handleAsync
+ //
+ // These do NOT instantiate SubscriptionManager (which would trigger a
+ // connection attempt in its constructor). Instead they exercise the same
+ // CompletableFuture chain used by the production fix.
+ //
-------------------------------------------------------------------------
+
+ /**
+ * Verifies that {@code whenCompleteAsync} (the fix) preserves an
exceptional completion.
+ *
+ * <p>
+ * Before CAMEL-25694 was fixed, {@code SubscriptionManager.write()} used
{@code handleAsync()}, which converted the
+ * exceptional completion into a normal null completion. This test
verifies the correct behavior: a
+ * {@code whenCompleteAsync} side-effect chain over a failed future must
stay failed.
+ */
+ @Test
+ void testWhenCompleteAsyncPreservesExceptionalCompletion() throws
Exception {
+ RuntimeException serviceError = new RuntimeException("Bad_Timeout
OPC-UA service fault");
+ CompletableFuture<Object> upstream = new CompletableFuture<>();
+ upstream.completeExceptionally(serviceError);
+
+ // Same chain as the fixed SubscriptionManager.write(): side-effect
only, completion preserved
+ CompletableFuture<?> result = upstream.whenCompleteAsync((r, e) -> {
+ // side-effect (e.g. handleConnectionFailure); exception
propagated by whenCompleteAsync
+ }, executor);
+
+ ExecutionException ex = null;
+ try {
+ result.get(5, TimeUnit.SECONDS);
+ } catch (ExecutionException e) {
+ ex = e;
+ } catch (Exception e) {
+ fail("Unexpected exception type: " + e);
+ }
+ assertNotNull(ex, "Get on a failed whenCompleteAsync future must throw
ExecutionException");
+ assertInstanceOf(RuntimeException.class, ex.getCause());
+ assertTrue(result.isCompletedExceptionally(),
+ "whenCompleteAsync must preserve the exceptional completion");
+ }
+
+ /**
+ * Demonstrates why the old code was wrong: {@code handleAsync} converts
an exceptional completion into a normal
+ * null completion, silently swallowing the service failure.
+ *
+ * <p>
+ * This test documents the buggy behavior. If the fix is regressed by
reintroducing {@code handleAsync},
+ * {@link #testWhenCompleteAsyncPreservesExceptionalCompletion()} will
fail, and this test shows the exact semantic
+ * difference.
+ */
+ @Test
+ void testHandleAsyncSwallowsExceptionalCompletion() throws Exception {
+ RuntimeException serviceError = new RuntimeException("Bad_Timeout
OPC-UA service fault");
+ CompletableFuture<Object> upstream = new CompletableFuture<>();
+ upstream.completeExceptionally(serviceError);
+
+ // Same chain as the old buggy SubscriptionManager.write(): returns
result (null) on exception
+ CompletableFuture<?> result = upstream.handleAsync((r, e) -> r,
executor);
+
+ // Wait for the handleAsync chain to complete normally (it swallows
the exception)
+ result.join();
+
+ assertFalse(result.isCompletedExceptionally(),
+ "handleAsync incorrectly completes normally with null when
upstream fails");
+ assertNull(result.get(),
+ "handleAsync returns null when upstream failed — this is the
swallowed failure");
+ }
+
+ //
-------------------------------------------------------------------------
+ // SubscriptionManager disconnected-path tests
+ //
+ // These instantiate a real SubscriptionManager with a default (no-server)
+ // configuration. The constructor triggers a connect() call that fails
+ // immediately (null endpoint URI), leaving the manager in the disconnected
+ // state. write(), call(), and readValues() must return exceptional
futures.
+ //
-------------------------------------------------------------------------
+
+ /**
+ * Verifies that a disconnected manager returns an exceptional future from
write(), ensuring producers fail fast
+ * when there is no connection.
+ */
+ @Test
+ void testWriteWhenDisconnectedReturnsExceptionalFuture() {
+ SubscriptionManager manager = new SubscriptionManager(new
MiloClientConfiguration(), executor, 10_000);
+ try {
+ CompletableFuture<?> result =
manager.write(ExpandedNodeId.parse("ns=1;s=myNode"),
+ new DataValue(new Variant("testValue")));
+
+ assertTrue(result.isCompletedExceptionally(),
+ "write() must return an exceptional future when not
connected");
+ } finally {
+ manager.dispose();
+ }
+ }
+
+ /**
+ * Verifies that a disconnected manager returns an exceptional future from
call(), ensuring producers fail fast when
+ * there is no connection.
+ */
+ @Test
+ void testCallWhenDisconnectedReturnsExceptionalFuture() {
+ SubscriptionManager manager = new SubscriptionManager(new
MiloClientConfiguration(), executor, 10_000);
+ try {
+ CompletableFuture<CallMethodResult> result = manager.call(
+ ExpandedNodeId.parse("ns=1;s=myNode"),
+ ExpandedNodeId.parse("ns=1;s=myMethod"),
+ new Variant[0]);
+
+ assertTrue(result.isCompletedExceptionally(),
+ "call() must return an exceptional future when not
connected");
+ } finally {
+ manager.dispose();
+ }
+ }
+
+ /**
+ * Verifies that a disconnected manager returns an exceptional future from
readValues(), ensuring consumers fail
+ * fast when there is no connection.
+ */
+ @Test
+ void testReadValuesWhenDisconnectedReturnsExceptionalFuture() {
+ SubscriptionManager manager = new SubscriptionManager(new
MiloClientConfiguration(), executor, 10_000);
+ try {
+ CompletableFuture<?> result =
manager.readValues(List.of(ExpandedNodeId.parse("ns=1;s=myNode")));
+
+ assertTrue(result.isCompletedExceptionally(),
+ "readValues() must return an exceptional future when not
connected");
+ } finally {
+ manager.dispose();
+ }
+ }
+}
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc
index 5a859ab95bfd..a42fe35b6b4e 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc
@@ -63,6 +63,18 @@ earlier in the route.
Signatures created by earlier versions may not verify and need to be created
again.
+=== camel-milo - an awaited write, call or read that fails now fails the
exchange
+
+An awaited `milo-client` write, method call or read that fails at the service
or transport level, for example a
+request that times out (`Bad_Timeout`) or a lost connection, now fails the
exchange and lets the route's error
+handler run. Before, the exchange completed without an error and with a `null`
body, silently discarding the failure.
+A bad status for the node itself, such as `Bad_TypeMismatch` on a write, is
not a failure of the operation and is
+still returned as the body.
+
+An awaited `call` that succeeds now returns the `CallMethodResult` as the
exchange body. Before, the body was
+always `null` even on success. A route that reads the result of an awaited
call must be updated to read the
+`CallMethodResult` from the body instead of expecting `null`.
+
=== camel-netty-http - securityConfiguration is copied per endpoint
When `securityConfiguration` is set on the `NettyHttpComponent`, each endpoint
now receives an independent copy of the configuration. Modifying
`securityConfiguration.*` options on an endpoint URI no longer alters the
component-level configuration or other endpoints.