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"));
+ }
}
}
}