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 e03833a795e4 CAMEL-25429: route topology - error paths are not calls 
between routes (#27557)
e03833a795e4 is described below

commit e03833a795e4a5c1bfaac850660c0ea1c9e5e7b2
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Oct 8 14:28:56 2026 +0200

    CAMEL-25429: route topology - error paths are not calls between routes 
(#27557)
    
    * CAMEL-25429: route topology - error paths are not calls between routes
    
    The route topology reported every send it found in a route as a call,
    also the sends of an onException clause. A global onException is copied
    into every route, so a send to an error route showed up as a call from
    every route, the error route itself included, and also from a route with
    noErrorHandler, which leaves the failure to its caller. The dead letter
    channel was not in the topology at all.
    
    Each edge now has a kind: call, or an error path (onException,
    deadLetter), and error paths come only from routes whose error handler
    handles the failure. The JSON of the route-topology console and the
    route dump carry the kind; the diagrams (camel-jbang monitor,
    camel-diagram) draw the calls only, and the text output lists the error
    paths with ..>. TopologyEdge keeps its four-argument constructor, which
    creates a call.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
    
    * CAMEL-25429: route topology - an error path says what happens to the 
failure
    
    Review of the edge kinds: a dead letter channel and an onException with
    handled(true) do the same to the message (it ends at the error route),
    yet were two kinds. An edge is now a call or an error path (kind), and
    an error path says what sends (via: errorHandler, onException) and what
    happens to the failure (handling: handled, continued, notHandled; not
    set when a predicate decides).
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
    
    * CAMEL-25429: review nit, group the conditions of handling()
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
 .../apache/camel/catalog/dev-consoles-openapi.json |  12 +++
 .../camel/catalog/dev-consoles/route-topology.json |  12 +++
 .../org/apache/camel/diagram/TopologyHelper.java   |  21 +++-
 .../apache/camel/diagram/TopologyLayoutEngine.java |  10 ++
 .../camel/diagram/TopologyHelperErrorPathTest.java |  72 ++++++++++++++
 .../org/apache/camel/spi/RouteTopologyDumper.java  |  59 +++++++++++-
 .../apache/camel/dev-console/route-topology.json   |  12 +++
 .../impl/console/RouteTopologyDevConsole.java      |  29 ++++--
 .../camel/impl/DefaultDumpRoutesStrategy.java      |   7 ++
 .../camel/impl/DefaultRouteTopologyDumper.java     |  87 ++++++++++++++---
 .../DefaultRouteTopologyDumperErrorPathTest.java   | 106 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc    |  11 +++
 .../commands/action/CamelRouteTopologyAction.java  |  33 ++++++-
 13 files changed, 444 insertions(+), 27 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles-openapi.json
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles-openapi.json
index 297bd579d51e..9b1ef9a3bdf0 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles-openapi.json
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles-openapi.json
@@ -6976,6 +6976,18 @@
                                                                                
                        "connectionType": {
                                                                                
                                "type": "string",
                                                                                
                                "description": "The connection type"
+                                                                               
                        },
+                                                                               
                        "kind": {
+                                                                               
                                "type": "string",
+                                                                               
                                "description": "call when the source route 
sends as it routes a message, or error when it sends only when it handles a 
failure (an error path)"
+                                                                               
                        },
+                                                                               
                        "via": {
+                                                                               
                                "type": "string",
+                                                                               
                                "description": "For an error path, what sends: 
errorHandler or onException"
+                                                                               
                        },
+                                                                               
                        "handling": {
+                                                                               
                                "type": "string",
+                                                                               
                                "description": "For an error path, what happens 
to the failure: handled (the message ends there), continued (the route goes on 
after it) or notHandled (the failure goes on to the caller); absent when a 
predicate decides it"
                                                                                
                        }
                                                                                
                }
                                                                                
        },
diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/route-topology.json
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/route-topology.json
index 26284535e933..170051aeae74 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/route-topology.json
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/route-topology.json
@@ -134,6 +134,18 @@
             "connectionType": {
               "type": "string",
               "description": "The connection type"
+            },
+            "kind": {
+              "type": "string",
+              "description": "call when the source route sends as it routes a 
message, or error when it sends only when it handles a failure (an error path)"
+            },
+            "via": {
+              "type": "string",
+              "description": "For an error path, what sends: errorHandler or 
onException"
+            },
+            "handling": {
+              "type": "string",
+              "description": "For an error path, what happens to the failure: 
handled (the message ends there), continued (the route goes on after it) or 
notHandled (the failure goes on to the caller); absent when a predicate decides 
it"
             }
           }
         },
diff --git 
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyHelper.java
 
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyHelper.java
index d59a2a3bb1ad..dc5f6ab09319 100644
--- 
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyHelper.java
+++ 
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyHelper.java
@@ -58,7 +58,20 @@ public final class TopologyHelper {
         return nodes;
     }
 
+    /**
+     * The edges a route sends along as it routes a message (the happy path), 
without the error paths: a send from an
+     * onException clause or a dead letter channel is not a call, and drawn as 
one it reads as if every route called the
+     * error route.
+     */
     public static List<TopologyEdgeInfo> parseEdges(JsonObject jo) {
+        return parseEdges(jo, false);
+    }
+
+    /**
+     * The edges of the topology, with the error paths too when asked (their 
{@code via} says what sends: errorHandler
+     * or onException, and their {@code handling} what happens to the failure).
+     */
+    public static List<TopologyEdgeInfo> parseEdges(JsonObject jo, boolean 
includeErrorPaths) {
         List<TopologyEdgeInfo> edges = new ArrayList<>();
         JsonArray arr = jo.getJsonArray("edges");
         if (arr == null) {
@@ -71,7 +84,13 @@ public final class TopologyHelper {
             edge.toRouteId = eo.getString("toRouteId");
             edge.endpoint = eo.getString("endpoint");
             edge.connectionType = eo.getStringOrDefault("connectionType", 
"internal");
-            edges.add(edge);
+            // an older integration does not say the kind: all its edges are 
calls
+            edge.kind = eo.getStringOrDefault("kind", "call");
+            edge.via = eo.getString("via");
+            edge.handling = eo.getString("handling");
+            if (includeErrorPaths || !edge.isErrorPath()) {
+                edges.add(edge);
+            }
         }
         return edges;
     }
diff --git 
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyLayoutEngine.java
 
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyLayoutEngine.java
index 264e0a2fe4a2..1e5d657ead90 100644
--- 
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyLayoutEngine.java
+++ 
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyLayoutEngine.java
@@ -332,6 +332,16 @@ public class TopologyLayoutEngine {
         public String toRouteId;
         public String endpoint;
         public String connectionType;
+        // call, or error for an error path (sent to only when the route 
handles a failure)
+        public String kind = "call";
+        // for an error path: errorHandler or onException
+        public String via;
+        // for an error path: handled, continued or notHandled (null when a 
predicate decides)
+        public String handling;
+
+        public boolean isErrorPath() {
+            return "error".equals(kind);
+        }
     }
 
     // Layout output
