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 5927c721d8a2 CAMEL-26791: camel-chaos - an expectation of the 
duplicates a route processed (route:duplicates<=0, key=), for the duplicate 
fault (#29558)
5927c721d8a2 is described below

commit 5927c721d8a2b92c2efa99bc159811e7b2eae1f6
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Nov 10 13:10:12 2026 +0100

    CAMEL-26791: camel-chaos - an expectation of the duplicates a route 
processed (route:duplicates<=0, key=), for the duplicate fault (#29558)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../org/apache/camel/catalog/chaos-model.json      |  19 +++
 .../org/apache/camel/catalog/docs/chaos.adoc       |  28 ++++
 .../org/apache/camel/chaos/chaos-model.json        |  19 +++
 components/camel-chaos/src/main/docs/chaos.adoc    |  28 ++++
 .../apache/camel/component/chaos/ChaosAdvisor.java |  39 +++++
 .../camel/component/chaos/ChaosExchangeState.java  |   3 +
 .../apache/camel/component/chaos/ChaosManager.java |  29 ++++
 .../apache/camel/component/chaos/ChaosModel.java   |  10 ++
 .../apache/camel/component/chaos/ChaosReport.java  |   8 +-
 .../component/chaos/ChaosRoutePolicyFactory.java   |  18 ++-
 .../apache/camel/component/chaos/ChaosRule.java    |  86 +++++++++-
 .../camel/component/chaos/ChaosRuleParser.java     |  35 +++-
 .../component/chaos/ChaosExpectDuplicatesTest.java | 180 +++++++++++++++++++++
 .../chaos/ChaosExpectTimeAndLossTest.java          |   3 +-
 .../camel/component/chaos/ChaosModelTest.java      |   4 +-
 .../camel/component/chaos/ChaosRuleParserTest.java |   6 +-
 .../core/commands/ai/ChaosExplorePlaybook.java     |   4 +-
 17 files changed, 504 insertions(+), 15 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/chaos-model.json
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/chaos-model.json
index 6e2ac07ee4ce..647d0ea14e77 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/chaos-model.json
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/chaos-model.json
@@ -194,6 +194,7 @@
           "expect",
           "from",
           "in",
+          "key",
           "rate",
           "until",
           "when",
@@ -232,6 +233,7 @@
           "expect",
           "from",
           "in",
+          "key",
           "rate",
           "until",
           "when",
@@ -259,6 +261,7 @@
           "expect",
           "from",
           "in",
+          "key",
           "rate",
           "until",
           "when",
@@ -308,6 +311,7 @@
           "expect",
           "from",
           "in",
+          "key",
           "rate",
           "until",
           "when",
@@ -333,6 +337,7 @@
           "expect",
           "from",
           "in",
+          "key",
           "rate",
           "until",
           "when",
@@ -388,6 +393,7 @@
         "options": [
           "expect",
           "from",
+          "key",
           "within"
         ]
       },
@@ -429,6 +435,7 @@
         "options": [
           "expect",
           "from",
+          "key",
           "within"
         ]
       }
@@ -479,6 +486,14 @@
         "pattern": true,
         "pick": "routes"
       },
