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 6719b6f26f0f CAMEL-26332: camel-core - doCatch is skipped after a
route called from the doTry continued a failure (#28755)
6719b6f26f0f is described below
commit 6719b6f26f0f6be2e0a83c413229e8dba882f762
Author: allthingssecurity <[email protected]>
AuthorDate: Sat Oct 31 15:32:30 2026 +0530
CAMEL-26332: camel-core - doCatch is skipped after a route called from the
doTry continued a failure (#28755)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../errorhandler/RedeliveryErrorHandler.java | 16 +-
...atchAfterContinuedFailureInCalledRouteTest.java | 218 +++++++++++++++++++++
2 files changed, 230 insertions(+), 4 deletions(-)
diff --git
a/core/camel-core-processor/src/main/java/org/apache/camel/processor/errorhandler/RedeliveryErrorHandler.java
b/core/camel-core-processor/src/main/java/org/apache/camel/processor/errorhandler/RedeliveryErrorHandler.java
index 6411c7eb6212..b31ef2bfb3f5 100644
---
a/core/camel-core-processor/src/main/java/org/apache/camel/processor/errorhandler/RedeliveryErrorHandler.java
+++
b/core/camel-core-processor/src/main/java/org/apache/camel/processor/errorhandler/RedeliveryErrorHandler.java
@@ -78,6 +78,14 @@ public abstract class RedeliveryErrorHandler extends
ErrorHandlerSupport
private static final Logger LOG =
LoggerFactory.getLogger(RedeliveryErrorHandler.class);
+ // the exception of a failure that was continued (onException continued),
which is not a previous exception of a
+ // later failure. A key of its own: Exchange.EXCEPTION_HANDLED is the
doCatch's marker that a previous doCatch of
+ // the doTry handled the exception, and a direct call shares the exchange
with the caller's doTry.
+ // Internal on purpose: a plain property, not an ExchangePropertyKey or a
constant on Exchange, so it is not public
+ // API. As any plain property, it shows in Exchange.getProperties() and in
tracer or debugger listings, and
+ // removeProperties("*") removes it.
+ private static final String CONTINUED_EXCEPTION =
"CamelErrorHandlerContinuedException";
+
// factory
protected PooledExchangeTaskFactory taskFactory;
@@ -864,7 +872,7 @@ public abstract class RedeliveryErrorHandler extends
ErrorHandlerSupport
exchange.removeProperty(ExchangePropertyKey.UNIT_OF_WORK_EXHAUSTED);
// continue but keep the caused exception stored as a property
// (by default we do not log the failure as we continue routing)
- exchange.setProperty(ExchangePropertyKey.EXCEPTION_HANDLED, e);
+ exchange.setProperty(CONTINUED_EXCEPTION, e);
exchange.getExchangeExtension().setErrorHandlerHandled(true);
LOG.trace("This exchange is continued: {}", exchange);
@@ -898,7 +906,7 @@ public abstract class RedeliveryErrorHandler extends
ErrorHandlerSupport
// e is never null
Throwable previous =
exchange.getProperty(ExchangePropertyKey.EXCEPTION_CAUGHT, Throwable.class);
- if (previous != null && previous ==
exchange.getProperty(ExchangePropertyKey.EXCEPTION_HANDLED)) {
+ if (previous != null && previous ==
exchange.getProperty(CONTINUED_EXCEPTION)) {
// the previous exception was handled (continued) so this is a
new failure
previous = null;
}
@@ -1422,7 +1430,7 @@ public abstract class RedeliveryErrorHandler extends
ErrorHandlerSupport
// and mark it as handled (continued), so it is not regarded as a
previous exception of a later failure
Exception handled =
exchange.getProperty(ExchangePropertyKey.EXCEPTION_CAUGHT, Exception.class);
if (handled != null) {
- exchange.setProperty(ExchangePropertyKey.EXCEPTION_HANDLED,
handled);
+ exchange.setProperty(CONTINUED_EXCEPTION, handled);
}
// create log message
@@ -1475,7 +1483,7 @@ public abstract class RedeliveryErrorHandler extends
ErrorHandlerSupport
// e is never null
Throwable previous =
exchange.getProperty(ExchangePropertyKey.EXCEPTION_CAUGHT, Throwable.class);
- if (previous != null && previous ==
exchange.getProperty(ExchangePropertyKey.EXCEPTION_HANDLED)) {
+ if (previous != null && previous ==
exchange.getProperty(CONTINUED_EXCEPTION)) {
// the previous exception was handled (continued) so this is a
new failure
previous = null;
}
diff --git
a/core/camel-core/src/test/java/org/apache/camel/processor/onexception/DoCatchAfterContinuedFailureInCalledRouteTest.java
b/core/camel-core/src/test/java/org/apache/camel/processor/onexception/DoCatchAfterContinuedFailureInCalledRouteTest.java
new file mode 100644
index 000000000000..36bb649eef63
--- /dev/null
+++
b/core/camel-core/src/test/java/org/apache/camel/processor/onexception/DoCatchAfterContinuedFailureInCalledRouteTest.java
@@ -0,0 +1,218 @@
+/*
+ * 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.onexception;
+
+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.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
+
+/**
+ * A route called from a doTry body continues a failure ({@code
onException(...).continued(true)}). A later exception in
+ * the same doTry body must still be caught by the doCatch.
+ */
+class DoCatchAfterContinuedFailureInCalledRouteTest extends ContextTestSupport
{
+
+ @Override
+ public boolean isUseRouteBuilder() {
+ return false;
+ }
+
+ private Exchange send(String call, boolean redelivery) throws Exception {
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ if (redelivery) {
+
onException(NumberFormatException.class).maximumRedeliveries(1).redeliveryDelay(0).continued(true);
+ } else {
+ onException(NumberFormatException.class).continued(true);
+ }
+
+ var body = from("direct:start").doTry();
+ switch (call) {
+ case "to" -> body.to("direct:callee");
+ case "routingSlip" ->
body.routingSlip(constant("direct:callee"));
+ case "recipientList" ->
body.recipientList(constant("direct:callee"));
+ case "dynamicRouter" ->
body.dynamicRouter(method(DoCatchAfterContinuedFailureInCalledRouteTest.class,
+ "callCalleeOnce"));
+ case "calleeOk" -> body.to("direct:calleeOk");
+ default -> {
+ // nothing before the exception
+ }
+ }
+ body.to("mock:next")
+ .throwException(new IllegalStateException("after the
call"))
+ .doCatch(IllegalArgumentException.class)
+ .to("mock:otherCatch")
+ .doCatch(IllegalStateException.class)
+ .to("mock:catch")
+ .doCatch(Exception.class)
+ .to("mock:lastCatch")
+ .end()
+ .to("mock:result");
+
+ from("direct:callee").throwException(new
NumberFormatException("in callee")).to("mock:calleeEnd");
+ from("direct:calleeOk").to("mock:calleeEnd");
+ }
+ });
+ context.start();
+
+ return sendBounded();
+ }
+
+ private Exchange sendBounded() {
+ return assertTimeoutPreemptively(Duration.ofSeconds(10),
+ () -> template.send("direct:start", e ->
e.getMessage().setBody("Hello")), "the send did not complete");
+ }
+
+ public static String callCalleeOnce(Exchange exchange) {
+ return exchange.getProperty(Exchange.SLIP_ENDPOINT) == null ?
"direct:callee" : null;
+ }
+
+ private void assertCaughtOnce(Exchange out) {
+ assertNull(out.getException(), "the doCatch handles the exception");
+ assertEquals(1, getMockEndpoint("mock:next").getReceivedCounter(),
"mock:next");
+ assertEquals(1, getMockEndpoint("mock:catch").getReceivedCounter(),
"mock:catch");
+ // only the first matching doCatch runs
+ assertEquals(0,
getMockEndpoint("mock:otherCatch").getReceivedCounter(), "mock:otherCatch");
+ assertEquals(0,
getMockEndpoint("mock:lastCatch").getReceivedCounter(), "mock:lastCatch");
+ assertEquals(1, getMockEndpoint("mock:result").getReceivedCounter(),
"mock:result");
+ // the doCatch sees the exception it caught, not the continued one
+ Exchange caught =
getMockEndpoint("mock:catch").getReceivedExchanges().get(0);
+ Exception e = caught.getProperty(Exchange.EXCEPTION_CAUGHT,
Exception.class);
+ assertInstanceOf(IllegalStateException.class, e);
+ assertEquals("after the call", e.getMessage());
+ assertEquals(0, e.getSuppressed().length, "the continued exception is
not a previous exception");
+ }
+
+ @Test
+ void continuedInCalledRoute() throws Exception {
+ Exchange out = send("to", false);
+ // the called route continued after its failure
+ assertEquals(1,
getMockEndpoint("mock:calleeEnd").getReceivedCounter(), "mock:calleeEnd");
+ // the doCatch (and the debugger, CatchDefinition.acceptDebugger)
reads this property as "a previous doCatch
+ // of the doTry handled the exception"
+ Exchange next =
getMockEndpoint("mock:next").getReceivedExchanges().get(0);
+ assertNull(next.getProperty(Exchange.EXCEPTION_HANDLED), "a continued
failure is not a handled doCatch");
+ assertCaughtOnce(out);
+ }
+
+ @Test
+ void continuedInRoutingSlip() throws Exception {
+ assertCaughtOnce(send("routingSlip", false));
+ }
+
+ @Test
+ void continuedInRecipientList() throws Exception {
+ assertCaughtOnce(send("recipientList", false));
+ }
+
+ @Test
+ void continuedInDynamicRouter() throws Exception {
+ assertCaughtOnce(send("dynamicRouter", false));
+ }
+
+ @Test
+ void continuedAfterRedeliveryInCalledRoute() throws Exception {
+ assertCaughtOnce(send("to", true));
+ }
+
+ @Test
+ void controlNothingBeforeTheException() throws Exception {
+ assertCaughtOnce(send("none", false));
+ }
+
+ @Test
+ void controlCalledRouteDoesNotFail() throws Exception {
+ Exchange out = send("calleeOk", false);
+ assertEquals(1,
getMockEndpoint("mock:calleeEnd").getReceivedCounter(), "mock:calleeEnd");
+ assertCaughtOnce(out);
+ }
+
+ @Test
+ void continuedBeforeTheDoTryIsNotAPreviousException() throws Exception {
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ onException(NumberFormatException.class).continued(true);
+
+ from("direct:start")
+ .to("direct:callee")
+ .doTry()
+ .to("direct:fail")
+ .doCatch(IllegalStateException.class)
+ .to("mock:catch")
+ .end();
+
+ from("direct:callee").throwException(new
NumberFormatException("in callee"));
+ from("direct:fail").throwException(new
IllegalStateException("in fail"));
+ }
+ });
+ context.start();
+
+ Exchange out = sendBounded();
+ assertNull(out.getException(), "the doCatch handles the exception");
+ assertEquals(1, getMockEndpoint("mock:catch").getReceivedCounter(),
"mock:catch");
+ Exception e =
getMockEndpoint("mock:catch").getReceivedExchanges().get(0)
+ .getProperty(Exchange.EXCEPTION_CAUGHT, Exception.class);
+ assertEquals("in fail", e.getMessage());
+ // as without the doTry
(ErrorHandlerFailurePathsTest.testContinuedThenUnrelatedFailure)
+ assertEquals(0, e.getSuppressed().length, "the continued exception is
not a previous exception");
+ }
+
+ @Test
+ void controlExceptionThrownInDoCatchIsNotCaughtByTheNextDoCatch() throws
Exception {
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ onException(NumberFormatException.class).continued(true);
+
+ from("direct:start")
+ .doTry()
+ .throwException(new IllegalArgumentException("in try"))
+ .doCatch(IllegalArgumentException.class)
+ .to("mock:catch")
+ // continues a failure, then this doCatch fails
+ .to("direct:callee")
+ .throwException(new IllegalStateException("in catch"))
+ .doCatch(IllegalStateException.class)
+ .to("mock:otherCatch")
+ .end()
+ .to("mock:result");
+
+ from("direct:callee").throwException(new
NumberFormatException("in callee")).to("mock:calleeEnd");
+ }
+ });
+ context.start();
+
+ Exchange out = sendBounded();
+ assertEquals(1, getMockEndpoint("mock:catch").getReceivedCounter(),
"mock:catch");
+ assertEquals(1,
getMockEndpoint("mock:calleeEnd").getReceivedCounter(), "mock:calleeEnd");
+ // as in Java, an exception thrown by a doCatch is not caught by
another doCatch of the same doTry
+ assertEquals(0,
getMockEndpoint("mock:otherCatch").getReceivedCounter(), "mock:otherCatch");
+ assertEquals(0, getMockEndpoint("mock:result").getReceivedCounter(),
"mock:result");
+ assertInstanceOf(IllegalStateException.class, out.getException());
+ assertEquals("in catch", out.getException().getMessage());
+ }
+}