This is an automated email from the ASF dual-hosted git repository.
Croway 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 ec6b31237ea3 CAMEL-26813: docs - explain error handling across routes
(#29562)
ec6b31237ea3 is described below
commit ec6b31237ea32f28abd6f1db16239432efc8c712
Author: Federico Mariani <[email protected]>
AuthorDate: Tue Nov 10 16:30:22 2026 +0100
CAMEL-26813: docs - explain error handling across routes (#29562)
* CAMEL-26813: docs - explain error handling across routes
A failure in a route called with direct (or seda InOut) is dealt with by
the error handler of the called route; the error handlers and onException
clauses of the calling routes do not deal with it again. Document the rule,
what the calling route sees (not handled, handled, continued), and the two
ways to deal with the failure in the calling route (noErrorHandler on the
called route, or doTry/doCatch in the calling route), with cross-links from
exception-clause.adoc and try-catch-finally.adoc.
ErrorHandlingAcrossRoutesTest pins each statement for direct and seda InOut.
eip-samples.json regenerated for the new YAML example.
* CAMEL-26813: keep only the error handling across routes tests no other
test covers
The redeliveries of the called route, a dead letter channel in the called
route, a continued failure and the redelivery of a whole called route without
error handler are already covered by DoTryCalledRouteErrorHandlerTest,
MultiErrorHandlerInRouteTest, SedaInOutWithErrorDeadLetterChannelTest,
OnExceptionContinueSubRouteTest and RedeliverToSubRouteTest. SEDA is kept for
the main rule only; the InOnly control is dropped as the section says nothing
about InOnly.
* CAMEL-26813: an onException defined for all routes runs once, in the
called route
An onException of a RouteBuilder, or of a route configuration, that applies
to the called route runs there once and not again in the calling route. When
the called route is in another RouteBuilder, the onException of the calling
route's RouteBuilder does not run for its failure.
---
.../onexception/ErrorHandlingAcrossRoutesTest.java | 279 +++++++++++++++++++++
.../modules/ROOT/pages/error-handler.adoc | 119 +++++++++
.../modules/ROOT/pages/exception-clause.adoc | 5 +
.../modules/ROOT/pages/try-catch-finally.adoc | 5 +
.../dsl/jbang/core/commands/ai/eip-samples.json | 4 +
5 files changed, 412 insertions(+)
diff --git
a/core/camel-core/src/test/java/org/apache/camel/processor/onexception/ErrorHandlingAcrossRoutesTest.java
b/core/camel-core/src/test/java/org/apache/camel/processor/onexception/ErrorHandlingAcrossRoutesTest.java
new file mode 100644
index 000000000000..1a8404c30c1a
--- /dev/null
+++
b/core/camel-core/src/test/java/org/apache/camel/processor/onexception/ErrorHandlingAcrossRoutesTest.java
@@ -0,0 +1,279 @@
+/*
+ * 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.io.IOException;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Exchange;
+import org.apache.camel.ExchangePattern;
+import org.apache.camel.Processor;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.builder.RouteConfigurationBuilder;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * The statements of the section "Error handling across routes" of
error-handler.adoc that no other test covers: the
+ * onException of the calling route does not run for a failure the called
route's error handler dealt with. Covered
+ * elsewhere: the redeliveries of the called route
(DoTryCalledRouteErrorHandlerTest), a dead letter channel in the
+ * called route (MultiErrorHandlerInRouteTest,
SedaInOutWithErrorDeadLetterChannelTest), a continued failure
+ * (OnExceptionContinueSubRouteTest) and the redelivery of a whole called
route without error handler
+ * (RedeliverToSubRouteTest).
+ */
+@Timeout(20)
+class ErrorHandlingAcrossRoutesTest extends ContextTestSupport {
+
+ private final AtomicInteger attempts = new AtomicInteger();
+
+ @Override
+ public boolean isUseRouteBuilder() {
+ return false;
+ }
+
+ private Processor failing() {
+ return e -> {
+ attempts.incrementAndGet();
+ throw new IOException("Forced");
+ };
+ }
+
+ /**
+ * The calling route: its onException handles any exception after 2
redeliveries, so a redelivery by the caller
+ * would call the sub route again.
+ */
+ private void caller(RouteBuilder builder, String sub) {
+ builder.from("direct:start")
+
.onException(Exception.class).handled(true).maximumRedeliveries(2).redeliveryDelay(0)
+ .to("mock:callerOnException").end()
+ .to(sub)
+ .to("mock:afterCall");
+ }
+
+ private Exchange send() {
+ return template.send("direct:start", ExchangePattern.InOut, e ->
e.getMessage().setBody("Hello"));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "direct:sub", "seda:sub" })
+ void calledRouteWithOnlyTheDefaultErrorHandler(String sub) throws
Exception {
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ caller(this, sub);
+ from(sub).process(failing());
+ }
+ });
+ context.start();
+
+ getMockEndpoint("mock:callerOnException").expectedMessageCount(0);
+ getMockEndpoint("mock:afterCall").expectedMessageCount(0);
+
+ Exchange out = send();
+
+ assertMockEndpointsSatisfied();
+ // the caller does not redeliver
+ assertEquals(1, attempts.get());
+ assertInstanceOf(IOException.class, out.getException());
+ }
+
+ @Test
+ void calledRouteHandlesTheFailure() throws Exception {
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ caller(this, "direct:sub");
+ from("direct:sub")
+
.onException(IOException.class).handled(true).setBody(constant("Handled by the
sub route")).end()
+ .process(failing());
+ }
+ });
+ context.start();
+
+ getMockEndpoint("mock:callerOnException").expectedMessageCount(0);
+ getMockEndpoint("mock:afterCall").expectedMessageCount(0);
+
+ Exchange out = send();
+
+ assertMockEndpointsSatisfied();
+ assertEquals(1, attempts.get());
+ assertNull(out.getException());
+ assertEquals("Handled by the sub route", out.getMessage().getBody());
+ }
+
+ @Test
+ void exceptionThrownFromTheOnExceptionOfTheCalledRoute() throws Exception {
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ caller(this, "direct:sub");
+ from("direct:sub")
+ .onException(IOException.class)
+ .throwException(new IllegalStateException("Thrown from
onException")).end()
+ .process(failing());
+ }
+ });
+ context.start();
+
+ getMockEndpoint("mock:callerOnException").expectedMessageCount(0);
+ getMockEndpoint("mock:afterCall").expectedMessageCount(0);
+
+ Exchange out = send();
+
+ assertMockEndpointsSatisfied();
+ assertEquals(1, attempts.get());
+ assertInstanceOf(IllegalStateException.class, out.getException());
+ }
+
+ /**
+ * The control of the cases above: with no error handler in the called
route, the same onException of the caller
+ * does run.
+ */
+ @Test
+ void noErrorHandlerOnTheCalledRoute() throws Exception {
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ caller(this, "direct:sub");
+ from("direct:sub").errorHandler(noErrorHandler())
+
.onException(IOException.class).maximumRedeliveries(5).redeliveryDelay(0)
+ .to("mock:subOnException").end()
+ .process(failing());
+ }
+ });
+ context.start();
+
+ getMockEndpoint("mock:subOnException").expectedMessageCount(0);
+ getMockEndpoint("mock:callerOnException").expectedMessageCount(1);
+ getMockEndpoint("mock:afterCall").expectedMessageCount(0);
+
+ Exchange out = send();
+
+ assertMockEndpointsSatisfied();
+ // the onException of the called route does not run, the redeliveries
of the caller call the sub route again
+ assertEquals(3, attempts.get());
+ assertNull(out.getException());
+ }
+
+ @Test
+ void doCatchInTheCallingRoute() throws Exception {
+ context.getRegistry().bind("inventory", new Inventory());
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ // the example of the documentation, with no redelivery delay
+ from("direct:order")
+ .doTry()
+ .to("direct:inventory")
+ .doCatch(IOException.class)
+ .setBody(constant("Inventory not available"))
+ .end()
+ .to("mock:afterTry");
+
+ from("direct:inventory")
+
.errorHandler(defaultErrorHandler().maximumRedeliveries(3).redeliveryDelay(0))
+ .bean("inventory", "reserve");
+ }
+ });
+ context.start();
+
+ getMockEndpoint("mock:afterTry").expectedBodiesReceived("Inventory not
available");
+
+ Exchange out = template.send("direct:order", ExchangePattern.InOut, e
-> e.getMessage().setBody("Order"));
+
+ assertMockEndpointsSatisfied();
+ // the error handler of the called route redelivers first, then the
doCatch of the caller catches the failure
+ assertEquals(4, attempts.get());
+ assertNull(out.getException());
+ assertEquals("Inventory not available", out.getMessage().getBody());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "routeBuilder", "routeConfiguration" })
+ void onExceptionForAllRoutesRunsOnceInTheCalledRoute(String where) throws
Exception {
+ if ("routeConfiguration".equals(where)) {
+ context.addRoutes(new RouteConfigurationBuilder() {
+ @Override
+ public void configuration() {
+
routeConfiguration().onException(IOException.class).maximumRedeliveries(2).redeliveryDelay(0)
+ .to("mock:onException");
+ }
+ });
+ }
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ if ("routeBuilder".equals(where)) {
+
onException(IOException.class).maximumRedeliveries(2).redeliveryDelay(0).to("mock:onException");
+ }
+ from("direct:start").to("direct:sub").to("mock:afterCall");
+ from("direct:sub").process(failing());
+ }
+ });
+ context.start();
+
+ getMockEndpoint("mock:onException").expectedMessageCount(1);
+ getMockEndpoint("mock:afterCall").expectedMessageCount(0);
+
+ Exchange out = send();
+
+ assertMockEndpointsSatisfied();
+ // the redeliveries of the called route only
+ assertEquals(3, attempts.get());
+ assertInstanceOf(IOException.class, out.getException());
+ }
+
+ @Test
+ void
onExceptionOfTheCallingRouteBuilderWithTheCalledRouteInAnotherRouteBuilder()
throws Exception {
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+
onException(IOException.class).handled(true).to("mock:callerOnException");
+ from("direct:start").to("direct:sub").to("mock:afterCall");
+ }
+ });
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:sub").process(failing());
+ }
+ });
+ context.start();
+
+ getMockEndpoint("mock:callerOnException").expectedMessageCount(0);
+ getMockEndpoint("mock:afterCall").expectedMessageCount(0);
+
+ Exchange out = send();
+
+ assertMockEndpointsSatisfied();
+ assertInstanceOf(IOException.class, out.getException());
+ }
+
+ public class Inventory {
+ public void reserve(String body) throws IOException {
+ attempts.incrementAndGet();
+ throw new IOException("Inventory service not reachable");
+ }
+ }
+}
diff --git a/docs/user-manual/modules/ROOT/pages/error-handler.adoc
b/docs/user-manual/modules/ROOT/pages/error-handler.adoc
index 3fbf8ef01979..bdb39e90e4f7 100644
--- a/docs/user-manual/modules/ROOT/pages/error-handler.adoc
+++ b/docs/user-manual/modules/ROOT/pages/error-handler.adoc
@@ -225,6 +225,125 @@ RouteBuilder builder = new RouteBuilder() {
};
----
+== Error handling across routes
+
+When a route calls another route, such as with
xref:components::direct-component.adoc[Direct], a failure in the
+called route is dealt with by the error handler of the called route, the route
where it happened: its redeliveries,
+its `onException` clauses and its dead letter channel. Once that error handler
is done with the failure, the error
+handlers of the calling routes do not deal with it again: they do not
redeliver, and their `onException` clauses do
+not run for it. This also applies when the called route has only the default
error handler, without any
+`onException`.
+
+An `onException` defined for all the routes of a `RouteBuilder`, or in a
+xref:route-configuration.adoc[route configuration], is part of the error
handler of each route it applies to. When it
+applies to the called route, it runs there, once, and not again in the calling
route. When the called route is in
+another `RouteBuilder`, the `onException` of the calling route's
`RouteBuilder` does not run for its failure.
+
+What the calling route sees depends on what the error handler of the called
route did with the failure:
+
+* not handled (the default error handler, or an `onException` that does not
handle it): the calling route fails with
+ the exception, and its next steps do not run. An exception thrown from an
`onException` of the called route is no
+ different: the calling route fails with it, and its `onException` clauses do
not run for it.
+* handled (a dead letter channel, or an `onException` with `handled(true)`):
the calling route does not fail, and its
+ next steps do not run either. The reply is the message as the called route
left it.
+* continued (an `onException` with `continued(true)`): the calling route
continues with its next steps.
+
+The same applies with xref:components::seda-component.adoc[SEDA] when the
calling route waits for the called route to
+complete (`InOut`).
+
+To deal with the failure in the calling route instead, either:
+
+* Turn off the error handler of the called route with
`errorHandler(noErrorHandler())`. The failure then goes to the
+ error handler of the calling route, as if it happened there, so its
`onException` clauses run. The called route has
+ no redeliveries and no `onException` of its own then, and a redelivery of
the calling route sends the message to
+ the called route again, so the whole called route runs again, as shown in
+
<<_how_do_i_retry_processing_a_message_from_a_certain_point_back_or_an_entire_route,retrying
an entire route>>.
+* Call the route from a xref:try-catch-finally.adoc[doTry ... doCatch] in the
calling route. The `doCatch` catches
+ the exception that the called route fails with, once the error handler of
the called route is done with it.
+
+In the example below, the error handler of the `direct:inventory` route
redelivers a failure up to 3 times. When the
+last attempt fails too, the `doCatch` of the `direct:order` route catches the
exception and sets the reply:
+
+[tabs]
+====
+
+Java::
++
+[source,java]
+----
+from("direct:order")
+ .doTry()
+ .to("direct:inventory")
+ .doCatch(IOException.class)
+ .setBody(constant("Inventory not available"))
+ .end();
+
+from("direct:inventory")
+
.errorHandler(defaultErrorHandler().maximumRedeliveries(3).redeliveryDelay(1000))
+ .bean("inventory", "reserve");
+----
+
+XML::
++
+[source,xml]
+----
+<route>
+ <from uri="direct:order"/>
+ <doTry>
+ <to uri="direct:inventory"/>
+ <doCatch>
+ <exception>java.io.IOException</exception>
+ <setBody>
+ <constant>Inventory not available</constant>
+ </setBody>
+ </doCatch>
+ </doTry>
+</route>
+
+<route>
+ <errorHandler>
+ <defaultErrorHandler>
+ <redeliveryPolicy maximumRedeliveries="3" redeliveryDelay="1000"/>
+ </defaultErrorHandler>
+ </errorHandler>
+ <from uri="direct:inventory"/>
+ <bean ref="inventory" method="reserve"/>
+</route>
+----
+
+YAML::
++
+[source,yaml]
+----
+- route:
+ from:
+ uri: direct:order
+ steps:
+ - doTry:
+ steps:
+ - to:
+ uri: direct:inventory
+ - doCatch:
+ exception:
+ - java.io.IOException
+ steps:
+ - setBody:
+ constant: Inventory not available
+- route:
+ errorHandler:
+ defaultErrorHandler:
+ redeliveryPolicy:
+ maximumRedeliveries: 3
+ redeliveryDelay: 1000
+ from:
+ uri: direct:inventory
+ steps:
+ - bean:
+ ref: inventory
+ method: reserve
+----
+====
+
== XML and YAML based configuration
*Java DSL vs. Spring DSL*
diff --git a/docs/user-manual/modules/ROOT/pages/exception-clause.adoc
b/docs/user-manual/modules/ROOT/pages/exception-clause.adoc
index f02adaca74b9..77968c86a6f7 100644
--- a/docs/user-manual/modules/ROOT/pages/exception-clause.adoc
+++ b/docs/user-manual/modules/ROOT/pages/exception-clause.adoc
@@ -168,6 +168,11 @@ its *`configure`* method. And then extend this class, and
make sure to
class *`super.configure()`*. We are just using the Java inheritance
technique.
+When a route calls another route, such as with `direct`, a failure in the
called route is dealt with by the error
+handler of the called route, also when it is the default error handler, and
the exception clauses of the calling
+route do not run for it. See
xref:error-handler.adoc#_error_handling_across_routes[Error handling across
routes]
+for what the calling route sees, and how to deal with the failure there
instead.
+
== How Does Camel Select Which Clause Should Handle a Given Thrown Exception?
Camel uses *`DefaultExceptionPolicyStrategy`* to determine a strategy
diff --git a/docs/user-manual/modules/ROOT/pages/try-catch-finally.adoc
b/docs/user-manual/modules/ROOT/pages/try-catch-finally.adoc
index 52462957ed12..39311ff0a0a4 100644
--- a/docs/user-manual/modules/ROOT/pages/try-catch-finally.adoc
+++ b/docs/user-manual/modules/ROOT/pages/try-catch-finally.adoc
@@ -21,6 +21,11 @@ The reason is that
`doTry ... doCatch ... doFinally` is in fact its own error handler and mimics
how try/catch/finally works in Java.
====
+A route called from the `doTry` block, such as with `direct`, still uses its
own error handler. The `doCatch` catches
+the exception that the called route fails with, once that error handler is
done with it (for example, after its
+redeliveries). This is one way for a calling route to deal with the failure of
a called route; see
+xref:error-handler.adoc#_error_handling_across_routes[Error handling across
routes].
+
== Using doTry ... doCatch ... doFinally
In the route below we have all of them in action:
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/org/apache/camel/dsl/jbang/core/commands/ai/eip-samples.json
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/org/apache/camel/dsl/jbang/core/commands/ai/eip-samples.json
index 7947c4072fee..38cead2caf95 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/org/apache/camel/dsl/jbang/core/commands/ai/eip-samples.json
+++
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/org/apache/camel/dsl/jbang/core/commands/ai/eip-samples.json
@@ -386,6 +386,10 @@
}
],
"errorHandler": [
+ {
+ "source": "error-handler.adoc",
+ "yaml": "- route:\n from:\n uri: direct:order\n steps:\n
- doTry:\n steps:\n - to:\n uri:
direct:inventory\n - doCatch:\n exception:\n
- java.io.IOException\n steps:\n
- setBody:\n constant: Inventory not available\n-
route:\n errorHandler:\n defaultErrorHandler:\n
redeliveryPolicy:\n maximum [...]
+ },
{
"source": "error-handler.adoc",
"yaml": "- route:\n errorHandler:\n deadLetterChannel:\n
deadLetterUri: mock:dead\n redeliveryPolicy:\n
maximumRedeliveries: 3\n redeliveryDelay: 250\n from:\n uri:
direct:in\n steps:\n - filter:\n simple:
\"${header.status} != 'reserved'\"\n steps:\n -
throwException:\n exceptionType:
java.lang.IllegalStateException\n message: \"Order
${header.orderId} i [...]