+      {
+        "name": "key",
+        "type": "expression",
+        "required": false,
+        "description": "The key of a message for an expectation of duplicates 
(<route>:duplicates<=0), in the simple language: two messages with the same key 
are the same message; without it the body is the key",
+        "example": "${header.orderId}",
+        "language": "simple"
+      },
       {
         "name": "rate",
         "type": "rate",
@@ -543,6 +558,10 @@
         {
           "name": "lost",
           "description": "The messages completed in the route and not in the 
next one, such as orders->shipping:lost<=0"
+        },
+        {
+          "name": "duplicates",
+          "description": "The messages the route completed a second time (the 
same body, or the same key=) that no idempotent consumer recognised as a 
duplicate, such as payment:duplicates<=0"
         }
       ],
       "ops": [
diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/chaos.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/chaos.adoc
index f833886500ab..8d37bfe685d2 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/chaos.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/chaos.adoc
@@ -1008,6 +1008,8 @@ two routes checks that.
 |`orders:maxTime<=5s` |No exchange of route `orders` took longer than 5 s. One 
that does makes it unmet at once.
 |`orders->shipping:lost<=0` (or `lost=0`) |Every message that completed in 
route `orders` also completed in route
 `shipping`: the count of `shipping` is at most 0 behind the count of `orders` 
when the time is up.
+|`payment:duplicates<=0` (or `duplicates=0`) |Route `payment` did no message 
twice: none completed in it a second time
+without an idempotent consumer recognising it as a duplicate. One more than 
the count makes it unmet at once.
 |===
 
 * The time of an exchange in a route is measured by chaos, from when the 
exchange enters the route until the route is
@@ -1041,6 +1043,32 @@ most 0 expected (rule r2): 1 lost`. In JSON these 
expectations have their `spec`
 the `value` in milliseconds for a time, the `toRoute` of a loss, and the 
`samples` (the exchanges of the time, or the
 ones completed in the first route).
 
+==== Duplicates
+
+A rule with the `duplicate` fault sends a message twice, as a sender or a 
broker that delivers it again. Whether that
+did harm is a question for the route that receives it: did it do the work 
twice, such as a payment taken twice? An
+expectation of duplicates answers it (CAMEL-26791):
+
+[source,text]
+----
+endpoint=http://localhost:8080/payment duplicate rate=0.1 until=1m 
expect=payment:duplicates<=0 within=90s
+----
+
+* Chaos notes the key of each message as it enters the route: its body as 
text, or the value of `key=` in the simple
+language when the body does not tell the messages apart 
(`key=${header.orderId}`, also for a request without a body).
+A message whose key it cannot read (no body, an empty one, or a stream when 
stream caching is off) is not counted.
+* A message that completes in the route with a key that completed there before 
is a duplicate processed, unless an
+idempotent consumer of the route recognised it: it sets the exchange property 
`CamelDuplicateMessage`, with or without
+`skipDuplicate`. A route that recognises duplicates in another way can set 
that property too.
+* Each pass through the route counts: the second send of a duplicate to a 
`direct` route is the same exchange again,
+and so is a redelivery that calls the route again after it completed, which 
did the work twice as well.
+* The route must be a route of the integration, such as the `platform-http` 
route of an HTTP call to the integration's
+own server, a `direct` or `seda` route, or the route of a stubbed input. What 
another system does with a duplicate is
+not seen. The suggestions of `chaos-suggest` add this expectation to a 
duplicate fault when a route of the integration
+receives the send.
+* The op is `<=` or `=`. The value is the duplicates processed, and its 
`samples` the messages that completed with a
+key, such as `2 duplicates processed (41 completed in payment)`.
+
 The route statistics of Camel have no percentiles, so there is no `p99` yet.
 
 === A rule in plain words
diff --git 
a/components/camel-chaos/src/generated/resources/META-INF/org/apache/camel/chaos/chaos-model.json
 
b/components/camel-chaos/src/generated/resources/META-INF/org/apache/camel/chaos/chaos-model.json
index 6e2ac07ee4ce..647d0ea14e77 100644
--- 
a/components/camel-chaos/src/generated/resources/META-INF/org/apache/camel/chaos/chaos-model.json
+++ 
b/components/camel-chaos/src/generated/resources/META-INF/org/apache/camel/chaos/chaos-model.json
@@ -194,6 +194,7 @@
           "expect",
           "from",
           "in",
+          "key",
           "rate",
           "until",
           "when",
@@ -232,6 +233,7 @@
           "expect",
           "from",
           "in",
+          "key",
           "rate",
           "until",
           "when",
@@ -259,6 +261,7 @@
           "expect",
           "from",
           "in",
+          "key",
           "rate",
           "until",
           "when",
@@ -308,6 +311,7 @@
           "expect",
           "from",
           "in",
+          "key",
           "rate",
           "until",
           "when",
@@ -333,6 +337,7 @@
           "expect",
           "from",
           "in",
+          "key",
           "rate",
           "until",
           "when",
@@ -388,6 +393,7 @@
         "options": [
           "expect",
           "from",
+          "key",
           "within"
         ]
       },
@@ -429,6 +435,7 @@
         "options": [
           "expect",
           "from",
+          "key",
           "within"
         ]
       }
@@ -479,6 +486,14 @@
         "pattern": true,
         "pick": "routes"
       },
+      {
+        "name": "key",
+        "type": "expression",
+        "required": false,
+        "description": "The key of a message for an expectation of duplicates 
(<route>:duplicates<=0), in the simple language: two messages with the same key 
are the same message; without it the body is the key",
+        "example": "${header.orderId}",
+        "language": "simple"
+      },
       {
         "name": "rate",
         "type": "rate",
@@ -543,6 +558,10 @@
         {
           "name": "lost",
           "description": "The messages completed in the route and not in the 
next one, such as orders->shipping:lost<=0"
+        },
+        {
+          "name": "duplicates",
+          "description": "The messages the route completed a second time (the 
same body, or the same key=) that no idempotent consumer recognised as a 
duplicate, such as payment:duplicates<=0"
         }
       ],
       "ops": [
diff --git a/components/camel-chaos/src/main/docs/chaos.adoc 
b/components/camel-chaos/src/main/docs/chaos.adoc
index f833886500ab..8d37bfe685d2 100644
--- a/components/camel-chaos/src/main/docs/chaos.adoc
+++ b/components/camel-chaos/src/main/docs/chaos.adoc
@@ -1008,6 +1008,8 @@ two routes checks that.
 |`orders:maxTime<=5s` |No exchange of route `orders` took longer than 5 s. One 
that does makes it unmet at once.
 |`orders->shipping:lost<=0` (or `lost=0`) |Every message that completed in 
route `orders` also completed in route
 `shipping`: the count of `shipping` is at most 0 behind the count of `orders` 
when the time is up.
+|`payment:duplicates<=0` (or `duplicates=0`) |Route `payment` did no message 
twice: none completed in it a second time
+without an idempotent consumer recognising it as a duplicate. One more than 
the count makes it unmet at once.
 |===
 
 * The time of an exchange in a route is measured by chaos, from when the 
exchange enters the route until the route is
@@ -1041,6 +1043,32 @@ most 0 expected (rule r2): 1 lost`. In JSON these 
expectations have their `spec`
 the `value` in milliseconds for a time, the `toRoute` of a loss, and the 
`samples` (the exchanges of the time, or the
 ones completed in the first route).
 
+==== Duplicates
+
+A rule with the `duplicate` fault sends a message twice, as a sender or a 
broker that delivers it again. Whether that
+did harm is a question for the route that receives it: did it do the work 
twice, such as a payment taken twice? An
+expectation of duplicates answers it (CAMEL-26791):
+
+[source,text]
+----
+endpoint=http://localhost:8080/payment duplicate rate=0.1 until=1m 
expect=payment:duplicates<=0 within=90s
+----
+
+* Chaos notes the key of each message as it enters the route: its body as 
text, or the value of `key=` in the simple
+language when the body does not tell the messages apart 
(`key=${header.orderId}`, also for a request without a body).
+A message whose key it cannot read (no body, an empty one, or a stream when 
stream caching is off) is not counted.
+* A message that completes in the route with a key that completed there before 
is a duplicate processed, unless an
+idempotent consumer of the route recognised it: it sets the exchange property 
`CamelDuplicateMessage`, with or without
+`skipDuplicate`. A route that recognises duplicates in another way can set 
that property too.
+* Each pass through the route counts: the second send of a duplicate to a 
`direct` route is the same exchange again,
+and so is a redelivery that calls the route again after it completed, which 
did the work twice as well.
+* The route must be a route of the integration, such as the `platform-http` 
route of an HTTP call to the integration's
+own server, a `direct` or `seda` route, or the route of a stubbed input. What 
another system does with a duplicate is
+not seen. The suggestions of `chaos-suggest` add this expectation to a 
duplicate fault when a route of the integration
+receives the send.
+* The op is `<=` or `=`. The value is the duplicates processed, and its 
`samples` the messages that completed with a
+key, such as `2 duplicates processed (41 completed in payment)`.
+
 The route statistics of Camel have no percentiles, so there is no `p99` yet.
 
 === A rule in plain words
diff --git 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosAdvisor.java
 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosAdvisor.java
index 84feba2a97ed..7ea06df426a9 100644
--- 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosAdvisor.java
+++ 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosAdvisor.java
@@ -297,6 +297,34 @@ final class ChaosAdvisor {
                 from.isRemote());
     }
 
