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 ba87a347b68f CAMEL-26104: camel-core - ThrottlingExceptionRoutePolicy: 
create the half-open timer through the ExecutorServiceManager (#28753)
ba87a347b68f is described below

commit ba87a347b68fbd98728b21802f04c73430c1510d
Author: allthingssecurity <[email protected]>
AuthorDate: Sat Oct 31 15:32:18 2026 +0530

    CAMEL-26104: camel-core - ThrottlingExceptionRoutePolicy: create the 
half-open timer through the ExecutorServiceManager (#28753)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../org/apache/camel/catalog/docs/test-dst.adoc    |  10 +-
 .../camel-test-dst/src/main/docs/test-dst.adoc     |  10 +-
 .../camel/test/dst/DstHalfOpenCheckTest.java       | 126 +++++++++
 ...tlingExceptionRoutePolicyFailureWindowTest.java |  36 ++-
 ...gExceptionRoutePolicyHalfOpenSchedulerTest.java | 294 +++++++++++++++++++++
 .../throttling/ThrottlingExceptionRoutePolicy.java | 165 ++++++++++--
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  16 ++
 7 files changed, 618 insertions(+), 39 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/test-dst.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/test-dst.adoc
index 9c0d809a57ad..2e7e4906b15f 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/test-dst.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/test-dst.adoc
@@ -92,9 +92,10 @@ the synchronous redelivery (`RedeliveryPolicy`, 
`RedeliveryErrorHandler`), `Fore
 the budgets of both tasks, the wait loop of `DefaultShutdownStrategy`, the 
synchronous delayer, `MulticastProcessor`,
 `OptimisticLockRetryPolicy`, `DefaultStartupConditionStrategy`, `StopWatch`, 
`MonotonicClock`, `BackOffTimerTask`,
 `DefaultTimeoutMap`, the clocks of the throttlers and the counters of 
`ThrottlingExceptionRoutePolicy`. A sleep there is
-a virtual wait on the loop thread, traced with its call site. Three waits of 
these classes are not virtual (see below):
-the permit queue of `TotalRequestsThrottler`, the `Semaphore` of 
`ConcurrentRequestsThrottler` and the half-open timer
-of `ThrottlingExceptionRoutePolicy`. The sleep of the route `delayer` option 
is not virtual either (see below).
+a virtual wait on the loop thread, traced with its call site. Two waits of 
these classes are not virtual (see below):
+the permit queue of `TotalRequestsThrottler` and the `Semaphore` of 
`ConcurrentRequestsThrottler`. The sleep of the
+route `delayer` option is not virtual either (see below). The half-open check 
of `ThrottlingExceptionRoutePolicy` runs
+on a scheduler of the `ExecutorServiceManager`, so the circuit is half opened 
after `halfOpenAfter` of virtual time.
 |===
 
 Event timestamps, the clock of an exchange and the direct clock reads of the 
debugger, the tracer, management and the
@@ -137,9 +138,6 @@ core:
 * The route `delayer` option (`CamelInternalProcessor.DelayerAdvice`) calls 
`Thread.sleep` directly, not through
   `TimeSource`: the delay takes real time on the loop thread and the virtual 
clock does not move. Use the `delay` EIP
   in a world instead.
-* The half-open check of `ThrottlingExceptionRoutePolicy` runs on a 
`java.util.Timer` thread, outside the harness. The
-  timer fires after `halfOpenAfter` of real time (30 s by default, longer than 
the watchdog) and its clock read is an
-  escape, so a world does not see the circuit half-open in virtual time.
 
 === The one-stack model
 
diff --git a/components/camel-test/camel-test-dst/src/main/docs/test-dst.adoc 
b/components/camel-test/camel-test-dst/src/main/docs/test-dst.adoc
index 9c0d809a57ad..2e7e4906b15f 100644
--- a/components/camel-test/camel-test-dst/src/main/docs/test-dst.adoc
+++ b/components/camel-test/camel-test-dst/src/main/docs/test-dst.adoc
@@ -92,9 +92,10 @@ the synchronous redelivery (`RedeliveryPolicy`, 
`RedeliveryErrorHandler`), `Fore
 the budgets of both tasks, the wait loop of `DefaultShutdownStrategy`, the 
synchronous delayer, `MulticastProcessor`,
 `OptimisticLockRetryPolicy`, `DefaultStartupConditionStrategy`, `StopWatch`, 
`MonotonicClock`, `BackOffTimerTask`,
 `DefaultTimeoutMap`, the clocks of the throttlers and the counters of 
`ThrottlingExceptionRoutePolicy`. A sleep there is
-a virtual wait on the loop thread, traced with its call site. Three waits of 
these classes are not virtual (see below):
-the permit queue of `TotalRequestsThrottler`, the `Semaphore` of 
`ConcurrentRequestsThrottler` and the half-open timer
-of `ThrottlingExceptionRoutePolicy`. The sleep of the route `delayer` option 
is not virtual either (see below).
+a virtual wait on the loop thread, traced with its call site. Two waits of 
these classes are not virtual (see below):
+the permit queue of `TotalRequestsThrottler` and the `Semaphore` of 
`ConcurrentRequestsThrottler`. The sleep of the
+route `delayer` option is not virtual either (see below). The half-open check 
of `ThrottlingExceptionRoutePolicy` runs
+on a scheduler of the `ExecutorServiceManager`, so the circuit is half opened 
after `halfOpenAfter` of virtual time.
 |===
 
 Event timestamps, the clock of an exchange and the direct clock reads of the 
debugger, the tracer, management and the
@@ -137,9 +138,6 @@ core:
 * The route `delayer` option (`CamelInternalProcessor.DelayerAdvice`) calls 
`Thread.sleep` directly, not through
   `TimeSource`: the delay takes real time on the loop thread and the virtual 
clock does not move. Use the `delay` EIP
   in a world instead.
-* The half-open check of `ThrottlingExceptionRoutePolicy` runs on a 
`java.util.Timer` thread, outside the harness. The
-  timer fires after `halfOpenAfter` of real time (30 s by default, longer than 
the watchdog) and its clock read is an
-  escape, so a world does not see the circuit half-open in virtual time.
 
 === The one-stack model
 
diff --git 
a/components/camel-test/camel-test-dst/src/test/java/org/apache/camel/test/dst/DstHalfOpenCheckTest.java
 
b/components/camel-test/camel-test-dst/src/test/java/org/apache/camel/test/dst/DstHalfOpenCheckTest.java
new file mode 100644
index 000000000000..3828b9f4a5d2
--- /dev/null
+++ 
b/components/camel-test/camel-test-dst/src/test/java/org/apache/camel/test/dst/DstHalfOpenCheckTest.java
@@ -0,0 +1,126 @@
+/*
+ * 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.test.dst;
+
+import java.util.List;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.support.DefaultConsumer;
+import org.apache.camel.support.service.ServiceSupport;
+import org.apache.camel.throttling.ThrottlingExceptionRoutePolicy;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The half open check of {@code ThrottlingExceptionRoutePolicy} runs on a 
scheduler created through the
+ * ExecutorServiceManager: in a run it is a task of the loop, and the circuit 
is half opened after {@code halfOpenAfter}
+ * of virtual time. When {@code halfOpenAfter} is raised while the circuit is 
open, the check schedules the next one for
+ * the time that is left.
+ */
+@DstInstrument("org.apache.camel.test.dst.sample")
+class DstHalfOpenCheckTest {
+
+    private static final long REAL_LIMIT = 5_000_000_000L;
+
+    @Test
+    void theCircuitIsHalfOpenedAfterHalfOpenAfterOfVirtualTime() throws 
Exception {
+        Dst dst = new Dst(1);
+        dst.strict(true, List.of());
+        DefaultCamelContext context = DstCamel.newContext(dst);
+        try {
+            ThrottlingExceptionRoutePolicy policy = new 
ThrottlingExceptionRoutePolicy(1, 60_000, 30_000, List.of());
+            context.addRoutes(new RouteBuilder() {
+                @Override
+                public void configure() {
+                    from("direct:in").routeId("r").routePolicy(policy)
+                            .throwException(new IllegalStateException("the 
downstream system is down"));
+                }
+            });
+            context.start();
+            Exchange[] done = new Exchange[1];
+            dst.executor("client", 1).schedule("send", 1_000, () -> 
send(context, done));
+            long start = System.nanoTime();
+
+            dst.runFor(30_000);
+            assertNotNull(done[0]);
+            assertEquals("opened", policy.getStateAsString());
+            assertTrue(((ServiceSupport) 
context.getRoute("r").getConsumer()).isSuspended());
+
+            dst.runUntil(() -> "half 
opened".equals(policy.getStateAsString()), "the circuit is half opened");
+            assertEquals(31_000, dst.now(), "half opened 30 s after the 
failure at 1 s");
+            assertFalse(((ServiceSupport) 
context.getRoute("r").getConsumer()).isSuspended());
+            assertTrue(dst.traceLines().stream()
+                    .anyMatch(l -> l.contains("thread pool created: 
ThrottlingExceptionRoutePolicy (1 threads)")),
+                    String.join("\n", dst.traceLines()));
+            assertTrue(System.nanoTime() - start < REAL_LIMIT, "30 s of 
virtual time take no real time");
+        } finally {
+            dst.dropPending();
+            context.stop();
+            dst.close();
+        }
+    }
+
+    @Test
+    void aRaisedHalfOpenAfterHalfOpensTheCircuitWhenTheNewTimeIsUp() throws 
Exception {
+        Dst dst = new Dst(1);
+        dst.strict(true, List.of());
+        DefaultCamelContext context = DstCamel.newContext(dst);
+        try {
+            ThrottlingExceptionRoutePolicy policy = new 
ThrottlingExceptionRoutePolicy(1, 60_000, 30_000, List.of());
+            context.addRoutes(new RouteBuilder() {
+                @Override
+                public void configure() {
+                    from("direct:in").routeId("r").routePolicy(policy)
+                            .throwException(new IllegalStateException("the 
downstream system is down"));
+                }
+            });
+            context.start();
+            Exchange[] done = new Exchange[1];
+            DstExecutor client = dst.executor("client", 1);
+            client.schedule("send", 1_000, () -> send(context, done));
+            // as over JMX, while the circuit is open: the check scheduled at 
1 s is due at 31 s, before the new time
+            client.schedule("raise halfOpenAfter", 20_000, () -> 
policy.setHalfOpenAfter(40_000));
+
+            dst.runFor(31_000);
+            assertEquals("opened", policy.getStateAsString());
+            assertTrue(((ServiceSupport) 
context.getRoute("r").getConsumer()).isSuspended());
+
+            // the check at 31 s schedules the next one for the 10 s that are 
left
+            dst.runUntil(() -> "half 
opened".equals(policy.getStateAsString()), "the circuit is half opened");
+            assertEquals(41_000, dst.now(), "half opened 40 s after the 
failure at 1 s");
+            assertFalse(((ServiceSupport) 
context.getRoute("r").getConsumer()).isSuspended());
+        } finally {
+            dst.dropPending();
+            context.stop();
+            dst.close();
+        }
+    }
+
+    /** Sends an exchange to the route's consumer without waiting for it (no 
blocking wait on the loop thread). */
+    private static void send(DefaultCamelContext context, Exchange[] done) {
+        DefaultConsumer consumer = (DefaultConsumer) 
context.getRoute("r").getConsumer();
+        Exchange exchange = consumer.createExchange(false);
+        exchange.getMessage().setBody("hello");
+        consumer.getAsyncProcessor().process(exchange, doneSync -> done[0] = 
exchange);
+    }
+}
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/processor/throttle/ThrottlingExceptionRoutePolicyFailureWindowTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/processor/throttle/ThrottlingExceptionRoutePolicyFailureWindowTest.java
index d14b861661f5..72ab905d2c21 100644
--- 
a/core/camel-core/src/test/java/org/apache/camel/processor/throttle/ThrottlingExceptionRoutePolicyFailureWindowTest.java
+++ 
b/core/camel-core/src/test/java/org/apache/camel/processor/throttle/ThrottlingExceptionRoutePolicyFailureWindowTest.java
@@ -19,12 +19,17 @@ package org.apache.camel.processor.throttle;
 import java.util.List;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ThreadFactory;
 import java.util.concurrent.TimeUnit;
 
+import org.apache.camel.CamelContext;
 import org.apache.camel.CamelExecutionException;
 import org.apache.camel.ContextTestSupport;
 import org.apache.camel.Route;
 import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.spi.ThreadPoolProfile;
+import org.apache.camel.support.DefaultThreadPoolFactory;
 import org.apache.camel.support.service.ServiceSupport;
 import org.apache.camel.throttling.ThrottlingExceptionRoutePolicy;
 import org.apache.camel.util.TimeSource;
@@ -56,6 +61,25 @@ class ThrottlingExceptionRoutePolicyFailureWindowTest 
extends ContextTestSupport
     private final CountDownLatch slowEntered = new CountDownLatch(1);
     private final CountDownLatch slowRelease = new CountDownLatch(1);
 
+    // the scheduler of the half open checks of the policy of the race route 
(the only policy whose circuit opens there)
+    private volatile ScheduledExecutorService raceScheduler;
+
+    @Override
+    protected CamelContext createCamelContext() throws Exception {
+        CamelContext context = super.createCamelContext();
+        context.getExecutorServiceManager().setThreadPoolFactory(new 
DefaultThreadPoolFactory() {
+            @Override
+            public ScheduledExecutorService 
newScheduledThreadPool(ThreadPoolProfile profile, ThreadFactory threadFactory) {
+                ScheduledExecutorService answer = 
super.newScheduledThreadPool(profile, threadFactory);
+                if ("ThrottlingExceptionRoutePolicy".equals(profile.getId())) {
+                    raceScheduler = answer;
+                }
+                return answer;
+            }
+        });
+        return context;
+    }
+
     @Test
     void testSuccessInHalfOpenStateClosesTheCircuit() throws Exception {
         ServiceSupport consumer = (ServiceSupport) 
context.getRoute("halfOpen").getConsumer();
@@ -120,10 +144,10 @@ class ThrottlingExceptionRoutePolicyFailureWindowTest 
extends ContextTestSupport
         assertEquals("half opened", racePolicy.getStateAsString());
         assertFalse(consumer.isSuspended());
 
-        // the timer's check goes on and ends
+        // the timer's check goes on and ends: the scheduler has one thread, 
so a task submitted now runs after it
         racePolicy.timerMayGo.countDown();
-        racePolicy.timerThread.join(10000);
-        assertFalse(racePolicy.timerThread.isAlive());
+        raceScheduler.submit(() -> {
+        }).get(10, TimeUnit.SECONDS);
         assertEquals("half opened", racePolicy.getStateAsString(),
                 "The half open timer should not open the circuit again after 
an exchange half opened it");
         assertFalse(consumer.isSuspended());
@@ -158,8 +182,8 @@ class ThrottlingExceptionRoutePolicyFailureWindowTest 
extends ContextTestSupport
     }
 
     /**
-     * The policy, with latches: halfOpened when the circuit has been half 
opened, and a gate that stops the half open
-     * check of the timer thread (the first one) before it reads the state, 
until timerMayGo.
+     * The policy, with latches: halfOpened when the circuit has been half 
opened, and a gate that stops the first half
+     * open check (on the thread of the policy's scheduler) before it reads 
the state, until timerMayGo.
      */
     private static final class LatchPolicy extends 
ThrottlingExceptionRoutePolicy {
         private final CountDownLatch halfOpened = new CountDownLatch(1);
@@ -174,7 +198,7 @@ class ThrottlingExceptionRoutePolicyFailureWindowTest 
extends ContextTestSupport
         @Override
         protected boolean isThresholdExceeded() {
             // the half open check of the timer calls this first, before it 
reads the state
-            if (timerThread == null && 
Thread.currentThread().getName().startsWith("Timer-")) {
+            if (timerThread == null && 
Thread.currentThread().getName().endsWith(" - ThrottlingExceptionRoutePolicy")) 
{
                 timerThread = Thread.currentThread();
                 timerChecking.countDown();
                 try {
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/processor/throttle/ThrottlingExceptionRoutePolicyHalfOpenSchedulerTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/processor/throttle/ThrottlingExceptionRoutePolicyHalfOpenSchedulerTest.java
new file mode 100644
index 000000000000..a92961c40d0f
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/processor/throttle/ThrottlingExceptionRoutePolicyHalfOpenSchedulerTest.java
@@ -0,0 +1,294 @@
+/*
+ * 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.throttle;
+
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.camel.CamelExecutionException;
+import org.apache.camel.Consumer;
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Exchange;
+import org.apache.camel.Route;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.log.ConsumingAppender;
+import org.apache.camel.spi.ExceptionHandler;
+import org.apache.camel.support.service.ServiceSupport;
+import org.apache.camel.throttling.ThrottlingExceptionRoutePolicy;
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.core.Appender;
+import org.apache.logging.log4j.core.LoggerContext;
+import org.junit.jupiter.api.Test;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The half open check of {@link ThrottlingExceptionRoutePolicy} runs on one 
thread of a scheduler that the policy
+ * creates through the ExecutorServiceManager, and that thread ends when the 
last route of the policy is removed. An
+ * exception of the check, or of suspending or resuming the consumer, goes to 
the exception handler of the policy.
+ */
+class ThrottlingExceptionRoutePolicyHalfOpenSchedulerTest extends 
ContextTestSupport {
+
+    private final LatchPolicy policy = new LatchPolicy(100, 2);
+    private final LatchPolicy removePolicy = new LatchPolicy(60000, 1);
+    private final LatchPolicy sharedPolicy = new LatchPolicy(3000, 1);
+    private final FailingSuspendPolicy suspendPolicy = new 
FailingSuspendPolicy();
+    private final LatchPolicy handlerPolicy = new LatchPolicy(100, 1);
+    private final List<Throwable> handled = new CopyOnWriteArrayList<>();
+    private final AtomicInteger handlerChecks = new AtomicInteger();
+
+    @Test
+    void testHalfOpenChecksRunOnOneThreadOfThePolicy() throws Exception {
+        ServiceSupport consumer = (ServiceSupport) 
context.getRoute("checked").getConsumer();
+
+        assertThrows(CamelExecutionException.class, () -> 
template.sendBody("direct:checked", "fail"));
+        assertEquals("opened", policy.getStateAsString());
+        await().atMost(10, TimeUnit.SECONDS).until(() -> 
policy.checkThreads.size() == 1);
+        assertEquals("half opened", policy.getStateAsString());
+        assertFalse(consumer.isSuspended());
+
+        // a failure in the half open state opens the circuit again, and the 
next check runs on the same thread
+        assertThrows(CamelExecutionException.class, () -> 
template.sendBody("direct:checked", "fail"));
+        assertTrue(policy.halfOpened.await(10, TimeUnit.SECONDS));
+        assertEquals("half opened", policy.getStateAsString());
+
+        Thread thread = policy.checkThreads.get(0);
+        assertSame(thread, policy.checkThreads.get(1));
+        assertTrue(isSchedulerThread(thread), thread.getName());
+    }
+
+    @Test
+    void testRemovingTheRouteEndsTheThreadOfTheHalfOpenCheck() throws 
Exception {
+        assertThrows(CamelExecutionException.class, () -> 
template.sendBody("direct:removed", "fail"));
+        assertEquals("opened", removePolicy.getStateAsString());
+        assertEquals(1, schedulerThreads().size(), "the half open check is 
scheduled");
+
+        context.getRouteController().stopRoute("removed");
+        assertTrue(context.removeRoute("removed"));
+
+        await().atMost(10, TimeUnit.SECONDS).until(() -> 
schedulerThreads().isEmpty());
+        assertEquals(1, removePolicy.halfOpened.getCount(), "the check 
scheduled before the removal must not run");
+    }
+
+    @Test
+    void testRemovingOneOfTwoRoutesOfAPolicyKeepsItsHalfOpenCheck() throws 
Exception {
+        ServiceSupport consumer = (ServiceSupport) 
context.getRoute("shared2").getConsumer();
+
+        assertThrows(CamelExecutionException.class, () -> 
template.sendBody("direct:shared2", "fail"));
+        assertEquals("opened", sharedPolicy.getStateAsString());
+        assertTrue(consumer.isSuspended());
+
+        context.getRouteController().stopRoute("shared1");
+        assertTrue(context.removeRoute("shared1"));
+        assertEquals(1, sharedPolicy.halfOpened.getCount(), "the check is 
still pending when the other route is removed");
+
+        // the route that is left still gets its half open check
+        assertTrue(sharedPolicy.halfOpened.await(10, TimeUnit.SECONDS));
+        assertFalse(consumer.isSuspended());
+
+        context.getRouteController().stopRoute("shared2");
+        assertTrue(context.removeRoute("shared2"));
+        await().atMost(10, TimeUnit.SECONDS).until(() -> 
schedulerThreads().isEmpty());
+    }
+
+    @Test
+    void testRestartedContextKeepsTheHalfOpenCheckOfTheRouteThatIsLeft() 
throws Exception {
+        // a CamelContext that is stopped calls onRemove of the policy for 
each route, and when it is started again it
+        // creates the routes again (onInit and onStart)
+        context.stop();
+        context.start();
+        ServiceSupport consumer = (ServiceSupport) 
context.getRoute("shared2").getConsumer();
+
+        assertThrows(CamelExecutionException.class, () -> 
template.sendBody("direct:shared2", "fail"));
+        assertEquals("opened", sharedPolicy.getStateAsString());
+        assertTrue(consumer.isSuspended());
+
+        context.getRouteController().stopRoute("shared1");
+        assertTrue(context.removeRoute("shared1"));
+        assertEquals(1, sharedPolicy.halfOpened.getCount(), "the check is 
still pending when the other route is removed");
+
+        // the route that is left still gets its half open check
+        assertTrue(sharedPolicy.halfOpened.await(10, TimeUnit.SECONDS));
+        assertFalse(consumer.isSuspended());
+    }
+
+    @Test
+    void testFailureToSuspendTheConsumerIsLogged() throws Exception {
+        // the policy logs with the LoggingExceptionHandler that 
RoutePolicySupport.onInit creates
+        String loggerName = FailingSuspendPolicy.class.getName();
+        List<Throwable> logged = new CopyOnWriteArrayList<>();
+        Appender appender = ConsumingAppender.newAppender(loggerName, 
"suspend-failure", Level.WARN, e -> {
+            if (e.getThrown() != null) {
+                logged.add(e.getThrown());
+            }
+        });
+        try {
+            assertThrows(CamelExecutionException.class, () -> 
template.sendBody("direct:suspendFails", "fail"));
+
+            assertEquals(1, logged.size(), "the failure to suspend the 
consumer is logged");
+            assertEquals("Forced suspend failure", logged.get(0).getMessage());
+        } finally {
+            LoggerContext ctx = (LoggerContext) LogManager.getContext(false);
+            ctx.getConfiguration().removeLogger(loggerName);
+            ctx.updateLoggers();
+            appender.stop();
+        }
+    }
+
+    @Test
+    void testExceptionOfTheHalfOpenHandlerIsHandledAndTheCheckIsRetried() 
throws Exception {
+        ServiceSupport consumer = (ServiceSupport) 
context.getRoute("handler").getConsumer();
+
+        assertThrows(CamelExecutionException.class, () -> 
template.sendBody("direct:handler", "fail"));
+        assertEquals("opened", handlerPolicy.getStateAsString());
+
+        // the first check fails, and keeps the circuit open; the next one, 
after halfOpenAfter, closes it
+        await().atMost(10, TimeUnit.SECONDS).until(() -> 
"closed".equals(handlerPolicy.getStateAsString()));
+        assertEquals(2, handlerChecks.get());
+        assertEquals(1, handled.size(), "the exception of the half open 
handler is handled");
+        assertEquals("Forced check failure", handled.get(0).getMessage());
+        assertFalse(consumer.isSuspended());
+    }
+
+    @Test
+    void testHalfOpenAfterMustBeGreaterThanZero() throws Exception {
+        ThrottlingExceptionRoutePolicy configured = new 
ThrottlingExceptionRoutePolicy();
+        assertThrows(IllegalArgumentException.class, () -> 
configured.setHalfOpenAfter(0));
+        assertThrows(IllegalArgumentException.class, () -> 
configured.setHalfOpenAfter(-1));
+        assertEquals(30000, configured.getHalfOpenAfter());
+
+        // set through the constructor: the route that uses the policy is not 
created
+        ThrottlingExceptionRoutePolicy constructed = new 
ThrottlingExceptionRoutePolicy(1, 60000, 0, List.of());
+        Exception e = assertThrows(Exception.class, () -> 
context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                
from("direct:zero").routeId("zero").routePolicy(constructed).to("mock:zero");
+            }
+        }));
+        Throwable cause = e;
+        while (cause != null && !(cause instanceof IllegalArgumentException)) {
+            cause = cause.getCause();
+        }
+        assertEquals("halfOpenAfter must be greater than 0, was: 0", cause != 
null ? cause.getMessage() : null, e.toString());
+    }
+
+    private boolean isSchedulerThread(Thread thread) {
+        // the thread name pattern of the ExecutorServiceManager, with the 
name of this CamelContext
+        String name = thread.getName();
+        return name.startsWith("Camel (" + context.getName() + ") thread #")
+                && name.endsWith(" - ThrottlingExceptionRoutePolicy");
+    }
+
+    private List<Thread> schedulerThreads() {
+        return 
Thread.getAllStackTraces().keySet().stream().filter(Thread::isAlive).filter(this::isSchedulerThread)
+                .toList();
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("direct:checked").routeId("checked").routePolicy(policy)
+                        .filter(body().isEqualTo("fail")).throwException(new 
IllegalArgumentException("Forced"));
+
+                
from("direct:removed").routeId("removed").routePolicy(removePolicy)
+                        .filter(body().isEqualTo("fail")).throwException(new 
IllegalArgumentException("Forced"));
+
+                
from("direct:shared1").routeId("shared1").routePolicy(sharedPolicy)
+                        .filter(body().isEqualTo("fail")).throwException(new 
IllegalArgumentException("Forced"));
+                
from("direct:shared2").routeId("shared2").routePolicy(sharedPolicy)
+                        .filter(body().isEqualTo("fail")).throwException(new 
IllegalArgumentException("Forced"));
+
+                
from("direct:suspendFails").routeId("suspendFails").routePolicy(suspendPolicy)
+                        .filter(body().isEqualTo("fail")).throwException(new 
IllegalArgumentException("Forced"));
+
+                handlerPolicy.setExceptionHandler(new 
RecordingExceptionHandler());
+                handlerPolicy.setHalfOpenHandler(() -> {
+                    if (handlerChecks.incrementAndGet() == 1) {
+                        throw new IllegalStateException("Forced check 
failure");
+                    }
+                    return true;
+                });
+                
from("direct:handler").routeId("handler").routePolicy(handlerPolicy)
+                        .filter(body().isEqualTo("fail")).throwException(new 
IllegalArgumentException("Forced"));
+            }
+        };
+    }
+
+    /**
+     * The policy, with the threads of its half open checks and a latch 
counted down when the circuit is half opened.
+     */
+    private static final class LatchPolicy extends 
ThrottlingExceptionRoutePolicy {
+        private final List<Thread> checkThreads = new CopyOnWriteArrayList<>();
+        private final CountDownLatch halfOpened;
+
+        LatchPolicy(long halfOpenAfter, int halfOpenings) {
+            super(1, 60000, halfOpenAfter, List.of());
+            this.halfOpened = new CountDownLatch(halfOpenings);
+        }
+
+        @Override
+        protected void halfOpenCircuit(Route route) {
+            super.halfOpenCircuit(route);
+            checkThreads.add(Thread.currentThread());
+            halfOpened.countDown();
+        }
+    }
+
+    /**
+     * The policy, failing to suspend the consumer.
+     */
+    private static final class FailingSuspendPolicy extends 
ThrottlingExceptionRoutePolicy {
+
+        FailingSuspendPolicy() {
+            super(1, 60000, 60000, List.of());
+        }
+
+        @Override
+        public boolean suspendOrStopConsumer(Consumer consumer) {
+            throw new IllegalStateException("Forced suspend failure");
+        }
+    }
+
+    private final class RecordingExceptionHandler implements ExceptionHandler {
+
+        @Override
+        public void handleException(Throwable exception) {
+            handled.add(exception);
+        }
+
+        @Override
+        public void handleException(String message, Throwable exception) {
+            handled.add(exception);
+        }
+
+        @Override
+        public void handleException(String message, Exchange exchange, 
Throwable exception) {
+            handled.add(exception);
+        }
+    }
+}
diff --git 
a/core/camel-support/src/main/java/org/apache/camel/throttling/ThrottlingExceptionRoutePolicy.java
 
b/core/camel-support/src/main/java/org/apache/camel/throttling/ThrottlingExceptionRoutePolicy.java
index 255bcd7be538..9ad737f8da7a 100644
--- 
a/core/camel-support/src/main/java/org/apache/camel/throttling/ThrottlingExceptionRoutePolicy.java
+++ 
b/core/camel-support/src/main/java/org/apache/camel/throttling/ThrottlingExceptionRoutePolicy.java
@@ -19,9 +19,12 @@ package org.apache.camel.throttling;
 import java.util.ArrayDeque;
 import java.util.ArrayList;
 import java.util.Deque;
+import java.util.HashSet;
 import java.util.List;
-import java.util.Timer;
-import java.util.TimerTask;
+import java.util.Set;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.concurrent.locks.Lock;
@@ -102,7 +105,16 @@ public class ThrottlingExceptionRoutePolicy extends 
RoutePolicySupport implement
     private final AtomicInteger success = new AtomicInteger();
     private final AtomicInteger state = new AtomicInteger(STATE_CLOSED);
     private final AtomicBoolean keepOpenBool = new AtomicBoolean();
-    private volatile Timer halfOpenTimer;
+    // the half open check: one at a time, on a scheduler created through the 
ExecutorServiceManager when the circuit
+    // opens the first time, and shut down when the last route of the policy 
is removed or the policy is stopped;
+    // guarded by schedulerLock (not by lock, which a running check holds 
while it suspends or resumes the consumer)
+    private final Lock schedulerLock = new ReentrantLock();
+    private final Set<String> routeIds = new HashSet<>();
+    private volatile ScheduledExecutorService halfOpenScheduler;
+    private volatile ScheduledFuture<?> halfOpenCheck;
+    // set when the scheduler is shut down, so that a check that is running 
then does not create a new one; cleared
+    // when the policy or a route of it is started again
+    private boolean halfOpenStopped;
     private volatile long lastFailure;
     private volatile long openedAt;
     // the times of the most recent failures (at most failureThreshold of 
them), oldest first, guarded by failureLock
@@ -165,6 +177,10 @@ public class ThrottlingExceptionRoutePolicy extends 
RoutePolicySupport implement
 
         this.stateLogger = new CamelLogger(LOG, stateLoggingLevel);
 
+        // with 0 or less, a check that keeps the circuit open (keepOpen, or a 
halfOpenHandler that is not ready)
+        // would schedule the next check without delay, a busy loop on the 
scheduler thread
+        checkHalfOpenAfter(halfOpenAfter);
+
         if (exceptions != null && throttledExceptions == null) {
             var list = new ArrayList<Class<?>>();
             for (String fqn : exceptions.split(",")) {
@@ -178,11 +194,20 @@ public class ThrottlingExceptionRoutePolicy extends 
RoutePolicySupport implement
     @Override
     public void onInit(Route route) {
         LOG.debug("Initializing ThrottlingExceptionRoutePolicy route policy");
+        // creates the exception handler that logs a failure to suspend or 
resume the consumer
+        super.onInit(route);
+        if (camelContext == null) {
+            camelContext = route.getCamelContext();
+        }
+        addRoute(route);
         logState();
     }
 
     @Override
     public void onStart(Route route) {
+        // also here, not only in onInit, so that a started route is always 
tracked (a CamelContext that is stopped
+        // calls onRemove for each route; when started again it creates the 
routes again and calls onInit too)
+        addRoute(route);
         // if keepOpen then start w/ the circuit open
         if (keepOpenBool.get()) {
             // the circuit may still be open from before the route was 
stopped, and then the started consumer must
@@ -191,12 +216,66 @@ public class ThrottlingExceptionRoutePolicy extends 
RoutePolicySupport implement
         }
     }
 
+    @Override
+    public void onRemove(Route route) {
+        // the policy is a service of the CamelContext, which does not stop it 
when its routes are removed
+        schedulerLock.lock();
+        try {
+            routeIds.remove(route.getRouteId());
+            if (routeIds.isEmpty()) {
+                shutdownHalfOpenScheduler();
+            }
+        } finally {
+            schedulerLock.unlock();
+        }
+    }
+
+    @Override
+    protected void doStart() throws Exception {
+        allowHalfOpenChecks();
+    }
+
     @Override
     protected void doStop() throws Exception {
-        Timer timer = halfOpenTimer;
-        if (timer != null) {
-            timer.cancel();
-            halfOpenTimer = null;
+        shutdownHalfOpenScheduler();
+    }
+
+    private void addRoute(Route route) {
+        schedulerLock.lock();
+        try {
+            routeIds.add(route.getRouteId());
+            halfOpenStopped = false;
+        } finally {
+            schedulerLock.unlock();
+        }
+    }
+
+    private void allowHalfOpenChecks() {
+        schedulerLock.lock();
+        try {
+            halfOpenStopped = false;
+        } finally {
+            schedulerLock.unlock();
+        }
+    }
+
+    private void shutdownHalfOpenScheduler() {
+        schedulerLock.lock();
+        try {
+            halfOpenStopped = true;
+            ScheduledFuture<?> check = halfOpenCheck;
+            if (check != null) {
+                check.cancel(false);
+                halfOpenCheck = null;
+            }
+            ScheduledExecutorService scheduler = halfOpenScheduler;
+            if (scheduler != null) {
+                halfOpenScheduler = null;
+                // does not wait for a check that is running
+                camelContext.getExecutorServiceManager().shutdown(scheduler);
+            }
+        } finally {
+            schedulerLock.unlock();
         }
     }
 
@@ -327,7 +406,7 @@ public class ThrottlingExceptionRoutePolicy extends 
RoutePolicySupport implement
                 if (openFor <= elapsedTimeSinceOpened) {
                     LOG.debug("Checking an open circuit...");
                     if (halfOpenHandler != null) {
-                        if (halfOpenHandler.isReadyToBeClosed()) {
+                        if (isReadyToBeClosed()) {
                             LOG.debug("Closing circuit...");
                             closeCircuit(route);
                         } else {
@@ -356,6 +435,16 @@ public class ThrottlingExceptionRoutePolicy extends 
RoutePolicySupport implement
 
     }
 
+    private boolean isReadyToBeClosed() {
+        try {
+            return halfOpenHandler.isReadyToBeClosed();
+        } catch (Exception e) {
+            // the circuit is kept open, and checked again after halfOpenAfter
+            handleException(e);
+            return false;
+        }
+    }
+
     protected boolean isThresholdExceeded() {
         boolean output = false;
         logState();
@@ -414,6 +503,10 @@ public class ThrottlingExceptionRoutePolicy extends 
RoutePolicySupport implement
         }
     }
 
+    /**
+     * Schedules the check of the open circuit after {@link 
#getHalfOpenAfter()} milliseconds, and cancels the check
+     * scheduled before, if any.
+     */
     protected void addHalfOpenTimer(Route route) {
         addHalfOpenTimer(route, halfOpenAfter);
     }
@@ -423,16 +516,27 @@ public class ThrottlingExceptionRoutePolicy extends 
RoutePolicySupport implement
      */
     protected void addHalfOpenTimer(Route route, long delay) {
         lock.lock();
+        schedulerLock.lock();
         try {
-            Timer previous = halfOpenTimer;
-            Timer timer = new Timer();
-            timer.schedule(new HalfOpenTask(route, timer), delay);
-            halfOpenTimer = timer;
+            if (halfOpenStopped) {
+                LOG.debug("Not scheduling a half open check (the route is 
removed or the policy is stopped)");
+                return;
+            }
+            ScheduledExecutorService scheduler = halfOpenScheduler;
+            if (scheduler == null || scheduler.isShutdown()) {
+                // one thread for the half open checks of this policy, named 
and managed by Camel
+                scheduler = 
camelContext.getExecutorServiceManager().newSingleThreadScheduledExecutor(this,
+                        "ThrottlingExceptionRoutePolicy");
+                halfOpenScheduler = scheduler;
+            }
+            ScheduledFuture<?> previous = halfOpenCheck;
+            halfOpenCheck = scheduler.schedule(new HalfOpenTask(route, 
scheduler), delay, TimeUnit.MILLISECONDS);
             if (previous != null) {
-                // only one half open check at a time (each timer has its own 
thread)
-                previous.cancel();
+                // only one half open check at a time (a check that is running 
is not interrupted)
+                previous.cancel(false);
             }
         } finally {
+            schedulerLock.unlock();
             lock.unlock();
         }
     }
@@ -506,20 +610,27 @@ public class ThrottlingExceptionRoutePolicy extends 
RoutePolicySupport implement
         }
     }
 
-    class HalfOpenTask extends TimerTask {
+    class HalfOpenTask implements Runnable {
         private final Route route;
-        private final Timer timer;
+        private final ScheduledExecutorService scheduler;
 
-        HalfOpenTask(Route route, Timer timer) {
+        HalfOpenTask(Route route, ScheduledExecutorService scheduler) {
             this.route = route;
-            this.timer = timer;
+            this.scheduler = scheduler;
         }
 
         @Override
         public void run() {
-            // cancel the timer of this task only, a newer timer may already 
have been scheduled
-            timer.cancel();
-            calculateState(route, null);
+            if (scheduler != halfOpenScheduler) {
+                // the route was removed or the policy stopped since this 
check was scheduled
+                return;
+            }
+            try {
+                calculateState(route, null);
+            } catch (Exception e) {
+                // the scheduler would keep it in the future of this check, 
where no one sees it
+                handleException(e);
+            }
         }
     }
 
@@ -578,10 +689,22 @@ public class ThrottlingExceptionRoutePolicy extends 
RoutePolicySupport implement
         return halfOpenAfter;
     }
 
+    /**
+     * Sets the interval (in millis) for how often to check whether a 
currently open circuit breaker may work again.
+     *
+     * @throws IllegalArgumentException if the interval is 0 or less
+     */
     public void setHalfOpenAfter(long halfOpenAfter) {
+        checkHalfOpenAfter(halfOpenAfter);
         this.halfOpenAfter = halfOpenAfter;
     }
 
+    private static void checkHalfOpenAfter(long halfOpenAfter) {
+        if (halfOpenAfter <= 0) {
+            throw new IllegalArgumentException("halfOpenAfter must be greater 
than 0, was: " + halfOpenAfter);
+        }
+    }
+
     public int getFailures() {
         return failures.get();
     }
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 ee38141b1a57..0ff788fe9f49 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
@@ -247,6 +247,22 @@ between `next()` and `remove()` must call `remove()` 
before `hasNext()`.
 outside the Basic Multilingual Plane (such as U+10400 and U+10428) as the same 
key, as `String.equalsIgnoreCase` does;
 before, they were usually kept as two entries. Keys without such letters are 
not affected.
 
+=== camel-core - ThrottlingExceptionRoutePolicy runs the half open check on a 
thread pool of Camel
+
+The half open check of `ThrottlingExceptionRoutePolicy` now runs on a thread 
pool created through the
+`ExecutorServiceManager` (one thread, named `ThrottlingExceptionRoutePolicy` 
and managed like the other thread pools of
+Camel), instead of a new `java.util.Timer` thread each time the circuit opens. 
The thread is created when the circuit
+opens the first time, and it ends when the last route of the policy is removed 
or the policy is stopped.
+
+`halfOpenAfter` must now be greater than 0: adding a route with a policy whose 
`halfOpenAfter` is 0 or less fails,
+and `setHalfOpenAfter` (also over JMX) rejects such a value with an 
`IllegalArgumentException`. With 0, a check that
+keeps the circuit open (`keepOpen`, or a `halfOpenHandler` that is not ready) 
scheduled the next check without delay, a
+busy loop. To half open the circuit almost at once, as 0 did, use a small 
value such as 1.
+
+An exception thrown by the `isReadyToBeClosed` method of a `halfOpenHandler` 
is now logged (by the exception handler of
+the policy), and the circuit is kept open and checked again after 
`halfOpenAfter`; before, the timer thread died and the
+circuit stayed open.
+
 === camel-main - a failure to start is logged
 
 A Camel Main application that fails to start now logs why, at ERROR: `Error 
starting Camel: ...` with the stack trace,

Reply via email to