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 ba044ff20a90 CAMEL-26333: camel-core - doFinally runs only its first
step after a called route's error handler handled a failure (#28756)
ba044ff20a90 is described below
commit ba044ff20a90ebd00b8166a87c0b6457aa63d201
Author: allthingssecurity <[email protected]>
AuthorDate: Sun Nov 1 00:46:37 2026 +0530
CAMEL-26333: camel-core - doFinally runs only its first step after a called
route's error handler handled a failure (#28756)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../apache/camel/processor/FinallyProcessor.java | 41 ++++-
.../DoFinallyAfterHandledCalledRouteTest.java | 204 +++++++++++++++++++++
...utingSlipInDoFinallyAfterRecipientListTest.java | 127 +++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 9 +
4 files changed, 379 insertions(+), 2 deletions(-)
diff --git
a/core/camel-core-processor/src/main/java/org/apache/camel/processor/FinallyProcessor.java
b/core/camel-core-processor/src/main/java/org/apache/camel/processor/FinallyProcessor.java
index 4cb237b9db6d..7b63ffc9fb62 100644
---
a/core/camel-core-processor/src/main/java/org/apache/camel/processor/FinallyProcessor.java
+++
b/core/camel-core-processor/src/main/java/org/apache/camel/processor/FinallyProcessor.java
@@ -18,6 +18,7 @@ package org.apache.camel.processor;
import org.apache.camel.AsyncCallback;
import org.apache.camel.Exchange;
+import org.apache.camel.ExchangeExtension;
import org.apache.camel.ExchangePropertyKey;
import org.apache.camel.Processor;
import org.apache.camel.Traceable;
@@ -60,8 +61,26 @@ public class FinallyProcessor extends
BaseDelegateProcessorSupport
}
}
+ // a route called from the doTry (such as with direct) can have
handled a failure with its error handler (dead
+ // letter channel or a handled onException), which marks the exchange
as handled by the error handler and as
+ // failure handled. The route then does not continue after the doTry,
but the doFinally block must still run all
+ // its steps (a pipeline stops before its second step when the
exchange is handled by the error handler), and a
+ // failure in a route that the block calls must be handled by that
route's error handler as usual (which does not
+ // handle a new failure of an exchange that is failure handled). So
the marks are cleared while the block runs,
+ // and set again afterwards (as the onCompletion does)
+ final ExchangeExtension ext = exchange.getExchangeExtension();
+ // a primitive "set and true" snapshot, unlike the nullable Boolean of
OnCompletionProcessor: only a failure that
+ // an error handler handled needs clearing and restoring here
+ final boolean errorHandlerHandled = ext.isErrorHandlerHandledSet() &&
ext.isErrorHandlerHandled();
+ final boolean failureHandled = errorHandlerHandled &&
ext.isFailureHandled();
+ if (errorHandlerHandled) {
+ ext.setErrorHandlerHandled(null);
+ ext.setFailureHandled(false);
+ }
+
// continue processing
- return processor.process(exchange, new FinallyAsyncCallback(exchange,
callback, exception));
+ return processor.process(exchange,
+ new FinallyAsyncCallback(exchange, callback, exception,
errorHandlerHandled, failureHandled));
}
@Override
@@ -114,16 +133,34 @@ public class FinallyProcessor extends
BaseDelegateProcessorSupport
private final Exchange exchange;
private final AsyncCallback callback;
private final Exception exception;
+ private final boolean errorHandlerHandled;
+ private final boolean failureHandled;
- FinallyAsyncCallback(Exchange exchange, AsyncCallback callback,
Exception exception) {
+ FinallyAsyncCallback(Exchange exchange, AsyncCallback callback,
Exception exception,
+ boolean errorHandlerHandled, boolean
failureHandled) {
this.exchange = exchange;
this.callback = callback;
this.exception = exception;
+ this.errorHandlerHandled = errorHandlerHandled;
+ this.failureHandled = failureHandled;
}
@Override
public void done(boolean doneSync) {
try {
+ if (errorHandlerHandled) {
+ // the doFinally block has run, so the route does not
continue after the doTry, as before
+ // (unless the block marked it itself, such as a route it
called that failed)
+ final ExchangeExtension ext =
exchange.getExchangeExtension();
+ if (!ext.isErrorHandlerHandledSet()) {
+ ext.setErrorHandlerHandled(true);
+ }
+ // one-way restore, unlike OnCompletionProcessor: set
again but never cleared, so a failure that the
+ // block handled itself stays marked (errorHandlerHandled
without failureHandled does not occur)
+ if (failureHandled) {
+ ext.setFailureHandled(true);
+ }
+ }
if (exception == null) {
// only clear the failure details when the doFinally did
not fail
// (the doTry restores the failure details of an earlier
failure)
diff --git
a/core/camel-core/src/test/java/org/apache/camel/processor/DoFinallyAfterHandledCalledRouteTest.java
b/core/camel-core/src/test/java/org/apache/camel/processor/DoFinallyAfterHandledCalledRouteTest.java
new file mode 100644
index 000000000000..5afe2e87bd71
--- /dev/null
+++
b/core/camel-core/src/test/java/org/apache/camel/processor/DoFinallyAfterHandledCalledRouteTest.java
@@ -0,0 +1,204 @@
+/*
+ * 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.processor;
+
+import java.time.Duration;
+
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
+
+/**
+ * The doTry calls a route whose error handler handles a failure (dead letter
channel or a handled onException). The
+ * doFinally block must run all its steps. The caller does not continue
routing after the doTry, as before
+ * (CAMEL-19441).
+ */
+class DoFinallyAfterHandledCalledRouteTest extends ContextTestSupport {
+
+ @Override
+ public boolean isUseRouteBuilder() {
+ return false;
+ }
+
+ private Exchange send(String errorHandler, boolean calleeFails) throws
Exception {
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ switch (errorHandler) {
+ case "dlc" ->
errorHandler(deadLetterChannel("mock:dead").redeliveryDelay(0));
+ case "dlcAsync" ->
errorHandler(deadLetterChannel("mock:dead").maximumRedeliveries(1)
+ .redeliveryDelay(10).asyncDelayedRedelivery());
+ default ->
onException(IllegalArgumentException.class).handled(true).to("mock:dead");
+ }
+
+ from("direct:start")
+ .process(e -> e.setProperty("startThread",
Thread.currentThread().getName()))
+ .doTry()
+ .to("direct:callee")
+ .to("mock:afterCallee")
+ .doCatch(Exception.class)
+ .to("mock:catch")
+ .doFinally()
+ .to("mock:f1")
+ .to("mock:f2")
+ .setHeader("f3", constant("yes"))
+ .process(e -> e.setProperty("finallyThread",
Thread.currentThread().getName()))
+ .to("mock:f4")
+ .end()
+ .to("mock:end");
+
+ if (calleeFails) {
+ from("direct:callee").setBody(constant("in callee"))
+ .throwException(new
IllegalArgumentException("Forced"));
+ } else {
+ from("direct:callee").setBody(constant("in callee"));
+ }
+ }
+ });
+ context.start();
+
+ return assertTimeoutPreemptively(Duration.ofSeconds(10),
+ () -> template.send("direct:start", e ->
e.getMessage().setBody("Hello")), "the send did not complete");
+ }
+
+ private void assertAllFinallySteps(Exchange out) {
+ assertEquals(1, getMockEndpoint("mock:f1").getReceivedCounter(),
"mock:f1");
+ assertEquals(1, getMockEndpoint("mock:f2").getReceivedCounter(),
"mock:f2");
+ assertEquals(1, getMockEndpoint("mock:f4").getReceivedCounter(),
"mock:f4");
+ assertEquals("yes", out.getMessage().getHeader("f3"), "the header set
by the doFinally");
+ }
+
+ private void assertHandledByCalledRoute(Exchange out) {
+ assertNull(out.getException(), "the called route handled the failure");
+ assertEquals(1, getMockEndpoint("mock:dead").getReceivedCounter(),
"mock:dead");
+ assertEquals(0,
getMockEndpoint("mock:afterCallee").getReceivedCounter(), "mock:afterCallee");
+ assertEquals(0, getMockEndpoint("mock:catch").getReceivedCounter(),
"there is no exception to catch");
+ // the caller does not continue routing after the doTry (CAMEL-19441)
+ assertEquals(0, getMockEndpoint("mock:end").getReceivedCounter(),
"mock:end");
+ }
+
+ @Test
+ void deadLetterChannel() throws Exception {
+ Exchange out = send("dlc", true);
+ assertHandledByCalledRoute(out);
+ assertAllFinallySteps(out);
+ }
+
+ @Test
+ void deadLetterChannelAsyncDelayedRedelivery() throws Exception {
+ Exchange out = send("dlcAsync", true);
+ assertHandledByCalledRoute(out);
+ assertAllFinallySteps(out);
+ // the redelivery, and so the doFinally block, ran on another thread
than the one that started the route
+ assertNotEquals(out.getProperty("startThread"),
out.getProperty("finallyThread"));
+ }
+
+ @Test
+ void handledOnException() throws Exception {
+ Exchange out = send("handled", true);
+ assertHandledByCalledRoute(out);
+ assertAllFinallySteps(out);
+ }
+
+ @Test
+ void controlCalledRouteDoesNotFail() throws Exception {
+ Exchange out = send("dlc", false);
+ assertNull(out.getException());
+ assertEquals(0, getMockEndpoint("mock:dead").getReceivedCounter(),
"mock:dead");
+ assertEquals(1,
getMockEndpoint("mock:afterCallee").getReceivedCounter(), "mock:afterCallee");
+ assertAllFinallySteps(out);
+ assertEquals(1, getMockEndpoint("mock:end").getReceivedCounter(),
"mock:end");
+ }
+
+ @Test
+ void routeCalledFromTheDoFinallyHandlesItsOwnFailure() throws Exception {
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+
errorHandler(deadLetterChannel("mock:dead").redeliveryDelay(0));
+
+ from("direct:start")
+ .doTry()
+ .to("direct:callee")
+ .doFinally()
+ .to("mock:f1")
+ .to("direct:callee2")
+ .to("mock:f2")
+ .end()
+ .to("mock:end");
+
+ from("direct:callee").throwException(new
IllegalArgumentException("Forced"));
+ from("direct:callee2").throwException(new
IllegalStateException("Forced in doFinally"));
+ }
+ });
+ context.start();
+
+ Exchange out = assertTimeoutPreemptively(Duration.ofSeconds(10),
+ () -> template.send("direct:start", e ->
e.getMessage().setBody("Hello")), "the send did not complete");
+ // the dead letter channel of the route called from the doFinally
handles that failure too, as when the doTry
+ // did not fail before
+ assertNull(out.getException(), "the called routes handled their
failures");
+ assertEquals(2, getMockEndpoint("mock:dead").getReceivedCounter(),
"mock:dead");
+ assertEquals(1, getMockEndpoint("mock:f1").getReceivedCounter(),
"mock:f1");
+ // a handled failure of a called route stops the caller, also in the
doFinally block
+ assertEquals(0, getMockEndpoint("mock:f2").getReceivedCounter(),
"mock:f2");
+ assertEquals(0, getMockEndpoint("mock:end").getReceivedCounter(),
"mock:end");
+ }
+
+ @Test
+ void routeCalledFromTheDoFinallyContinuesItsOwnFailure() throws Exception {
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+
errorHandler(deadLetterChannel("mock:dead").redeliveryDelay(0));
+ onException(NumberFormatException.class).continued(true);
+
+ from("direct:start")
+ .doTry()
+ .to("direct:callee")
+ .doFinally()
+ .to("mock:f1")
+ .to("direct:callee2")
+ .to("mock:f2")
+ .end()
+ .to("mock:end");
+
+ from("direct:callee").throwException(new
IllegalArgumentException("Forced"));
+ from("direct:callee2").throwException(new
NumberFormatException("Forced in doFinally"))
+ .to("mock:callee2End");
+ }
+ });
+ context.start();
+
+ Exchange out = assertTimeoutPreemptively(Duration.ofSeconds(10),
+ () -> template.send("direct:start", e ->
e.getMessage().setBody("Hello")), "the send did not complete");
+ assertNull(out.getException(), "the called routes handled or continued
their failures");
+ assertEquals(1, getMockEndpoint("mock:dead").getReceivedCounter(),
"mock:dead");
+ // the onException of the route called from the doFinally continues
its failure
+ assertEquals(1,
getMockEndpoint("mock:callee2End").getReceivedCounter(), "mock:callee2End");
+ assertEquals(1, getMockEndpoint("mock:f1").getReceivedCounter(),
"mock:f1");
+ assertEquals(1, getMockEndpoint("mock:f2").getReceivedCounter(),
"mock:f2");
+ // the caller does not continue routing after the doTry (CAMEL-19441)
+ assertEquals(0, getMockEndpoint("mock:end").getReceivedCounter(),
"mock:end");
+ }
+}
diff --git
a/core/camel-core/src/test/java/org/apache/camel/processor/routingslip/RoutingSlipInDoFinallyAfterRecipientListTest.java
b/core/camel-core/src/test/java/org/apache/camel/processor/routingslip/RoutingSlipInDoFinallyAfterRecipientListTest.java
new file mode 100644
index 000000000000..56e43c3340a4
--- /dev/null
+++
b/core/camel-core/src/test/java/org/apache/camel/processor/routingslip/RoutingSlipInDoFinallyAfterRecipientListTest.java
@@ -0,0 +1,127 @@
+/*
+ * 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.processor.routingslip;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
+
+/**
+ * A recipient list is the first step of a doFinally after a called route's
error handler handled a failure, and a
+ * routing slip (or dynamic router) runs later on the same exchange. The
recipient list must send to its recipients, and
+ * the routing slip must route to its own endpoints, not to the value the
recipient list evaluated.
+ */
+class RoutingSlipInDoFinallyAfterRecipientListTest extends ContextTestSupport {
+
+ @Override
+ public boolean isUseRouteBuilder() {
+ return false;
+ }
+
+ private void routes(boolean deadLetterChannel, boolean dynamicRouter,
boolean calleeFails) throws Exception {
+ AtomicInteger routerCalls = new AtomicInteger();
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ if (deadLetterChannel) {
+
errorHandler(deadLetterChannel("mock:dead").redeliveryDelay(0));
+ } else {
+ onException(Exception.class).handled(true).to("mock:dead");
+ }
+
+ var outerFinally =
from("direct:start").doTry().to("direct:middle").doFinally();
+ if (dynamicRouter) {
+ outerFinally.dynamicRouter(method(new Object() {
+ public String route() {
+ return routerCalls.getAndIncrement() == 0 ?
"mock:slip" : null;
+ }
+ }, "route"));
+ } else {
+ outerFinally.routingSlip(constant("mock:slip"));
+ }
+ outerFinally.end();
+
+
from("direct:middle").doTry().to("direct:callee").doFinally().recipientList(constant("mock:a,mock:b"))
+ .end();
+
+ if (calleeFails) {
+ from("direct:callee").throwException(new
IOException("Forced"));
+ } else {
+ from("direct:callee").to("mock:callee");
+ }
+ }
+ });
+ context.start();
+ }
+
+ private Exchange send() {
+ return assertTimeoutPreemptively(Duration.ofSeconds(10),
+ () -> template.send("direct:start", e ->
e.getMessage().setBody("Hello")), "the send did not complete");
+ }
+
+ private void assertEachDestinationOnce(Exchange out) {
+ assertNull(out.getException());
+ assertEquals(1, getMockEndpoint("mock:a").getReceivedCounter(),
"mock:a (recipient list)");
+ assertEquals(1, getMockEndpoint("mock:b").getReceivedCounter(),
"mock:b (recipient list)");
+ assertEquals(1, getMockEndpoint("mock:slip").getReceivedCounter(),
"mock:slip (the routing slip's own endpoint)");
+
assertFalse(out.getProperties().containsKey(Exchange.EVALUATE_EXPRESSION_RESULT),
+ "the recipient list removed the value it evaluated");
+ }
+
+ @Test
+ void routingSlipWithDeadLetterChannel() throws Exception {
+ routes(true, false, true);
+ Exchange out = send();
+ assertEquals(1, getMockEndpoint("mock:dead").getReceivedCounter(),
"mock:dead");
+ assertEachDestinationOnce(out);
+ }
+
+ @Test
+ void routingSlipWithHandledOnException() throws Exception {
+ routes(false, false, true);
+ Exchange out = send();
+ assertEquals(1, getMockEndpoint("mock:dead").getReceivedCounter(),
"mock:dead");
+ assertEachDestinationOnce(out);
+ }
+
+ @Test
+ void dynamicRouterWithDeadLetterChannel() throws Exception {
+ routes(true, true, true);
+ Exchange out = send();
+ assertEquals(1, getMockEndpoint("mock:dead").getReceivedCounter(),
"mock:dead");
+ assertEachDestinationOnce(out);
+ }
+
+ @Test
+ void controlCalledRouteDoesNotFail() throws Exception {
+ routes(true, false, false);
+ Exchange out = send();
+ assertEquals(0, getMockEndpoint("mock:dead").getReceivedCounter(),
"mock:dead");
+ assertEquals(1, getMockEndpoint("mock:callee").getReceivedCounter(),
"mock:callee");
+ assertEachDestinationOnce(out);
+ }
+}
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 3be645a14d24..33e287c98cab 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
@@ -142,6 +142,15 @@ or payload from the body after a timed out `pollEnrich`
must keep it before the
with the same message id, headers and attachments, so after a timeout it is no
longer of the message type of the
consumer (such as `GenericFileMessage` or `JmsMessage`).
+=== camel-core - doFinally runs all its steps after a called route handled a
failure
+
+When a `doTry` calls a route (such as with `direct`) whose error handler
handles a failure (a dead letter channel or a
+handled `onException`), the `doFinally` block now runs all its steps. Before,
in all Camel 4.x releases, it ran only its
+first step and skipped the rest, so a second or later step of the block (such
as a `to`, a recipient list or a routing
+slip) did not send its message. A route called from such a `doFinally` that
fails is now handled (or continued) by its
+own error handler, as anywhere else; before, its error handler left the
failure alone and the exchange failed with that
+exception. As before, the route does not continue after the `doTry` once a
called route handled the failure.
+
=== camel-core - ThrottlingExceptionRoutePolicy counts the failures within the
failure window
The circuit of `ThrottlingExceptionRoutePolicy` now opens when
`failureThreshold` failures occurred within the last