+    /**
+     * The route of the integration that receives a send: the route that 
consumes the endpoint (direct, seda), or for an
+     * HTTP call to this machine the platform-http route of its path; null 
when it is another system.
+     */
+    static String receiver(String uri, Map<String, String> consumers) {
+        String route = consumers.get(uri);
+        if (route != null) {
+            return route;
+        }
+        String scheme = ChaosPatterns.scheme(uri);
+        if (!"http".equals(scheme) && !"https".equals(scheme)) {
+            return null;
+        }
+        String rest = uri.substring(uri.indexOf("//") + 2);
+        int slash = rest.indexOf('/');
+        String host = (slash >= 0 ? rest.substring(0, slash) : 
rest).split(":")[0];
+        if (!"localhost".equals(host) && !"127.0.0.1".equals(host) && 
!"0.0.0.0".equals(host)) {
+            return null;
+        }
+        String path = slash >= 0 ? rest.substring(slash) : "/";
+        for (Map.Entry<String, String> e : consumers.entrySet()) {
+            if (e.getKey().startsWith("platform-http:") && 
path.equals(httpPath(e.getKey()))) {
+                return e.getValue();
+            }
+        }
+        return null;
+    }
+
     /** The method of a platform-http or rest input: the one it restricts to, 
else POST. */
     static String httpMethod(String uri) {
         if (uri.startsWith("rest:")) {
@@ -451,10 +479,21 @@ final class ChaosAdvisor {
                         "a call to " + uri + ": what does a slow answer do to 
the route and its input?", 50);
             }
             if (atLeastOnce) {
+                int before = answer.size();
                 add(answer, recipes, "duplicate", id, Kind.DUPLICATE, 
Map.of("endpoint", uri),
                         "the input of route " + id + " can deliver a message 
again: is the call to " + uri
                                                                                
                + " safe to make twice?",
                         80);
+                // a route of the integration receives the call: the 
expectation tells whether it did the work twice
+                // (CAMEL-26791)
+                String receiver = receiver(uri, consumers);
+                if (receiver != null && answer.size() > before) {
+                    Suggestion d = answer.remove(answer.size() - 1);
+                    answer.add(new Suggestion(
+                            d.route(), d.kind(), d.recipe(), d.title(), 
d.rule() + " expect=" + receiver + ":duplicates<=0",
+                            d.why() + " Route " + receiver + " receives it, 
and the expectation counts what it does twice",
+                            d.rank(), d.routes(), d.with()));
+                }
             }
         }
 
diff --git 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosExchangeState.java
 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosExchangeState.java
index c1e1d4540d08..6c20ba3e5939 100644
--- 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosExchangeState.java
+++ 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosExchangeState.java
@@ -74,6 +74,9 @@ final class ChaosExchangeState implements SafeCopyProperty {
         // they counted its time
         long enteredAt = -1;
         boolean expectedTime;
+        // the key of the message as it entered the route this time, per 
expectation of duplicates (CAMEL-26791), else
+        // null
+        Map<ChaosRule.Expectation, String> passKeys;
     }
 
     private final Map<String, RouteState> routes = new HashMap<>(4);
diff --git 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosManager.java
 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosManager.java
index db42b6c96976..f26bd9ab3fbe 100644
--- 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosManager.java
+++ 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosManager.java
@@ -38,6 +38,7 @@ import org.apache.camel.CamelContext;
 import org.apache.camel.Consumer;
 import org.apache.camel.Exchange;
 import org.apache.camel.ExchangeExtension;
+import org.apache.camel.ExchangePropertyKey;
 import org.apache.camel.ExtendedCamelContext;
 import org.apache.camel.Route;
 import org.apache.camel.component.chaos.ChaosRule.Hook;
@@ -266,6 +267,17 @@ public final class ChaosManager {
                         e);
             }
         }
+        if (b.key != null) {
+            try {
+                b.keyExpression = 
camelContext.resolveLanguage("simple").createExpression(b.key);
+                b.keyExpression.init(camelContext);
+            } catch (RuntimeException e) {
+                throw new IllegalArgumentException(
+                        "Invalid chaos rule [" + b.text + "]: key=" + b.key + 
" is not a valid simple expression: "
+                                                   + e.getMessage(),
+                        e);
+            }
+        }
         return b;
     }
 
@@ -1021,6 +1033,15 @@ public final class ChaosManager {
         }
         ChaosExchangeState.Outcome outcome = outcome(route, exchange);
         ChaosExchangeState.RouteState state = 
ChaosExchangeState.of(exchange).route(routeId);
+        if (state.passKeys != null) {
+            // each pass through the route counts for the duplicates: the 
second send of a duplicate fault to a direct
+            // route is the same exchange again (CAMEL-26791)
+            if (outcome == ChaosExchangeState.Outcome.COMPLETED) {
+                boolean recognised = 
exchange.getProperty(ExchangePropertyKey.DUPLICATE_MESSAGE, false, 
Boolean.class);
+                state.passKeys.forEach((expectation, key) -> 
expectation.recordKey(key, recognised));
+            }
+            state.passKeys = null;
+        }
         ChaosExchangeState.Outcome previous = state.counts == counts ? 
