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 b7d85696b1ed CAMEL-25770: camel-minio - skip a listed object that 
cannot be got instead of failing the poll (#28133)
b7d85696b1ed is described below

commit b7d85696b1edcec50e8575a601b32786448f213c
Author: allthingssecurity <[email protected]>
AuthorDate: Tue Oct 20 16:01:41 2026 +0530

    CAMEL-25770: camel-minio - skip a listed object that cannot be got instead 
of failing the poll (#28133)
    
    The minio consumer stats every listed object when it creates the exchanges 
and
    gets each body when it routes the batch. Any failure other than a missing 
object
    (access denied, an object encrypted with another SSE-C key) failed the 
poll, and
    as the next poll lists the same objects again, one such object kept the 
consumer
    from consuming the rest of the bucket.
    
    As camel-aws2-s3 does since CAMEL-25558, a failed stat or get of one listed
    object is now reported to the exception handler with its name and skipped 
for
    this poll; the other objects are consumed. The poll still fails when none 
of the
    listed objects could be got, when the listing fails, or with objectName set.
    
    The next poll now continues after the last listed object (as 
camel-alibaba-oss
    does), also when the poll fails, so a refused last object of a full page no
    longer holds the listing back. The exchange of an object whose body cannot 
be
    got is given back to the exchange factory. The behaviour change is noted in 
the
    4.24 upgrade guide.
    
    Closes #28133
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../camel/component/minio/MinioConsumer.java       | 140 ++++++++++++---
 .../apache/camel/component/minio/FakeS3Server.java |  40 ++++-
 ...merObjectThatCannotBeGotPooledExchangeTest.java |  65 +++++++
 .../MinioConsumerObjectThatCannotBeGotTest.java    | 189 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  12 ++
 5 files changed, 416 insertions(+), 30 deletions(-)

diff --git 
a/components/camel-minio/src/main/java/org/apache/camel/component/minio/MinioConsumer.java
 
b/components/camel-minio/src/main/java/org/apache/camel/component/minio/MinioConsumer.java
index d1bcebcb75cc..a0e6cc460539 100644
--- 
a/components/camel-minio/src/main/java/org/apache/camel/component/minio/MinioConsumer.java
+++ 
b/components/camel-minio/src/main/java/org/apache/camel/component/minio/MinioConsumer.java
@@ -19,7 +19,9 @@ package org.apache.camel.component.minio;
 import java.io.InputStream;
 import java.util.Deque;
 import java.util.Iterator;
+import java.util.LinkedHashMap;
 import java.util.LinkedList;
+import java.util.Map;
 import java.util.Queue;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
@@ -67,6 +69,10 @@ public class MinioConsumer extends 
ScheduledBatchPollingConsumer {
     private final Set<String> inProgress = ConcurrentHashMap.newKeySet();
     private long totalCounter;
     private String continuationToken;
+    // the name of the last object listed by the poll, and the number of 
objects it listed (set once the listing is
+    // read to the end)
+    private String lastListedObjectName;
+    private int listedObjects;
     private transient String minioConsumerToString;
 
     public MinioConsumer(MinioEndpoint endpoint, Processor processor) {
@@ -173,11 +179,21 @@ public class MinioConsumer extends 
ScheduledBatchPollingConsumer {
             forceConsumerAsReady();
 
             if (listObjects.hasNext()) {
-                exchanges = createExchanges(listObjects);
-                if (maxMessagesPerPoll <= 0 || exchanges.size() < 
maxMessagesPerPoll) {
-                    continuationToken = null;
-                } else {
-                    continuationToken = 
exchanges.getLast().getIn().getHeader(MinioConstants.OBJECT_NAME, String.class);
+                lastListedObjectName = null;
+                listedObjects = 0;
+                try {
+                    exchanges = createExchanges(listObjects);
+                } finally {
+                    // the next poll continues after the last listed object, 
also when objects of this poll are skipped
+                    // or none of them could be got: such objects must not 
hold the consumer back, and they are listed
+                    // again when the listing starts over
+                    if (lastListedObjectName != null) {
+                        if (maxMessagesPerPoll <= 0 || listedObjects < 
maxMessagesPerPoll) {
+                            continuationToken = null;
+                        } else {
+                            continuationToken = lastListedObjectName;
+                        }
+                    }
                 }
                 if (LOG.isTraceEnabled()) {
                     LOG.trace("Found {} objects in bucket {}...", 
totalCounter, bucketName);
@@ -201,23 +217,31 @@ public class MinioConsumer extends 
ScheduledBatchPollingConsumer {
     protected Deque<Exchange> createExchanges(Iterator<Result<Item>> 
minioObjectSummaries) throws Exception {
         int messageCounter = 0;
         Deque<Exchange> answer = new LinkedList<>();
+        // the listed objects whose stat failed, other than those that no 
longer exist, by object name
+        Map<String, Exception> failures = new LinkedHashMap<>();
+        // whether the stat of at least one listed object succeeded
+        boolean anyGot = false;
+        String lastListed = null;
         try {
-            if (getConfiguration().isIncludeFolders()) {
-                do {
-                    messageCounter++;
-                    Item minioObjectSummary = 
minioObjectSummaries.next().get();
-                    addExchange(minioObjectSummary.objectName(), answer, true);
-                } while (minioObjectSummaries.hasNext());
-            } else {
-                do {
-                    messageCounter++;
-                    Item minioObjectSummary = 
minioObjectSummaries.next().get();
-                    // ignore if directory
-                    if (!minioObjectSummary.isDir()) {
-                        addExchange(minioObjectSummary.objectName(), answer, 
true);
+            do {
+                messageCounter++;
+                Item minioObjectSummary = minioObjectSummaries.next().get();
+                // ignore if directory, unless folders are included
+                if (getConfiguration().isIncludeFolders() || 
!minioObjectSummary.isDir()) {
+                    String objectName = minioObjectSummary.objectName();
+                    // a failure to get one listed object does not fail the 
poll as long as another listed object could
+                    // be got: the object is skipped, the other listed objects 
are consumed, and a later poll tries it
+                    // again
+                    try {
+                        anyGot |= addExchange(objectName, answer, true);
+                    } catch (Exception e) {
+                        failures.put(objectName, e);
                     }
-                } while (minioObjectSummaries.hasNext());
-            }
+                }
+                lastListed = minioObjectSummary.objectName();
+            } while (minioObjectSummaries.hasNext());
+            lastListedObjectName = lastListed;
+            listedObjects = messageCounter;
 
             if (LOG.isTraceEnabled()) {
                 LOG.trace("Received {} messages in this poll", messageCounter);
@@ -231,28 +255,61 @@ public class MinioConsumer extends 
ScheduledBatchPollingConsumer {
             throw e;
         }
 
+        if (!failures.isEmpty()) {
+            if (!anyGot) {
+                // none of the listed objects could be got, so this is not 
about one object (expired credentials, no
+                // permission, no connection): the poll fails, and the backoff 
of the scheduled consumer applies
+                releaseInProgress(null, answer);
+                Map.Entry<String, Exception> first = 
failures.entrySet().iterator().next();
+                LOG.warn("None of the listed objects of bucket {} could be got 
({} failed, the first is {}): the poll fails",
+                        getConfiguration().getBucketName(), failures.size(), 
first.getKey());
+                throw first.getValue();
+            }
+            for (Map.Entry<String, Exception> failure : failures.entrySet()) {
+                handleSkippedObject(failure.getKey(), failure.getValue());
+            }
+        }
+
         return answer;
     }
 
-    private void addExchange(String objectName, Deque<Exchange> answer, 
boolean listed) throws Exception {
+    /**
+     * Creates the exchange of an object and adds it.
+     *
+     * @return true if the exchange was added, false if the object is skipped 
(an exchange of a previous poll is still
+     *         processing it, or it no longer exists)
+     */
+    private boolean addExchange(String objectName, Deque<Exchange> answer, 
boolean listed) throws Exception {
         // skip the object if an exchange of a previous poll is still 
processing it
         if (!inProgress.add(objectName)) {
             LOG.trace("Skipping object {} as it is already in progress", 
objectName);
-            return;
+            return false;
         }
         try {
             answer.add(createExchange(objectName));
+            return true;
         } catch (Exception e) {
             inProgress.remove(objectName);
             if (listed && isNoSuchKey(e)) {
                 // deleted (for example by another consumer) after it was 
listed
                 LOG.debug("Skipping object {} as it no longer exists", 
objectName);
-                return;
+                return false;
             }
             throw e;
         }
     }
 
+    private void handleSkippedObject(String objectName, Exception cause) {
+        getExceptionHandler().handleException("Error getting object " + 
objectName + " from bucket "
+                                              + 
getConfiguration().getBucketName()
+                                              + ". The object is skipped for 
this poll.",
+                cause);
+    }
+
+    private boolean objectNameConfigured() {
+        return isNotEmpty(getConfiguration().getObjectName());
+    }
+
     private static boolean isNoSuchKey(Exception e) {
         return e instanceof ErrorResponseException ere && ere.errorResponse() 
!= null
                 && "NoSuchKey".equals(ere.errorResponse().code());
@@ -289,6 +346,10 @@ public class MinioConsumer extends 
ScheduledBatchPollingConsumer {
     public int processBatch(Queue<Object> exchanges) throws Exception {
         int total = exchanges.size();
         int skipped = 0;
+        // the listed objects whose body could not be got, other than those 
that no longer exist, by object name
+        Map<String, Exception> failures = new LinkedHashMap<>();
+        // whether the body of at least one object of the batch could be got
+        boolean anyGot = false;
 
         for (int index = 0; index < total && isBatchAllowed(); index++) {
             // only loop if we are started (allowed to run)
@@ -308,6 +369,7 @@ public class MinioConsumer extends 
ScheduledBatchPollingConsumer {
                 try {
                     minioObject = getObject(srcBucketName, getMinioClient(), 
srcObjectName);
                     exchange.getIn().setBody(IOUtils.toByteArray(minioObject));
+                    anyGot = true;
                     if (getConfiguration().isAutoCloseBody()) {
                         exchange.getExchangeExtension().addOnCompletion(new 
SynchronizationAdapter() {
                             @Override
@@ -317,6 +379,8 @@ public class MinioConsumer extends 
ScheduledBatchPollingConsumer {
                         });
                     }
                 } catch (Exception e) {
+                    // the exchange is not routed, so nothing else gives it 
back to the exchange factory
+                    releaseExchange(exchange, false);
                     if (isNoSuchKey(e)) {
                         // deleted (for example by another consumer) after it 
was listed
                         LOG.debug("Skipping object {} as it no longer exists", 
srcObjectName);
@@ -324,10 +388,18 @@ public class MinioConsumer extends 
ScheduledBatchPollingConsumer {
                         skipped++;
                         continue;
                     }
-                    LOG.warn("Error getting MinioObject due: {}", 
e.getMessage());
-                    // this and the remaining exchanges of the batch are not 
processed
-                    releaseInProgress(srcObjectName, exchanges);
-                    throw e;
+                    if (objectNameConfigured()) {
+                        LOG.warn("Error getting MinioObject due: {}", 
e.getMessage());
+                        // the one object of the poll is not processed
+                        releaseInProgress(srcObjectName, exchanges);
+                        throw e;
+                    }
+                    // a failure to get one listed object does not stop the 
other objects of the batch: the object is
+                    // skipped, and a later poll tries it again
+                    inProgress.remove(srcObjectName);
+                    skipped++;
+                    failures.put(srcObjectName, e);
+                    continue;
                 }
             }
 
@@ -360,6 +432,20 @@ public class MinioConsumer extends 
ScheduledBatchPollingConsumer {
         // the remaining exchanges are not processed as the consumer is 
stopping
         releaseInProgress(null, exchanges);
 
+        if (!failures.isEmpty()) {
+            if (!anyGot) {
+                // none of the objects of the batch could be got, so this is 
not about one object: the poll fails, and
+                // the backoff of the scheduled consumer applies
+                Map.Entry<String, Exception> first = 
failures.entrySet().iterator().next();
+                LOG.warn("None of the objects of bucket {} could be got ({} 
failed, the first is {}): the poll fails",
+                        getConfiguration().getBucketName(), failures.size(), 
first.getKey());
+                throw first.getValue();
+            }
+            for (Map.Entry<String, Exception> failure : failures.entrySet()) {
+                handleSkippedObject(failure.getKey(), failure.getValue());
+            }
+        }
+
         // skipped objects were not polled: they must not count for 
sendEmptyMessageWhenIdle and greedy polling
         return total - skipped;
     }
diff --git 
a/components/camel-minio/src/test/java/org/apache/camel/component/minio/FakeS3Server.java
 
b/components/camel-minio/src/test/java/org/apache/camel/component/minio/FakeS3Server.java
index 2769bd8c973e..5b5cc68d411c 100644
--- 
a/components/camel-minio/src/test/java/org/apache/camel/component/minio/FakeS3Server.java
+++ 
b/components/camel-minio/src/test/java/org/apache/camel/component/minio/FakeS3Server.java
@@ -34,7 +34,8 @@ import com.sun.net.httpserver.HttpServer;
 
 /**
  * A minimal in-process fake of the S3 API calls the minio consumer makes for 
a single bucket: head bucket, list objects
- * (v2, paginated with max-keys and start-after/continuation-token), stat, get 
and delete object.
+ * (v2, paginated with max-keys and start-after/continuation-token), stat, get 
and delete object. The gets (or the stats
+ * and gets) of an object can be refused with 403 (AccessDenied).
  */
 final class FakeS3Server {
 
@@ -44,6 +45,8 @@ final class FakeS3Server {
     private final Map<String, AtomicInteger> gets = new ConcurrentHashMap<>();
     private final Set<String> deleteWhenListed = ConcurrentHashMap.newKeySet();
     private final Set<String> deleteWhenStatted = 
ConcurrentHashMap.newKeySet();
+    private final Set<String> refusedGets = ConcurrentHashMap.newKeySet();
+    private final Set<String> refusedStats = ConcurrentHashMap.newKeySet();
 
     FakeS3Server(String bucket) throws IOException {
         this.bucket = bucket;
@@ -81,6 +84,25 @@ final class FakeS3Server {
         deleteWhenStatted.add(key);
     }
 
+    /**
+     * Every get of the object is refused with 403 (AccessDenied), which the 
client does not retry.
+     */
+    void refuseGets(String key) {
+        refusedGets.add(key);
+    }
+
+    /**
+     * Every stat and get of the object is refused with 403, as for an object 
encrypted with a customer key.
+     */
+    void refuseStats(String key) {
+        refusedStats.add(key);
+        refusedGets.add(key);
+    }
+
+    boolean contains(String key) {
+        return objects.containsKey(key);
+    }
+
     int gets(String key) {
         AtomicInteger n = gets.get(key);
         return n != null ? n.get() : 0;
@@ -102,7 +124,9 @@ final class FakeS3Server {
         byte[] data = objects.get(key);
         switch (method) {
             case "HEAD" -> {
-                if (data == null) {
+                if (refusedStats.contains(key)) {
+                    respond(http, 403, null, null);
+                } else if (data == null) {
                     respond(http, 404, null, null);
                 } else {
                     objectHeaders(http, data);
@@ -114,7 +138,9 @@ final class FakeS3Server {
                 }
             }
             case "GET" -> {
-                if (data == null) {
+                if (refusedGets.contains(key)) {
+                    accessDenied(http, key);
+                } else if (data == null) {
                     noSuchKey(http, key);
                 } else {
                     gets.computeIfAbsent(key, k -> new 
AtomicInteger()).incrementAndGet();
@@ -179,6 +205,14 @@ final class FakeS3Server {
         http.getResponseHeaders().add("Content-Length", 
String.valueOf(data.length));
     }
 
+    private void accessDenied(HttpExchange http, String key) throws 
IOException {
+        String xml = "<?xml version=\"1.0\" 
encoding=\"UTF-8\"?><Error><Code>AccessDenied</Code>"
+                     + "<Message>Access Denied</Message><Key>" + key + "</Key>"
+                     + "<BucketName>" + bucket + "</BucketName><Resource>/" + 
bucket + "/" + key + "</Resource>"
+                     + "<RequestId>1</RequestId><HostId>1</HostId></Error>";
+        respond(http, 403, "application/xml", xml);
+    }
+
     private void noSuchKey(HttpExchange http, String key) throws IOException {
         String xml = "<?xml version=\"1.0\" 
encoding=\"UTF-8\"?><Error><Code>NoSuchKey</Code>"
                      + "<Message>The specified key does not 
exist.</Message><Key>" + key + "</Key>"
diff --git 
a/components/camel-minio/src/test/java/org/apache/camel/component/minio/MinioConsumerObjectThatCannotBeGotPooledExchangeTest.java
 
b/components/camel-minio/src/test/java/org/apache/camel/component/minio/MinioConsumerObjectThatCannotBeGotPooledExchangeTest.java
new file mode 100644
index 000000000000..7da11e43d8ad
--- /dev/null
+++ 
b/components/camel-minio/src/test/java/org/apache/camel/component/minio/MinioConsumerObjectThatCannotBeGotPooledExchangeTest.java
@@ -0,0 +1,65 @@
+/*
+ * 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.minio;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.impl.engine.PooledExchangeFactory;
+import org.apache.camel.spi.ExchangeFactory;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * The same tests with pooled exchanges, and the exchange of an object whose 
body cannot be got is given back to the
+ * pool.
+ */
+class MinioConsumerObjectThatCannotBeGotPooledExchangeTest extends 
MinioConsumerObjectThatCannotBeGotTest {
+
+    @Override
+    protected CamelContext createCamelContext() throws Exception {
+        CamelContext context = super.createCamelContext();
+        PooledExchangeFactory factory = new PooledExchangeFactory();
+        factory.setStatisticsEnabled(true);
+        context.getCamelContextExtension().setExchangeFactory(factory);
+        return context;
+    }
+
+    @Test
+    void exchangeOfAnObjectWhoseBodyCannotBeGotIsGivenBackToThePool() throws 
Exception {
+        server.put("a.txt", "a");
+        server.put("b.txt", "b");
+        server.put("c.txt", "c");
+        server.refuseGets("b.txt");
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceivedInAnyOrder("a", "c");
+
+        // polled from the test: the route is synchronous, so the exchanges 
are done when poll returns
+        
addConsumerRoute("&startScheduler=false&exceptionHandler=#recordErrors");
+        MinioConsumer consumer = (MinioConsumer) 
context.getRoute("consumer").getConsumer();
+        
context.getCamelContextExtension().getExchangeFactoryManager().setStatisticsEnabled(true);
+
+        assertEquals(2, consumer.poll());
+
+        mock.assertIsSatisfied();
+        // the exchange created for b.txt is given back, as are those of a.txt 
and c.txt
+        ExchangeFactory.Statistics stats = 
context.getCamelContextExtension().getExchangeFactoryManager().getStatistics();
+        assertEquals(3, stats.getCreatedCounter() + 
stats.getAcquiredCounter());
+        assertEquals(3, stats.getReleasedCounter());
+        assertEquals(0, stats.getDiscardedCounter());
+    }
+}
diff --git 
a/components/camel-minio/src/test/java/org/apache/camel/component/minio/MinioConsumerObjectThatCannotBeGotTest.java
 
b/components/camel-minio/src/test/java/org/apache/camel/component/minio/MinioConsumerObjectThatCannotBeGotTest.java
new file mode 100644
index 000000000000..d3063075119a
--- /dev/null
+++ 
b/components/camel-minio/src/test/java/org/apache/camel/component/minio/MinioConsumerObjectThatCannotBeGotTest.java
@@ -0,0 +1,189 @@
+/*
+ * 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.minio;
+
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Consumer;
+import org.apache.camel.Endpoint;
+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.PollingConsumerPollStrategy;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * A listed object that cannot be got (access denied, an object encrypted with 
a customer key the consumer does not
+ * have) must not stop the consumer: the other listed objects are consumed, 
and the poll fails only when none of the
+ * listed objects could be got. The bucket is an in-process fake of the S3 
API, used through the real Minio client.
+ */
+class MinioConsumerObjectThatCannotBeGotTest extends CamelTestSupport {
+
+    FakeS3Server server;
+    private final CountDownLatch failedPolls = new CountDownLatch(2);
+    private final List<String> handled = new CopyOnWriteArrayList<>();
+    private final CountDownLatch skippedReported = new CountDownLatch(1);
+
+    @Override
+    protected CamelContext createCamelContext() throws Exception {
+        server = new FakeS3Server("bucket");
+        server.start();
+        CamelContext context = super.createCamelContext();
+        context.getRegistry().bind("countPolls", new 
PollingConsumerPollStrategy() {
+            @Override
+            public boolean begin(Consumer consumer, Endpoint endpoint) {
+                return true;
+            }
+
+            @Override
+            public void commit(Consumer consumer, Endpoint endpoint, int 
polledMessages) {
+                // noop
+            }
+
+            @Override
+            public boolean rollback(Consumer consumer, Endpoint endpoint, int 
retryCounter, Exception e) {
+                failedPolls.countDown();
+                return false;
+            }
+        });
+        context.getRegistry().bind("recordErrors", new ExceptionHandler() {
+            @Override
+            public void handleException(Throwable exception) {
+                handled.add(String.valueOf(exception.getMessage()));
+            }
+
+            @Override
+            public void handleException(String message, Throwable exception) {
+                handled.add(message);
+                if (message.contains("b.txt")) {
+                    skippedReported.countDown();
+                }
+            }
+
+            @Override
+            public void handleException(String message, Exchange exchange, 
Throwable exception) {
+                handled.add(message);
+                if (message.contains("b.txt")) {
+                    skippedReported.countDown();
+                }
+            }
+        });
+        return context;
+    }
+
+    @AfterEach
+    void stopServer() {
+        if (server != null) {
+            server.stop();
+        }
+    }
+
+    @Test
+    void objectWhoseBodyCannotBeGotDoesNotBlockTheOtherObjects() throws 
Exception {
+        server.put("a.txt", "a");
+        server.put("b.txt", "b");
+        server.put("c.txt", "c");
+        server.refuseGets("b.txt");
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceivedInAnyOrder("a", "c");
+
+        addConsumerRoute("&exceptionHandler=#recordErrors");
+
+        mock.assertIsSatisfied();
+        assertTrue(skippedReported.await(20, TimeUnit.SECONDS), "the skipped 
object is reported: " + handled);
+        assertTrue(server.contains("b.txt"), "b.txt stays");
+    }
+
+    @Test
+    void objectThatCannotBeStatedDoesNotBlockTheOtherObjects() throws 
Exception {
+        server.put("a.txt", "a");
+        server.put("b.txt", "b");
+        server.put("c.txt", "c");
+        server.refuseStats("b.txt");
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceivedInAnyOrder("a", "c");
+
+        addConsumerRoute("&exceptionHandler=#recordErrors");
+
+        mock.assertIsSatisfied();
+        assertTrue(skippedReported.await(20, TimeUnit.SECONDS), "the skipped 
object is reported: " + handled);
+        assertTrue(server.contains("b.txt"), "b.txt stays");
+    }
+
+    @Test
+    void pollFailsWhenNoListedObjectCanBeGot() throws Exception {
+        server.put("a.txt", "a");
+        server.put("b.txt", "b");
+        server.refuseGets("a.txt");
+        server.refuseGets("b.txt");
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedMessageCount(0);
+
+        addConsumerRoute("&pollStrategy=#countPolls");
+
+        // as for a failed listing (expired credentials, no permission to get 
objects): the poll fails, so the error
+        // handling and the backoff of the scheduled consumer apply
+        assertTrue(failedPolls.await(20, TimeUnit.SECONDS), "the polls fail");
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void refusedLastListedObjectDoesNotHoldTheListingBack() throws Exception {
+        // with maxMessagesPerPoll=2 the first poll lists a.txt and b.txt (and 
c.txt if the client reads the next page
+        // too): c.txt, which cannot be stated, must not keep later polls from 
listing an object added before it
+        server.put("a.txt", "a");
+        server.put("b.txt", "b");
+        server.put("c.txt", "c");
+        server.refuseStats("c.txt");
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceivedInAnyOrder("a", "b");
+
+        
addConsumerRoute("&maxMessagesPerPoll=2&exceptionHandler=#recordErrors&pollStrategy=#countPolls");
+
+        mock.assertIsSatisfied();
+        // the polls that list only c.txt fail
+        assertTrue(failedPolls.await(20, TimeUnit.SECONDS), "the polls that 
list only c.txt fail");
+
+        mock.reset();
+        mock.expectedBodiesReceived("a2");
+        server.put("a2.txt", "a2");
+        mock.assertIsSatisfied();
+        assertTrue(server.contains("c.txt"), "c.txt stays");
+    }
+
+    void addConsumerRoute(String options) throws Exception {
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                
from("minio://bucket?accessKey=xxx&secretKey=yyy&region=us-east-1&initialDelay=0&delay=10"
+                     + "&endpoint=" + server.endpoint() + options)
+                        .routeId("consumer")
+                        .convertBodyTo(String.class)
+                        .to("mock:result");
+            }
+        });
+    }
+}
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 6648e2c72877..6872b0344856 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
@@ -1592,6 +1592,18 @@ A started PQC data format no longer keeps a 
`KeyGenerator` of its own: `getKeyGe
 instance and, unless one is configured, the `CamelPQCKemAlgorithm` header now 
selects the KEM algorithm of each
 operation.
 
+=== camel-minio - a listed object that cannot be got is skipped
+
+When the consumer lists the bucket, a failure to stat or get one of the listed 
objects (for example access denied, or
+an object encrypted with a customer key the consumer does not have) no longer 
fails the poll, as long as another listed
+object could be got: the object is skipped, the other listed objects are 
consumed, and the skipped object is tried
+again by a later poll. The failure is given to the exception handler of the 
consumer, with the object name in the
+message (by default a `WARN` with the stack trace, at every poll that lists 
the object). Before, the objects listed
+after it in the same poll were not consumed, so one such object could keep the 
consumer from consuming the rest of the
+bucket. The poll still fails when none of the listed objects could be got, and 
when `objectName` is set. When a poll
+lists `maxMessagesPerPoll` objects, the next poll now continues after the last 
listed object (before, after the last
+object that was consumed), so an object that cannot be got at the end of a 
page does not hold the listing back.
+
 === camel-box - getEnterpriseEvents returns the events of every chunk
 
 The `getEnterpriseEvents` operation of the `event-logs` API now returns all 
the events between `after` and `before`.

Reply via email to