This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch CAMEL-26607-multi-hook
in repository https://gitbox.apache.org/repos/asf/camel.git

commit cd553ed4ec0ad071acfb93a4b956ba83f3dcd74d
Author: Claus Ibsen <[email protected]>
AuthorDate: Sat Nov 7 11:05:19 2026 +0100

    CAMEL-26607: camel-core - more than one StepHook and SendHook, ordered, so 
chaos runs with other dev tools
    
    ExtendedCamelContext gets addStepHook/removeStepHook and 
addSendHook/removeSendHook in place of the
    single-slot setters (unreleased, 4.24). The context keeps one volatile 
field that is null, the one
    hook, or a copy-on-write CompositeStepHook/CompositeSendHook 
(camel-support), so the hot path stays
    one null check. Hooks are sorted by Ordered (stable, no Ordered counts as 
0).
    
    A composite step hook asks the hooks in order, stops at the first that 
fails the step, and adds up
    the delays. A composite send hook asks in order, the first SKIP wins, the 
longest delay wins, and
    afterSend is called only on the hooks that were asked, in reverse order.
    
    camel-chaos installs its hooks with add*, with Ordered.LOWEST so they run 
innermost, and no longer
    refuses to install when another hook is there.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../org/apache/camel/catalog/docs/chaos.adoc       |   7 +-
 components/camel-chaos/src/main/docs/chaos.adoc    |   7 +-
 .../apache/camel/component/chaos/ChaosManager.java |  14 +-
 .../camel/component/chaos/ChaosSendHook.java       |  11 +-
 .../camel/component/chaos/ChaosStepHook.java       |  11 +-
 .../camel/component/chaos/ChaosActivationTest.java |  13 +-
 .../java/org/apache/camel/jta/StepHookJtaTest.java |   2 +-
 .../org/apache/camel/ExtendedCamelContext.java     |  52 ++-
 .../main/java/org/apache/camel/spi/SendHook.java   |  17 +-
 .../main/java/org/apache/camel/spi/StepHook.java   |  18 +-
 .../impl/engine/DefaultCamelContextExtension.java  |  58 ++-
 .../camel/impl/ExtendedCamelContextConfigurer.java |  12 -
 .../apache/camel/processor/MultipleHooksTest.java  | 157 ++++++++
 .../apache/camel/processor/SendHookStressTest.java |   2 +-
 .../org/apache/camel/processor/SendHookTest.java   |  10 +-
 .../apache/camel/processor/StepHookStressTest.java |   2 +-
 .../org/apache/camel/processor/StepHookTest.java   |   6 +-
 .../apache/camel/support/CompositeHookTest.java    | 423 +++++++++++++++++++++
 .../org/apache/camel/support/SendHelperTest.java   |   5 +-
 .../org/apache/camel/support/StepHelperTest.java   |   5 +-
 .../camel/management/ManagedStepHookTest.java      |   2 +-
 .../apache/camel/support/CompositeSendHook.java    | 136 +++++++
 .../apache/camel/support/CompositeStepHook.java    | 101 +++++
 .../java/org/apache/camel/support/SendHelper.java  |  71 +++-
 design/exchange-sanitizer.adoc                     |  15 +-
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |   2 +-
 26 files changed, 1074 insertions(+), 85 deletions(-)

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 9776b4e8b418..54671d202bf6 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
@@ -386,6 +386,11 @@ The steps inside `onException` and `doCatch` are steps of 
the route, and the sen
 letter channel) are sends: so `step=*` also fails the steps that handle a 
failure, and `endpoint=*` the sends of
 `onException`. Name the endpoints or steps under test in the pattern to keep 
them out.
 
+Chaos shares the send hook and the step hook of camel-core with the other 
tools that use them: they all run, each
+send and step asks every hook. The hooks of chaos have the lowest order 
(`Ordered.LOWEST`), so they are asked last
+and run innermost, right next to the producer or the processor: a hook that 
observes the sends or steps sees the
+faults of chaos as failures of the producer or the step.
+
 == Route lifecycle faults
 
 `stall` and `lifecycle` act on a route, not on its messages: `route=poller 
stall duration=20s` suspends route
@@ -667,7 +672,5 @@ expectation is decided) into the report history, where 
their ids can no longer b
 keeps the last 100 removed, replaced or ended ones. An unmet expectation of a 
rule that leaves the report still counts
 in the verdict and in `lost`.
 * A rule is at most 2000 characters long.
-* There is one send hook and one step hook per `CamelContext`: when another 
one is installed already, chaos keeps it,
-logs a WARN, and its rules do not fire at those hooks.
 * A `stall` or `lifecycle` rule acts on one route, once, or `repeat=` times 
(at most 100). To take a route down again
 after that, add the rule again.
diff --git a/components/camel-chaos/src/main/docs/chaos.adoc 
b/components/camel-chaos/src/main/docs/chaos.adoc
index 9776b4e8b418..54671d202bf6 100644
--- a/components/camel-chaos/src/main/docs/chaos.adoc
+++ b/components/camel-chaos/src/main/docs/chaos.adoc
@@ -386,6 +386,11 @@ The steps inside `onException` and `doCatch` are steps of 
the route, and the sen
 letter channel) are sends: so `step=*` also fails the steps that handle a 
failure, and `endpoint=*` the sends of
 `onException`. Name the endpoints or steps under test in the pattern to keep 
them out.
 
+Chaos shares the send hook and the step hook of camel-core with the other 
tools that use them: they all run, each
+send and step asks every hook. The hooks of chaos have the lowest order 
(`Ordered.LOWEST`), so they are asked last
+and run innermost, right next to the producer or the processor: a hook that 
observes the sends or steps sees the
+faults of chaos as failures of the producer or the step.
+
 == Route lifecycle faults
 
 `stall` and `lifecycle` act on a route, not on its messages: `route=poller 
stall duration=20s` suspends route
@@ -667,7 +672,5 @@ expectation is decided) into the report history, where 
their ids can no longer b
 keeps the last 100 removed, replaced or ended ones. An unmet expectation of a 
rule that leaves the report still counts
 in the verdict and in `lost`.
 * A rule is at most 2000 characters long.
-* There is one send hook and one step hook per `CamelContext`: when another 
one is installed already, chaos keeps it,
-logs a WARN, and its rules do not fire at those hooks.
 * A `stall` or `lifecycle` rule acts on one route, once, or `repeat=` times 
