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 b18e4520df39 CAMEL-26339: camel-plc4x - log a run of failed polls once 
and stop polling a lost connection without autoReconnect (#28556)
b18e4520df39 is described below

commit b18e4520df39341f5134d79eae85d320023fd8f1
Author: Serdar Gökay <[email protected]>
AuthorDate: Tue Oct 27 13:17:43 2026 +0300

    CAMEL-26339: camel-plc4x - log a run of failed polls once and stop polling 
a lost connection without autoReconnect (#28556)
    
    The triggered plc4x consumer now installs its own default exception 
handler: the
    first failed poll of a run is logged at WARN with the stack trace, the 
following
    ones at DEBUG with the message only, and the first poll that reads the PLC 
again
    logs one INFO line with the number of failed polls. A configured 
exceptionHandler
    or bridgeErrorHandler=true still replaces it, and every poll still fails, 
so the
    health check sees each failure.
    
    With autoReconnect=false, Plc4XEndpoint.reconnectIfNeeded() shuts a lost
    connection down once instead of on every call, and the triggered consumer 
then
    stops polling (unscheduleTask, as repeatCount does) until the route is
    restarted. The upgrade guide documents the new behaviour.
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../apache/camel/catalog/docs/plc4x-component.adoc |  10 +-
 .../camel-plc4x/src/main/docs/plc4x-component.adoc |  10 +-
 .../camel/component/plc4x/Plc4XEndpoint.java       |  16 +-
 .../component/plc4x/Plc4XTriggeredConsumer.java    |  55 +++++-
 .../apache/camel/component/plc4x/LogCapture.java   |  73 +++++++
 .../component/plc4x/Plc4XConsumerRouteTest.java    |  34 ----
 .../plc4x/Plc4XRepeatedFailureRouteTest.java       | 217 +++++++++++++++++++++
 .../apache/camel/component/plc4x/PollFailures.java |  62 ++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |   3 +-
 9 files changed, 435 insertions(+), 45 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/plc4x-component.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/plc4x-component.adoc
index 37f10570ac51..c273c34409d3 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/plc4x-component.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/plc4x-component.adoc
@@ -101,12 +101,14 @@ may use. While the trigger stays `true`, no further 
exchange is delivered.
 A poll that fails, for example because a tag cannot be read, the PLC cannot be 
reached or does not answer within five
 seconds, or the trigger reads as a value that is not a boolean (such as a 
number or an array), is reported to the
 exception handler of the consumer (or to the error handler of the route with 
`bridgeErrorHandler=true`), and polling
-continues.
+continues. The default exception handler logs the first failed poll of a run 
at WARN with the stack trace and the
+following ones at DEBUG without it; the first successful poll after a run of 
failed polls is logged at INFO.
 The trigger value read before the failure is forgotten, so a trigger that is 
`true` at the first successful poll after
 a failure counts as a rising edge. Automatic recovery after a lost connection 
needs `autoReconnect=true`: a poll then
-replaces a lost connection with a new one. With the default 
`autoReconnect=false`, the endpoint stops on the first lost
-connection and does not recover. Likewise, a connection that cannot be set up 
when the route starts is retried by the
-polls with `autoReconnect=true`, and fails the start of the route without it.
+replaces a lost connection with a new one. With the default 
`autoReconnect=false`, the endpoint stops on each lost
+connection, and the consumer fails that poll and stops polling until the route 
is restarted. Likewise, a connection
+that cannot be set up when the route starts is retried by the polls with 
`autoReconnect=true`, and fails the start of
+the route without it.
 
 The following route reads a register of a Modbus TCP device each time coil 1 
goes from `false` to `true`, checking the
 coil every 100 milliseconds:
diff --git a/components/camel-plc4x/src/main/docs/plc4x-component.adoc 
b/components/camel-plc4x/src/main/docs/plc4x-component.adoc
index 37f10570ac51..c273c34409d3 100644
--- a/components/camel-plc4x/src/main/docs/plc4x-component.adoc
+++ b/components/camel-plc4x/src/main/docs/plc4x-component.adoc
@@ -101,12 +101,14 @@ may use. While the trigger stays `true`, no further 
exchange is delivered.
 A poll that fails, for example because a tag cannot be read, the PLC cannot be 
reached or does not answer within five
 seconds, or the trigger reads as a value that is not a boolean (such as a 
number or an array), is reported to the
 exception handler of the consumer (or to the error handler of the route with 
`bridgeErrorHandler=true`), and polling
-continues.
+continues. The default exception handler logs the first failed poll of a run 
at WARN with the stack trace and the
+following ones at DEBUG without it; the first successful poll after a run of 
failed polls is logged at INFO.
 The trigger value read before the failure is forgotten, so a trigger that is 
`true` at the first successful poll after
 a failure counts as a rising edge. Automatic recovery after a lost connection 
needs `autoReconnect=true`: a poll then
-replaces a lost connection with a new one. With the default 
`autoReconnect=false`, the endpoint stops on the first lost
-connection and does not recover. Likewise, a connection that cannot be set up 
when the route starts is retried by the
-polls with `autoReconnect=true`, and fails the start of the route without it.
+replaces a lost connection with a new one. With the default 
`autoReconnect=false`, the endpoint stops on each lost
+connection, and the consumer fails that poll and stops polling until the route 
is restarted. Likewise, a connection
+that cannot be set up when the route starts is retried by the polls with 
`autoReconnect=true`, and fails the start of
+the route without it.
 
 The following route reads a register of a Modbus TCP device each time coil 1 
goes from `false` to `true`, checking the
 coil every 100 milliseconds:
diff --git 
a/components/camel-plc4x/src/main/java/org/apache/camel/component/plc4x/Plc4XEndpoint.java
 
b/components/camel-plc4x/src/main/java/org/apache/camel/component/plc4x/Plc4XEndpoint.java
index c541fb899801..2fd507d342a7 100644
--- 
a/components/camel-plc4x/src/main/java/org/apache/camel/component/plc4x/Plc4XEndpoint.java
+++ 
b/components/camel-plc4x/src/main/java/org/apache/camel/component/plc4x/Plc4XEndpoint.java
@@ -169,7 +169,8 @@ public class Plc4XEndpoint extends DefaultEndpoint 
implements EndpointServiceLoc
     }
 
     /**
-     * Reconnects if needed. If connection is lost and auto-reconnect is 
turned off, endpoint will be shutdown.
+     * Reconnects if needed. If connection is lost and auto-reconnect is 
turned off, endpoint will be shutdown, once for
+     * each lost connection.
      * <p>
      *
      * @throws PlcConnectionException If reconnect failed and auto-reconnect 
is turned on
@@ -189,6 +190,12 @@ public class Plc4XEndpoint extends DefaultEndpoint 
implements EndpointServiceLoc
                 LOGGER.debug("Successfully reconnected");
                 return;
             }
+            if (connection == null) {
+                // no connection left to lose, as when an earlier call shut 
the lost one down or none could be set up
+                return;
+            }
+            // closed under the lock, so that only one caller shuts the 
endpoint down for the lost connection
+            closeConnection();
         }
         // stop() is called outside the lock, as a concurrent stop() holds the 
service lock while doStop() waits for it
         LOGGER.warn("Connection lost and auto-reconnect is turned off, 
shutting down Plc4XEndpoint");
@@ -222,6 +229,13 @@ public class Plc4XEndpoint extends DefaultEndpoint 
implements EndpointServiceLoc
         return current != null && current.isConnected();
     }
 
+    /**
+     * @return false if the lost connection was shut down, or none was set up
+     */
+    boolean hasConnection() {
+        return connection != null;
+    }
+
     @Override
     public Producer createProducer() {
         return new Plc4XProducer(this);
diff --git 
a/components/camel-plc4x/src/main/java/org/apache/camel/component/plc4x/Plc4XTriggeredConsumer.java
 
b/components/camel-plc4x/src/main/java/org/apache/camel/component/plc4x/Plc4XTriggeredConsumer.java
index a742ac2a878b..09af4edcdb5d 100644
--- 
a/components/camel-plc4x/src/main/java/org/apache/camel/component/plc4x/Plc4XTriggeredConsumer.java
+++ 
b/components/camel-plc4x/src/main/java/org/apache/camel/component/plc4x/Plc4XTriggeredConsumer.java
@@ -22,18 +22,25 @@ import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 
+import org.apache.camel.CamelContext;
 import org.apache.camel.Exchange;
 import org.apache.camel.Processor;
+import org.apache.camel.support.LoggingExceptionHandler;
 import org.apache.camel.support.ScheduledPollConsumer;
+import org.apache.plc4x.java.api.exceptions.PlcConnectionException;
 import org.apache.plc4x.java.api.exceptions.PlcException;
 import org.apache.plc4x.java.api.messages.PlcReadResponse;
 import org.apache.plc4x.java.api.types.PlcResponseCode;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * Reads the trigger tag together with the tags every {@code period} 
milliseconds and emits the tags once on each rising
  * edge of the trigger.
  */
 public class Plc4XTriggeredConsumer extends ScheduledPollConsumer {
+    private static final Logger LOGGER = 
LoggerFactory.getLogger(Plc4XTriggeredConsumer.class);
+
     private final Plc4XEndpoint plc4XEndpoint;
     private final String trigger;
     private final int period;
@@ -49,6 +56,8 @@ public class Plc4XTriggeredConsumer extends 
ScheduledPollConsumer {
         setInitialDelay(0);
         setDelay(period);
         setTimeUnit(TimeUnit.MILLISECONDS);
+        // the endpoint replaces it when an exceptionHandler or 
bridgeErrorHandler is configured
+        setExceptionHandler(new 
FailedPollExceptionHandler(endpoint.getCamelContext()));
     }
 
     @Override
@@ -86,6 +95,10 @@ public class Plc4XTriggeredConsumer extends 
ScheduledPollConsumer {
             // the consumer stopped while the read was in progress
             return 0;
         }
+        if (getErrorCounter() > 0) {
+            // the error counter is reset after this poll, so it still counts 
the failed polls before it
+            LOGGER.info("Polling endpoint: {} succeeded again after {} failed 
polls", getEndpoint(), getErrorCounter());
+        }
         boolean triggered = (Boolean) response.getObject(Constants.TRIGGER);
         boolean risingEdge = triggered && !Boolean.TRUE.equals(lastTrigger);
         lastTrigger = triggered;
@@ -111,10 +124,22 @@ public class Plc4XTriggeredConsumer extends 
ScheduledPollConsumer {
     }
 
     /**
-     * Reads the trigger and the tags in one request; fails unless every tag 
was read and the trigger is a boolean.
+     * Reads the trigger and the tags in one request; fails unless every tag 
was read and the trigger is a boolean. A
+     * connection that is lost without autoReconnect also stops the polling.
      */
     private PlcReadResponse read() throws Exception {
         plc4XEndpoint.reconnectIfNeeded();
+        // a connection that is lost after reconnectIfNeeded() fails the read 
instead, and the next poll shuts it down
+        if (!plc4XEndpoint.isAutoReconnect() && 
!plc4XEndpoint.hasConnection()) {
+            // the endpoint shut the lost connection down, so no later poll 
could read: polling stops until the route is
+            // restarted, and this poll fails, so the health check of the 
consumer stays down
+            getScheduler().unscheduleTask();
+            // unscheduling cancels the task this poll runs in, which 
interrupts this thread: clear it, so that the
+            // failure is reported as for any other poll
+            Thread.interrupted();
+            throw new PlcConnectionException(
+                    "Connection lost and auto-reconnect is turned off, polling 
stops until the route is restarted");
+        }
         CompletableFuture<? extends PlcReadResponse> future
                 = plc4XEndpoint.buildPlcReadRequest(Map.of(Constants.TRIGGER, 
trigger)).execute();
         PlcReadResponse response;
@@ -147,4 +172,32 @@ public class Plc4XTriggeredConsumer extends 
ScheduledPollConsumer {
     public String toString() {
         return "Plc4XTriggeredConsumer[" + plc4XEndpoint + "]";
     }
+
+    /**
+     * Logs like the default exception handler of a consumer, but a failed 
poll only at WARN with the stack trace when
+     * it starts a run of failed polls or stops the polling, and the following 
failed polls of the run at DEBUG without
+     * it, so that a PLC that cannot be reached is not logged on every poll. 
The failed poll that stops the polling says
+     * so, instead of announcing a next poll.
+     */
+    private final class FailedPollExceptionHandler extends 
LoggingExceptionHandler {
+        FailedPollExceptionHandler(CamelContext camelContext) {
+            super(camelContext, Plc4XTriggeredConsumer.class);
+        }
+
+        @Override
+        public void handleException(String message, Throwable exception) {
+            // a failed poll is reported without an exchange and before it is 
counted, so the first one of a run finds an
+            // error counter of 0, and the one that stops the polling finds 
its task unscheduled
+            if (!isSchedulerStarted()) {
+                super.handleException(
+                        "Failed polling endpoint: " + getEndpoint() + ". 
Polling stops until the route is restarted",
+                        exception);
+            } else if (getErrorCounter() == 0) {
+                super.handleException(message, exception);
+            } else {
+                // the first failed poll of the run is logged with the stack 
trace already
+                LOGGER.debug("{}. Caused by: {}", message, 
exception.getMessage());
+            }
+        }
+    }
 }
diff --git 
a/components/camel-plc4x/src/test/java/org/apache/camel/component/plc4x/LogCapture.java
 
b/components/camel-plc4x/src/test/java/org/apache/camel/component/plc4x/LogCapture.java
new file mode 100644
index 000000000000..d195ec71c720
--- /dev/null
+++ 
b/components/camel-plc4x/src/test/java/org/apache/camel/component/plc4x/LogCapture.java
@@ -0,0 +1,73 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.plc4x;
+
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.core.LogEvent;
+import org.apache.logging.log4j.core.Logger;
+import org.apache.logging.log4j.core.appender.AbstractAppender;
+import org.apache.logging.log4j.core.config.Property;
+
+/**
+ * Captures the log events of one logger, down to DEBUG, while it is open. 
Only one capture is open at a time: opening
+ * another one resets the level of the loggers.
+ */
+final class LogCapture extends AbstractAppender implements AutoCloseable {
+
+    private final List<LogEvent> events = new CopyOnWriteArrayList<>();
+    private final Logger logger;
+    private final Level previousLevel;
+
+    LogCapture(Class<?> loggerClass) {
+        super("LogCapture-" + loggerClass.getSimpleName(), null, null, true, 
Property.EMPTY_ARRAY);
+        this.logger = (Logger) LogManager.getLogger(loggerClass);
+        this.previousLevel = logger.getLevel();
+        start();
+        logger.addAppender(this);
+        logger.setLevel(Level.DEBUG);
+    }
+
+    @Override
+    public void append(LogEvent event) {
+        events.add(event.toImmutable());
+    }
+
+    /**
+     * @return the events logged so far, in the order they were logged
+     */
+    List<LogEvent> events() {
+        return List.copyOf(events);
+    }
+
+    /**
+     * @return the events logged so far whose message contains the given text
+     */
+    List<LogEvent> eventsContaining(String text) {
+        return events.stream().filter(e -> 
e.getMessage().getFormattedMessage().contains(text)).toList();
+    }
+
+    @Override
+    public void close() {
+        logger.removeAppender(this);
+        logger.setLevel(previousLevel);
+        stop();
+    }
+}
diff --git 
a/components/camel-plc4x/src/test/java/org/apache/camel/component/plc4x/Plc4XConsumerRouteTest.java
 
b/components/camel-plc4x/src/test/java/org/apache/camel/component/plc4x/Plc4XConsumerRouteTest.java
index 6a94252023fd..6ad5fff4ecd9 100644
--- 
a/components/camel-plc4x/src/test/java/org/apache/camel/component/plc4x/Plc4XConsumerRouteTest.java
+++ 
b/components/camel-plc4x/src/test/java/org/apache/camel/component/plc4x/Plc4XConsumerRouteTest.java
@@ -19,13 +19,10 @@ package org.apache.camel.component.plc4x;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.TimeUnit;
 
-import org.apache.camel.Exchange;
 import org.apache.camel.builder.RouteBuilder;
 import org.apache.camel.component.mock.MockEndpoint;
-import org.apache.camel.spi.ExceptionHandler;
 import org.apache.camel.spi.Registry;
 import org.apache.camel.test.junit6.CamelTestSupport;
 import org.apache.plc4x.java.api.exceptions.PlcConnectionException;
@@ -363,35 +360,4 @@ public class Plc4XConsumerRouteTest extends 
CamelTestSupport {
         return cause;
     }
 
-    /**
-     * Collects the failures the consumer reports to its exception handler.
-     */
-    private static final class PollFailures implements ExceptionHandler {
-
-        private final List<Throwable> causes = new CopyOnWriteArrayList<>();
-
-        @Override
-        public void handleException(Throwable exception) {
-            causes.add(exception);
-        }
-
-        @Override
-        public void handleException(String message, Throwable exception) {
-            causes.add(exception);
-        }
-
-        @Override
-        public void handleException(String message, Exchange exchange, 
Throwable exception) {
-            causes.add(exception);
-        }
-
-        int count() {
-            return causes.size();
-        }
-
-        List<Throwable> causes() {
-            return causes;
-        }
-    }
-
 }
diff --git 
a/components/camel-plc4x/src/test/java/org/apache/camel/component/plc4x/Plc4XRepeatedFailureRouteTest.java
 
b/components/camel-plc4x/src/test/java/org/apache/camel/component/plc4x/Plc4XRepeatedFailureRouteTest.java
new file mode 100644
index 000000000000..4e897e9762a3
--- /dev/null
+++ 
b/components/camel-plc4x/src/test/java/org/apache/camel/component/plc4x/Plc4XRepeatedFailureRouteTest.java
@@ -0,0 +1,217 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.plc4x;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.spi.Registry;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.core.LogEvent;
+import org.apache.plc4x.java.api.exceptions.PlcConnectionException;
+import org.apache.plc4x.java.api.exceptions.PlcException;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeEach;
+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.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * How a route reports a PLC that keeps failing its requests: the triggered 
consumer logs the first failed poll of a run
+ * at WARN and the end of the run at INFO, instead of a WARN with a stack 
trace on every poll, while
+ * {@code bridgeErrorHandler} still gets every failure. Without {@code 
autoReconnect}, a lost connection shuts the
+ * endpoint down once, and the triggered consumer stops polling until the 
route is restarted.
+ */
+public class Plc4XRepeatedFailureRouteTest extends CamelTestSupport {
+
+    private static final String PLC = 
"plc4x:mock:10.10.10.1/1/1?trigger=TRIG&period=50&tag.temperature=TEMP";
+    private static final String BRIDGED = PLC + "&bridgeErrorHandler=true";
+    private static final String REPORTED = PLC + 
"&exceptionHandler=#pollFailures";
+    private static final String WRITE = "plc4x:mock:10.10.10.2/1/1";
+    private static final String SHUTDOWN = "Connection lost and auto-reconnect 
is turned off";
+    private static final String POLLING_STOPS = "polling stops until the route 
is restarted";
+
+    private final PollFailures pollFailures = new PollFailures();
+
+    @BeforeEach
+    void resetMockDriver() {
+        MockDriver.reset();
+    }
+
+    @AfterAll
+    static void leaveMockDriverUnprogrammed() {
+        MockDriver.reset();
+    }
+
+    @Test
+    public void logsARunOfFailedPollsOnceAtWarnAndItsEndOnceAtInfo() throws 
Exception {
+        Object failed = MockDriver.failedRead();
+        // two runs of failed polls, each followed by successful polls, and a 
rising edge after them
+        MockDriver.programRead("TRIG", failed, failed, failed, false, false, 
false, failed, failed, false, false, true);
+        MockDriver.programRead("TEMP", 21);
+        MockEndpoint result = getMockEndpoint("mock:logged");
+        result.expectedMessageCount(1);
+
+        try (LogCapture log = new LogCapture(Plc4XTriggeredConsumer.class)) {
+            context.getRouteController().startRoute("logged");
+            result.assertIsSatisfied(10000);
+
+            List<LogEvent> events = log.events();
+            assertEquals(List.of(Level.WARN, Level.DEBUG, Level.DEBUG, 
Level.INFO, Level.WARN, Level.DEBUG, Level.INFO),
+                    events.stream().map(LogEvent::getLevel).toList(), () -> 
"Unexpected log: " + messages(events));
+            assertEquals(5, log.eventsContaining("Failed polling 
endpoint").size(),
+                    () -> "Unexpected log: " + messages(events));
+            assertNotNull(events.get(0).getThrown(), "the first failed poll of 
a run is logged with its stack trace");
+            assertNotNull(events.get(4).getThrown(), "the first failed poll of 
a run is logged with its stack trace");
+            assertNull(events.get(1).getThrown(), "a following failed poll of 
a run is logged without its stack trace");
+            // the error counter of the consumer still counts each failed poll 
of a run
+            String firstEnd = events.get(3).getMessage().getFormattedMessage();
+            assertTrue(firstEnd.endsWith("after 3 failed polls"), firstEnd);
+            String secondEnd = 
events.get(6).getMessage().getFormattedMessage();
+            assertTrue(secondEnd.endsWith("after 2 failed polls"), secondEnd);
+        }
+    }
+
+    @Test
+    public void 
bridgeErrorHandlerRoutesEveryFailedPollToTheErrorHandlerOfTheRoute() throws 
Exception {
+        Object failed = MockDriver.failedRead();
+        MockDriver.programRead("TRIG", failed, failed, failed, false);
+        MockDriver.programRead("TEMP", 21);
+        MockEndpoint failures = getMockEndpoint("mock:failures");
+        failures.expectedMessageCount(3);
+        failures.setAssertPeriod(500);
+
+        context.getRouteController().startRoute("bridged");
+
+        failures.assertIsSatisfied(10000);
+        assertTrue(failures.getExchanges().stream()
+                .allMatch(exchange -> 
exchange.getProperty(Exchange.EXCEPTION_CAUGHT) instanceof PlcException),
+                () -> "Unexpected failures: " + failures.getExchanges());
+    }
+
+    @Test
+    public void 
aLostConnectionWithoutAutoReconnectStopsPollingUntilTheRouteIsRestarted() 
throws Exception {
+        MockDriver.programRead("TRIG", false);
+        MockDriver.programRead("TEMP", 21);
+        MockEndpoint result = getMockEndpoint("mock:reported");
+
+        try (LogCapture log = new LogCapture(Plc4XEndpoint.class)) {
+            context.getRouteController().startRoute("reported");
+            Plc4XTriggeredConsumer consumer = (Plc4XTriggeredConsumer) 
context.getRoute("reported").getConsumer();
+            await().atMost(10, TimeUnit.SECONDS).until(() -> 
consumer.getSuccessCounter() >= 2);
+
+            MockDriver.dropConnections();
+
+            // the poll that finds the connection lost fails, and no poll 
follows it
+            await().atMost(10, TimeUnit.SECONDS).until(() -> 
!pollFailures.failures().isEmpty());
+            await().during(500, TimeUnit.MILLISECONDS).atMost(1, 
TimeUnit.SECONDS)
+                    .untilAsserted(() -> assertEquals(1, 
pollFailures.failures().size(), "the failed polls"));
+            assertEquals(1, log.eventsContaining(SHUTDOWN).size(), "the 
endpoint is shut down once");
+            assertEquals(1, consumer.getErrorCounter(), "the failed poll keeps 
the health check of the consumer down");
+            PollFailures.Failure failure = pollFailures.failures().get(0);
+            assertInstanceOf(PlcConnectionException.class, failure.cause());
+            assertFalse(failure.interrupted(), "the failure is reported on a 
thread that is not interrupted");
+
+            // the restarted route sets up a new connection and polls again
+            MockDriver.programRead("TRIG", false, true);
+            result.expectedMessageCount(1);
+            context.getRouteController().stopRoute("reported");
+            context.getRouteController().startRoute("reported");
+            result.assertIsSatisfied(10000);
+
+            // and a loss of the new connection shuts the endpoint down again
+            MockDriver.dropConnections();
+            await().atMost(10, TimeUnit.SECONDS).until(() -> 
log.eventsContaining(SHUTDOWN).size() == 2);
+            await().atMost(10, TimeUnit.SECONDS).until(() -> 
pollFailures.failures().size() == 2);
+        }
+    }
+
+    @Test
+    public void logsTheFailedPollThatStopsPollingAtWarnAfterAFailedRead() 
throws Exception {
+        MockDriver.programRead("TRIG", false, MockDriver.failedRead());
+        MockDriver.programRead("TEMP", 21);
+
+        try (LogCapture log = new LogCapture(Plc4XTriggeredConsumer.class)) {
+            context.getRouteController().startRoute("logged");
+            Plc4XTriggeredConsumer consumer = (Plc4XTriggeredConsumer) 
context.getRoute("logged").getConsumer();
+            // a read fails as the PLC goes away, and a later poll finds the 
connection lost within the same run
+            await().atMost(10, TimeUnit.SECONDS).until(() -> 
consumer.getErrorCounter() >= 1);
+
+            MockDriver.dropConnections();
+
+            await().atMost(10, TimeUnit.SECONDS).until(() -> 
!log.eventsContaining(POLLING_STOPS).isEmpty());
+            assertEquals(List.of(Level.WARN),
+                    
log.eventsContaining(POLLING_STOPS).stream().map(LogEvent::getLevel).toList(),
+                    () -> "Unexpected log: " + messages(log.events()));
+            // the log line of the poll says that no next poll follows it
+            String stopped = 
log.eventsContaining(POLLING_STOPS).get(0).getMessage().getFormattedMessage();
+            assertTrue(stopped.startsWith(
+                    "Failed polling endpoint: " + consumer.getEndpoint() + ". 
Polling stops until the route is restarted"),
+                    stopped);
+        }
+    }
+
+    @Test
+    public void aProducerReportsALostConnectionWithoutAutoReconnectOnce() 
throws Exception {
+        Map<String, Map<String, Object>> tags = Map.of("temperature", 
Map.of("%DB1:DINT", 21));
+        template.sendBody(WRITE, tags);
+
+        try (LogCapture log = new LogCapture(Plc4XEndpoint.class)) {
+            MockDriver.dropConnections();
+
+            for (int i = 0; i < 3; i++) {
+                Exchange exchange = template.send(WRITE, e -> 
e.getIn().setBody(tags));
+                assertInstanceOf(PlcConnectionException.class, 
exchange.getException(), "each write fails");
+            }
+            assertEquals(1, log.eventsContaining(SHUTDOWN).size(), "the 
endpoint is shut down once");
+        }
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                
from(PLC).routeId("logged").autoStartup(false).to("mock:logged");
+                from(BRIDGED).routeId("bridged").autoStartup(false)
+                        
.onException(Exception.class).handled(true).to("mock:failures").end()
+                        .to("mock:bridged");
+                
from(REPORTED).routeId("reported").autoStartup(false).to("mock:reported");
+            }
+        };
+    }
+
+    @Override
+    protected void bindToRegistry(Registry registry) {
+        registry.bind("pollFailures", pollFailures);
+    }
+
+    private static List<String> messages(List<LogEvent> events) {
+        return events.stream().map(event -> event.getLevel() + " " + 
event.getMessage().getFormattedMessage()).toList();
+    }
+}
diff --git 
a/components/camel-plc4x/src/test/java/org/apache/camel/component/plc4x/PollFailures.java
 
b/components/camel-plc4x/src/test/java/org/apache/camel/component/plc4x/PollFailures.java
new file mode 100644
index 000000000000..d6abf39f764b
--- /dev/null
+++ 
b/components/camel-plc4x/src/test/java/org/apache/camel/component/plc4x/PollFailures.java
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.plc4x;
+
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.spi.ExceptionHandler;
+
+/**
+ * Collects the failures a consumer reports to its exception handler, and 
whether the thread that reported each one was
+ * interrupted.
+ */
+final class PollFailures implements ExceptionHandler {
+
+    private final List<Failure> failures = new CopyOnWriteArrayList<>();
+
+    @Override
+    public void handleException(Throwable exception) {
+        handleException(null, null, exception);
+    }
+
+    @Override
+    public void handleException(String message, Throwable exception) {
+        handleException(message, null, exception);
+    }
+
+    @Override
+    public void handleException(String message, Exchange exchange, Throwable 
exception) {
+        failures.add(new Failure(exception, 
Thread.currentThread().isInterrupted()));
+    }
+
+    List<Failure> failures() {
+        return failures;
+    }
+
+    int count() {
+        return failures.size();
+    }
+
+    List<Throwable> causes() {
+        return failures.stream().map(Failure::cause).toList();
+    }
+
+    record Failure(Throwable cause, boolean interrupted) {
+    }
+}
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 a2e88f6e9c37..64ee1374f3fd 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
@@ -3743,7 +3743,8 @@ and read the tags in a later, separate request on another 
connection. A failed r
 handler of the consumer (or to the error handler of the route with 
`bridgeErrorHandler=true`), and polling continues;
 before, the consumer stopped triggering after the first failed read of the 
trigger. After a failed poll the remembered
 trigger value is unknown, so a trigger that is still `true` when the PLC 
returns emits once. Since the consumer now uses
-the connection of the endpoint, `autoReconnect` governs its reconnects. A 
connection that cannot be set up when the
+the connection of the endpoint, `autoReconnect` governs its reconnects: 
without it, a lost connection stops
+the endpoint and the polling until the route is restarted. A connection that 
cannot be set up when the
 route starts fails the start of the route, unless `autoReconnect=true`, in 
which case the polls retry it. A route that
 starts with the CamelContext then fails the start of the CamelContext too, 
unless the route is supervised by the
 xref:route-controller.adoc[supervising route controller] or has 
`autoStartup=false`. As before, `period` must be

Reply via email to