state.outcome : ChaosExchangeState.Outcome.ENTERED;
         if (previous == outcome) {
             return;
@@ -1351,6 +1372,9 @@ public final class ChaosManager {
         final ChaosRule[] expectationRules;
         // the routes with an expectation on their time, whose route policy 
notes when an exchange enters
         final Set<String> timedRoutes;
+        // the rules with an expectation of duplicates, per route: the route 
policy notes the key of each message as it
+        // enters (CAMEL-26791)
+        final Map<String, List<ChaosRule>> keyedRoutes;
 
         RuleSet(List<ChaosRule> rules, long now) {
             // also the rules that start later (from=): the hooks skip them 
until then
@@ -1370,6 +1394,11 @@ public final class ChaosManager {
                     .toArray(ChaosRule.Expectation[]::new);
             timedRoutes = Arrays.stream(expectations).filter(e -> 
e.kind.time()).map(e -> e.route)
                     .collect(Collectors.toUnmodifiableSet());
+            Set<ChaosRule.Expectation> pending = Set.of(expectations);
+            keyedRoutes = Arrays.stream(expectationRules)
+                    .filter(r -> r.expectation.kind == 
ChaosRule.Expectation.Kind.DUPLICATES
+                            && pending.contains(r.expectation))
+                    .collect(Collectors.groupingBy(r -> r.expectation.route));
         }
     }
 
diff --git 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosModel.java
 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosModel.java
index 85e3497c57dc..6317e0737c25 100644
--- 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosModel.java
+++ 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosModel.java
@@ -343,6 +343,16 @@ final class ChaosModel {
                                                                        + " 
(<route>:meanTime<=2s) or the messages lost"
                                                                        + " 
between two routes (<route>-><route>:lost<=0)",
                         "orders:handled<=0");
+                case "key" -> {
+                    JsonObject p = param("key", "expression", false,
+                            "The key of a message for an expectation of 
duplicates (<route>:duplicates<=0), in the"
+                                                                     + " 
simple language: two messages with the same key"
+                                                                     + " are 
the same message; without it the body is the"
+                                                                     + " key",
+                            "${header.orderId}");
+                    p.put("language", "simple");
+                    yield p;
+                }
                 case "within" -> param("within", "duration", false,
                         "The time of the expectation (by default until the 
rule ends)", "2m");
                 case "draw" -> param("draw", "ruleId", false,
diff --git 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosReport.java
 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosReport.java
index d2641bf44ba4..b986a6e5b50e 100644
--- 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosReport.java
+++ 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosReport.java
@@ -577,13 +577,15 @@ public final class ChaosReport {
                 if (r.expectKind == ChaosRule.Expectation.Kind.COMPLETED) {
                     e.put("completed", r.value);
                 }
-                // the expectation as the rule writes it, and for a time 
(count and value in milliseconds) or the
-                // messages lost between two routes, the exchanges it counted 
(CAMEL-26726)
+                // the expectation as the rule writes it, and for a time 
(count and value in milliseconds), the
+                // messages lost between two routes (CAMEL-26726) or the 
duplicates processed (CAMEL-26791), the
+                // exchanges it counted
                 e.put("spec", r.expectSpec);
                 if (r.expectToRoute != null) {
                     e.put("toRoute", r.expectToRoute);
                 }
-                if (r.expectKind.time() || r.expectToRoute != null) {
+                if (r.expectKind.time() || r.expectToRoute != null
+                        || r.expectKind == 
ChaosRule.Expectation.Kind.DUPLICATES) {
                     e.put("samples", r.samples);
                 }
                 e.put("outcome", r.outcome.key);
diff --git 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRoutePolicyFactory.java
 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRoutePolicyFactory.java
index b452c41c4858..b969427ced7b 100644
--- 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRoutePolicyFactory.java
+++ 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRoutePolicyFactory.java
@@ -16,6 +16,10 @@
  */
 package org.apache.camel.component.chaos;
 
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
 import org.apache.camel.CamelContext;
 import org.apache.camel.Exchange;
 import org.apache.camel.NamedNode;
@@ -64,7 +68,9 @@ final class ChaosRoutePolicyFactory implements 
RoutePolicyFactory {
             ChaosRule[] rules = set.entry;
             // an expectation on the time of the route needs when the exchange 
entered it (CAMEL-26726)
             boolean timed = set.timedRoutes.contains(route.getRouteId());
-            if (rules.length == 0 && !timed) {
+            // an expectation of duplicates needs the key of the message as it 
enters, each time (CAMEL-26791)
+            List<ChaosRule> keyed = set.keyedRoutes.get(route.getRouteId());
+            if (rules.length == 0 && !timed && keyed == null) {
                 return;
             }
             ChaosExchangeState exchangeState = ChaosExchangeState.of(exchange);
@@ -73,6 +79,16 @@ final class ChaosRoutePolicyFactory implements 
RoutePolicyFactory {
             if (timed && first) {
                 exchangeState.route(route.getRouteId()).enteredAt = now;
             }
+            if (keyed != null) {
+                Map<ChaosRule.Expectation, String> keys = new HashMap<>(2);
+                for (ChaosRule rule : keyed) {
+                    String key = rule.keyOf(exchange);
+                    if (key != null) {
+                        keys.put(rule.expectation, key);
+                    }
+                }
+                exchangeState.route(route.getRouteId()).passKeys = 
keys.isEmpty() ? null : keys;
+            }
             if (rules.length == 0 || !first) {
                 return;
             }
diff --git 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRule.java
 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRule.java
index 14127dd59479..3c65e2831255 100644
--- 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRule.java
+++ 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRule.java
@@ -20,6 +20,7 @@ import java.io.InputStream;
 import java.io.Reader;
 import java.lang.reflect.Constructor;
 import java.util.ArrayList;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -30,6 +31,7 @@ import java.util.concurrent.atomic.AtomicLong;
 import java.util.concurrent.atomic.LongAdder;
 
 import org.apache.camel.Exchange;
+import org.apache.camel.Expression;
 import org.apache.camel.Predicate;
 import org.apache.camel.StreamCache;
 import org.apache.camel.support.MessageHelper;
@@ -169,6 +171,10 @@ final class ChaosRule {
     // added, so it is null in a rule made without a context
     final String when;
     private final Predicate condition;
+    // the key of a message for an expectation of duplicates (key=, in the 
simple language), else null: the body; the
+    // expression is made when the rule is added, so it is null in a rule made 
without a context (CAMEL-26791)
+    final String key;
+    private final Expression keyExpression;
     private final boolean conditionReadsBody;
     // the endpoint= pattern without the // after its scheme, made once (the 
hooks match it on every send and poll)
     private final String uriPattern;
@@ -262,6 +268,8 @@ final class ChaosRule {
         this.in = b.in;
         this.when = b.when;
         this.condition = b.condition;
+        this.key = b.key;
+        this.keyExpression = b.keyExpression;
         this.conditionReadsBody = b.whenReadsBody;
         this.uriPattern = target == Target.ENDPOINT ? 
ChaosPatterns.withoutSlashes(pattern) : null;
         this.hooks = Set.copyOf(b.hooks);
@@ -573,6 +581,9 @@ final class ChaosRule {
                     sb.append(e.op.words).append(' ').append(e.count).append(' 
').append(e.kind.words(e.count));
                 }
                 sb.append(" in route ").append(e.route);
+                if (key != null) {
+                    sb.append(" (a message known by ").append(key).append(')');
+                }
             }
             if (within != plus(from, runFor)) {
                 // a within= of its own (the default is until the rule ends)
@@ -833,6 +844,43 @@ final class ChaosRule {
      * not cached yet (such as the reply of a send) is cached first, and the 
cache is reset after the check, so the
      * route reads the body as before.
      */
+    /**
+     * The key of the message as it enters a route, for an expectation of 
duplicates (CAMEL-26791): the value of key=,
+     * else the body as text. Null when there is none (no body, an empty one, 
or a stream that cannot be read twice), as
+     * such a message is not counted.
+     */
+    String keyOf(Exchange exchange) {
+        try {
+            Object value;
+            if (keyExpression != null) {
+                if (key.contains("body")) {
+                    cacheStreamBody(exchange);
+                }
+                value = keyExpression.evaluate(exchange, String.class);
+            } else {
+                cacheStreamBody(exchange);
+                Object body = exchange.getMessage().getBody();
+                if (body == null || (body instanceof InputStream || body 
instanceof Reader) && !(body instanceof StreamCache)) {
+                    return null;
+                }
+                value = exchange.getMessage().getBody(String.class);
+                if (body instanceof StreamCache cache) {
+                    cache.reset();
+                }
+            }
+            String text = value != null ? value.toString() : null;
+            if (text == null || text.isEmpty()) {
+                return null;
+            }
+            // a long body is kept as its length and hash, not as itself
+            return text.length() <= 200 ? text : text.length() + ":" + 
Integer.toHexString(text.hashCode());
+        } catch (Exception e) {
+            LOG.debug("The key of chaos rule {} ({}) failed on exchange {}: 
{}", id, key != null ? "key=" + key : "the body",
+                    exchange.getExchangeId(), e.getMessage(), e);
+            return null;
+        }
+    }
+
     boolean matchesWhen(Exchange exchange) {
         Predicate c = condition;
         if (c == null) {
@@ -933,7 +981,12 @@ final class ChaosRule {
                      "The longest time of an exchange in the route, from 
entering it until done, such as maxTime<=5s"),
             // the messages one route completed that the next one did not: 
<route>-><next route>:lost<=0
             LOST("lost", "message lost", "messages lost",
-                 "The messages completed in the route and not in the next one, 
such as orders->shipping:lost<=0");
+                 "The messages completed in the route and not in the next one, 
such as orders->shipping:lost<=0"),
+            // the messages a route completed again, with the key of one it 
completed before, that no idempotent
+            // consumer recognised as a duplicate (CAMEL-26791)
+            DUPLICATES("duplicates", "duplicate processed", "duplicates 
processed",
+                       "The messages the route completed a second time (the 
same body, or the same key=) that no"
+                                                                               
     + " idempotent consumer recognised as a duplicate, such as 
payment:duplicates<=0");
 
             final String key;
             // for the reason of an unmet expectation
@@ -973,6 +1026,9 @@ final class ChaosRule {
             }
         }
 
+        // the most keys a duplicates expectation keeps; the messages after 
that are not counted as duplicates
+        static final int MAX_KEYS = 100_000;
+
         final String route;
         // the next route of a lost expectation, else null
         final String toRoute;
@@ -987,6 +1043,8 @@ final class ChaosRule {
         private long sum;
         private long max;
         private long toValue;
+        // duplicates: the keys of the messages the route completed (at most 
MAX_KEYS)
+        private Set<String> keys;
         // when it was decided before the time was up (met for at least, unmet 
for at most and exactly), and how
         private long decidedAt = -1;
         private Outcome decided;
@@ -1030,6 +1088,28 @@ final class ChaosRule {
             }
         }
 
+        /**
+         * A message completed in the route of a duplicates expectation, with 
its key: the second time a key completes
+         * it is a duplicate processed, unless an idempotent consumer 
recognised it ({@code CamelDuplicateMessage}).
+         */
+        synchronized void recordKey(String key, boolean recognised) {
+            long now = TimeSource.currentTimeMillis();
+            if (frozenAt >= 0 || now > deadline) {
+                return;
+            }
+            samples++;
+            if (keys == null) {
+                keys = new HashSet<>();
+            }
+            if (keys.contains(key)) {
+                if (!recognised) {
+                    recordAt(now);
+                }
+            } else if (keys.size() < MAX_KEYS) {
+                keys.add(key);
+            }
+        }
+
         /** An exchange was done in the route, after it was so long in it (a 
time kind). */
         synchronized void recordTime(long millis) {
             recordTimeAt(millis, TimeSource.currentTimeMillis());
@@ -1155,6 +1235,8 @@ final class ChaosRule {
                           + reading.samples() + (reading.samples() == 1 ? " 
exchange" : " exchanges");
                 case LOST -> reading.value() + " lost (" + reading.samples() + 
" completed in " + route + ", "
                              + reading.next() + " in " + toRoute + ")";
+                case DUPLICATES -> reading.value() + (reading.value() == 1 ? " 
duplicate" : " duplicates")
+                                   + " processed (" + reading.samples() + " 
completed in " + route + ")";
                 default -> reading.value() + " " + kind.key;
             };
         }
@@ -1232,6 +1314,8 @@ final class ChaosRule {
         String when;
         boolean whenReadsBody;
         Predicate condition;
+        String key;
+        Expression keyExpression;
         Hook at;
         Set<Hook> hooks;
         Fault fault;
diff --git 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRuleParser.java
 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRuleParser.java
index 728cbad2c7ec..bfbead14a9f5 100644
--- 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRuleParser.java
+++ 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRuleParser.java
@@ -58,8 +58,8 @@ final class ChaosRuleParser {
     // the options of a rule besides the parameters of its fault: all of them 
for the faults at a hook, and those of a
     // stall or lifecycle rule, which acts on its route at no hook
     static final Set<String> GENERAL_KEYS
-            = Set.of("at", "in", "when", "rate", "until", "from", "expect", 
"within", "draw");
-    static final Set<String> LIFECYCLE_KEYS = Set.of("from", "expect", 
"within");
+            = Set.of("at", "in", "when", "rate", "until", "from", "expect", 
"within", "key", "draw");
+    static final Set<String> LIFECYCLE_KEYS = Set.of("from", "expect", 
"within", "key");
     // the prefix of the response headers of error status=: 
header.<name>=<value>
     static final String HEADER_PREFIX = "header.";
     private static final Set<String> ERROR_KEYS
@@ -241,6 +241,7 @@ final class ChaosRuleParser {
             // stall and lifecycle act on the route: no hook, rate or until
             lifecycle(b, params, maxDuration);
             expectation(b, params.get("expect"), params.get("within"), 
maxDuration);
+            key(b, params.get("key"));
             return b;
         }
 
@@ -321,6 +322,7 @@ final class ChaosRuleParser {
                                                + " fire");
         }
         expectation(b, params.get("expect"), params.get("within"), 
maxDuration);
+        key(b, params.get("key"));
         return b;
     }
 
@@ -928,6 +930,28 @@ final class ChaosRuleParser {
         }
     }
 
+    /**
+     * The key of a message for an expectation of duplicates (key=, in the 
simple language): two messages with the same
+     * key are the same message. Without it the body is the key (CAMEL-26791).
+     */
+    private static void key(ChaosRule.Builder b, String key) {
+        if (key == null) {
+            return;
+        }
+        if (b.expectKind != ChaosRule.Expectation.Kind.DUPLICATES) {
+            throw new IllegalArgumentException(
+                    "key= is the key of a message for an expectation of 
duplicates, such as"
+                                               + " 
expect=payment:duplicates<=0 key=${header.orderId}");
+        }
+        if (languagePrefix(key) != null) {
+            throw new IllegalArgumentException(
+                    "key= takes an expression in the simple language, without 
a language in front ("
+                                               + languagePrefix(key) + ": is 
not supported), such as"
+                                               + " key=${header.orderId}");
+        }
+        b.key = key;
+    }
+
     private static void expectation(ChaosRule.Builder b, String expect, String 
within, long maxDuration) {
         if (expect == null) {
             if (within != null) {
@@ -977,7 +1001,7 @@ final class ChaosRuleParser {
                 throw new IllegalArgumentException(
                         "expect=" + expect + " counts " + (kind.isEmpty() ? 
"nothing" : kind)
                                                    + ": the kinds are 
completed, handled, failed, redeliveries, meanTime,"
-                                                   + " maxTime and lost, such 
as expect=orders:handled<=0,"
+                                                   + " maxTime, lost and 
duplicates, such as expect=orders:handled<=0,"
                                                    + " 
expect=orders:meanTime<=2s or expect=orders->shipping:lost<=0");
             }
             String rest = spec.substring(op);
@@ -1015,6 +1039,11 @@ final class ChaosRuleParser {
             withinOf(b, within, maxDuration);
             return;
         }
+        if (b.expectKind == ChaosRule.Expectation.Kind.DUPLICATES && 
b.expectOp == ChaosRule.Expectation.Op.AT_LEAST) {
+            throw new IllegalArgumentException(
+                    "expect=" + expect + " needs <= or = for the duplicates 
processed, such as expect=" + b.expectRoute
+                                               + ":duplicates<=0");
+        }
         if (b.expectKind == ChaosRule.Expectation.Kind.LOST && b.expectOp == 
ChaosRule.Expectation.Op.AT_LEAST) {
             throw new IllegalArgumentException(
                     "expect=" + expect + " needs <= or = for the messages 
lost, such as expect=" + b.expectRoute + "->"
diff --git 
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosExpectDuplicatesTest.java
 
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosExpectDuplicatesTest.java
new file mode 100644
index 000000000000..9e17bd7a0780
--- /dev/null
+++ 
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosExpectDuplicatesTest.java
@@ -0,0 +1,180 @@
+/*
+ * 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.component.chaos;
+
+import java.util.Map;
+
+import org.apache.camel.builder.RouteBuilder;
+import 
org.apache.camel.support.processor.idempotent.MemoryIdempotentRepository;
+import org.apache.camel.test.AvailablePortFinder;
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.Isolated;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * CAMEL-26791: an expectation of the duplicates a route processed 
(payment:duplicates<=0), for a duplicate fault: the
+ * messages the route completed a second time, with the same body or the same 
key=, that no idempotent consumer
+ * recognised. The clock is virtual.
+ */
+@Isolated
+class ChaosExpectDuplicatesTest extends ChaosTestSupport {
+
+    // the HTTP server of the integration, which a call of the shop reaches 
over HTTP
+    private static final int PORT = AvailablePortFinder.getNextAvailable();
+
+    private FakeClock clock;
+
+    @Override
+    protected void setupResources() throws Exception {
+        super.setupResources();
+        clock = FakeClock.install();
+    }
+
+    @Override
+    protected void cleanupResources() throws Exception {
+        clock.close();
+        super.cleanupResources();
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                // the shop charges each order at the payment route, which 
charges every message it gets
+                from("direct:orders").routeId("orders").to("direct:payment");
+                from("direct:payment").routeId("payment").to("mock:charged");
+                // the same, with an idempotent consumer in front of the charge
+                from("direct:refunds").routeId("refunds").to("direct:payback");
+                from("direct:payback").routeId("payback")
+                        .idempotentConsumer(body(), 
MemoryIdempotentRepository.memoryIdempotentRepository())
+                        .to("mock:paidBack");
+                // a call over HTTP to a route of the same integration: each 
call is an exchange of its own there, with
+                // the body as a stream
+                
from("direct:checkout").routeId("checkout").to("netty-http:http://localhost:"; + 
PORT + "/charge");
+                from("netty-http:http://0.0.0.0:"; + PORT + 
"/charge").routeId("charge")
+                        // read after the key was: an empty body would mean 
the key consumed it
+                        .convertBodyTo(String.class)
+                        .to("mock:charge")
+                        .setBody(constant("{\"charged\": true}"));
+            }
+        };
+    }
+
+    private JsonObject expectation(String id) {
+        return (JsonObject) ruleReport(id).get("expectation");
+    }
+
+    @Test
+    void theDuplicatesARouteProcessed() throws Exception {
+        // every second order is sent to payment twice, as a sender that 
delivers it again
+        String id = chaos().addRule("endpoint=direct:payment duplicate 
rate=every:2 expect=payment:duplicates<=0 within=1m");
+        for (int i = 0; i < 4; i++) {
+            template.sendBody("direct:orders", "order " + i);
+        }
+
+        // charged twice: decided at once, as it cannot become met
+        JsonObject e = expectation(id);
+        assertThat(e.getString("outcome")).isEqualTo("unmet");
+        assertThat(e.getLong("value")).isEqualTo(2);
+        assertThat(e.getLong("samples")).isEqualTo(6);
+        
assertThat(getMockEndpoint("mock:charged").getReceivedCounter()).isEqualTo(6);
+        ChaosReport report = chaos().report();
+        assertThat(report.getVerdict()).isEqualTo(ChaosReport.FAIL);
+        assertThat(report.toText()).contains(
+                "expect payment:duplicates<=0 within 1m0s: unmet, 2 duplicates 
processed (6 completed in payment)");
+        // a duplicate processed is not a message lost
+        assertThat(report.getLost()).isZero();
+        assertThat(chaos().describeRule(id)).endsWith("; expects no duplicates 
processed in route payment within 1 minute");
+    }
+
+    @Test
+    void theDuplicatesOfAnHttpCallToARouteOfTheIntegration() {
+        String id = chaos().addRule("endpoint=netty-http:http://localhost:"; + 
PORT
+                                    + "/charge duplicate rate=every:2 
expect=charge:duplicates<=0 within=1m");
+        for (int i = 0; i < 4; i++) {
+            template.sendBody("direct:checkout", "{\"order\": " + i + "}");
+        }
+
+        JsonObject e = expectation(id);
+        assertThat(e.getString("outcome")).isEqualTo("unmet");
+        assertThat(e.getLong("value")).isEqualTo(2);
+        // the key read the body, and the route still had it
+        assertThat(getMockEndpoint("mock:charge").getReceivedExchanges())
+                .extracting(x -> x.getMessage().getBody(String.class))
+                .containsExactly("{\"order\": 0}", "{\"order\": 1}", 
"{\"order\": 1}", "{\"order\": 2}", "{\"order\": 3}",
+                        "{\"order\": 3}");
+    }
+
+    @Test
+    void theDuplicatesAnIdempotentConsumerRecognisedAreNotCounted() {
+        String id = chaos().addRule("endpoint=direct:payback duplicate 
rate=every:2 expect=payback:duplicates<=0 within=1m");
+        for (int i = 0; i < 4; i++) {
+            template.sendBody("direct:refunds", "refund " + i);
+        }
+        clock.advance(60_001);
+
+        JsonObject e = expectation(id);
+        assertThat(e.getString("outcome")).isEqualTo("met");
+        assertThat(e.getLong("value")).isZero();
+        
assertThat(getMockEndpoint("mock:paidBack").getReceivedCounter()).isEqualTo(4);
+        assertThat(chaos().report().getVerdict()).isEqualTo(ChaosReport.PASS);
+    }
+
+    @Test
+    void theKeyOfAMessage() {
+        // the body does not tell the orders apart, the header does
+        String id = chaos().addRule(
+                "endpoint=direct:payment duplicate rate=every:2 
expect=payment:duplicates<=0 within=1m key=${header.orderId}");
+        for (int i = 0; i < 4; i++) {
+            template.sendBodyAndHeader("direct:orders", "an order", "orderId", 
"ORD-" + i);
+        }
+
+        JsonObject e = expectation(id);
+        assertThat(e.getString("outcome")).isEqualTo("unmet");
+        // with the body as the key, each order after the first would count
+        assertThat(e.getLong("value")).isEqualTo(2);
+        assertThat(chaos().describeRule(id)).contains("in route payment (a 
message known by ${header.orderId})");
+    }
+
+    @Test
+    void theRouteThatReceivesADuplicateForTheSuggestion() {
+        Map<String, String> consumers = Map.of("direct://payment", "payment", 
"platform-http:///refunds";, "refunds");
+
+        // a suggestion of a duplicate fault adds the expectation of the route 
that receives it
+        assertThat(ChaosAdvisor.receiver("direct://payment", 
consumers)).isEqualTo("payment");
+        assertThat(ChaosAdvisor.receiver("http://localhost:8080/refunds";, 
consumers)).isEqualTo("refunds");
+        // another system, or a path no route serves
+        
assertThat(ChaosAdvisor.receiver("http://payments.example.com/refunds";, 
consumers)).isNull();
+        assertThat(ChaosAdvisor.receiver("http://localhost:8080/stock";, 
consumers)).isNull();
+        assertThat(ChaosAdvisor.receiver("kafka://orders", 
consumers)).isNull();
+    }
+
+    @Test
+    void aDuplicatesExpectationIsAtMostAndKeyNeedsIt() {
+        assertThatThrownBy(() -> chaos().addRule("endpoint=direct:payment 
duplicate expect=payment:duplicates>=1"))
+                .hasMessageContaining("needs <= or = for the duplicates 
processed, such as expect=payment:duplicates<=0");
+        assertThatThrownBy(() -> chaos().addRule("endpoint=direct:payment 
duplicate expect=payment:failed<=0 key=${body}"))
+                .hasMessageContaining("key= is the key of a message for an 
expectation of duplicates");
+        assertThatThrownBy(() -> chaos().addRule(
+                "endpoint=direct:payment duplicate 
expect=payment:duplicates<=0 key=groovy:body.id"))
+                .hasMessageContaining("key= takes an expression in the simple 
language");
+    }
+}
diff --git 
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosExpectTimeAndLossTest.java
 
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosExpectTimeAndLossTest.java
index dfbfc352df79..2e42931bee3b 100644
--- 
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosExpectTimeAndLossTest.java
+++ 
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosExpectTimeAndLossTest.java
@@ -196,6 +196,7 @@ class ChaosExpectTimeAndLossTest extends ChaosTestSupport {
         assertThatThrownBy(() -> chaos().addRule("step=callStock delay 
duration=1ms expect=orders->orders:lost<=0"))
                 .hasMessageContaining("needs two routes");
         assertThatThrownBy(() -> chaos().addRule("step=callStock delay 
duration=1ms expect=orders:speed<=1"))
-                .hasMessageContaining("the kinds are completed, handled, 
failed, redeliveries, meanTime, maxTime and lost");
+                .hasMessageContaining(
+                        "the kinds are completed, handled, failed, 
redeliveries, meanTime, maxTime, lost and duplicates");
     }
 }
diff --git 
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosModelTest.java
 
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosModelTest.java
index ed553b29af1c..28dae2733975 100644
--- 
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosModelTest.java
+++ 
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosModelTest.java
@@ -93,14 +93,14 @@ class ChaosModelTest {
         assertThat(paramNames(byName("faults", 
"lifecycle"))).containsExactly("action", "duration", "every", "repeat");
         assertThat(paramNames(byName("faults", "error")))
                 .containsExactly("after", "body", "exception", 
"header.<name>", "message", "status");
-        assertThat(strings(byName("faults", 
"lifecycle").get("options"))).containsExactly("expect", "from", "within");
+        assertThat(strings(byName("faults", 
"lifecycle").get("options"))).containsExactly("expect", "from", "key", 
"within");
         assertThat(strings(byName("faults", 
"error").get("hooks"))).containsExactly("send", "reply", "dlq", "poll", "entry",
                 "step");
         // duplicate is a fault of the sends only
         assertThat(strings(byName("faults", 
"duplicate").get("hooks"))).containsExactly("send");
         assertThat(strings(byName("faults", 
"duplicate").get("targets"))).containsExactly("component", "endpoint");
         assertThat(paramNames(byName("faults", "duplicate"))).isEmpty();
-        assertThat(names("options")).containsExactly("at", "draw", "expect", 
"from", "in", "rate", "until", "when",
+        assertThat(names("options")).containsExactly("at", "draw", "expect", 
"from", "in", "key", "rate", "until", "when",
                 "within");
         // the TUI form picks the route of in=
         assertThat(byName("options", 
"in").getString("pick")).isEqualTo("routes");
diff --git 
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosRuleParserTest.java
 
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosRuleParserTest.java
index 375aec13f1fe..91c7311cf785 100644
--- 
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosRuleParserTest.java
+++ 
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosRuleParserTest.java
@@ -392,9 +392,9 @@ class ChaosRuleParserTest {
             "route=orders stall duration=1m until=1m|until= does not apply to 
the fault stall",
             "route=orders stall duration=1m at=entry|at= does not apply to the 
fault stall",
             "route=orders stall duration=1m mode=headers|unknown parameter 
mode for the fault stall (its parameters are"
-                                                                               
              + " duration, every, repeat, expect, from, within)",
+                                                                               
              + " duration, every, repeat, expect, from, key, within)",
             "route=orders lifecycle action=stop duration=1m mode=x|unknown 
parameter mode for the fault lifecycle (its"
-                                                                               
                                                                   + " 
parameters are action, duration, every, repeat, expect, from, within)",
+                                                                               
                                                                        + " 
parameters are action, duration, every, repeat, expect, from, key, within)",
             "route=orders stall lifecycle duration=1m|a rule has one fault, 
found stall and lifecycle",
             "endpoint=kafka:x stall duration=1m|the fault stall acts on a 
route, so it takes only a route=<id> target, not"
                                                                                
                         + " endpoint=",
@@ -463,7 +463,7 @@ class ChaosRuleParserTest {
             "endpoint=x error expect=orders:|is not <route>:<count>",
             "endpoint=x error expect=orders:0|needs a count of 1 or more",
             "endpoint=x error expect=orders:completed>=0|needs a count of 1 or 
more",
-            "endpoint=x error expect=orders:speed<=0|counts speed: the kinds 
are completed, handled, failed, redeliveries, meanTime, maxTime and lost",
+            "endpoint=x error expect=orders:speed<=0|counts speed: the kinds 
are completed, handled, failed, redeliveries, meanTime, maxTime, lost and 
duplicates",
             "endpoint=x error expect=orders:<=0|counts nothing",
             "endpoint=x error expect=orders:handled<0|needs >=, <= or = after 
handled",
             "endpoint=x error expect=orders:handled<=x|needs a count that is a 
whole number",
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ChaosExplorePlaybook.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ChaosExplorePlaybook.java
index d548793c77a8..e4f809384651 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ChaosExplorePlaybook.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ChaosExplorePlaybook.java
@@ -84,7 +84,9 @@ public final class ChaosExplorePlaybook {
                 wrong, not a guess such as failed<=5, which hides the messages 
that were lost: expect=<route>:failed<=0 \
                 (nothing fails), expect=<route>-><next route>:lost<=0 (nothing 
lost when a route hands messages to \
                 the next route, as the suggestions of kind lost name them), \
-                expect=<route>:20 (20 completed), expect=<route>:maxTime<=5s 
(none slower than 5s)
+                expect=<route>:20 (20 completed), expect=<route>:maxTime<=5s 
(none slower than 5s), \
+                expect=<route>:duplicates<=0 for a duplicate fault (the route 
that receives the message did not do \
+                the work twice; add key=${header.<id>} when the body does not 
tell the messages apart)
                    - feed: a route that does not feed itself gets no messages 
unless you give feed. When the feed \
                 of the suggestion is kind send, always give its endpoint, with 
body (a message as the route \
                 expects it, such as JSON), repeat (such as 20) and interval 
(such as 100..300); leave feed out \

Reply via email to