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 330754002072 CAMEL-25694: camel-milo - fix write/call service failures 
being reported as success (#27956)
330754002072 is described below

commit 33075400207277fe6cf9ab3cb2c1cf514e50af3d
Author: Guillaume Nodet <[email protected]>
AuthorDate: Fri Oct 23 19:21:13 2026 +0200

    CAMEL-25694: camel-milo - fix write/call service failures being reported as 
success (#27956)
    
    * CAMEL-25694: camel-milo - fix write/call service failures being reported 
as success
    
    In SubscriptionManager.write(), call(), readValues() and browse(), the 
futures
    returned by the underlying Milo client were chained with handleAsync(). 
When the
    upstream future completed exceptionally (e.g. Bad_Timeout service fault), 
the
    handleAsync() callback ran with a non-null 'e' parameter but returned the 
result
    value (null), converting the exceptional completion into a normal 
completion with
    a null value.
    
    MiloClientProducer awaits the future with whenComplete() and only sets an 
exchange
    exception when the throwable is non-null. Since handleAsync() had already 
discarded
    the exception, the producer saw a null-completed future, reported success, 
and set
    the exchange body to null.
    
    The fix replaces handleAsync() with whenCompleteAsync() in all four methods.
    whenCompleteAsync() runs the side-effect (handleConnectionFailure) but 
propagates
    the original completion unchanged: exceptional stays exceptional, success 
stays
    success.
    
    Co-authored-by: Claude Sonnet 4.6 <[email protected]>
    
    * fix: ServerCertificateValidationTest - start consumer routes after 
initial write
    
    OPC UA consumers deliver the current server node value when they subscribe.
    In the original test, all routes started automatically before the write, so
    each consumer received two messages: the uninitialised value at subscription
    time, and the 'Foo' write. The mock expected exactly 1 message, causing a
    deterministic failure ('Expected: <1> but was: <2>').
    
    Follow the pattern established in MonitorItemTest: mark consumer routes with
    autoStartup(false), write the initial value first via the server producer,
    then start all consumer routes. Each consumer then sees only one 
notification
    (the value written before it subscribed).
    
    * fix: update SubscriptionManager test constructor calls to match new 
signature
    
    * fix: address review - add awaited call test, upgrade guide, revert 
redundant test changes
    
    - Add testAwaitedCallReturnsResult() to CallClientTest to verify that an 
awaited
      call returns a non-null CallMethodResult with a good status and the 
expected
      output argument (CAMEL-25694 regression test on the real server path)
    - Add upgrade guide entry for the two observable behaviour changes:
      * an awaited write/call/read that fails now fails the exchange
      * an awaited call that succeeds now returns CallMethodResult as the body
    - Revert comment-only changes to ServerCertificateValidationTest: main 
already
      has the autoStartup(false) fix from CAMEL-25645 (#27999), so these 
rewrites
      only add noise to the commit history
    
    * fix: fix upgrade guide wording - Bad_TypeMismatch is a per-node status, 
not a transport failure
    
    * fix: use correct requestBodyAndHeader signature with endpoint URI in 
CallClientTest
    
    ---------
    
    Co-authored-by: Claude Sonnet 4.6 <[email protected]>
---
 .../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_24.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 7ff69a4ec4bc..882d185c1069 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
@@ -1708,13 +1708,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);
         } finally {
             lock.unlock();
@@ -1728,13 +1727,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);
         } finally {
             lock.unlock();
@@ -1748,13 +1746,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);
         } finally {
             lock.unlock();
@@ -1772,13 +1769,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);
         } finally {
             lock.unlock();
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..aec7677cad6d
--- /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());
+            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..061e857dd2fa
--- /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, 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, 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, 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_24.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index c513494b38f6..b213826e71a7 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
@@ -2416,6 +2416,18 @@ To keep the previous behaviour, filter the exchanges 
with a bad status out right
 `filter(simple("${body.statusCode.bad} == false"))`. This also drops the other 
bad statuses, such as the refusal of a
 node, and the current values delivered after an outage still arrive.
 
+=== 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-milo - the reconnectTimeout of the components sets the delay between 
connection attempts
 
 `setReconnectTimeout` on the `milo-client` and `milo-browse` components set 
the request timeout of every service call,

Reply via email to