(at most 100). To take a route down again
 after that, add the rule again.
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 532f439ccdb6..67d589e5bb26 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
@@ -744,21 +744,15 @@ public final class ChaosManager {
      */
     void install() {
         ExtendedCamelContext ecc = camelContext.getCamelContextExtension();
+        // the hooks of chaos take the lowest order, so they run innermost, 
next to the producer and the processor,
+        // after the other hooks that may be installed (such as hooks that 
observe the sends and steps)
         try {
-            if (ecc.getSendHook() != null) {
-                LOG.warn("A SendHook is installed already ({}), so chaos rules 
do not fire at sends", ecc.getSendHook());
-            } else {
-                ecc.setSendHook(new ChaosSendHook(this));
-            }
+            ecc.addSendHook(new ChaosSendHook(this));
         } catch (UnsupportedOperationException e) {
             LOG.warn("This CamelContext does not support a SendHook, so chaos 
rules do not fire at sends");
         }
         try {
-            if (ecc.getStepHook() != null) {
-                LOG.warn("A StepHook is installed already ({}), so chaos rules 
do not fire at steps", ecc.getStepHook());
-            } else {
-                ecc.setStepHook(new ChaosStepHook(this));
-            }
+            ecc.addStepHook(new ChaosStepHook(this));
         } catch (UnsupportedOperationException e) {
             LOG.warn("This CamelContext does not support a StepHook, so chaos 
rules do not fire at steps");
         }
diff --git 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosSendHook.java
 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosSendHook.java
index 17384f2bb64d..b013e4b29946 100644
--- 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosSendHook.java
+++ 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosSendHook.java
@@ -18,6 +18,7 @@ package org.apache.camel.component.chaos;
 
 import org.apache.camel.Endpoint;
 import org.apache.camel.Exchange;
+import org.apache.camel.Ordered;
 import org.apache.camel.component.chaos.ChaosRule.Hook;
 import org.apache.camel.spi.SendHook;
 import org.apache.camel.util.TimeSource;
@@ -31,7 +32,7 @@ import org.apache.camel.util.TimeSource;
  * rules with {@code at=reply} are asked after the producer succeeded: {@code 
error} fails the send after its side
  * effect happened, and {@code corrupt} changes the reply.
  */
-final class ChaosSendHook implements SendHook {
+final class ChaosSendHook implements SendHook, Ordered {
 
     private final ChaosManager manager;
 
@@ -124,6 +125,14 @@ final class ChaosSendHook implements SendHook {
         }
     }
 
+    /**
+     * The lowest order: the hook runs innermost, after the other hooks, right 
next to the producer.
+     */
+    @Override
+    public int getOrder() {
+        return Ordered.LOWEST;
+    }
+
     @Override
     public String toString() {
         return "ChaosSendHook";
diff --git 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosStepHook.java
 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosStepHook.java
index 501b5e866373..2cf2f5216335 100644
--- 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosStepHook.java
+++ 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosStepHook.java
@@ -17,6 +17,7 @@
 package org.apache.camel.component.chaos;
 
 import org.apache.camel.Exchange;
+import org.apache.camel.Ordered;
 import org.apache.camel.component.chaos.ChaosRule.Fault;
 import org.apache.camel.component.chaos.ChaosRule.Hook;
 import org.apache.camel.spi.StepHook;
@@ -28,7 +29,7 @@ import org.apache.camel.util.TimeSource;
  * {@code delay} (the thread that runs the step waits, through {@link 
TimeSource}, before it calls the processor). The
  * first rule that fires decides the fault of a step.
  */
-final class ChaosStepHook implements StepHook {
+final class ChaosStepHook implements StepHook, Ordered {
 
     private final ChaosManager manager;
 
@@ -58,6 +59,14 @@ final class ChaosStepHook implements StepHook {
         return PROCEED;
     }
 
+    /**
+     * The lowest order: the hook runs innermost, after the other hooks, right 
next to the processor of the step.
+     */
+    @Override
+    public int getOrder() {
+        return Ordered.LOWEST;
+    }
+
     @Override
     public String toString() {
         return "ChaosStepHook";
diff --git 
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosActivationTest.java
 
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosActivationTest.java
index d9d5e2223810..81eac47ddcc3 100644
--- 
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosActivationTest.java
+++ 
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosActivationTest.java
@@ -27,6 +27,7 @@ import 
org.apache.camel.component.chaos.ChaosRoutePolicyFactory.ChaosRoutePolicy
 import org.apache.camel.component.properties.PropertiesComponent;
 import org.apache.camel.impl.DefaultCamelContext;
 import org.apache.camel.spi.SendHook;
+import org.apache.camel.support.CompositeSendHook;
 import org.apache.camel.support.component.PropertyConfigurerSupport;
 import org.apache.camel.util.json.JsonArray;
 import org.junit.jupiter.api.AfterEach;
@@ -283,7 +284,7 @@ class ChaosActivationTest {
     }
 
     @Test
-    void anotherSendHookIsKept() throws Exception {
+    void anotherSendHookRunsWithChaos() throws Exception {
         context = new DefaultCamelContext();
         SendHook mine = new SendHook() {
             @Override
@@ -296,10 +297,16 @@ class ChaosActivationTest {
                 // nothing
             }
         };
-        context.getCamelContextExtension().setSendHook(mine);
+        context.getCamelContextExtension().addSendHook(mine);
         
context.getPropertiesComponent().addInitialProperty("camel.chaos.enabled", 
"true");
         context.start();
-        
assertThat(context.getCamelContextExtension().getSendHook()).isSameAs(mine);
+        // both run: the hook of chaos has the lowest order, so it is asked 
last, next to the producer
+        
assertThat(context.getCamelContextExtension().getSendHook()).isInstanceOf(CompositeSendHook.class);
+        assertThat(((CompositeSendHook) 
context.getCamelContextExtension().getSendHook()).getHooks())
+                .hasSize(2)
+                .first().isSameAs(mine);
+        assertThat(((CompositeSendHook) 
context.getCamelContextExtension().getSendHook()).getHooks().get(1))
+                .isInstanceOf(ChaosSendHook.class);
         
assertThat(context.getCamelContextExtension().getStepHook()).isInstanceOf(ChaosStepHook.class);
     }
 }
diff --git 
a/components/camel-jta/src/test/java/org/apache/camel/jta/StepHookJtaTest.java 
b/components/camel-jta/src/test/java/org/apache/camel/jta/StepHookJtaTest.java
index 5033ba383af9..8cd34edf222e 100644
--- 
a/components/camel-jta/src/test/java/org/apache/camel/jta/StepHookJtaTest.java
+++ 
b/components/camel-jta/src/test/java/org/apache/camel/jta/StepHookJtaTest.java
@@ -76,7 +76,7 @@ class StepHookJtaTest {
                 commits.incrementAndGet();
             }
         });
-        camelContext.getCamelContextExtension().setStepHook(new FailingHook());
+        camelContext.getCamelContextExtension().addStepHook(new FailingHook());
         camelContext.addRoutes(new RouteBuilder() {
             @Override
             public void configure() {
diff --git 
a/core/camel-api/src/main/java/org/apache/camel/ExtendedCamelContext.java 
b/core/camel-api/src/main/java/org/apache/camel/ExtendedCamelContext.java
index a703b5e0d01b..05892874356b 100644
--- a/core/camel-api/src/main/java/org/apache/camel/ExtendedCamelContext.java
+++ b/core/camel-api/src/main/java/org/apache/camel/ExtendedCamelContext.java
@@ -521,7 +521,8 @@ public interface ExtendedCamelContext {
     List<InterceptStrategy> getInterceptStrategies();
 
     /**
-     * Gets the {@link StepHook} that is asked before every processing step of 
a route.
+     * Gets the {@link StepHook} that is asked before every processing step of 
a route: the one hook that is installed,
+     * or a hook that asks all the installed hooks in their order (see {@link 
#addStepHook(StepHook)}).
      *
      * @return the hook, or <tt>null</tt> if no hook is installed
      * @since  4.24
@@ -531,18 +532,30 @@ public interface ExtendedCamelContext {
     }
 
     /**
-     * Installs a {@link StepHook} that is asked before every processing step 
of a route, or removes it with
-     * <tt>null</tt>. There is one hook; a new hook replaces the one installed 
before. A step that waits for a delay of
-     * the hook already is called when the delay ends. The CamelContext does 
not start or stop the hook, and the hook
-     * stays installed when the CamelContext is stopped and started again.
+     * Installs a {@link StepHook} that is asked before every processing step 
of a route. More than one hook can be
+     * installed: they are asked in their {@link Ordered order} (a hook that 
does not implement {@link Ordered} has
+     * order 0, and hooks with the same order are asked in the order they were 
added), see {@link StepHook} for how they
+     * combine. Adding a hook that is installed already does nothing. The 
CamelContext does not start or stop the hook,
+     * and the hook stays installed when the CamelContext is stopped and 
started again.
      *
-     * @param stepHook the hook, or <tt>null</tt> to remove it
+     * @param stepHook the hook
      * @since          4.24
      */
-    default void setStepHook(@Nullable StepHook stepHook) {
+    default void addStepHook(StepHook stepHook) {
         throw new UnsupportedOperationException("This CamelContext does not 
support a StepHook");
     }
 
+    /**
+     * Removes a {@link StepHook} that was installed with {@link 
#addStepHook(StepHook)}; a hook that is not installed
+     * is ignored. A step that waits for a delay of the hook already is called 
when the delay ends.
+     *
+     * @param stepHook the hook
+     * @since          4.24
+     */
+    default void removeStepHook(StepHook stepHook) {
+        // a CamelContext without hooks has nothing to remove
+    }
+
     /**
      * Adds a {@link StepListener} that observes each call of the processor of 
a step of a route. Many listeners can be
      * installed; adding a listener that is already installed (equal to one 
installed) is ignored. Whether a route calls
@@ -645,7 +658,8 @@ public interface ExtendedCamelContext {
 
     /**
      * Gets the {@link SendHook} that runs around the sends to the producer of 
an endpoint that fire the
-     * {@link org.apache.camel.spi.CamelEvent.ExchangeSendingEvent}.
+     * {@link org.apache.camel.spi.CamelEvent.ExchangeSendingEvent}: the one 
hook that is installed, or a hook that runs
+     * all the installed hooks in their order (see {@link 
#addSendHook(SendHook)}).
      *
      * @return the hook, or <tt>null</tt> if no hook is installed
      * @since  4.24
@@ -656,17 +670,29 @@ public interface ExtendedCamelContext {
 
     /**
      * Installs a {@link SendHook} that runs around the sends to the producer 
of an endpoint that fire the
-     * {@link org.apache.camel.spi.CamelEvent.ExchangeSendingEvent}, or 
removes it with <tt>null</tt>. There is one
-     * hook; a new hook replaces the one installed before. Sends that are 
already in the hook complete with the hook
-     * that started them.
+     * {@link org.apache.camel.spi.CamelEvent.ExchangeSendingEvent}. More than 
one hook can be installed: they are asked
+     * before the send in their {@link Ordered order} (a hook that does not 
implement {@link Ordered} has order 0, and
+     * hooks with the same order are asked in the order they were added), and 
after the send in the reverse order, see
+     * {@link SendHook} for how they combine. Adding a hook that is installed 
already does nothing.
      *
-     * @param sendHook the hook, or <tt>null</tt> to remove it
+     * @param sendHook the hook
      * @since          4.24
      */
-    default void setSendHook(@Nullable SendHook sendHook) {
+    default void addSendHook(SendHook sendHook) {
         throw new UnsupportedOperationException("This CamelContext does not 
support a SendHook");
     }
 
+    /**
+     * Removes a {@link SendHook} that was installed with {@link 
#addSendHook(SendHook)}; a hook that is not installed
+     * is ignored. Sends that are already in the hook complete with the hooks 
that started them.
+     *
+     * @param sendHook the hook
+     * @since          4.24
+     */
+    default void removeSendHook(SendHook sendHook) {
+        // a CamelContext without hooks has nothing to remove
+    }
+
     /**
      * Internal {@link RouteController} that are only used internally by Camel 
to perform basic route operations. Do not
      * use this as end user.
diff --git a/core/camel-api/src/main/java/org/apache/camel/spi/SendHook.java 
b/core/camel-api/src/main/java/org/apache/camel/spi/SendHook.java
index 8db73d822927..59e3e97ba7f9 100644
--- a/core/camel-api/src/main/java/org/apache/camel/spi/SendHook.java
+++ b/core/camel-api/src/main/java/org/apache/camel/spi/SendHook.java
@@ -28,9 +28,20 @@ import org.apache.camel.Exchange;
  * Before the producer is called, the hook can change the message, stop the 
send, skip it or delay it. After the
  * producer is done, it can change the reply.
  * <p/>
- * The hook is installed with {@link 
org.apache.camel.ExtendedCamelContext#setSendHook(SendHook)}. There is one hook 
per
- * {@link org.apache.camel.CamelContext}; to use more than one, combine them 
in one hook. Without a hook, a send costs
- * one check whether a hook is installed.
+ * The hook is installed with {@link 
org.apache.camel.ExtendedCamelContext#addSendHook(SendHook)}. Without a hook, a
+ * send costs one check whether a hook is installed.
+ * <p/>
+ * <b>More than one hook.</b> Several hooks can be installed. {@link 
#beforeSend(Exchange, Endpoint)} is called on them
+ * one after the other, in their {@link org.apache.camel.Ordered order} (lower 
first; a hook that does not implement
+ * {@link org.apache.camel.Ordered} has order 0, and hooks with the same order 
are asked in the order they were added).
+ * A hook that stops the send (it throws an exception or sets one on the 
exchange), skips it ({@link #SKIP}, so the
+ * first {@link #SKIP} wins) or returns a reserved value ends it: the hooks 
after it are not asked, and the send does
+ * not wait for a delay. Otherwise the longest of the delays wins, and the 
send waits for it once.
+ * {@link #afterSend(Exchange, Endpoint)} is called only on the hooks whose 
{@link #beforeSend(Exchange, Endpoint)}
+ * returned, in the reverse order: the hook asked first before the send is 
called last after it, so the hooks nest. A
+ * hook that only observes the sends takes a low order, so it is outside the 
hooks that change the outcome and sees the
+ * send as the route sees it; a hook that changes the outcome of a send (such 
as the faults of camel-chaos, with
+ * {@link org.apache.camel.Ordered#LOWEST}) takes a high order, to run 
innermost, right next to the producer.
  * <p/>
  * <b>Where it runs.</b> The hook runs right around the call of the producer, 
inside the error handler that handles a
  * failure of that producer: the error handler of the route for {@code to}, 
{@code toD} and {@code enrich}, the error
diff --git a/core/camel-api/src/main/java/org/apache/camel/spi/StepHook.java 
b/core/camel-api/src/main/java/org/apache/camel/spi/StepHook.java
index 15ec859c781f..065d3384118f 100644
--- a/core/camel-api/src/main/java/org/apache/camel/spi/StepHook.java
+++ b/core/camel-api/src/main/java/org/apache/camel/spi/StepHook.java
@@ -23,11 +23,19 @@ import org.apache.camel.Exchange;
  * of a route, with its node id: {@code to}, {@code process}, {@code log}, 
{@code split}, {@code choice} and each step
  * inside them, not only the {@code step} EIP.
  * <p/>
- * The hook is installed with {@link 
org.apache.camel.ExtendedCamelContext#setStepHook(StepHook)}. There is one hook 
per
- * {@link org.apache.camel.CamelContext}; to use more than one, combine them 
in one hook. Without a hook, a step costs
- * one check whether a hook is installed (with JMX and an error handler that 
can redeliver, one more small processor
- * inside the JMX instrumentation of the step makes that check). The {@link 
org.apache.camel.CamelContext} does not
- * start or stop the hook, also when it is a {@link org.apache.camel.Service}: 
whoever installs it manages it.
+ * The hook is installed with {@link 
org.apache.camel.ExtendedCamelContext#addStepHook(StepHook)}. Without a hook, a
+ * step costs one check whether a hook is installed (with JMX and an error 
handler that can redeliver, one more small
+ * processor inside the JMX instrumentation of the step makes that check). The 
{@link org.apache.camel.CamelContext}
+ * does not start or stop the hook, also when it is a {@link 
org.apache.camel.Service}: whoever installs it manages it.
+ * <p/>
+ * <b>More than one hook.</b> Several hooks can be installed. They are asked 
one after the other, in their
+ * {@link org.apache.camel.Ordered order} (lower first; a hook that does not 
implement {@link org.apache.camel.Ordered}
+ * has order 0, and hooks with the same order are asked in the order they were 
added). A hook that fails the step (it
+ * throws an exception or sets one on the exchange) ends it: the hooks after 
it are not asked. The delays of the hooks
+ * add up, and the step waits for their sum once, after all the hooks were 
asked. So a hook that only observes the steps
+ * takes a low order, to be asked before the hooks that can fail a step; a 
hook that changes the outcome of a step (such
+ * as the faults of camel-chaos, with {@link org.apache.camel.Ordered#LOWEST}) 
takes a high order, to be asked last,
+ * right next to the processor.
  * <p/>
  * <b>Where it is asked.</b> Each step runs through its {@link 
org.apache.camel.Channel}, which runs its advices (such
  * as tracing, the debuggers and JMX) and calls the error handler of the step, 
which calls the processor of the step.
diff --git 
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultCamelContextExtension.java
 
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultCamelContextExtension.java
index 7f31883855bc..776be4df8db4 100644
--- 
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultCamelContextExtension.java
+++ 
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultCamelContextExtension.java
@@ -86,6 +86,8 @@ import org.apache.camel.spi.TransformerRegistry;
 import org.apache.camel.spi.TypeConverterRegistry;
 import org.apache.camel.spi.UuidGenerator;
 import org.apache.camel.spi.ValidatorRegistry;
+import org.apache.camel.support.CompositeSendHook;
+import org.apache.camel.support.CompositeStepHook;
 import org.apache.camel.support.EndpointHelper;
 import org.apache.camel.support.NormalizedUri;
 import org.apache.camel.support.PluginHelper;
@@ -127,7 +129,10 @@ class DefaultCamelContextExtension implements 
ExtendedCamelContext {
     private volatile ManagementMBeanAssembler managementMBeanAssembler;
     private volatile HeadersMapFactory headersMapFactory;
     private volatile boolean eventNotificationApplicable;
+    // null, the one hook, or a composite of the hooks: the senders read this 
field only
     private volatile SendHook sendHook;
+    // the installed hooks, in the order they were added (guarded by the lock)
+    private final List<SendHook> sendHooks = new ArrayList<>();
     private volatile CamelContextNameStrategy nameStrategy;
     private volatile ManagementNameStrategy managementNameStrategy;
     private volatile PropertiesComponent propertiesComponent;
@@ -141,7 +146,10 @@ class DefaultCamelContextExtension implements 
ExtendedCamelContext {
     private volatile ErrorRegistry errorRegistry;
     private volatile UuidGenerator uuidGenerator;
     private volatile Tracer tracer;
+    // null, the one hook, or a composite of the hooks: the steps read this 
field only
     private volatile StepHook stepHook;
+    // the installed hooks, in the order they were added (guarded by the lock)
+    private final List<StepHook> stepHooks = new ArrayList<>();
     private volatile TransformerRegistry transformerRegistry;
     private volatile ValidatorRegistry validatorRegistry;
     private volatile TypeConverterRegistry typeConverterRegistry;
@@ -536,8 +544,29 @@ class DefaultCamelContextExtension implements 
ExtendedCamelContext {
     }
 
     @Override
-    public void setStepHook(StepHook stepHook) {
-        this.stepHook = stepHook;
+    public void addStepHook(StepHook stepHook) {
+        ObjectHelper.notNull(stepHook, "stepHook");
+        lock.lock();
+        try {
+            if (stepHooks.stream().noneMatch(h -> h == stepHook)) {
+                stepHooks.add(stepHook);
+                this.stepHook = CompositeStepHook.of(stepHooks);
+            }
+        } finally {
+            lock.unlock();
+        }
+    }
+
+    @Override
+    public void removeStepHook(StepHook stepHook) {
+        lock.lock();
+        try {
+            if (stepHooks.removeIf(h -> h == stepHook)) {
+                this.stepHook = CompositeStepHook.of(stepHooks);
+            }
+        } finally {
+            lock.unlock();
+        }
     }
 
     @Override
@@ -595,8 +624,29 @@ class DefaultCamelContextExtension implements 
ExtendedCamelContext {
     }
 
     @Override
-    public void setSendHook(SendHook sendHook) {
-        this.sendHook = sendHook;
+    public void addSendHook(SendHook sendHook) {
+        ObjectHelper.notNull(sendHook, "sendHook");
+        lock.lock();
+        try {
+            if (sendHooks.stream().noneMatch(h -> h == sendHook)) {
+                sendHooks.add(sendHook);
+                this.sendHook = CompositeSendHook.of(sendHooks);
+            }
+        } finally {
+            lock.unlock();
+        }
+    }
+
+    @Override
+    public void removeSendHook(SendHook sendHook) {
+        lock.lock();
+        try {
+            if (sendHooks.removeIf(h -> h == sendHook)) {
+                this.sendHook = CompositeSendHook.of(sendHooks);
+            }
+        } finally {
+            lock.unlock();
+        }
     }
 
     @Override
diff --git 
a/core/camel-core-engine/src/generated/java/org/apache/camel/impl/ExtendedCamelContextConfigurer.java
 
b/core/camel-core-engine/src/generated/java/org/apache/camel/impl/ExtendedCamelContextConfigurer.java
index 8dd15e260652..1112afcc55e6 100644
--- 
a/core/camel-core-engine/src/generated/java/org/apache/camel/impl/ExtendedCamelContextConfigurer.java
+++ 
b/core/camel-core-engine/src/generated/java/org/apache/camel/impl/ExtendedCamelContextConfigurer.java
@@ -54,12 +54,8 @@ public class ExtendedCamelContextConfigurer extends 
org.apache.camel.support.com
         case "reactiveexecutor":
         case "reactiveExecutor": 
target.setReactiveExecutor(property(camelContext, 
org.apache.camel.spi.ReactiveExecutor.class, value)); return true;
         case "registry": target.setRegistry(property(camelContext, 
org.apache.camel.spi.Registry.class, value)); return true;
-        case "sendhook":
-        case "sendHook": target.setSendHook(property(camelContext, 
org.apache.camel.spi.SendHook.class, value)); return true;
         case "startupsteprecorder":
         case "startupStepRecorder": 
target.setStartupStepRecorder(property(camelContext, 
org.apache.camel.spi.StartupStepRecorder.class, value)); return true;
-        case "stephook":
-        case "stepHook": target.setStepHook(property(camelContext, 
org.apache.camel.spi.StepHook.class, value)); return true;
         default: return false;
         }
     }
@@ -97,12 +93,8 @@ public class ExtendedCamelContextConfigurer extends 
org.apache.camel.support.com
         case "reactiveexecutor":
         case "reactiveExecutor": return 
org.apache.camel.spi.ReactiveExecutor.class;
         case "registry": return org.apache.camel.spi.Registry.class;
-        case "sendhook":
-        case "sendHook": return org.apache.camel.spi.SendHook.class;
         case "startupsteprecorder":
         case "startupStepRecorder": return 
org.apache.camel.spi.StartupStepRecorder.class;
-        case "stephook":
-        case "stepHook": return org.apache.camel.spi.StepHook.class;
         default: return null;
         }
     }
@@ -141,12 +133,8 @@ public class ExtendedCamelContextConfigurer extends 
org.apache.camel.support.com
         case "reactiveexecutor":
         case "reactiveExecutor": return target.getReactiveExecutor();
         case "registry": return target.getRegistry();
-        case "sendhook":
-        case "sendHook": return target.getSendHook();
         case "startupsteprecorder":
         case "startupStepRecorder": return target.getStartupStepRecorder();
-        case "stephook":
-        case "stepHook": return target.getStepHook();
         default: return null;
         }
     }
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/processor/MultipleHooksTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/processor/MultipleHooksTest.java
new file mode 100644
index 000000000000..999418413b15
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/processor/MultipleHooksTest.java
@@ -0,0 +1,157 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.processor;
+
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Endpoint;
+import org.apache.camel.Exchange;
+import org.apache.camel.Ordered;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.spi.SendHook;
+import org.apache.camel.spi.StepHook;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Two step hooks and two send hooks active together (CAMEL-26607): a hook 
that fails the first attempt, as chaos does,
+ * with the lowest order, and a hook that records, with order 0. Both run, and 
the recording hook sees each attempt of
+ * the error handler, with the failure of the other hook.
+ */
+class MultipleHooksTest extends ContextTestSupport {
+
+    private final List<String> recorded = new CopyOnWriteArrayList<>();
+    private final AtomicInteger stepFailures = new AtomicInteger();
+    private final AtomicInteger sendFailures = new AtomicInteger();
+
+    @Override
+    protected CamelContext createCamelContext() throws Exception {
+        CamelContext context = super.createCamelContext();
+        // the failing hooks are added first, but their order puts them last
+        context.getCamelContextExtension().addStepHook(new FailingStepHook());
+        context.getCamelContextExtension().addStepHook(new 
RecordingStepHook());
+        context.getCamelContextExtension().addSendHook(new FailingSendHook());
+        context.getCamelContextExtension().addSendHook(new 
RecordingSendHook());
+        return context;
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                
errorHandler(defaultErrorHandler().maximumRedeliveries(2).redeliveryDelay(0));
+
+                from("direct:start").routeId("start")
+                        .process(e -> e.getMessage().setHeader("worked", 
true)).id("work")
+                        .to("mock:result").id("toResult");
+            }
+        };
+    }
+
+    @Test
+    void testBothHooksRun() throws Exception {
+        getMockEndpoint("mock:result").expectedBodiesReceived("Hello");
+        getMockEndpoint("mock:result").expectedHeaderReceived("worked", true);
+
+        template.sendBody("direct:start", "Hello");
+
+        assertMockEndpointsSatisfied();
+        // the failing hooks failed the first attempt of the step and of the 
send, and the error handler redelivered
+        assertEquals(1, stepFailures.get());
+        assertEquals(1, sendFailures.get());
+        // the recording hooks were asked first, for each attempt, and saw the 
failure of the send after it
+        assertEquals(List.of(
+                "step:work", "step:work",
+                "step:toResult",
+                "before:mock://result", "after:mock://result:failed",
+                "step:toResult",
+                "before:mock://result", "after:mock://result:ok"),
+                recorded);
+    }
+
+    private final class RecordingStepHook implements StepHook {
+
+        @Override
+        public long beforeStep(Exchange exchange, String nodeId) {
+            recorded.add("step:" + nodeId);
+            return PROCEED;
+        }
+    }
+
+    private final class FailingStepHook implements StepHook, Ordered {
+
+        @Override
+        public long beforeStep(Exchange exchange, String nodeId) {
+            if ("work".equals(nodeId) && stepFailures.get() == 0) {
+                stepFailures.incrementAndGet();
+                exchange.setException(new IllegalStateException("Chaos at the 
step"));
+            }
+            return PROCEED;
+        }
+
+        @Override
+        public int getOrder() {
+            return Ordered.LOWEST;
+        }
+    }
+
+    private final class RecordingSendHook implements SendHook {
+
+        @Override
+        public long beforeSend(Exchange exchange, Endpoint endpoint) {
+            if (endpoint.getEndpointUri().startsWith("mock:")) {
+                recorded.add("before:" + endpoint.getEndpointUri());
+            }
+            return PROCEED;
+        }
+
+        @Override
+        public void afterSend(Exchange exchange, Endpoint endpoint) {
+            if (endpoint.getEndpointUri().startsWith("mock:")) {
+                recorded.add("after:" + endpoint.getEndpointUri() + 
(exchange.getException() != null ? ":failed" : ":ok"));
+            }
+        }
+    }
+
+    private final class FailingSendHook implements SendHook, Ordered {
+
+        @Override
+        public long beforeSend(Exchange exchange, Endpoint endpoint) {
+            if ("mock://result".equals(endpoint.getEndpointUri()) && 
sendFailures.get() == 0) {
+                sendFailures.incrementAndGet();
+                exchange.setException(new IllegalStateException("Chaos at the 
send"));
+            }
+            return PROCEED;
+        }
+
+        @Override
+        public void afterSend(Exchange exchange, Endpoint endpoint) {
+            // nothing
+        }
+
+        @Override
+        public int getOrder() {
+            return Ordered.LOWEST;
+        }
+    }
+}
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/processor/SendHookStressTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/processor/SendHookStressTest.java
index 6fc199518110..42bb97d0fdef 100644
--- 
a/core/camel-core/src/test/java/org/apache/camel/processor/SendHookStressTest.java
+++ 
b/core/camel-core/src/test/java/org/apache/camel/processor/SendHookStressTest.java
@@ -77,7 +77,7 @@ class SendHookStressTest extends ContextTestSupport {
                 }
             }
         });
-        context.getCamelContextExtension().setSendHook(new RandomHook());
+        context.getCamelContextExtension().addSendHook(new RandomHook());
         return context;
     }
 
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/processor/SendHookTest.java 
b/core/camel-core/src/test/java/org/apache/camel/processor/SendHookTest.java
index 48faab98af3b..0392ac3561e3 100644
--- a/core/camel-core/src/test/java/org/apache/camel/processor/SendHookTest.java
+++ b/core/camel-core/src/test/java/org/apache/camel/processor/SendHookTest.java
@@ -77,7 +77,7 @@ class SendHookTest extends ContextTestSupport {
                 }
             }
         });
-        context.getCamelContextExtension().setSendHook(hook);
+        context.getCamelContextExtension().addSendHook(hook);
         return context;
     }
 
@@ -283,21 +283,21 @@ class SendHookTest extends ContextTestSupport {
         String hooked = SendHelper.class.getName() + "$SendHookProducer";
 
         // no hook: the error handler of the recipient calls the producer 
itself, as when there is no send hook at all
-        context.getCamelContextExtension().setSendHook(null);
+        context.getCamelContextExtension().removeSendHook(hook);
         template.sendBodyAndHeader("direct:recipientList", "Hello", "to", 
"mock:rl");
         assertTrue(stack.get(stack.size() - 
1).startsWith(RedeliveryErrorHandler.class.getName()), stack::toString);
         assertFalse(stack.contains(hooked), stack::toString);
 
         // a hook installed later runs for the same recipient, through another 
error handler
         stack.clear();
-        context.getCamelContextExtension().setSendHook(hook);
+        context.getCamelContextExtension().addSendHook(hook);
         template.sendBodyAndHeader("direct:recipientList", "Hello", "to", 
"mock:rl");
         assertTrue(stack.contains(hooked), stack::toString);
         assertEquals(1, hook.before("mock://rl"));
 
         // and when it is removed again, the producer is called by itself again
         stack.clear();
-        context.getCamelContextExtension().setSendHook(null);
+        context.getCamelContextExtension().removeSendHook(hook);
         template.sendBodyAndHeader("direct:recipientList", "Hello", "to", 
"mock:rl");
         assertFalse(stack.contains(hooked), stack::toString);
         assertEquals(1, hook.before("mock://rl"));
@@ -411,7 +411,7 @@ class SendHookTest extends ContextTestSupport {
         assertEquals(List.of("sending:mock://a", "before:mock://a", 
"after:mock://a", "sent:mock://a"),
                 calls("mock://a"));
 
-        context.getCamelContextExtension().setSendHook(null);
+        context.getCamelContextExtension().removeSendHook(hook);
         assertNull(context.getCamelContextExtension().getSendHook());
         List<String> withoutHook = runAllPaths();
 
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/processor/StepHookStressTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/processor/StepHookStressTest.java
index 702fec327057..d160c94ab0ee 100644
--- 
a/core/camel-core/src/test/java/org/apache/camel/processor/StepHookStressTest.java
+++ 
b/core/camel-core/src/test/java/org/apache/camel/processor/StepHookStressTest.java
@@ -75,7 +75,7 @@ class StepHookStressTest extends ContextTestSupport {
                 }
             }
         });
-        context.getCamelContextExtension().setStepHook(new RandomHook());
+        context.getCamelContextExtension().addStepHook(new RandomHook());
         return context;
     }
 
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/processor/StepHookTest.java 
b/core/camel-core/src/test/java/org/apache/camel/processor/StepHookTest.java
index 220865fe3042..45352c755363 100644
--- a/core/camel-core/src/test/java/org/apache/camel/processor/StepHookTest.java
+++ b/core/camel-core/src/test/java/org/apache/camel/processor/StepHookTest.java
@@ -59,7 +59,7 @@ class StepHookTest extends ContextTestSupport {
     @Override
     protected CamelContext createCamelContext() throws Exception {
         CamelContext context = super.createCamelContext();
-        context.getCamelContextExtension().setStepHook(hook);
+        context.getCamelContextExtension().addStepHook(hook);
         return context;
     }
 
@@ -434,7 +434,7 @@ class StepHookTest extends ContextTestSupport {
 
     @Test
     void testNoHook() throws Exception {
-        context.getCamelContextExtension().setStepHook(null);
+        context.getCamelContextExtension().removeStepHook(hook);
         failures.put("flaky", -1);
         getMockEndpoint("mock:flaky").expectedBodiesReceived("Hello");
         getMockEndpoint("mock:dead").expectedMessageCount(0);
@@ -447,7 +447,7 @@ class StepHookTest extends ContextTestSupport {
         assertTrue(asked.isEmpty(), asked::toString);
 
         // installed again, it is asked by the routes that already run
-        context.getCamelContextExtension().setStepHook(hook);
+        context.getCamelContextExtension().addStepHook(hook);
         template.sendBody("direct:steps", "Hello");
         assertEquals(List.of("setHeader", "log", "process", "toResult"), new 
ArrayList<>(asked));
     }
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/support/CompositeHookTest.java 
b/core/camel-core/src/test/java/org/apache/camel/support/CompositeHookTest.java
new file mode 100644
index 000000000000..885ab249fc9a
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/support/CompositeHookTest.java
@@ -0,0 +1,423 @@
+/*
+ * 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.support;
+
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import org.apache.camel.AsyncProcessor;
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Endpoint;
+import org.apache.camel.Exchange;
+import org.apache.camel.ExtendedCamelContext;
+import org.apache.camel.Ordered;
+import org.apache.camel.spi.SendHook;
+import org.apache.camel.spi.StepHook;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * More than one {@link StepHook} and {@link SendHook} (CAMEL-26607): the 
composites the context installs, and how
+ * {@link StepHelper} and {@link SendHelper} run them.
+ */
+class CompositeHookTest extends ContextTestSupport {
+
+    // what the hooks and the producer did, in the order they did it
+    private final List<String> calls = new CopyOnWriteArrayList<>();
+
+    @Override
+    public boolean isUseRouteBuilder() {
+        return false;
+    }
+
+    // the step hooks
+
+    @Test
+    void testStepHooksInstalledAndRemoved() {
+        ExtendedCamelContext ecc = context.getCamelContextExtension();
+        assertNull(ecc.getStepHook());
+
+        StepHook a = new TestStepHook("a", 0, PROCEED_STEP);
+        StepHook b = new TestStepHook("b", 0, PROCEED_STEP);
+        ecc.addStepHook(a);
+        assertSame(a, ecc.getStepHook(), "one hook is installed as it is");
+        // the same hook again changes nothing
+        ecc.addStepHook(a);
+        assertSame(a, ecc.getStepHook());
+
+        ecc.addStepHook(b);
+        CompositeStepHook composite = 
assertInstanceOf(CompositeStepHook.class, ecc.getStepHook());
+        assertEquals(List.of(a, b), composite.getHooks());
+
+        ecc.removeStepHook(a);
+        assertSame(b, ecc.getStepHook());
+        // a hook that is not installed is ignored
+        ecc.removeStepHook(a);
+        assertSame(b, ecc.getStepHook());
+        ecc.removeStepHook(b);
+        assertNull(ecc.getStepHook());
+    }
+
+    @Test
+    void testStepHooksAskedInOrder() throws Exception {
+        ExtendedCamelContext ecc = context.getCamelContextExtension();
+        // added out of order: sorted by Ordered, lower first, a hook without 
Ordered counts as 0, and hooks with the
+        // same order keep the order they were added
+        ecc.addStepHook(new TestStepHook("late", 10, PROCEED_STEP));
+        ecc.addStepHook(new UnorderedStepHook("plain1"));
+        ecc.addStepHook(new TestStepHook("early", -5, PROCEED_STEP));
+        ecc.addStepHook(new UnorderedStepHook("plain2"));
+        ecc.addStepHook(new TestStepHook("zero", 0, PROCEED_STEP));
+        ecc.addStepHook(new TestStepHook("last", Ordered.LOWEST, 
PROCEED_STEP));
+
+        Exchange exchange = new DefaultExchange(context);
+        StepHelper.process(ecc.getStepHook(), exchange, "step1", processor());
+
+        assertEquals(List.of("early:step1", "plain1:step1", "plain2:step1", 
"zero:step1", "late:step1", "last:step1",
+                "processor"), calls);
+        assertNull(exchange.getException());
+    }
+
+    @Test
+    void testStepHookThatThrowsStopsTheChain() throws Exception {
+        ExtendedCamelContext ecc = context.getCamelContextExtension();
+        ecc.addStepHook(new TestStepHook("first", 1, PROCEED_STEP));
+        ecc.addStepHook(new TestStepHook("throws", 2, THROW));
+        ecc.addStepHook(new TestStepHook("third", 3, PROCEED_STEP));
+
+        Exchange exchange = new DefaultExchange(context);
+        StepHelper.process(ecc.getStepHook(), exchange, "step1", processor());
+
+        // the later hook is not asked, and the processor is not called
+        assertEquals(List.of("first:step1", "throws:step1"), calls);
+        assertEquals("throws failed the step", 
exchange.getException().getMessage());
+    }
+
+    @Test
+    void testStepHookThatSetsAnExceptionStopsTheChain() throws Exception {
+        ExtendedCamelContext ecc = context.getCamelContextExtension();
+        ecc.addStepHook(new TestStepHook("fails", 1, SET_EXCEPTION));
+        ecc.addStepHook(new TestStepHook("second", 2, PROCEED_STEP));
+
+        Exchange exchange = new DefaultExchange(context);
+        StepHelper.process(ecc.getStepHook(), exchange, "step1", processor());
+
+        assertEquals(List.of("fails:step1"), calls);
+        assertEquals("fails failed the step", 
exchange.getException().getMessage());
+    }
+
+    @Test
+    void testStepHookWithReservedValueStopsTheChain() throws Exception {
+        ExtendedCamelContext ecc = context.getCamelContextExtension();
+        ecc.addStepHook(new TestStepHook("reserved", 1, -7));
+        ecc.addStepHook(new TestStepHook("second", 2, PROCEED_STEP));
+
+        Exchange exchange = new DefaultExchange(context);
+        StepHelper.process(ecc.getStepHook(), exchange, "step1", processor());
+
+        assertEquals(List.of("reserved:step1"), calls);
+        assertInstanceOf(IllegalArgumentException.class, 
exchange.getException());
+    }
+
+    @Test
+    void testStepHookDelaysAddUp() {
+        StepHook composite = CompositeStepHook.of(List.of(
+                new TestStepHook("a", 1, 10),
+                new TestStepHook("b", 2, PROCEED_STEP),
+                new TestStepHook("c", 3, 25)));
+
+        assertEquals(35, composite.beforeStep(new DefaultExchange(context), 
"step1"));
+        assertEquals(List.of("a:step1", "b:step1", "c:step1"), calls);
+
+        // and do not overflow
+        StepHook huge = CompositeStepHook.of(List.of(
+                new TestStepHook("x", 1, Long.MAX_VALUE - 1), new 
TestStepHook("y", 2, 10)));
+        assertEquals(Long.MAX_VALUE, huge.beforeStep(new 
DefaultExchange(context), "step1"));
+    }
+
+    @Test
+    void testStepHookOfNoneOrOne() {
+        assertNull(CompositeStepHook.of(List.of()));
+        StepHook one = new TestStepHook("one", 0, PROCEED_STEP);
+        assertSame(one, CompositeStepHook.of(List.of(one)));
+    }
+
+    // the send hooks
+
+    @Test
+    void testSendHooksInstalledAndRemoved() {
+        ExtendedCamelContext ecc = context.getCamelContextExtension();
+        assertNull(ecc.getSendHook());
+
+        SendHook a = new TestSendHook("a", 0, SendHook.PROCEED);
+        SendHook b = new TestSendHook("b", 0, SendHook.PROCEED);
+        ecc.addSendHook(a);
+        assertSame(a, ecc.getSendHook());
+        ecc.addSendHook(a);
+        assertSame(a, ecc.getSendHook());
+        ecc.addSendHook(b);
+        CompositeSendHook composite = 
assertInstanceOf(CompositeSendHook.class, ecc.getSendHook());
+        assertEquals(List.of(a, b), composite.getHooks());
+
+        ecc.removeSendHook(b);
+        assertSame(a, ecc.getSendHook());
+        ecc.removeSendHook(a);
+        assertNull(ecc.getSendHook());
+    }
+
+    @Test
+    void testSendHooksInOrderAndAfterInReverse() {
+        ExtendedCamelContext ecc = context.getCamelContextExtension();
+        ecc.addSendHook(new TestSendHook("c", 3, SendHook.PROCEED));
+        ecc.addSendHook(new TestSendHook("a", 1, SendHook.PROCEED));
+        ecc.addSendHook(new TestSendHook("b", 2, SendHook.PROCEED));
+
+        Exchange exchange = send();
+
+        assertEquals(List.of("before:a", "before:b", "before:c", "producer", 
"after:c", "after:b", "after:a"), calls);
+        assertNull(exchange.getException());
+    }
+
+    @Test
+    void testFirstSkipWins() {
+        ExtendedCamelContext ecc = context.getCamelContextExtension();
+        ecc.addSendHook(new TestSendHook("a", 1, SendHook.PROCEED));
+        ecc.addSendHook(new TestSendHook("skip", 2, SendHook.SKIP));
+        ecc.addSendHook(new TestSendHook("c", 3, SendHook.PROCEED));
+
+        Exchange exchange = send();
+
+        // the later hook is not asked, so it is not called after the send 
either; the producer is not called
+        assertEquals(List.of("before:a", "before:skip", "after:skip", 
"after:a"), calls);
+        assertNull(exchange.getException());
+    }
+
+    @Test
+    void testSendHookThatThrowsStopsTheChain() {
+        ExtendedCamelContext ecc = context.getCamelContextExtension();
+        ecc.addSendHook(new TestSendHook("a", 1, SendHook.PROCEED));
+        ecc.addSendHook(new TestSendHook("throws", 2, THROW));
+        ecc.addSendHook(new TestSendHook("c", 3, SendHook.PROCEED));
+
+        Exchange exchange = send();
+
+        // the hook that threw is not called after the send (its before did 
not return), the one asked before it is
+        assertEquals(List.of("before:a", "before:throws", "after:a"), calls);
+        assertEquals("throws stopped the send", 
exchange.getException().getMessage());
+    }
+
+    @Test
+    void testSendHookThatSetsAnExceptionStopsTheChain() {
+        ExtendedCamelContext ecc = context.getCamelContextExtension();
+        ecc.addSendHook(new TestSendHook("a", 1, SendHook.PROCEED));
+        ecc.addSendHook(new TestSendHook("stops", 2, SET_EXCEPTION));
+        ecc.addSendHook(new TestSendHook("c", 3, SendHook.PROCEED));
+
+        Exchange exchange = send();
+
+        assertEquals(List.of("before:a", "before:stops", "after:stops", 
"after:a"), calls);
+        assertEquals("stops stopped the send", 
exchange.getException().getMessage());
+    }
+
+    @Test
+    void testSendHookWithReservedValueStopsTheChain() {
+        ExtendedCamelContext ecc = context.getCamelContextExtension();
+        ecc.addSendHook(new TestSendHook("a", 1, SendHook.PROCEED));
+        ecc.addSendHook(new TestSendHook("reserved", 2, -99));
+        ecc.addSendHook(new TestSendHook("c", 3, SendHook.PROCEED));
+
+        Exchange exchange = send();
+
+        assertEquals(List.of("before:a", "before:reserved", "after:reserved", 
"after:a"), calls);
+        assertInstanceOf(IllegalArgumentException.class, 
exchange.getException());
+    }
+
+    @Test
+    void testAfterSendThatThrowsDoesNotStopTheOthers() {
+        ExtendedCamelContext ecc = context.getCamelContextExtension();
+        ecc.addSendHook(new TestSendHook("a", 1, SendHook.PROCEED));
+        ecc.addSendHook(new TestSendHook("b", 2, SendHook.PROCEED, true));
+
+        Exchange exchange = send();
+
+        assertEquals(List.of("before:a", "before:b", "producer", "after:b", 
"after:a"), calls);
+        assertEquals("b failed after the send", 
exchange.getException().getMessage());
+    }
+
+    @Test
+    void testLongestSendDelayWins() {
+        SendHook composite = CompositeSendHook.of(List.of(
+                new TestSendHook("a", 1, 10),
+                new TestSendHook("b", 2, 40),
+                new TestSendHook("c", 3, 20)));
+        Endpoint endpoint = context.getEndpoint("mock:target");
+
+        assertEquals(40, composite.beforeSend(new DefaultExchange(context), 
endpoint));
+        assertEquals(List.of("before:a", "before:b", "before:c"), calls);
+    }
+
+    @Test
+    void testCompositeCalledDirectly() {
+        SendHook composite = CompositeSendHook.of(List.of(
+                new TestSendHook("a", 1, SendHook.PROCEED),
+                new TestSendHook("skip", 2, SendHook.SKIP),
+                new TestSendHook("c", 3, SendHook.PROCEED)));
+        Endpoint endpoint = context.getEndpoint("mock:target");
+        Exchange exchange = new DefaultExchange(context);
+
+        // the first SKIP wins; called directly, afterSend calls every hook in 
the reverse order
+        assertEquals(SendHook.SKIP, composite.beforeSend(exchange, endpoint));
+        composite.afterSend(exchange, endpoint);
+        assertEquals(List.of("before:a", "before:skip", "after:c", 
"after:skip", "after:a"), calls);
+    }
+
+    @Test
+    void testSendHookOfNoneOrOne() {
+        assertNull(CompositeSendHook.of(List.of()));
+        SendHook one = new TestSendHook("one", 0, SendHook.PROCEED);
+        assertSame(one, CompositeSendHook.of(List.of(one)));
+    }
+
+    private Exchange send() {
+        Endpoint endpoint = context.getEndpoint("mock:target");
+        Exchange exchange = new DefaultExchange(context);
+        boolean[] done = new boolean[1];
+        boolean sync = SendHelper.process(exchange, endpoint, producer(), 
doneSync -> done[0] = true);
+        assertTrue(sync);
+        assertTrue(done[0], "the callback is called");
+        return exchange;
+    }
+
+    private AsyncProcessor processor() {
+        return AsyncProcessorConverterHelper.convert(e -> 
calls.add("processor"));
+    }
+
+    private AsyncProcessor producer() {
+        return AsyncProcessorConverterHelper.convert(e -> 
calls.add("producer"));
+    }
+
+    // the values a test hook returns that are not a delay
+    private static final long PROCEED_STEP = StepHook.PROCEED;
+    private static final long THROW = Long.MIN_VALUE;
+    private static final long SET_EXCEPTION = Long.MIN_VALUE + 1;
+
+    private final class TestStepHook implements StepHook, Ordered {
+
+        private final String name;
+        private final int order;
+        private final long answer;
+
+        private TestStepHook(String name, int order, long answer) {
+            this.name = name;
+            this.order = order;
+            this.answer = answer;
+        }
+
+        @Override
+        public long beforeStep(Exchange exchange, String nodeId) {
+            calls.add(name + ":" + nodeId);
+            if (answer == THROW) {
+                throw new IllegalStateException(name + " failed the step");
+            }
+            if (answer == SET_EXCEPTION) {
+                exchange.setException(new IllegalStateException(name + " 
failed the step"));
+                return PROCEED;
+            }
+            return answer;
+        }
+
+        @Override
+        public int getOrder() {
+            return order;
+        }
+
+        @Override
+        public String toString() {
+            return name;
+        }
+    }
+
+    private final class UnorderedStepHook implements StepHook {
+
+        private final String name;
+
+        private UnorderedStepHook(String name) {
+            this.name = name;
+        }
+
+        @Override
+        public long beforeStep(Exchange exchange, String nodeId) {
+            calls.add(name + ":" + nodeId);
+            return PROCEED;
+        }
+    }
+
+    private final class TestSendHook implements SendHook, Ordered {
+
+        private final String name;
+        private final int order;
+        private final long answer;
+        private final boolean afterThrows;
+
+        private TestSendHook(String name, int order, long answer) {
+            this(name, order, answer, false);
+        }
+
+        private TestSendHook(String name, int order, long answer, boolean 
afterThrows) {
+            this.name = name;
+            this.order = order;
+            this.answer = answer;
+            this.afterThrows = afterThrows;
+        }
+
+        @Override
+        public long beforeSend(Exchange exchange, Endpoint endpoint) {
+            calls.add("before:" + name);
+            if (answer == THROW) {
+                throw new IllegalStateException(name + " stopped the send");
+            }
+            if (answer == SET_EXCEPTION) {
+                exchange.setException(new IllegalStateException(name + " 
stopped the send"));
+                return PROCEED;
+            }
+            return answer;
+        }
+
+        @Override
+        public void afterSend(Exchange exchange, Endpoint endpoint) {
+            calls.add("after:" + name);
+            if (afterThrows) {
+                throw new IllegalStateException(name + " failed after the 
send");
+            }
+        }
+
+        @Override
+        public int getOrder() {
+            return order;
+        }
+
+        @Override
+        public String toString() {
+            return name;
+        }
+    }
+}
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/support/SendHelperTest.java 
b/core/camel-core/src/test/java/org/apache/camel/support/SendHelperTest.java
index 12e14d8c9fc7..dd2f99f599f2 100644
--- a/core/camel-core/src/test/java/org/apache/camel/support/SendHelperTest.java
+++ b/core/camel-core/src/test/java/org/apache/camel/support/SendHelperTest.java
@@ -68,6 +68,7 @@ class SendHelperTest extends ContextTestSupport {
     private static final String PRODUCER_COUNT = "producerCount";
     private static final String FIXED_DELAY = "fixedDelay";
 
+    private final ModeHook modeHook = new ModeHook();
     private ExecutorService producerThreads;
     private Endpoint endpoint;
 
@@ -94,7 +95,7 @@ class SendHelperTest extends ContextTestSupport {
         super.setUp();
         producerThreads = Executors.newFixedThreadPool(4);
         endpoint = context.getEndpoint("mock:target");
-        context.getCamelContextExtension().setSendHook(new ModeHook());
+        context.getCamelContextExtension().addSendHook(modeHook);
     }
 
     @Override
@@ -229,7 +230,7 @@ class SendHelperTest extends ContextTestSupport {
 
     @Test
     void testNoHookCallsTheProducerOnly() {
-        context.getCamelContextExtension().setSendHook(null);
+        context.getCamelContextExtension().removeSendHook(modeHook);
         Exchange exchange = newExchange(Mode.STOP, Kind.SYNC);
         Completion completion = new Completion();
 
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/support/StepHelperTest.java 
b/core/camel-core/src/test/java/org/apache/camel/support/StepHelperTest.java
index 66ef28b2120b..a6feae743409 100644
--- a/core/camel-core/src/test/java/org/apache/camel/support/StepHelperTest.java
+++ b/core/camel-core/src/test/java/org/apache/camel/support/StepHelperTest.java
@@ -65,6 +65,7 @@ class StepHelperTest extends ContextTestSupport {
     private static final String FIXED_DELAY = "fixedDelay";
     private static final String RESERVED = "StepHook.beforeStep returned -5: 
negative values are reserved";
 
+    private final ModeHook modeHook = new ModeHook();
     private ExecutorService processorThreads;
 
     enum Mode {
@@ -86,7 +87,7 @@ class StepHelperTest extends ContextTestSupport {
     public void setUp() throws Exception {
         super.setUp();
         processorThreads = Executors.newFixedThreadPool(4);
-        context.getCamelContextExtension().setStepHook(new ModeHook());
+        context.getCamelContextExtension().addStepHook(modeHook);
     }
 
     @Override
@@ -282,7 +283,7 @@ class StepHelperTest extends ContextTestSupport {
 
     @Test
     void testNoHookCallsTheProcessorOnly() {
-        context.getCamelContextExtension().setStepHook(null);
+        context.getCamelContextExtension().removeStepHook(modeHook);
         Exchange exchange = newExchange(Mode.FAIL, Kind.SYNC);
         Completion completion = new Completion();
 
diff --git 
a/core/camel-management/src/test/java/org/apache/camel/management/ManagedStepHookTest.java
 
b/core/camel-management/src/test/java/org/apache/camel/management/ManagedStepHookTest.java
index c5ee80a84bd6..0fef39db794a 100644
--- 
a/core/camel-management/src/test/java/org/apache/camel/management/ManagedStepHookTest.java
+++ 
b/core/camel-management/src/test/java/org/apache/camel/management/ManagedStepHookTest.java
@@ -53,7 +53,7 @@ class ManagedStepHookTest extends ManagementTestSupport {
     @Override
     protected CamelContext createCamelContext() throws Exception {
         CamelContext context = super.createCamelContext();
-        context.getCamelContextExtension().setStepHook(this::beforeStep);
+        context.getCamelContextExtension().addStepHook(this::beforeStep);
         return context;
     }
 
diff --git 
a/core/camel-support/src/main/java/org/apache/camel/support/CompositeSendHook.java
 
b/core/camel-support/src/main/java/org/apache/camel/support/CompositeSendHook.java
new file mode 100644
index 000000000000..33847a8832b6
--- /dev/null
+++ 
b/core/camel-support/src/main/java/org/apache/camel/support/CompositeSendHook.java
@@ -0,0 +1,136 @@
+/*
+ * 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.support;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+
+import org.apache.camel.Endpoint;
+import org.apache.camel.Exchange;
+import org.apache.camel.Ordered;
+import org.apache.camel.spi.SendHook;
+
+/**
+ * A {@link SendHook} that runs several hooks, in their {@link Ordered order} 
(lower first; a hook that does not
+ * implement {@link Ordered} has order 0, and hooks with the same order keep 
the order they were given in).
+ * <p/>
+ * {@link SendHelper} runs the hooks of a composite one by one, as follows:
+ * <ul>
+ * <li>{@link #beforeSend(Exchange, Endpoint)} is called on the hooks in their 
order. A hook that throws an exception,
+ * sets one on the exchange, returns {@link SendHook#SKIP} or a reserved 
negative value ends it: the hooks after it are
+ * not asked. So the first {@link SendHook#SKIP} wins.</li>
+ * <li>When all the hooks proceed, the longest of their delays wins: the send 
waits for it, once.</li>
+ * <li>{@link #afterSend(Exchange, Endpoint)} is called only on the hooks 
whose {@code beforeSend} returned, in the
+ * reverse order, so the hook that was asked first is called last, as for 
nested hooks.</li>
+ * </ul>
+ * <p/>
+ * Code that calls a composite itself does not know which hooks were asked: 
its {@code beforeSend} combines the hooks as
+ * above, and its {@code afterSend} calls every hook, in the reverse order.
+ * <p/>
+ * It is immutable: the {@link org.apache.camel.CamelContext} creates a new 
one when a hook is added or removed (see
+ * {@link org.apache.camel.ExtendedCamelContext#addSendHook(SendHook)}).
+ *
+ * @since 4.24
+ */
+public final class CompositeSendHook implements SendHook {
+
+    private final SendHook[] hooks;
+
+    private CompositeSendHook(SendHook[] hooks) {
+        this.hooks = hooks;
+    }
+
+    /**
+     * The hook to install for the given hooks: {@code null} for none, the 
hook itself for one, or else a composite that
+     * runs them in their order.
+     *
+     * @param  hooks the hooks, in the order they were added
+     * @return       the hook, or {@code null}
+     */
+    public static SendHook of(Collection<? extends SendHook> hooks) {
+        if (hooks.isEmpty()) {
+            return null;
+        }
+        SendHook[] sorted = hooks.toArray(new SendHook[0]);
+        if (sorted.length == 1) {
+            return sorted[0];
+        }
+        // a stable sort, so hooks with the same order keep the order they 
were added
+        Arrays.sort(sorted, OrderedComparator.get());
+        return new CompositeSendHook(sorted);
+    }
+
+    /**
+     * The hooks, in the order {@code beforeSend} is called.
+     */
+    public List<SendHook> getHooks() {
+        return List.of(hooks);
+    }
+
+    /**
+     * The hooks for {@link SendHelper}, which must not change the array.
+     */
+    SendHook[] hooks() {
+        return hooks;
+    }
+
+    @Override
+    public long beforeSend(Exchange exchange, Endpoint endpoint) {
+        final Exception existing = exchange.getException();
+        long answer = PROCEED;
+        for (SendHook hook : hooks) {
+            long value = hook.beforeSend(exchange, endpoint);
+            Exception exception = exchange.getException();
+            if (exception != null && exception != existing) {
+                // the hook stopped the send: the return value is ignored
+                return PROCEED;
+            }
+            if (value < 0) {
+                // SKIP, or a reserved value that fails the send
+                return value;
+            }
+            answer = Math.max(answer, value);
+        }
+        return answer;
+    }
+
+    @Override
+    public void afterSend(Exchange exchange, Endpoint endpoint) {
+        RuntimeException failure = null;
+        for (int i = hooks.length - 1; i >= 0; i--) {
+            try {
+                hooks[i].afterSend(exchange, endpoint);
+            } catch (RuntimeException e) {
+                // the other hooks are still called
+                if (failure == null) {
+                    failure = e;
+                } else if (failure != e) {
+                    failure.addSuppressed(e);
+                }
+            }
+        }
+        if (failure != null) {
+            throw failure;
+        }
+    }
+
+    @Override
+    public String toString() {
+        return "CompositeSendHook" + Arrays.toString(hooks);
+    }
+}
diff --git 
a/core/camel-support/src/main/java/org/apache/camel/support/CompositeStepHook.java
 
b/core/camel-support/src/main/java/org/apache/camel/support/CompositeStepHook.java
new file mode 100644
index 000000000000..af0d3bd424d6
--- /dev/null
+++ 
b/core/camel-support/src/main/java/org/apache/camel/support/CompositeStepHook.java
@@ -0,0 +1,101 @@
+/*
+ * 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.support;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.Ordered;
+import org.apache.camel.spi.StepHook;
+
+/**
+ * A {@link StepHook} that asks several hooks, in their {@link Ordered order} 
(lower first; a hook that does not
+ * implement {@link Ordered} has order 0, and hooks with the same order keep 
the order they were given in).
+ * <p/>
+ * The hooks are asked one after the other: a hook that throws an exception, 
or sets one on the exchange, fails the
+ * step, and the hooks after it are not asked. A hook that returns a reserved 
negative value fails the step as well. The
+ * delays of the hooks add up: the step waits for their sum, once, after all 
the hooks were asked.
+ * <p/>
+ * It is immutable: the {@link org.apache.camel.CamelContext} creates a new 
one when a hook is added or removed (see
+ * {@link org.apache.camel.ExtendedCamelContext#addStepHook(StepHook)}).
+ *
+ * @since 4.24
+ */
+public final class CompositeStepHook implements StepHook {
+
+    private final StepHook[] hooks;
+
+    private CompositeStepHook(StepHook[] hooks) {
+        this.hooks = hooks;
+    }
+
+    /**
+     * The hook to install for the given hooks: {@code null} for none, the 
hook itself for one, or else a composite that
+     * asks them in their order.
+     *
+     * @param  hooks the hooks, in the order they were added
+     * @return       the hook, or {@code null}
+     */
+    public static StepHook of(Collection<? extends StepHook> hooks) {
+        if (hooks.isEmpty()) {
+            return null;
+        }
+        StepHook[] sorted = hooks.toArray(new StepHook[0]);
+        if (sorted.length == 1) {
+            return sorted[0];
+        }
+        // a stable sort, so hooks with the same order keep the order they 
were added
+        Arrays.sort(sorted, OrderedComparator.get());
+        return new CompositeStepHook(sorted);
+    }
+
+    /**
+     * The hooks, in the order they are asked.
+     */
+    public List<StepHook> getHooks() {
+        return List.of(hooks);
+    }
+
+    @Override
+    public long beforeStep(Exchange exchange, String nodeId) {
+        // an exchange can reach a step with an exception on it already, which 
does not fail the step
+        final Exception existing = exchange.getException();
+        long total = PROCEED;
+        for (StepHook hook : hooks) {
+            // a hook that throws fails the step (the caller sets the 
exception), and the later hooks are not asked
+            long delay = hook.beforeStep(exchange, nodeId);
+            Exception exception = exchange.getException();
+            if (exception != null && exception != existing) {
+                // the hook failed the step: the return value is ignored
+                return PROCEED;
+            }
+            if (delay < 0) {
+                // reserved: the caller fails the step
+                return delay;
+            }
+            total = delay > Long.MAX_VALUE - total ? Long.MAX_VALUE : total + 
delay;
+        }
+        return total;
+    }
+
+    @Override
+    public String toString() {
+        return "CompositeStepHook" + Arrays.toString(hooks);
+    }
+}
diff --git 
a/core/camel-support/src/main/java/org/apache/camel/support/SendHelper.java 
b/core/camel-support/src/main/java/org/apache/camel/support/SendHelper.java
index 8964c0dcc63a..cefce803acb4 100644
--- a/core/camel-support/src/main/java/org/apache/camel/support/SendHelper.java
+++ b/core/camel-support/src/main/java/org/apache/camel/support/SendHelper.java
@@ -167,18 +167,32 @@ public final class SendHelper {
      * One send with a hook. The hook is called before the producer, the 
producer is called right away, after a delay
      * (that this thread waits for), or not at all, and then the hook is 
called after it and the callback is called,
      * each exactly once.
+     * <p/>
+     * With several hooks (a {@link CompositeSendHook}) the hooks are asked 
one by one, see {@link CompositeSendHook}:
+     * the send keeps how many were asked, so only those are called after it, 
in the reverse order.
      */
     private static final class HookedSend implements AsyncCallback {
 
+        // the one hook, or null when there are several
         private final SendHook hook;
+        // the hooks of a composite, in the order they are asked, or null when 
there is one
+        private final SendHook[] hooks;
         private final Exchange exchange;
         private final Endpoint endpoint;
         private final AsyncProcessor producer;
         private final AsyncCallback callback;
+        // how many hooks returned from beforeSend: they, and only they, are 
called after the send
+        private int asked;
 
         private HookedSend(SendHook hook, Exchange exchange, Endpoint 
endpoint, AsyncProcessor producer,
                            AsyncCallback callback) {
-            this.hook = hook;
+            if (hook instanceof CompositeSendHook composite) {
+                this.hook = null;
+                this.hooks = composite.hooks();
+            } else {
+                this.hook = hook;
+                this.hooks = null;
+            }
             this.exchange = exchange;
             this.endpoint = endpoint;
             this.producer = producer;
@@ -190,10 +204,12 @@ public final class SendHelper {
             final Exception existing = exchange.getException();
             final long delay;
             try {
-                delay = hook.beforeSend(exchange, endpoint);
+                delay = beforeSend(existing);
             } catch (Exception e) {
-                // the hook failed: the producer is not called, and there is 
no after
+                // a hook failed: the producer is not called, and there is no 
after for that hook (the hooks asked
+                // before it are called after the send, as for any send they 
proceeded with)
                 exchange.setException(e);
+                afterSend();
                 callback.done(true);
                 return true;
             }
@@ -239,6 +255,38 @@ public final class SendHelper {
             }
         }
 
+        /**
+         * Asks the hooks before the send.
+         *
+         * @param  existing the exception the exchange had before the send
+         * @return          what the hooks decided: {@link SendHook#PROCEED}, 
a delay, {@link SendHook#SKIP} or a
+         *                  reserved value (ignored when a hook set an 
exception on the exchange)
+         */
+        private long beforeSend(Exception existing) {
+            if (hooks == null) {
+                long answer = hook.beforeSend(exchange, endpoint);
+                asked = 1;
+                return answer;
+            }
+            long answer = SendHook.PROCEED;
+            for (SendHook h : hooks) {
+                long value = h.beforeSend(exchange, endpoint);
+                asked++;
+                Exception exception = exchange.getException();
+                if (exception != null && exception != existing) {
+                    // this hook stopped the send: the later hooks are not 
asked
+                    return SendHook.PROCEED;
+                }
+                if (value < 0) {
+                    // the first SKIP wins (or a reserved value fails the 
send): the later hooks are not asked
+                    return value;
+                }
+                // the longest delay wins
+                answer = Math.max(answer, value);
+            }
+            return answer;
+        }
+
         private boolean completeWithoutProducer() {
             afterSend();
             callback.done(true);
@@ -254,9 +302,24 @@ public final class SendHelper {
             callback.done(doneSync);
         }
 
+        /**
+         * Calls the hooks that were asked before the send, in the reverse 
order.
+         */
         private void afterSend() {
+            if (hooks == null) {
+                if (asked > 0) {
+                    afterSend(hook);
+                }
+                return;
+            }
+            for (int i = asked - 1; i >= 0; i--) {
+                afterSend(hooks[i]);
+            }
+        }
+
+        private void afterSend(SendHook h) {
             try {
-                hook.afterSend(exchange, endpoint);
+                h.afterSend(exchange, endpoint);
             } catch (Exception e) {
                 Exception existing = exchange.getException();
                 if (existing == null) {
diff --git a/design/exchange-sanitizer.adoc b/design/exchange-sanitizer.adoc
index 660871616c78..11346fd0c622 100644
--- a/design/exchange-sanitizer.adoc
+++ b/design/exchange-sanitizer.adoc
@@ -507,19 +507,18 @@ of the cached view it gets from the context extension, 
and goes on. The JMH run
 
 === Relation to StepHook
 
-`StepHook` (CAMEL-26338) acts: it can delay or fail a step, before the 
processor is called. There is one per
-`CamelContext`, because two hooks that act on the same step need a rule for 
how they combine (chaos logs a WARN and does
-not fire at steps when another hook is installed). The step listener only 
observes, also after the call (R1 and R3 are
+`StepHook` (CAMEL-26338) acts: it can delay or fail a step, before the 
processor is called. Two hooks that act on
+the same step need a rule for how they combine (see below). The step listener 
only observes, also after the call (R1 and R3 are
 about what happens after it), so any number can be installed with no rule for 
combining them. The hook is asked first,
 outside the `InterceptStrategy` wrappers; if it fails the step, the processor 
is not called and the listeners see no
 call (`StepHelper` calls the callback, which R1 does not check). The 
`StepHook` stays as it is: it cannot be expressed
 on a listener, which cannot act. A later version could let chaos count its 
faults through a listener; that is not
 proposed here.
 
-Making the `StepHook` composable is future work, outside this design. Acting 
hooks can compose with a fixed rule: ask
-them in the order they were added, stop at the first that fails the step, and 
wait for the sum of the delays. The
-Javadoc of `StepHook` already tells users to combine their hooks in one; a 
composite in camel-support, installed in the
-one slot, would do that for them, and chaos would then no longer need its 
WARN. The sanitizer does not need it.
+Since CAMEL-26607 more than one `StepHook` (and `SendHook`) can be installed, 
with `addStepHook`: the hooks compose
+with a fixed rule, ask them in their `Ordered` order, stop at the first that 
fails the step, and wait for the sum of
+the delays (a `CompositeStepHook` of camel-support, kept in the one field the 
steps read). Chaos takes the lowest
+order, so it is asked last, and no longer needs a WARN. The sanitizer does not 
need a hook.
 
 === The sanitizer module
 
@@ -713,7 +712,7 @@ Each step is its own pull request after this document:
   attempt, and R2 and R4 are not about steps, so nothing would be left for the 
advice to do; the wrapper is needed at
   every step either way. Two mechanisms for the same steps would cost more 
than one. The hybrid becomes worth it if a
   later rule needs one event per step only.
-* *The `StepHook`.* One per context, and before the call only (see Relation to 
StepHook).
+* *The `StepHook`.* It acts, and before the call only (see Relation to 
StepHook).
 * *Listener calls in `StepHelper`, next to the hook.* `StepHelper` is called 
just outside the chosen wrapper in most
   cases: by the error handler for each attempt, or by the `StepHook` processor 
or the channel. Every step
   already reads the `stepHook` field there, so holding the hook and the 
listeners in one field would add no read per
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 2e2652c1fa71..b0057373f13b 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -7175,7 +7175,7 @@ an older offset, so the consumer can process again the 
messages after that offse
 
 === camel-core - a hook before each step of a route
 
-A new SPI `org.apache.camel.spi.StepHook`, set with 
`ExtendedCamelContext.setStepHook`, can fail or delay each step of
+A new SPI `org.apache.camel.spi.StepHook`, added with 
`ExtendedCamelContext.addStepHook`, can fail or delay each step of
 a route. `ErrorHandlerRedeliveryCustomizer` has a new default method 
`useStepHook(String)`, so existing implementations
 need no change. A custom error handler that wants a failure from the hook 
handled like a failure of the step's
 processor (with `onException` and redelivery) can override it.

Reply via email to