diff --git 
a/components/camel-diagram/src/test/java/org/apache/camel/diagram/TopologyHelperErrorPathTest.java
 
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/TopologyHelperErrorPathTest.java
new file mode 100644
index 000000000000..d3e6f50d7ea2
--- /dev/null
+++ 
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/TopologyHelperErrorPathTest.java
@@ -0,0 +1,72 @@
+/*
+ * 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.diagram;
+
+import java.util.List;
+
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyEdgeInfo;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-25429: the diagrams draw the happy path; the error paths 
(onException, dead letter channel) are only there when
+ * asked for.
+ */
+class TopologyHelperErrorPathTest {
+
+    @Test
+    void theErrorPathsAreLeftOutUnlessAskedFor() {
+        JsonObject root = new JsonObject();
+        JsonArray edges = new JsonArray();
+        edges.add(edge("checkout", "payment-provider", "call", null, null));
+        edges.add(edge("checkout", "parked", "error", "errorHandler", 
"handled"));
+        edges.add(edge("parked", "parked", "error", "onException", 
"notHandled"));
+        // an older integration does not say the kind
+        edges.add(edge("legacy", "parked", null, null, null));
+        root.put("edges", edges);
+
+        List<TopologyEdgeInfo> calls = TopologyHelper.parseEdges(root);
+        assertThat(calls).extracting(e -> e.fromRouteId + "->" + e.toRouteId)
+                .containsExactly("checkout->payment-provider", 
"legacy->parked");
+
+        List<TopologyEdgeInfo> all = TopologyHelper.parseEdges(root, true);
+        assertThat(all).hasSize(4);
+        assertThat(all).filteredOn(TopologyEdgeInfo::isErrorPath).extracting(e 
-> e.via + "/" + e.handling)
+                .containsExactly("errorHandler/handled", 
"onException/notHandled");
+    }
+
+    private static JsonObject edge(String from, String to, String kind, String 
via, String handling) {
+        JsonObject e = new JsonObject();
+        e.put("fromRouteId", from);
+        e.put("toRouteId", to);
+        e.put("endpoint", "direct:" + to);
+        e.put("connectionType", "internal");
+        if (kind != null) {
+            e.put("kind", kind);
+        }
+        if (via != null) {
+            e.put("via", via);
+        }
+        if (handling != null) {
+            e.put("handling", handling);
+        }
+        return e;
+    }
+}
diff --git 
a/core/camel-api/src/main/java/org/apache/camel/spi/RouteTopologyDumper.java 
b/core/camel-api/src/main/java/org/apache/camel/spi/RouteTopologyDumper.java
index 3a436f219b65..eb4a37340000 100644
--- a/core/camel-api/src/main/java/org/apache/camel/spi/RouteTopologyDumper.java
+++ b/core/camel-api/src/main/java/org/apache/camel/spi/RouteTopologyDumper.java
@@ -58,6 +58,41 @@ public interface RouteTopologyDumper {
     record TopologyNode(String routeId, String description, String from, 
String fromScheme, String nodeType) {
     }
 
+    /**
+     * The kind of an edge where the route sends to the endpoint as it routes 
a message (the happy path).
+     */
+    String EDGE_CALL = "call";
+
+    /**
+     * The kind of an edge where the route sends to the endpoint only when it 
handles a failure (an error path).
+     */
+    String EDGE_ERROR = "error";
+
+    /**
+     * An error path of the error handler of the route, such as the dead 
letter uri of a dead letter channel.
+     */
+    String VIA_ERROR_HANDLER = "errorHandler";
+
+    /**
+     * An error path of an onException clause.
+     */
+    String VIA_ON_EXCEPTION = "onException";
+
+    /**
+     * The failure is handled: the message ends at the error path (as with a 
dead letter channel).
+     */
+    String HANDLING_HANDLED = "handled";
+
+    /**
+     * The failure is handled and the route continues after the error path.
+     */
+    String HANDLING_CONTINUED = "continued";
+
+    /**
+     * The failure is not handled: the error path is a side trip, and the 
failure goes on to the caller.
+     */
+    String HANDLING_NOT_HANDLED = "notHandled";
+
     /**
      * An edge in the topology representing a connection between two routes 
via a shared endpoint.
      *
@@ -65,9 +100,31 @@ public interface RouteTopologyDumper {
      * @param toRouteId      the target route id (the route that consumes from 
the endpoint)
      * @param endpoint       the shared endpoint URI (scheme:context-path, 
query parameters stripped)
      * @param connectionType the type of connection: "internal" for 
direct/seda, "external" for remote components
+     * @param kind           {@link #EDGE_CALL} when the source route sends as 
it routes a message, or
+     *                       {@link #EDGE_ERROR} when it sends only when it 
handles a failure (since 4.23)
+     * @param via            for an error path, what sends: {@link 
#VIA_ERROR_HANDLER} or {@link #VIA_ON_EXCEPTION};
+     *                       null for a call (since 4.23)
+     * @param handling       for an error path, what happens to the failure: 
{@link #HANDLING_HANDLED},
+     *                       {@link #HANDLING_CONTINUED} or {@link 
#HANDLING_NOT_HANDLED}; null for a call, and when a
+     *                       predicate decides it at runtime (since 4.23)
      * @since                4.21
      */
-    record TopologyEdge(String fromRouteId, String toRouteId, String endpoint, 
String connectionType) {
+    record TopologyEdge(String fromRouteId, String toRouteId, String endpoint, 
String connectionType, String kind,
+            String via, String handling) {
+
+        /**
+         * An edge of kind {@link #EDGE_CALL}.
+         */
+        public TopologyEdge(String fromRouteId, String toRouteId, String 
endpoint, String connectionType) {
+            this(fromRouteId, toRouteId, endpoint, connectionType, EDGE_CALL, 
null, null);
+        }
+
+        /**
+         * Whether the edge is an error path: the route sends to the target 
only when it handles a failure.
+         */
+        public boolean isErrorPath() {
+            return EDGE_ERROR.equals(kind);
+        }
     }
 
     /**
diff --git 
a/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/route-topology.json
 
b/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/route-topology.json
index 26284535e933..170051aeae74 100644
--- 
a/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/route-topology.json
+++ 
b/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/route-topology.json
@@ -134,6 +134,18 @@
             "connectionType": {
               "type": "string",
               "description": "The connection type"
+            },
+            "kind": {
+              "type": "string",
+              "description": "call when the source route sends as it routes a 
message, or error when it sends only when it handles a failure (an error path)"
+            },
+            "via": {
+              "type": "string",
+              "description": "For an error path, what sends: errorHandler or 
onException"
+            },
+            "handling": {
+              "type": "string",
+              "description": "For an error path, what happens to the failure: 
handled (the message ends there), continued (the route goes on after it) or 
notHandled (the failure goes on to the caller); absent when a predicate decides 
it"
             }
           }
         },
diff --git 
a/core/camel-console/src/main/java/org/apache/camel/impl/console/RouteTopologyDevConsole.java
 
b/core/camel-console/src/main/java/org/apache/camel/impl/console/RouteTopologyDevConsole.java
index 8af931d7d7d8..6d57c50284cb 100644
--- 
a/core/camel-console/src/main/java/org/apache/camel/impl/console/RouteTopologyDevConsole.java
+++ 
b/core/camel-console/src/main/java/org/apache/camel/impl/console/RouteTopologyDevConsole.java
@@ -60,7 +60,13 @@ public class RouteTopologyDevConsole extends 
AbstractDevConsole {
             @Metadata(description = "The source route ID") String fromRouteId,
             @Metadata(description = "The target route ID") String toRouteId,
             @Metadata(description = "The endpoint URI connecting the two 
routes") String endpoint,
-            @Metadata(description = "The connection type") String 
connectionType) {
+            @Metadata(description = "The connection type") String 
connectionType,
+            @Metadata(description = "call when the source route sends as it 
routes a message, or error when it sends only"
+                                    + " when it handles a failure (an error 
path)") String kind,
+            @Metadata(description = "For an error path, what sends: 
errorHandler or onException") String via,
+            @Metadata(description = "For an error path, what happens to the 
failure: handled (the message ends there),"
+                                    + " continued (the route goes on after it) 
or notHandled (the failure goes on to the"
+                                    + " caller); absent when a predicate 
decides it") String handling) {
     }
 
     public record ExternalEndpointEntry(
@@ -112,16 +118,25 @@ public class RouteTopologyDevConsole extends 
AbstractDevConsole {
         boolean external = optionBoolean(options, EXTERNAL, false);
 
         StringBuilder sb = new StringBuilder();
-        sb.append(String.format("Route Topology (%d routes, %d 
connections)%n%n",
-                result.nodes().size(), result.edges().size()));
+        long errorPaths = 
result.edges().stream().filter(TopologyEdge::isErrorPath).count();
+        sb.append(String.format("Route Topology (%d routes, %d 
connections%s)%n%n",
+                result.nodes().size(), result.edges().size() - errorPaths,
+                errorPaths > 0 ? ", " + errorPaths + " error paths" : ""));
 
         for (TopologyNode node : result.nodes()) {
             sb.append(String.format("  %s (%s) type=%s%n", node.routeId(), 
node.from(), node.nodeType()));
 
             for (TopologyEdge edge : result.edges()) {
                 if (edge.fromRouteId().equals(node.routeId())) {
-                    sb.append(String.format("    --> %s via %s [%s]%n",
-                            edge.toRouteId(), edge.endpoint(), 
edge.connectionType()));
+                    if (edge.isErrorPath()) {
+                        // sent to only when the route handles a failure
+                        sb.append(String.format("    ..> %s via %s [%s, on 
failure: %s%s]%n",
+                                edge.toRouteId(), edge.endpoint(), 
edge.connectionType(), edge.via(),
+                                edge.handling() != null ? ", " + 
edge.handling() : ""));
+                    } else {
+                        sb.append(String.format("    --> %s via %s [%s]%n",
+                                edge.toRouteId(), edge.endpoint(), 
edge.connectionType()));
+                    }
                 }
             }
         }
@@ -169,7 +184,9 @@ public class RouteTopologyDevConsole extends 
AbstractDevConsole {
 
         List<EdgeEntry> edges = new ArrayList<>();
         for (TopologyEdge edge : result.edges()) {
-            edges.add(new EdgeEntry(edge.fromRouteId(), edge.toRouteId(), 
edge.endpoint(), edge.connectionType()));
+            edges.add(new EdgeEntry(
+                    edge.fromRouteId(), edge.toRouteId(), edge.endpoint(), 
edge.connectionType(), edge.kind(),
+                    edge.via(), edge.handling()));
         }
 
         List<ExternalEndpointEntry> externalEndpoints = null;
diff --git 
a/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultDumpRoutesStrategy.java
 
b/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultDumpRoutesStrategy.java
index ef40fb12617f..fc7a187c317a 100644
--- 
a/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultDumpRoutesStrategy.java
+++ 
b/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultDumpRoutesStrategy.java
@@ -318,6 +318,13 @@ public class DefaultDumpRoutesStrategy extends 
ServiceSupport implements DumpRou
             jo.put("toRouteId", edge.toRouteId());
             jo.put("endpoint", edge.endpoint());
             jo.put("connectionType", edge.connectionType());
+            jo.put("kind", edge.kind());
+            if (edge.via() != null) {
+                jo.put("via", edge.via());
+            }
+            if (edge.handling() != null) {
+                jo.put("handling", edge.handling());
+            }
             edgesArr.add(jo);
         }
         root.put("edges", edgesArr);
diff --git 
a/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultRouteTopologyDumper.java
 
b/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultRouteTopologyDumper.java
index 0c447b55f67c..444c295f8bac 100644
--- 
a/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultRouteTopologyDumper.java
+++ 
b/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultRouteTopologyDumper.java
@@ -27,10 +27,17 @@ import java.util.TreeMap;
 
 import org.apache.camel.CamelContext;
 import org.apache.camel.Endpoint;
+import org.apache.camel.ErrorHandlerFactory;
 import org.apache.camel.model.EndpointRequiredDefinition;
+import org.apache.camel.model.ExpressionSubElementDefinition;
 import org.apache.camel.model.Model;
+import org.apache.camel.model.OnExceptionDefinition;
+import org.apache.camel.model.ProcessorDefinition;
 import org.apache.camel.model.ProcessorDefinitionHelper;
 import org.apache.camel.model.RouteDefinition;
+import org.apache.camel.model.errorhandler.DeadLetterChannelDefinition;
+import org.apache.camel.model.errorhandler.NoErrorHandlerDefinition;
+import org.apache.camel.model.language.ConstantExpression;
 import org.apache.camel.spi.EndpointUriFactory;
 import org.apache.camel.spi.RouteTopologyDumper;
 import org.apache.camel.spi.annotations.JdkService;
@@ -65,21 +72,30 @@ public class DefaultRouteTopologyDumper implements 
RouteTopologyDumper {
 
         List<TopologyEdge> edges = new ArrayList<>();
         for (RouteDefinition rd : routeDefs) {
-            Collection<EndpointRequiredDefinition> outputs
-                    = ProcessorDefinitionHelper.filterTypeInOutputs(
-                            rd.getOutputs(), EndpointRequiredDefinition.class);
-
-            for (EndpointRequiredDefinition erd : outputs) {
-                String outputUri = normalizeUri(context, erd.getEndpointUri());
-                String scheme = extractScheme(outputUri);
-
-                List<String> targetRouteIds = 
inputUriToRouteIds.get(outputUri);
-                if (targetRouteIds != null) {
-                    String connType = INTERNAL_SCHEMES.contains(scheme) ? 
"internal" : "external";
-                    for (String targetId : targetRouteIds) {
-                        edges.add(new TopologyEdge(rd.getRouteId(), targetId, 
outputUri, connType));
-                    }
+            ErrorHandlerFactory eh = rd.isErrorHandlerFactorySet()
+                    ? rd.getErrorHandlerFactory() : 
context.getCamelContextExtension().getErrorHandlerFactory();
+            // with no error handler a failure goes back to the caller, whose 
error handler acts: no error paths here
+            boolean handlesFailures = !(eh instanceof 
NoErrorHandlerDefinition);
+            for (ProcessorDefinition<?> output : rd.getOutputs()) {
+                // an onException clause (a global one is copied into every 
route) sends only when handling a failure
+                boolean onException = output instanceof OnExceptionDefinition;
+                if (onException && !handlesFailures) {
+                    continue;
                 }
+                String kind = onException ? EDGE_ERROR : EDGE_CALL;
+                String via = onException ? VIA_ON_EXCEPTION : null;
+                String handling = onException ? 
handling((OnExceptionDefinition) output) : null;
+                Collection<EndpointRequiredDefinition> sends
+                        = 
ProcessorDefinitionHelper.filterTypeInOutputs(List.of(output), 
EndpointRequiredDefinition.class);
+                for (EndpointRequiredDefinition erd : sends) {
+                    addEdges(context, edges, inputUriToRouteIds, 
rd.getRouteId(), erd.getEndpointUri(), kind, via,
+                            handling);
+                }
+            }
+            if (handlesFailures && eh instanceof DeadLetterChannelDefinition 
dlc && dlc.getDeadLetterUri() != null) {
+                // a dead letter channel handles the failure: the message ends 
at the dead letter uri
+                addEdges(context, edges, inputUriToRouteIds, rd.getRouteId(), 
dlc.getDeadLetterUri(), EDGE_ERROR,
+                        VIA_ERROR_HANDLER, HANDLING_HANDLED);
             }
         }
 
@@ -89,6 +105,49 @@ public class DefaultRouteTopologyDumper implements 
RouteTopologyDumper {
         return new TopologyResult(nodes, edges, externalEndpoints);
     }
 
+    private static void addEdges(
+            CamelContext context, List<TopologyEdge> edges, Map<String, 
List<String>> inputUriToRouteIds,
+            String fromRouteId, String uri, String kind, String via, String 
handling) {
+        String outputUri = normalizeUri(context, uri);
+        List<String> targetRouteIds = inputUriToRouteIds.get(outputUri);
+        if (targetRouteIds != null) {
+            String scheme = extractScheme(outputUri);
+            String connType = INTERNAL_SCHEMES.contains(scheme) ? "internal" : 
"external";
+            for (String targetId : targetRouteIds) {
+                edges.add(new TopologyEdge(fromRouteId, targetId, outputUri, 
connType, kind, via, handling));
+            }
+        }
+    }
+
+    /**
+     * What an onException clause does with the failure: continued, handled or 
not handled (a side trip, the failure
+     * goes on to the caller); null when a predicate decides it at runtime.
+     */
+    private static String handling(OnExceptionDefinition oe) {
+        Boolean continued = constant(oe.getContinued());
+        Boolean handled = constant(oe.getHandled());
+        if (Boolean.TRUE.equals(continued)) {
+            return HANDLING_CONTINUED;
+        }
+        if (Boolean.TRUE.equals(handled)) {
+            return HANDLING_HANDLED;
+        }
+        boolean decidedAtRuntime = (oe.getContinued() != null && continued == 
null)
+                || (oe.getHandled() != null && handled == null);
+        return decidedAtRuntime ? null : HANDLING_NOT_HANDLED;
+    }
+
+    /**
+     * The value of a handled or continued option set as a constant (true or 
false), or null when it is not set or is a
+     * predicate.
+     */
+    private static Boolean constant(ExpressionSubElementDefinition option) {
+        if (option != null && option.getExpressionType() instanceof 
ConstantExpression ce) {
+            return Boolean.valueOf(ce.getExpression().trim());
+        }
+        return null;
+    }
+
     private List<TopologyExternalEndpoint> computeExternalEndpoints(
             CamelContext context, List<RouteDefinition> routeDefs,
             Map<String, List<String>> inputUriToRouteIds) {
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/impl/DefaultRouteTopologyDumperErrorPathTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/impl/DefaultRouteTopologyDumperErrorPathTest.java
new file mode 100644
index 000000000000..b9d1871391f6
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/impl/DefaultRouteTopologyDumperErrorPathTest.java
@@ -0,0 +1,106 @@
+/*
+ * 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.impl;
+
+import java.util.List;
+
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.spi.RouteTopologyDumper;
+import org.apache.camel.spi.RouteTopologyDumper.TopologyEdge;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-25429: a send from an onException clause or the error handler is an 
error path, not a call, and only from a
+ * route whose error handler acts: a route with no error handler leaves the 
failure to its caller. An error path says
+ * what happens to the failure.
+ */
+class DefaultRouteTopologyDumperErrorPathTest extends ContextTestSupport {
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                
errorHandler(deadLetterChannel("direct:parked").maximumRedeliveries(2));
+
+                
onException(IllegalStateException.class).handled(true).to("direct:parked");
+                
onException(IllegalArgumentException.class).continued(true).to("direct:audit");
+                
onException(UnsupportedOperationException.class).to("direct:alert");
+                
onException(ArithmeticException.class).handled(header("handle").isEqualTo("yes")).to("direct:review");
+
+                from("direct:checkout").routeId("checkout")
+                        .to("direct:charge");
+
+                
from("direct:charge").routeId("payment-provider").errorHandler(noErrorHandler())
+                        .throwException(new IllegalStateException("card 
declined"));
+
+                from("direct:parked").routeId("parked").to("mock:parked");
+                from("direct:audit").routeId("audit").to("mock:audit");
+                from("direct:alert").routeId("alert").to("mock:alert");
+                from("direct:review").routeId("review").to("mock:review");
+            }
+        };
+    }
+
+    @Test
+    void errorPathsAreNotCalls() {
+        List<TopologyEdge> edges = edges();
+
+        assertThat(edges).filteredOn(e -> !e.isErrorPath())
+                .extracting(e -> e.fromRouteId() + "->" + e.toRouteId())
+                .containsExactly("checkout->payment-provider");
+        // payment-provider has no error handler: its failure goes back to 
checkout
+        assertThat(edges).noneMatch(e -> 
"payment-provider".equals(e.fromRouteId()) && e.isErrorPath());
+        // parked is handled by the same error handler: an error path to 
itself, never a call
+        assertThat(edges).filteredOn(e -> "parked".equals(e.fromRouteId()) && 
"parked".equals(e.toRouteId()))
+                .isNotEmpty().allMatch(TopologyEdge::isErrorPath);
+    }
+
+    @Test
+    void anErrorPathSaysWhatHappensToTheFailure() {
+        List<TopologyEdge> checkout = edges().stream()
+                .filter(e -> "checkout".equals(e.fromRouteId()) && 
e.isErrorPath()).toList();
+
+        assertThat(checkout).extracting(e -> e.toRouteId() + " " + e.via() + " 
" + e.handling())
+                .containsExactlyInAnyOrder(
+                        // the dead letter channel and an onException with 
handled(true): the message ends there
+                        "parked errorHandler handled",
+                        "parked onException handled",
+                        "audit onException continued",
+                        // a side trip: the failure goes on to the caller
+                        "alert onException notHandled",
+                        // a predicate decides at runtime
+                        "review onException null");
+    }
+
+    @Test
+    void anEdgeWithoutAKindIsACall() {
+        TopologyEdge edge = new TopologyEdge("a", "b", "direct:b", "internal");
+
+        assertThat(edge.kind()).isEqualTo(RouteTopologyDumper.EDGE_CALL);
+        assertThat(edge.via()).isNull();
+        assertThat(edge.handling()).isNull();
+        assertThat(edge.isErrorPath()).isFalse();
+    }
+
+    private List<TopologyEdge> edges() {
+        return new DefaultRouteTopologyDumper().dumpTopology(context).edges();
+    }
+}
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index dcacf9f1a24e..cf9dfdc7c058 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -729,6 +729,17 @@ The `route-topology` developer console (used by `camel cmd 
route-topology` and t
 includes routes created by Kamelets, as these are an implementation detail of 
the Kamelet and were already hidden
 from the route console. Set the `kamelets=true` option (or `--kamelets` on the 
CLI) to include them.
 
+The route topology no longer reports a send from an `onException` clause as a 
call between routes. Such a send, and the
+dead letter uri of a dead letter channel, is now an error path: each edge has 
a `kind` field, `call` as before or
+`error`, and an error path also says what sends (`via`: `errorHandler` or 
`onException`) and what happens to the failure
+(`handling`: `handled`, the message ends there as with a dead letter channel; 
`continued`, the route goes on after it;
+or `notHandled`, the failure goes on to the caller). Error paths come only 
from routes whose error handler handles the
+failure: a route with `noErrorHandler` has none, as its failure goes back to 
the calling route. Before, a global
+`onException` that sent to another route showed up as a call from every route, 
the target route itself included. The
+diagrams (the Camel TUI, camel-diagram) draw the calls only, and the text 
output of the console and of `camel cmd
+route-topology` lists the error paths with `..>`. The 
`RouteTopologyDumper.TopologyEdge` record has the new `kind`,
+`via` and `handling` components; its four-argument constructor is kept and 
creates a `call` edge.
+
 The `inflight` and `blocked` developer consoles now carry a `nodeSource` field 
per entry, saying where the node the
 exchange sits at is in the source (such as `orders.camel.yaml:18`). It is 
`null` when message history or source
 location is disabled. Existing fields are unchanged.
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteTopologyAction.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteTopologyAction.java
index e5ed1e3fa5da..995711824925 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteTopologyAction.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteTopologyAction.java
@@ -201,13 +201,25 @@ public class CamelRouteTopologyAction extends 
ActionBaseCommand {
         return 0;
     }
 
+    private static boolean isErrorPath(JsonObject edge) {
+        return "error".equals(edge.getString("kind"));
+    }
+
     private void printTopology(JsonObject jo) {
         JsonArray nodes = (JsonArray) jo.get("nodes");
         JsonArray edges = (JsonArray) jo.get("edges");
 
         int nodeCount = nodes != null ? nodes.size() : 0;
         int edgeCount = edges != null ? edges.size() : 0;
-        printer().printf("Route Topology (%d routes, %d connections)%n%n", 
nodeCount, edgeCount);
+        int errorPaths = 0;
+        for (int i = 0; i < edgeCount; i++) {
+            if (isErrorPath((JsonObject) edges.get(i))) {
+                errorPaths++;
+            }
+        }
+        // the error paths are counted only when there are any, so the line 
stays as it was without them
+        printer().printf("Route Topology (%d routes, %d connections%s)%n%n", 
nodeCount, edgeCount - errorPaths,
+                errorPaths > 0 ? ", " + errorPaths + " error paths" : "");
 
         if (nodes != null) {
             for (Object n : nodes) {
@@ -221,10 +233,21 @@ public class CamelRouteTopologyAction extends 
ActionBaseCommand {
                     for (Object e : edges) {
                         JsonObject edge = (JsonObject) e;
                         if 
(node.getString("routeId").equals(edge.getString("fromRouteId"))) {
-                            printer().printf("    --> %s via %s [%s]%n",
-                                    edge.getString("toRouteId"),
-                                    edge.getString("endpoint"),
-                                    edge.getString("connectionType"));
+                            if (isErrorPath(edge)) {
+                                // sent to only when the route handles a 
failure
+                                String handling = edge.getString("handling");
+                                printer().printf("    ..> %s via %s [%s, on 
failure: %s%s]%n",
+                                        edge.getString("toRouteId"),
+                                        edge.getString("endpoint"),
+                                        edge.getString("connectionType"),
+                                        edge.getString("via"),
+                                        handling != null ? ", " + handling : 
"");
+                            } else {
+                                printer().printf("    --> %s via %s [%s]%n",
+                                        edge.getString("toRouteId"),
+                                        edge.getString("endpoint"),
+                                        edge.getString("connectionType"));
+                            }
                         }
                     }
                 }

Reply via email to