Repository navigation
CAMEL-25694: camel-milo - fix write/call service failures being reported as success #27956
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
davsclaus
merged 7 commits into
apache:main
from
gnodet:camel-25694-milo-write-call-failures
Oct 23, 2026
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
8a27728
CAMEL-25694: camel-milo - fix write/call service failures being repor…
gnodet 1ef8bd8
fix: ServerCertificateValidationTest - start consumer routes after in…
gnodet a2a0eb8
fix: update SubscriptionManager test constructor calls to match new s…
gnodet 689e77a
fix: address review - add awaited call test, upgrade guide, revert re…
gnodet 76dcf12
Merge remote-tracking branch 'upstream/main' into camel-25694-milo-wr…
gnodet 0b4c0e7
fix: fix upgrade guide wording - Bad_TypeMismatch is a per-node statu…
gnodet f0992a7
fix: use correct requestBodyAndHeader signature with endpoint URI in …
gnodet File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
221 changes: 221 additions & 0 deletions
221
.../test/java/org/apache/camel/component/milo/client/MiloClientProducerWriteFailureTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
|
||
| /** | ||
|
gnodet marked this conversation as resolved.
|
||
| * 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(); | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.