This is an automated email from the ASF dual-hosted git repository. gnodet pushed a commit to branch backport/27956-to-camel-4.22.x in repository https://gitbox.apache.org/repos/asf/camel.git
commit c5de078b5fd4431df71d15c25e6233b05b2eadcf Author: Guillaume Nodet <[email protected]> AuthorDate: Fri Oct 23 17:28:14 2026 +0000 [backport camel-4.22.x] CAMEL-25694: camel-milo - fix write/call service failures being reported as success --- .../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_22.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 646e2554cfad..5e4e0a31a532 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 @@ -1014,13 +1014,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(); @@ -1034,13 +1033,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(); @@ -1054,13 +1052,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(); @@ -1078,13 +1075,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_22.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc index 72f5e955aef7..fd51e2e24a32 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc @@ -2102,3 +2102,15 @@ acknowledged and the failure is left to the Camel error handler. A consumer that option leaves a failed QoS 1 message on a session that the broker keeps unacknowledged, as before; as the consumer does not connect that client, set the `cleanStart` and `sessionExpiryInterval` of the endpoint to match its connection options. + +=== 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`.
