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 9f4e799f75bc CAMEL-26434: camel-core - a route called from a doCatch 
handles its own failure after the doTry called a route that failed unhandled 
(#28786)
9f4e799f75bc is described below

commit 9f4e799f75bcfd763451c818af12c9b58da19949
Author: allthingssecurity <[email protected]>
AuthorDate: Sun Nov 1 15:18:31 2026 +0530

    CAMEL-26434: camel-core - a route called from a doCatch handles its own 
failure after the doTry called a route that failed unhandled (#28786)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../org/apache/camel/processor/CatchProcessor.java |  31 ++
 ...DoCatchCalledRouteAfterUnhandledCalleeTest.java | 483 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  28 ++
 3 files changed, 542 insertions(+)

diff --git 
a/core/camel-core-processor/src/main/java/org/apache/camel/processor/CatchProcessor.java
 
b/core/camel-core-processor/src/main/java/org/apache/camel/processor/CatchProcessor.java
index 69a0e536b31f..c962e2e7a7dd 100644
--- 
a/core/camel-core-processor/src/main/java/org/apache/camel/processor/CatchProcessor.java
+++ 
b/core/camel-core-processor/src/main/java/org/apache/camel/processor/CatchProcessor.java
@@ -25,6 +25,7 @@ import java.util.concurrent.atomic.AtomicLong;
 import org.apache.camel.AsyncCallback;
 import org.apache.camel.CamelContext;
 import org.apache.camel.Exchange;
+import org.apache.camel.ExchangeExtension;
 import org.apache.camel.ExchangePropertyKey;
 import org.apache.camel.Predicate;
 import org.apache.camel.Processor;
@@ -177,6 +178,24 @@ public class CatchProcessor extends 
BaseDelegateProcessorSupport
         // and we should not be regarded as exhausted as we are in a try .. 
catch block
         exchange.getExchangeExtension().setRedeliveryExhausted(false);
 
+        // a route called from the doTry (such as with direct) that failed 
unhandled has marked the exchange as failure
+        // handled, and as not handled by the error handler, before the 
exception came back here. A failure in a route
+        // that the doCatch 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, and keeps a 
"not handled" that is already set), as when
+        // the doTry failed in this route. So both marks are cleared when the 
doCatch takes over the exception
+        final ExchangeExtension ext = exchange.getExchangeExtension();
+        final boolean failureHandled = ext.isFailureHandled();
+        // a primitive "set and true" snapshot, as the doFinally does: a "not 
handled" of the caught failure is not set
+        // again, as the doCatch handled that failure (as when the doTry 
failed in this route). No known case reaches
+        // the restore of a true mark today (a failure that an error handler 
handled has no exception left for a doCatch
+        // to catch); it is kept for symmetry with FinallyProcessor
+        final boolean errorHandlerHandled = ext.isErrorHandlerHandledSet() && 
ext.isErrorHandlerHandled();
+        final boolean restore = failureHandled || 
ext.isErrorHandlerHandledSet();
+        if (restore) {
+            ext.setFailureHandled(false);
+            ext.setErrorHandlerHandled(null);
+        }
+
         if (LOG.isDebugEnabled()) {
             LOG.debug("The exception is handled for the exception: {} caused 
by: {}",
                     e.getClass().getName(), e.getMessage());
@@ -193,6 +212,18 @@ public class CatchProcessor extends 
BaseDelegateProcessorSupport
                 // always clear redelivery exhausted in a catch clause
                 exchange.getExchangeExtension().setRedeliveryExhausted(false);
 
+                if (restore) {
+                    // set the marks of the earlier failure again, unless a 
route called from the block set the handled
+                    // mark itself (such as its dead letter channel that 
handled its failure); failure handled is set
+                    // again but never cleared, so a failure that the block 
handled itself stays marked
+                    if (errorHandlerHandled && 
!ext.isErrorHandlerHandledSet()) {
+                        ext.setErrorHandlerHandled(true);
+                    }
+                    if (failureHandled) {
+                        ext.setFailureHandled(true);
+                    }
+                }
+
                 if (rollbackOnly || rollbackOnlyLast || stop) {
                     exchange.setRouteStop(stop);
                     exchange.setRollbackOnly(rollbackOnly);
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/processor/DoCatchCalledRouteAfterUnhandledCalleeTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/processor/DoCatchCalledRouteAfterUnhandledCalleeTest.java
new file mode 100644
index 000000000000..1312c6e9d6b1
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/processor/DoCatchCalledRouteAfterUnhandledCalleeTest.java
@@ -0,0 +1,483 @@
+/*
+ * 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.io.IOException;
+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.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The doTry calls a route that fails unhandled (default error handler), and 
the doCatch then calls a route with an
+ * error handler of its own. That route must handle (or continue) its own 
failure, as when the doTry failed in the same
+ * route. Most cases that go through a failing callee have a control where the 
doTry fails in the same route, with the
+ * same expected outcome.
+ */
+class DoCatchCalledRouteAfterUnhandledCalleeTest extends ContextTestSupport {
+
+    private final AtomicInteger attempts = new AtomicInteger();
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                // the route called from the doCatch, for each case: a dead 
letter channel, a handled onException, a
+                // continued onException, a dead letter channel that fails 
asynchronously, and a default error handler
+                // that redelivers
+                for (String target : new String[] {
+                        "dlcRoute", "onExceptionRoute", "continuedRoute", 
"dlcRouteAsync", "redeliveryRoute" }) {
+                    from("direct:viaCallee-" + target)
+                            .doTry()
+                                .to("direct:failing")
+                            .doCatch(IllegalArgumentException.class)
+                                .to("mock:catch")
+                                .to("direct:" + target)
+                                .to("mock:catchEnd")
+                            .end()
+                            .to("mock:after");
+                    from("direct:inRoute-" + target)
+                            .doTry()
+                                .throwException(new 
IllegalArgumentException("in route"))
+                            .doCatch(IllegalArgumentException.class)
+                                .to("mock:catch")
+                                .to("direct:" + target)
+                                .to("mock:catchEnd")
+                            .end()
+                            .to("mock:after");
+                }
+
+                // a doTry and doCatch inside a handled onException: the 
onException still handles the failure
+                from("direct:onExceptionViaCallee")
+                        .onException(IOException.class).handled(true)
+                            .doTry()
+                                .to("direct:failing")
+                            .doCatch(IllegalArgumentException.class)
+                                .to("mock:catch")
+                            .end()
+                            .to("mock:onExceptionEnd")
+                        .end()
+                        .throwException(new IOException("in route"));
+                from("direct:onExceptionInRoute")
+                        .onException(IOException.class).handled(true)
+                            .doTry()
+                                .throwException(new 
IllegalArgumentException("in onException"))
+                            .doCatch(IllegalArgumentException.class)
+                                .to("mock:catch")
+                            .end()
+                            .to("mock:onExceptionEnd")
+                        .end()
+                        .throwException(new IOException("in route"));
+
+                // a second doTry in the same route, after a doCatch caught 
the failure of a called route
+                from("direct:secondDoTryViaCallee")
+                        .doTry()
+                            .to("direct:failing")
+                        .doCatch(IllegalArgumentException.class)
+                            .to("mock:catch")
+                        .end()
+                        .doTry()
+                            .throwException(new 
IllegalArgumentException("second doTry"))
+                        .doCatch(IllegalArgumentException.class)
+                            .to("mock:secondCatch")
+                            .to("direct:dlcRoute")
+                        .end()
+                        .to("mock:after");
+                from("direct:secondDoTryInRoute")
+                        .doTry()
+                            .throwException(new IllegalArgumentException("in 
route"))
+                        .doCatch(IllegalArgumentException.class)
+                            .to("mock:catch")
+                        .end()
+                        .doTry()
+                            .throwException(new 
IllegalArgumentException("second doTry"))
+                        .doCatch(IllegalArgumentException.class)
+                            .to("mock:secondCatch")
+                            .to("direct:dlcRoute")
+                        .end()
+                        .to("mock:after");
+
+                // a split after a doCatch that caught the failure of a called 
route: the split copies take over the
+                // handled mark of the exchange
+                from("direct:splitViaCallee")
+                        .doTry()
+                            .to("direct:failing")
+                        .doCatch(IllegalArgumentException.class)
+                            .to("mock:catch")
+                        .end()
+                        
.split(body().tokenize(",")).to("direct:dlcRoute").end()
+                        .to("mock:after");
+                from("direct:splitInRoute")
+                        .doTry()
+                            .throwException(new IllegalArgumentException("in 
route"))
+                        .doCatch(IllegalArgumentException.class)
+                            .to("mock:catch")
+                        .end()
+                        
.split(body().tokenize(",")).to("direct:dlcRoute").end()
+                        .to("mock:after");
+
+                // the route called from the doCatch handles its failure, and 
the doFinally has two steps
+                from("direct:finallyViaCallee")
+                        .doTry()
+                            .to("direct:failing")
+                        .doCatch(IllegalArgumentException.class)
+                            .to("mock:catch")
+                            .to("direct:dlcRoute")
+                            .to("mock:catchEnd")
+                        .doFinally()
+                            .to("mock:finally1")
+                            .to("mock:finally2")
+                        .end()
+                        .to("mock:after");
+                from("direct:finallyInRoute")
+                        .doTry()
+                            .throwException(new IllegalArgumentException("in 
route"))
+                        .doCatch(IllegalArgumentException.class)
+                            .to("mock:catch")
+                            .to("direct:dlcRoute")
+                            .to("mock:catchEnd")
+                        .doFinally()
+                            .to("mock:finally1")
+                            .to("mock:finally2")
+                        .end()
+                        .to("mock:after");
+
+                // the doTry calls a route that fails asynchronously
+                from("direct:viaAsyncCallee")
+                        .process(e -> e.setProperty("startThread", 
Thread.currentThread().getName()))
+                        .doTry()
+                            .to("direct:failingAsync")
+                        .doCatch(IllegalArgumentException.class)
+                            .process(e -> e.setProperty("catchThread", 
Thread.currentThread().getName()))
+                            .to("mock:catch")
+                            .to("direct:dlcRoute")
+                            .to("mock:catchEnd")
+                        .end()
+                        .to("mock:after");
+
+                // the doCatch block does not call a failing route: the caller 
continues after the doTry, as before
+                from("direct:catchOnly")
+                        .doTry()
+                            .to("direct:failing")
+                        .doCatch(IllegalArgumentException.class)
+                            .to("mock:catch")
+                        .end()
+                        .to("mock:after");
+
+                // the doCatch block fails itself: the exchange fails with 
that exception, as before
+                from("direct:catchFails")
+                        .doTry()
+                            .to("direct:failing")
+                        .doCatch(IllegalArgumentException.class)
+                            .to("mock:catch")
+                            .throwException(new IllegalStateException("in 
catch"))
+                        .end()
+                        .to("mock:after");
+
+                // nested: the inner doCatch handles the failure with the 
route it calls
+                from("direct:nestedInnerHandles")
+                        .doTry()
+                            .doTry()
+                                .to("direct:failing")
+                            .doCatch(IllegalArgumentException.class)
+                                .to("mock:catch")
+                                .to("direct:dlcRoute")
+                            .end()
+                        .endDoTry()
+                        .doCatch(Exception.class)
+                            .to("mock:outerCatch")
+                        .end()
+                        .to("mock:after");
+
+                // nested: the inner doCatch fails, and the outer doCatch 
calls the route with the dead letter channel
+                from("direct:nestedOuterHandles")
+                        .doTry()
+                            .doTry()
+                                .to("direct:failing")
+                            .doCatch(IllegalArgumentException.class)
+                                .to("mock:catch")
+                                .throwException(new IllegalStateException("in 
inner catch"))
+                            .end()
+                        .endDoTry()
+                        .doCatch(IllegalStateException.class)
+                            .to("mock:outerCatch")
+                            .to("direct:dlcRoute")
+                        .end()
+                        .to("mock:after");
+
+                // the callees fail unhandled (default error handler)
+                from("direct:failing").throwException(new 
IllegalArgumentException("callee"));
+                from("direct:failingAsync")
+                        .delay(10).asyncDelayed()
+                        .throwException(new IllegalArgumentException("async 
callee"));
+
+                // the routes called from the doCatch, with error handlers of 
their own
+                
from("direct:dlcRoute").errorHandler(deadLetterChannel("mock:dead").redeliveryDelay(0))
+                        .throwException(new IOException("in catch"));
+                
from("direct:dlcRouteAsync").errorHandler(deadLetterChannel("mock:dead").redeliveryDelay(0))
+                        .delay(10).asyncDelayed()
+                        .process(e -> e.setProperty("dlcRouteThread", 
Thread.currentThread().getName()))
+                        .throwException(new IOException("in catch"));
+                from("direct:onExceptionRoute")
+                        
.onException(IOException.class).handled(true).to("mock:handled").end()
+                        .throwException(new IOException("in catch"));
+                from("direct:redeliveryRoute")
+                        
.errorHandler(defaultErrorHandler().maximumRedeliveries(2).redeliveryDelay(0))
+                        // the error handler redelivers the step that failed, 
so count in that step
+                        .process(e -> {
+                            attempts.incrementAndGet();
+                            throw new IOException("in catch");
+                        });
+                from("direct:continuedRoute")
+                        .onException(IOException.class).continued(true).end()
+                        .throwException(new IOException("in catch"))
+                        .to("mock:continued");
+            }
+        };
+    }
+
+    private Exchange send(String uri) {
+        return template.send(uri, e -> e.getMessage().setBody("Hello"));
+    }
+
+    private int count(String uri) {
+        return getMockEndpoint(uri).getReceivedCounter();
+    }
+
+    /**
+     * A route called from the doCatch that handles its failure with its error 
handler stops the caller, as a handled
+     * failure of a called route does anywhere else: the rest of the doCatch 
block and the route after the doTry do not
+     * run.
+     */
+    private void assertHandledInCatch(Exchange out, String handledBy) {
+        assertNull(out.getException(), "the route called from the doCatch 
handled its failure");
+        assertEquals(1, count("mock:catch"), "mock:catch");
+        assertEquals(1, count(handledBy), handledBy);
+        assertEquals(0, count("mock:catchEnd"), "mock:catchEnd");
+        assertEquals(0, count("mock:after"), "mock:after");
+    }
+
+    @Test
+    void deadLetterChannelOfRouteCalledFromDoCatchAfterUnhandledCallee() {
+        assertHandledInCatch(send("direct:viaCallee-dlcRoute"), "mock:dead");
+    }
+
+    @Test
+    void controlDeadLetterChannelOfRouteCalledFromDoCatchAfterInRouteFailure() 
{
+        assertHandledInCatch(send("direct:inRoute-dlcRoute"), "mock:dead");
+    }
+
+    @Test
+    void onExceptionOfRouteCalledFromDoCatchAfterUnhandledCallee() {
+        assertHandledInCatch(send("direct:viaCallee-onExceptionRoute"), 
"mock:handled");
+    }
+
+    @Test
+    void controlOnExceptionOfRouteCalledFromDoCatchAfterInRouteFailure() {
+        assertHandledInCatch(send("direct:inRoute-onExceptionRoute"), 
"mock:handled");
+    }
+
+    @Test
+    void 
asynchronousDeadLetterChannelOfRouteCalledFromDoCatchAfterUnhandledCallee() {
+        String startThread = Thread.currentThread().getName();
+        Exchange out = send("direct:viaCallee-dlcRouteAsync");
+        assertHandledInCatch(out, "mock:dead");
+        // the route called from the doCatch continued on the delayer's 
thread, not on the thread that sent
+        assertNotNull(out.getProperty("dlcRouteThread"), "the route called 
from the doCatch ran");
+        assertNotEquals(startThread, out.getProperty("dlcRouteThread"));
+    }
+
+    @Test
+    void 
controlAsynchronousDeadLetterChannelOfRouteCalledFromDoCatchAfterInRouteFailure()
 {
+        assertHandledInCatch(send("direct:inRoute-dlcRouteAsync"), 
"mock:dead");
+    }
+
+    /**
+     * The route called from the doCatch handles its failure, and the 
doFinally then runs both its steps (CAMEL-26333);
+     * the caller does not continue after the doTry.
+     */
+    private void assertHandledInCatchThenFinally(Exchange out) {
+        assertHandledInCatch(out, "mock:dead");
+        assertEquals(1, count("mock:finally1"), "mock:finally1");
+        assertEquals(1, count("mock:finally2"), "mock:finally2");
+    }
+
+    @Test
+    void doFinallyWithTwoStepsAfterDoCatchCalledRouteHandledItsFailure() {
+        assertHandledInCatchThenFinally(send("direct:finallyViaCallee"));
+    }
+
+    @Test
+    void 
controlDoFinallyWithTwoStepsAfterDoCatchCalledRouteHandledInRouteFailure() {
+        assertHandledInCatchThenFinally(send("direct:finallyInRoute"));
+    }
+
+    private void assertContinuedInCatch(Exchange out) {
+        assertNull(out.getException(), "the route called from the doCatch 
continued its failure");
+        assertEquals(1, count("mock:catch"), "mock:catch");
+        assertEquals(1, count("mock:continued"), "mock:continued");
+        assertEquals(1, count("mock:catchEnd"), "mock:catchEnd");
+        assertEquals(1, count("mock:after"), "mock:after");
+    }
+
+    @Test
+    void continuedOnExceptionOfRouteCalledFromDoCatchAfterUnhandledCallee() {
+        assertContinuedInCatch(send("direct:viaCallee-continuedRoute"));
+    }
+
+    @Test
+    void 
controlContinuedOnExceptionOfRouteCalledFromDoCatchAfterInRouteFailure() {
+        assertContinuedInCatch(send("direct:inRoute-continuedRoute"));
+    }
+
+    @Test
+    void asynchronousCallee() {
+        Exchange out = send("direct:viaAsyncCallee");
+        assertHandledInCatch(out, "mock:dead");
+        // the callee failed on the delayer's thread, so the doCatch ran on 
another thread than the route started on
+        assertNotEquals(out.getProperty("startThread"), 
out.getProperty("catchThread"));
+    }
+
+    @Test
+    void callerContinuesAfterTheDoTryWhenTheDoCatchDoesNotFail() {
+        Exchange out = send("direct:catchOnly");
+        assertNull(out.getException());
+        assertEquals(1, count("mock:catch"), "mock:catch");
+        assertEquals(1, count("mock:after"), "mock:after");
+        // failure handled is set again after the doCatch block, as before; 
the "not handled" of the caught failure is
+        // not, as when the doTry failed in this route
+        assertTrue(out.getExchangeExtension().isFailureHandled(), "failure 
handled");
+        assertNull(out.getExchangeExtension().getErrorHandlerHandled(), 
"handled by the error handler");
+    }
+
+    @Test
+    void doCatchThatFailsFailsTheExchangeAsBefore() {
+        Exchange out = send("direct:catchFails");
+        assertInstanceOf(IllegalStateException.class, out.getException());
+        assertEquals("in catch", out.getException().getMessage());
+        assertEquals(1, count("mock:catch"), "mock:catch");
+        assertEquals(0, count("mock:after"), "mock:after");
+        assertTrue(out.getExchangeExtension().isFailureHandled(), "failure 
handled");
+        assertNull(out.getExchangeExtension().getErrorHandlerHandled(), 
"handled by the error handler");
+    }
+
+    /**
+     * The error handler of the route called from the doCatch runs in full 
also when it does not handle the failure: it
+     * redelivers, and the exchange then fails with that route's exception.
+     */
+    private void assertRedeliveredInCatch(Exchange out) {
+        assertInstanceOf(IOException.class, out.getException());
+        assertEquals("in catch", out.getException().getMessage());
+        assertEquals(3, attempts.get(), "the delivery and 2 redeliveries");
+        assertEquals(1, count("mock:catch"), "mock:catch");
+        assertEquals(0, count("mock:catchEnd"), "mock:catchEnd");
+        assertEquals(0, count("mock:after"), "mock:after");
+    }
+
+    @Test
+    void redeliveryOfRouteCalledFromDoCatchAfterUnhandledCallee() {
+        assertRedeliveredInCatch(send("direct:viaCallee-redeliveryRoute"));
+    }
+
+    @Test
+    void controlRedeliveryOfRouteCalledFromDoCatchAfterInRouteFailure() {
+        assertRedeliveredInCatch(send("direct:inRoute-redeliveryRoute"));
+    }
+
+    private void assertHandledByOnException(Exchange out) {
+        assertNull(out.getException(), "the onException handled the failure");
+        assertEquals(1, count("mock:catch"), "mock:catch");
+        assertEquals(1, count("mock:onExceptionEnd"), "mock:onExceptionEnd");
+    }
+
+    @Test
+    void doTryInHandledOnExceptionCallsRouteThatFails() {
+        // the caught failure of the callee must not come back when the 
onException completes
+        assertHandledByOnException(send("direct:onExceptionViaCallee"));
+    }
+
+    @Test
+    void controlDoTryInHandledOnExceptionFailsInRoute() {
+        assertHandledByOnException(send("direct:onExceptionInRoute"));
+    }
+
+    private void assertSecondDoTry(Exchange out) {
+        assertNull(out.getException(), "the route called from the second 
doCatch handled its failure");
+        assertEquals(1, count("mock:catch"), "mock:catch");
+        assertEquals(1, count("mock:secondCatch"), "mock:secondCatch");
+        assertEquals(1, count("mock:dead"), "mock:dead");
+        assertEquals(0, count("mock:after"), "mock:after");
+    }
+
+    @Test
+    void secondDoTryAfterCaughtCalleeFailure() {
+        // the failure handled mark that is set again after the first doCatch 
is cleared again by the second doCatch
+        assertSecondDoTry(send("direct:secondDoTryViaCallee"));
+    }
+
+    @Test
+    void controlSecondDoTryAfterCaughtInRouteFailure() {
+        assertSecondDoTry(send("direct:secondDoTryInRoute"));
+    }
+
+    private void assertSplitAfterDoCatch(Exchange out) {
+        assertNull(out.getException(), "the route called from the split 
handled its failure");
+        assertEquals(1, count("mock:catch"), "mock:catch");
+        assertEquals(1, count("mock:dead"), "mock:dead");
+        assertEquals(1, count("mock:after"), "mock:after");
+    }
+
+    @Test
+    void splitAfterCaughtCalleeFailure() {
+        assertSplitAfterDoCatch(send("direct:splitViaCallee"));
+    }
+
+    @Test
+    void controlSplitAfterCaughtInRouteFailure() {
+        assertSplitAfterDoCatch(send("direct:splitInRoute"));
+    }
+
+    @Test
+    void nestedDoTryInnerDoCatchCallsTheRouteThatHandlesItsFailure() {
+        Exchange out = send("direct:nestedInnerHandles");
+        assertNull(out.getException(), "the route called from the inner 
doCatch handled its failure");
+        assertEquals(1, count("mock:catch"), "mock:catch");
+        assertEquals(1, count("mock:dead"), "mock:dead");
+        assertEquals(0, count("mock:outerCatch"), "mock:outerCatch");
+        assertEquals(0, count("mock:after"), "mock:after");
+    }
+
+    @Test
+    void nestedDoTryOuterDoCatchCallsTheRouteThatHandlesItsFailure() {
+        Exchange out = send("direct:nestedOuterHandles");
+        assertNull(out.getException(), "the route called from the outer 
doCatch handled its failure");
+        assertEquals(1, count("mock:catch"), "mock:catch");
+        assertEquals(1, count("mock:outerCatch"), "mock:outerCatch");
+        assertEquals(1, count("mock:dead"), "mock:dead");
+        assertEquals(0, count("mock:after"), "mock:after");
+    }
+}
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 e62114bd364f..4b2f0584d565 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
@@ -233,6 +233,34 @@ graphql, kubernetes-custom-resources and typesafe-ai) now 
have type converters t
 or `InputStream`, so such a body sent to http or a file failed. A `JsonObject` 
or `JsonArray` that holds a value that
 is not JSON, such as a POJO, converts as before.
 
+=== camel-core - a route called from a doCatch handles its failure after the 
doTry called a route that failed
+
+When the `doTry` block calls a route (such as with `direct`) that fails 
without handling the failure (no `onException`
+handles it, and no dead letter channel), the error handler of a route called 
from the `doCatch` block now runs as it
+does anywhere else, as when the `doTry` failed in the same route: it 
redelivers, runs its `onException` (also one that
+does not handle the failure) and its dead letter channel, and logs the 
exhausted failure. Before, it gave the failure
+straight back, and the exchange failed with that route's exception after one 
attempt. When that route handles its
+failure, the rest of the `doCatch` block and the steps after the `doTry` do 
not run, as for any failure that a called
+route handles; when it continues its failure, they run. When the `doCatch` 
block does not call a failing route, the
+route continues after the `doTry` as before.
+
+The exchange no longer keeps the marks of the caught failure, which also 
changes the following:
+
+* the same applies to a later `doTry` in the same route, and to a `doTry` 
inside an `onCompletion`;
+* the same applies to a route called later from a `split`, `multicast`, 
`recipientList`, `enrich` or `wireTap` (their
+  copies of the exchange carried the mark that the caught failure was not 
handled);
+* a `doTry` and `doCatch` inside an `onException(...).handled(true)`, whose 
`doTry` calls such a route, no longer makes
+  the exchange fail with the exception the `doCatch` caught;
+* with `bridgeErrorHandler=true` on the consumer, a failure of such an 
exchange that is not handled (a `doCatch` that
+  fails, or a later failure in the route) now goes to the route's error 
handler, as an exchange with the body
+  `Error processing exchange`, instead of only being logged;
+* as when the `doTry` failed in the same route, the error registry (`camel get 
error`) now also lists the failure that
+  such a `doCatch` caught, and the JMX `failuresHandled` counters of the steps 
inside the `doCatch` block no longer
+  count it.
+
+To keep the exchange failing after one attempt as before, give the route 
called from the `doCatch` no error handler
+(`noErrorHandler()`); this applies to every caller of that route.
+
 === camel-core - Simple: more predicates inside ${ } are read as predicates
 
 A predicate written inside the braces (CAMEL-24921, 4.23) is now also read as 
one when it uses the operators

Reply via email to