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

gnodet pushed a commit to branch backport/27697-to-camel-4.22.x
in repository https://gitbox.apache.org/repos/asf/camel.git

commit e47c8c99d5c5e380aac0fc454ec8fa1c2256c3f4
Author: Guillaume Nodet <[email protected]>
AuthorDate: Mon Oct 12 20:47:50 2026 +0000

    [backport camel-4.22.x] CAMEL-25558: camel-aws2-s3 - A listed object that 
cannot be fetched no longer blocks the consumer or stays in progress
---
 .../camel/component/aws2/s3/AWS2S3Consumer.java    |  99 ++++-
 ...2S3ConsumerDeletedObjectPooledExchangeTest.java |  74 ++++
 .../aws2/s3/AWS2S3ConsumerDeletedObjectTest.java   | 413 +++++++++++++++++++++
 .../camel/component/aws2/s3/FakeS3Server.java      | 259 +++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_22.adoc    |  18 +
 5 files changed, 847 insertions(+), 16 deletions(-)

diff --git 
a/components/camel-aws/camel-aws2-s3/src/main/java/org/apache/camel/component/aws2/s3/AWS2S3Consumer.java
 
b/components/camel-aws/camel-aws2-s3/src/main/java/org/apache/camel/component/aws2/s3/AWS2S3Consumer.java
index a0e5fce60195..ae01bbe4aa45 100644
--- 
a/components/camel-aws/camel-aws2-s3/src/main/java/org/apache/camel/component/aws2/s3/AWS2S3Consumer.java
+++ 
b/components/camel-aws/camel-aws2-s3/src/main/java/org/apache/camel/component/aws2/s3/AWS2S3Consumer.java
@@ -19,8 +19,10 @@ package org.apache.camel.component.aws2.s3;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.LinkedHashMap;
 import java.util.LinkedList;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
 import java.util.Queue;
 
@@ -29,6 +31,7 @@ import org.apache.camel.Exchange;
 import org.apache.camel.ExchangePattern;
 import org.apache.camel.ExchangePropertyKey;
 import org.apache.camel.Message;
+import org.apache.camel.PooledExchange;
 import org.apache.camel.Processor;
 import org.apache.camel.RuntimeCamelException;
 import org.apache.camel.component.aws2.s3.utils.AWS2S3Utils;
@@ -237,6 +240,10 @@ public class AWS2S3Consumer extends 
ScheduledBatchPollingConsumer {
 
         Collection<ResponseInputStream<GetObjectResponse>> s3Objects = new 
ArrayList<>();
         Queue<Exchange> answer = new LinkedList<>();
+        // the listed objects that could not be got, other than those that no 
longer exist, by key
+        Map<String, RuntimeException> failures = new LinkedHashMap<>();
+        // whether S3 answered the get of at least one listed object
+        boolean anyGot = false;
         try {
             for (S3Object s3ObjectSummary : s3ObjectSummaries) {
                 String key = s3ObjectSummary.key();
@@ -264,23 +271,37 @@ public class AWS2S3Consumer extends 
ScheduledBatchPollingConsumer {
                         
getRequest.sseCustomerAlgorithm(getConfiguration().getCustomerAlgorithm());
                     }
                 }
-                ResponseInputStream<GetObjectResponse> s3Object
-                        = getAmazonS3Client().getObject(getRequest.build(), 
ResponseTransformer.toInputStream());
-
-                if (includeS3Object(s3Object)) {
-                    s3Objects.add(s3Object);
-                    Exchange exchange = createExchange(s3Object, key);
-                    answer.add(exchange);
-                } else {
-                    // If includeFolders != true and the object is not 
included, it is safe to close the object here.
-                    // If includeFolders == true, the exchange will close the 
object.
-                    IOHelper.close(s3Object);
-                    // remove in progress
-                    getEndpoint().getInProgressRepository().remove(key);
+                // a failure to get one listed object, or to create its 
exchange, 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
+                ResponseInputStream<GetObjectResponse> s3Object = null;
+                try {
+                    s3Object = 
getAmazonS3Client().getObject(getRequest.build(), 
ResponseTransformer.toInputStream());
+                    if (includeS3Object(s3Object)) {
+                        Exchange exchange = createExchange(s3Object, key);
+                        s3Objects.add(s3Object);
+                        answer.add(exchange);
+                        anyGot = true;
+                    } else {
+                        // If includeFolders != true and the object is not 
included, it is safe to close the object
+                        // here. If includeFolders == true, the exchange will 
close the object.
+                        IOHelper.close(s3Object);
+                        // remove in progress
+                        getEndpoint().getInProgressRepository().remove(key);
+                        anyGot = true;
+                    }
+                } catch (NoSuchKeyException e) {
+                    // deleted after it was listed (by another consumer, a 
lifecycle rule, or the exchange of an
+                    // earlier poll that completed meanwhile)
+                    LOG.debug("Skipping as s3 object no longer exists: {}", 
key);
+                    skipS3Object(s3Object, key);
+                } catch (RuntimeException e) {
+                    skipS3Object(s3Object, key);
+                    failures.put(key, e);
                 }
             }
         } catch (Exception e) {
-            LOG.warn("Error getting S3Object due: {}. This exception is 
ignored.", e.getMessage(), e);
+            LOG.warn("Error getting S3Object due: {}", e.getMessage(), e);
             // ensure all previous gathered s3 objects are closed
             // if there was an exception creating the exchanges in this batch
             s3Objects.forEach(IOHelper::close);
@@ -294,9 +315,38 @@ public class AWS2S3Consumer 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 to get, throttling, no connection): the poll 
fails as it does when the listing fails, and
+                // the error handling and the backoff of the scheduled 
consumer apply
+                Map.Entry<String, RuntimeException> 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();
+            }
+            // as processCommit does: the consumer goes on, and a custom 
exception handler or bridgeErrorHandler sees
+            // the failure
+            for (Map.Entry<String, RuntimeException> failure : 
failures.entrySet()) {
+                String message = "Error getting S3Object " + failure.getKey() 
+ " from bucket "
+                                 + getConfiguration().getBucketName()
+                                 + ". The object is skipped for this poll.";
+                getExceptionHandler().handleException(message, 
failure.getValue());
+            }
+        }
+
         return answer;
     }
 
+    /**
+     * The listed object is not consumed by this poll: closes its stream (if 
it was got) and removes its key from the
+     * in-progress repository, so that a later poll can consume it.
+     */
+    private void skipS3Object(ResponseInputStream<GetObjectResponse> s3Object, 
String key) {
+        IOHelper.close(s3Object);
+        getEndpoint().getInProgressRepository().remove(key);
+    }
+
     /**
      * Decide whether to include the S3Objects in the results
      *
@@ -455,6 +505,25 @@ public class AWS2S3Consumer extends 
ScheduledBatchPollingConsumer {
         LOG.trace("Got object [{}]", s3Object);
 
         Exchange exchange = createExchange(true);
+        try {
+            populateExchange(exchange, pattern, s3Object, key);
+        } catch (RuntimeException e) {
+            // the object is not consumed (for example its download failed), 
so the exchange created for it is not
+            // routed, and nothing else gives it back to the exchange factory
+            if (exchange instanceof PooledExchange pooled) {
+                // created with autoRelease, so done() also gives it back to 
the pool, once: releaseExchange would
+                // then add it to the pool a second time, and two later 
objects would share the exchange
+                pooled.done();
+            } else {
+                releaseExchange(exchange, true);
+            }
+            throw e;
+        }
+        return exchange;
+    }
+
+    private void populateExchange(
+            Exchange exchange, ExchangePattern pattern, 
ResponseInputStream<GetObjectResponse> s3Object, String key) {
         exchange.setPattern(pattern);
         Message message = exchange.getIn();
 
@@ -509,8 +578,6 @@ public class AWS2S3Consumer extends 
ScheduledBatchPollingConsumer {
                 });
             }
         }
-
-        return exchange;
     }
 
     @Override
diff --git 
a/components/camel-aws/camel-aws2-s3/src/test/java/org/apache/camel/component/aws2/s3/AWS2S3ConsumerDeletedObjectPooledExchangeTest.java
 
b/components/camel-aws/camel-aws2-s3/src/test/java/org/apache/camel/component/aws2/s3/AWS2S3ConsumerDeletedObjectPooledExchangeTest.java
new file mode 100644
index 000000000000..545457b7555a
--- /dev/null
+++ 
b/components/camel-aws/camel-aws2-s3/src/test/java/org/apache/camel/component/aws2/s3/AWS2S3ConsumerDeletedObjectPooledExchangeTest.java
@@ -0,0 +1,74 @@
+/*
+ * 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.aws2.s3;
+
+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;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+/**
+ * The same with the pooled exchange factory: the exchange of an object that 
is skipped after it was created (its
+ * download failed) is never routed, and is given back to the pool, once, so 
the objects after it do not share it.
+ */
+class AWS2S3ConsumerDeletedObjectPooledExchangeTest extends 
AWS2S3ConsumerDeletedObjectTest {
+
+    @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 exchangeOfAnObjectWhoseDownloadFailsIsGivenBackToThePool() throws 
Exception {
+        s3.put("a.txt", "a");
+        s3.put("b.txt", "b");
+        s3.put("c.txt", "c");
+        s3.put("d.txt", "d");
+        // the connection is closed while the content of b.txt is read into 
the body
+        s3.cutBodyOfNextGet("b.txt");
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("a", "c", "d");
+        AWS2S3Consumer consumer = startRoute("");
+        
context.getCamelContextExtension().getExchangeFactoryManager().setStatisticsEnabled(true);
+
+        assertEquals(3, consumer.poll());
+
+        mock.assertIsSatisfied();
+        
assertFalse(consumer.getEndpoint().getInProgressRepository().contains("b.txt"), 
"b.txt is still in progress");
+        ExchangeFactory.Statistics stats
+                = 
context.getCamelContextExtension().getExchangeFactoryManager().getStatistics();
+        // every exchange that was taken (those of a, c and d that were 
routed, and the one of b.txt that was not) was
+        // given back, and only once: the pool does not hold an exchange twice
+        assertEquals(4, stats.getCreatedCounter() + 
stats.getAcquiredCounter());
+        assertEquals(4, stats.getReleasedCounter());
+        assertEquals(0, stats.getDiscardedCounter());
+
+        // and b.txt is consumed by the next poll
+        mock.reset();
+        mock.expectedBodiesReceived("b");
+        assertEquals(1, consumer.poll());
+        mock.assertIsSatisfied();
+    }
+}
diff --git 
a/components/camel-aws/camel-aws2-s3/src/test/java/org/apache/camel/component/aws2/s3/AWS2S3ConsumerDeletedObjectTest.java
 
b/components/camel-aws/camel-aws2-s3/src/test/java/org/apache/camel/component/aws2/s3/AWS2S3ConsumerDeletedObjectTest.java
new file mode 100644
index 000000000000..482f9188834c
--- /dev/null
+++ 
b/components/camel-aws/camel-aws2-s3/src/test/java/org/apache/camel/component/aws2/s3/AWS2S3ConsumerDeletedObjectTest.java
@@ -0,0 +1,413 @@
+/*
+ * 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.aws2.s3;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import org.apache.camel.RuntimeCamelException;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.spi.ExceptionHandler;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import software.amazon.awssdk.core.ResponseInputStream;
+import software.amazon.awssdk.core.sync.ResponseTransformer;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.GetObjectRequest;
+import software.amazon.awssdk.services.s3.model.GetObjectResponse;
+import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
+import software.amazon.awssdk.services.s3.model.ListObjectsV2Response;
+import software.amazon.awssdk.services.s3.model.S3Exception;
+import software.amazon.awssdk.services.s3.model.S3Object;
+
+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.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * A listed object that cannot be got (it no longer exists, its get is 
refused, its download fails) does not fail the
+ * poll when another listed object could be got: the consumer skips it, gives 
the failure to its exception handler
+ * (unless the object no longer exists), consumes the other objects of the 
poll, and does not leave it in the
+ * in-progress repository, so a later poll consumes it (or a new object with 
the same key). When none of the listed
+ * objects could be got, the poll fails.
+ * <p>
+ * The scheduler of the consumer is not started: the tests call {@code poll()} 
themselves.
+ */
+class AWS2S3ConsumerDeletedObjectTest extends CamelTestSupport {
+
+    protected FakeS3Server s3;
+
+    @Override
+    protected CamelContext createCamelContext() throws Exception {
+        s3 = new FakeS3Server("bkt");
+        s3.start();
+        return super.createCamelContext();
+    }
+
+    @AfterEach
+    void stopServer() {
+        if (s3 != null) {
+            s3.stop();
+        }
+    }
+
+    private String s3Uri(String options) {
+        return 
"aws2-s3://bkt?accessKey=xxx&secretKey=yyy&region=us-east-1&overrideEndpoint=true&forcePathStyle=true"
+               + "&uriEndpointOverride=" + s3.endpoint() + 
"&startScheduler=false" + options;
+    }
+
+    protected AWS2S3Consumer startRoute(String options) throws Exception {
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                from(s3Uri(options)).routeId("s3")
+                        .convertBodyTo(String.class)
+                        .to("mock:result");
+            }
+        });
+        return (AWS2S3Consumer) context.getRoute("s3").getConsumer();
+    }
+
+    @Test
+    void listedObjectThatNoLongerExistsIsSkipped() throws Exception {
+        s3.put("a.txt", "a");
+        s3.put("b.txt", "b");
+        s3.put("c.txt", "c");
+        // deleted after the listing has returned it and before the consumer 
gets it
+        s3.removeWhenListed("b.txt");
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("a", "c");
+        mock.expectedPropertyReceived(Exchange.BATCH_SIZE, 2);
+        AWS2S3Consumer consumer = startRoute("");
+        RecordingExceptionHandler handler = new RecordingExceptionHandler();
+        consumer.setExceptionHandler(handler);
+
+        int polled = consumer.poll();
+
+        // the object that is gone is not counted either
+        assertEquals(2, polled);
+        mock.assertIsSatisfied();
+        // and it is not an error
+        assertTrue(handler.messages.isEmpty(), "exception handler was called: 
" + handler.messages);
+        
assertFalse(consumer.getEndpoint().getInProgressRepository().contains("b.txt"), 
"b.txt is still in progress");
+    }
+
+    @Test
+    void objectCreatedAgainAfterItWasSkippedIsConsumed() throws Exception {
+        s3.put("a.txt", "a");
+        s3.put("b.txt", "b");
+        s3.removeWhenListed("b.txt");
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("a");
+        AWS2S3Consumer consumer = startRoute("&includeBody=false");
+
+        assertEquals(1, consumer.poll());
+        mock.assertIsSatisfied();
+
+        // a new object with the key of the one that was skipped
+        mock.reset();
+        mock.expectedBodiesReceived("b2");
+        s3.put("b.txt", "b2");
+
+        assertEquals(1, consumer.poll());
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void objectWhoseGetIsRefusedDoesNotStopTheOtherObjects() throws Exception {
+        s3.put("a.txt", "a");
+        s3.put("b.txt", "b");
+        s3.put("c.txt", "c");
+        // as an object that cannot be read at all (archived, access denied): 
every get fails
+        s3.refuseGets("b.txt");
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("a", "c");
+        mock.expectedPropertyReceived(Exchange.BATCH_SIZE, 2);
+        AWS2S3Consumer consumer = startRoute("");
+        RecordingExceptionHandler handler = new RecordingExceptionHandler();
+        consumer.setExceptionHandler(handler);
+
+        // the poll does not fail, and the objects listed before and after the 
refused one are consumed
+        assertEquals(2, consumer.poll());
+        mock.assertIsSatisfied();
+        assertEquals(1, s3.refusedGets("b.txt"));
+        // the exception handler of the consumer got the failure of the 
skipped object
+        assertEquals(1, handler.exceptions.size());
+        S3Exception refused = assertInstanceOf(S3Exception.class, 
handler.exceptions.get(0));
+        assertEquals(403, refused.statusCode());
+        assertTrue(handler.messages.get(0).contains("b.txt"), 
handler.messages.get(0));
+        
assertFalse(consumer.getEndpoint().getInProgressRepository().contains("b.txt"), 
"b.txt is still in progress");
+
+        // the following poll consumes a new object, and tries the refused one 
again
+        mock.reset();
+        mock.expectedBodiesReceived("d");
+        s3.put("d.txt", "d");
+        assertEquals(1, consumer.poll());
+        mock.assertIsSatisfied();
+        assertEquals(2, s3.refusedGets("b.txt"));
+
+        // and the object is consumed once it can be got
+        mock.reset();
+        mock.expectedBodiesReceived("b");
+        s3.allowGets("b.txt");
+        assertEquals(1, consumer.poll());
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void 
skippedObjectOfATruncatedListingIsTriedAgainWhenTheListingStartsOver() throws 
Exception {
+        s3.put("a.txt", "a");
+        s3.put("b.txt", "b");
+        s3.put("c.txt", "c");
+        s3.put("d.txt", "d");
+        s3.refuseGets("b.txt");
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("a");
+        AWS2S3Consumer consumer = startRoute("&maxMessagesPerPoll=2");
+
+        // first page (a, b): the skipped object is not counted
+        assertEquals(1, consumer.poll());
+        mock.assertIsSatisfied();
+
+        // the marker has moved on as for any page: the second page (c, d) is 
consumed, and b is not tried
+        mock.reset();
+        mock.expectedBodiesReceived("c", "d");
+        assertEquals(2, consumer.poll());
+        mock.assertIsSatisfied();
+        assertEquals(1, s3.refusedGets("b.txt"));
+
+        // the listing starts over: b is tried again (with e.txt, as a poll in 
which no object could be got fails)
+        mock.reset();
+        mock.expectedBodiesReceived("e");
+        s3.put("e.txt", "e");
+        assertEquals(1, consumer.poll());
+        mock.assertIsSatisfied();
+        assertEquals(2, s3.refusedGets("b.txt"));
+
+        mock.reset();
+        mock.expectedBodiesReceived("b");
+        s3.allowGets("b.txt");
+        assertEquals(1, consumer.poll());
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void pollFailsWhenNoneOfTheListedObjectsCouldBeGot() throws Exception {
+        s3.put("a.txt", "a");
+        s3.put("b.txt", "b");
+        // as with expired credentials or no permission to get: the listing 
works and every get fails
+        s3.refuseGets("a.txt");
+        s3.refuseGets("b.txt");
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedMessageCount(0);
+        AWS2S3Consumer consumer = startRoute("");
+        RecordingExceptionHandler handler = new RecordingExceptionHandler();
+        consumer.setExceptionHandler(handler);
+
+        // the poll fails with the failure of the first object, so the 
scheduled consumer counts an error (backoff)
+        S3Exception e = assertThrows(S3Exception.class, consumer::poll);
+        assertEquals(403, e.statusCode());
+        mock.assertIsSatisfied();
+        // every listed object was tried, and none is left in progress
+        assertEquals(1, s3.refusedGets("a.txt"));
+        assertEquals(1, s3.refusedGets("b.txt"));
+        
assertFalse(consumer.getEndpoint().getInProgressRepository().contains("a.txt"), 
"a.txt is still in progress");
+        
assertFalse(consumer.getEndpoint().getInProgressRepository().contains("b.txt"), 
"b.txt is still in progress");
+        // the failure is the failure of the poll, and is not given to the 
exception handler per object as well
+        assertTrue(handler.messages.isEmpty(), "exception handler was called: 
" + handler.messages);
+
+        // the next poll tries the objects again
+        mock.reset();
+        mock.expectedBodiesReceived("a", "b");
+        s3.allowGets("a.txt");
+        s3.allowGets("b.txt");
+        assertEquals(2, consumer.poll());
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void pollDoesNotFailWhenTheOnlyListedObjectNoLongerExists() throws 
Exception {
+        s3.put("a.txt", "a");
+        s3.removeWhenListed("a.txt");
+        AWS2S3Consumer consumer = startRoute("");
+
+        assertEquals(0, consumer.poll());
+    }
+
+    @Test
+    void failureOfASkippedObjectIsRoutedWithBridgeErrorHandler() throws 
Exception {
+        s3.put("a.txt", "a");
+        s3.put("b.txt", "b");
+        s3.refuseGets("b.txt");
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("a");
+        MockEndpoint error = getMockEndpoint("mock:error");
+        error.expectedMessageCount(1);
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                onException(S3Exception.class).handled(true).to("mock:error");
+
+                from(s3Uri("&bridgeErrorHandler=true")).routeId("s3")
+                        .convertBodyTo(String.class)
+                        .to("mock:result");
+            }
+        });
+        AWS2S3Consumer consumer = (AWS2S3Consumer) 
context.getRoute("s3").getConsumer();
+
+        assertEquals(1, consumer.poll());
+
+        mock.assertIsSatisfied();
+        error.assertIsSatisfied();
+        String message = 
error.getReceivedExchanges().get(0).getMessage().getBody(String.class);
+        assertTrue(message.contains("b.txt"), message);
+    }
+
+    @Test
+    void objectWhoseDownloadFailsIsSkippedAndNotLeftInProgress() throws 
Exception {
+        AWS2S3Endpoint endpoint = context.getEndpoint(s3Uri(""), 
AWS2S3Endpoint.class);
+        AtomicBoolean closed = new AtomicBoolean();
+        // the download of the object fails while its content is read into the 
body (includeBody=true)
+        InputStream content = new InputStream() {
+            @Override
+            public int read() throws IOException {
+                throw new IOException("connection reset");
+            }
+
+            @Override
+            public void close() {
+                closed.set(true);
+            }
+        };
+        AWS2S3Consumer consumer = new AWS2S3Consumer(endpoint, exchange -> {
+        }) {
+            @Override
+            protected S3Client getAmazonS3Client() {
+                return oneObject("a.txt", content);
+            }
+        };
+        // not started, but the exchange factory of the consumer must be ready
+        consumer.build();
+
+        // it is the only listed object, so the poll fails
+        RuntimeCamelException e = assertThrows(RuntimeCamelException.class, 
consumer::poll);
+        assertInstanceOf(IOException.class, e.getCause());
+
+        assertTrue(closed.get(), "the object stream of a.txt was not closed");
+        assertFalse(endpoint.getInProgressRepository().contains("a.txt"), 
"a.txt is still in progress");
+    }
+
+    @Test
+    void objectStreamIsClosedWhenIncludeS3ObjectFails() throws Exception {
+        AWS2S3Endpoint endpoint = context.getEndpoint(s3Uri(""), 
AWS2S3Endpoint.class);
+        AtomicBoolean closed = new AtomicBoolean();
+        InputStream content = new InputStream() {
+            @Override
+            public int read() {
+                return -1;
+            }
+
+            @Override
+            public void close() {
+                closed.set(true);
+            }
+        };
+        AWS2S3Consumer consumer = new AWS2S3Consumer(endpoint, exchange -> {
+        }) {
+            @Override
+            protected S3Client getAmazonS3Client() {
+                return oneObject("a.txt", content);
+            }
+
+            @Override
+            protected boolean 
includeS3Object(ResponseInputStream<GetObjectResponse> s3Object) {
+                throw new IllegalStateException("cannot decide");
+            }
+        };
+        // not started, but the exchange factory of the consumer must be ready
+        consumer.build();
+
+        // it is the only listed object, so the poll fails
+        assertThrows(IllegalStateException.class, consumer::poll);
+
+        assertTrue(closed.get(), "the object stream of a.txt was not closed");
+        assertFalse(endpoint.getInProgressRepository().contains("a.txt"), 
"a.txt is still in progress");
+    }
+
+    /**
+     * Records what the consumer gives to its exception handler.
+     */
+    private static final class RecordingExceptionHandler implements 
ExceptionHandler {
+        final List<String> messages = new CopyOnWriteArrayList<>();
+        final List<Throwable> exceptions = 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) {
+            messages.add(String.valueOf(message));
+            exceptions.add(exception);
+        }
+    }
+
+    /**
+     * A client whose listing has the one object, and whose get returns the 
content.
+     */
+    private static S3Client oneObject(String key, InputStream content) {
+        return new S3Client() {
+            @Override
+            public ListObjectsV2Response listObjectsV2(ListObjectsV2Request 
request) {
+                return 
ListObjectsV2Response.builder().contents(S3Object.builder().key(key).build())
+                        .isTruncated(false).build();
+            }
+
+            @Override
+            public <T> T getObject(GetObjectRequest request, 
ResponseTransformer<GetObjectResponse, T> transformer) {
+                @SuppressWarnings("unchecked")
+                T answer = (T) new 
ResponseInputStream<>(GetObjectResponse.builder().eTag("tag").build(), content);
+                return answer;
+            }
+
+            @Override
+            public String serviceName() {
+                return SERVICE_NAME;
+            }
+
+            @Override
+            public void close() {
+                // noop
+            }
+        };
+    }
+}
diff --git 
a/components/camel-aws/camel-aws2-s3/src/test/java/org/apache/camel/component/aws2/s3/FakeS3Server.java
 
b/components/camel-aws/camel-aws2-s3/src/test/java/org/apache/camel/component/aws2/s3/FakeS3Server.java
new file mode 100644
index 000000000000..c9869dc581b0
--- /dev/null
+++ 
b/components/camel-aws/camel-aws2-s3/src/test/java/org/apache/camel/component/aws2/s3/FakeS3Server.java
@@ -0,0 +1,259 @@
+/*
+ * 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.aws2.s3;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.NavigableMap;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentSkipListMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+
+/**
+ * A minimal in-process fake of the S3 API calls the consumer makes for a 
single bucket (path-style requests): head
+ * bucket, list objects (v2, paginated with max-keys and 
start-after/continuation-token), head, get and delete object.
+ * An object can be removed right after it was listed, the gets of an object 
can be refused, and the body of an object
+ * can be cut short.
+ */
+final class FakeS3Server {
+
+    private final String bucket;
+    private final HttpServer server;
+    private final NavigableMap<String, byte[]> objects = new 
ConcurrentSkipListMap<>();
+    private final Map<String, AtomicInteger> gets = new ConcurrentHashMap<>();
+    private final Set<String> removedWhenListed = 
ConcurrentHashMap.newKeySet();
+    private final Set<String> refused = ConcurrentHashMap.newKeySet();
+    private final Map<String, AtomicInteger> refusedGets = new 
ConcurrentHashMap<>();
+    private final Set<String> cutBodies = ConcurrentHashMap.newKeySet();
+
+    FakeS3Server(String bucket) throws IOException {
+        this.bucket = bucket;
+        this.server = HttpServer.create(new InetSocketAddress("localhost", 0), 
0);
+        this.server.createContext("/", this::handle);
+    }
+
+    void start() {
+        server.start();
+    }
+
+    void stop() {
+        server.stop(0);
+    }
+
+    String endpoint() {
+        return "http://localhost:"; + server.getAddress().getPort();
+    }
+
+    void put(String key, String content) {
+        objects.put(key, content.getBytes(StandardCharsets.UTF_8));
+    }
+
+    /**
+     * The object is removed as soon as a listing has returned it (once), as 
another consumer or a lifecycle rule does.
+     */
+    void removeWhenListed(String key) {
+        removedWhenListed.add(key);
+    }
+
+    /**
+     * Every get of the object is refused with 403 (AccessDenied), which the 
client does not retry, until
+     * {@link #allowGets(String)}.
+     */
+    void refuseGets(String key) {
+        refused.add(key);
+    }
+
+    void allowGets(String key) {
+        refused.remove(key);
+    }
+
+    /**
+     * The number of gets of the object that were refused.
+     */
+    int refusedGets(String key) {
+        AtomicInteger n = refusedGets.get(key);
+        return n != null ? n.get() : 0;
+    }
+
+    /**
+     * The connection is closed before the whole body of the object was sent 
(once), so its download fails.
+     */
+    void cutBodyOfNextGet(String key) {
+        cutBodies.add(key);
+    }
+
+    int gets(String key) {
+        AtomicInteger n = gets.get(key);
+        return n != null ? n.get() : 0;
+    }
+
+    private void handle(HttpExchange http) throws IOException {
+        String path = http.getRequestURI().getRawPath();
+        String key = path.length() > bucket.length() + 2 ? 
decode(path.substring(bucket.length() + 2)) : null;
+        String method = http.getRequestMethod();
+        if (key == null) {
+            if ("GET".equals(method) && query(http).containsKey("list-type")) {
+                list(http, query(http));
+            } else {
+                // head bucket, location, ...
+                respond(http, 200, null, null);
+            }
+            return;
+        }
+        byte[] data = objects.get(key);
+        switch (method) {
+            case "HEAD" -> {
+                if (data == null) {
+                    respond(http, 404, null, null);
+                } else {
+                    objectHeaders(http, data);
+                    http.sendResponseHeaders(200, -1);
+                    http.close();
+                }
+            }
+            case "GET" -> {
+                if (refused.contains(key)) {
+                    refusedGets.computeIfAbsent(key, k -> new 
AtomicInteger()).incrementAndGet();
+                    error(http, 403, "AccessDenied", "Access Denied", key);
+                } else if (data == null) {
+                    noSuchKey(http, key);
+                } else {
+                    gets.computeIfAbsent(key, k -> new 
AtomicInteger()).incrementAndGet();
+                    objectHeaders(http, data);
+                    if (cutBodies.remove(key)) {
+                        // announces more than is sent, and closes the 
connection
+                        http.sendResponseHeaders(200, data.length + 100L);
+                        http.getResponseBody().write(data);
+                        http.getResponseBody().flush();
+                        http.close();
+                        return;
+                    }
+                    http.sendResponseHeaders(200, data.length);
+                    try (OutputStream os = http.getResponseBody()) {
+                        os.write(data);
+                    }
+                }
+            }
+            case "DELETE" -> {
+                objects.remove(key);
+                http.sendResponseHeaders(204, -1);
+                http.close();
+            }
+            default -> respond(http, 405, null, null);
+        }
+    }
+
+    private void list(HttpExchange http, Map<String, String> query) throws 
IOException {
+        int maxKeys = query.containsKey("max-keys") ? 
Integer.parseInt(query.get("max-keys")) : 1000;
+        String after = query.get("continuation-token");
+        if (after == null) {
+            after = query.get("start-after");
+        }
+        NavigableMap<String, byte[]> view = after != null ? 
objects.tailMap(after, false) : objects;
+        StringBuilder contents = new StringBuilder();
+        int count = 0;
+        String last = null;
+        List<String> listed = new ArrayList<>();
+        for (Map.Entry<String, byte[]> e : view.entrySet()) {
+            if (count == maxKeys) {
+                break;
+            }
+            
contents.append("<Contents><Key>").append(e.getKey()).append("</Key>")
+                    
.append("<LastModified>2026-10-09T00:00:00.000Z</LastModified>")
+                    
.append("<ETag>\"0123456789abcdef0123456789abcdef\"</ETag>")
+                    
.append("<Size>").append(e.getValue().length).append("</Size>")
+                    
.append("<StorageClass>STANDARD</StorageClass></Contents>");
+            last = e.getKey();
+            listed.add(last);
+            count++;
+        }
+        boolean truncated = last != null && objects.higherKey(last) != null;
+        String xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+                     + "<ListBucketResult 
xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\";>"
+                     + "<Name>" + bucket + "</Name><Prefix></Prefix>"
+                     + "<KeyCount>" + count + "</KeyCount><MaxKeys>" + maxKeys 
+ "</MaxKeys>"
+                     + "<IsTruncated>" + truncated + "</IsTruncated>"
+                     + (truncated ? "<NextContinuationToken>" + last + 
"</NextContinuationToken>" : "")
+                     + contents + "</ListBucketResult>";
+        respond(http, 200, "application/xml", xml);
+        for (String key : listed) {
+            if (removedWhenListed.remove(key)) {
+                objects.remove(key);
+            }
+        }
+    }
+
+    private static void objectHeaders(HttpExchange http, byte[] data) {
+        http.getResponseHeaders().add("ETag", 
"\"0123456789abcdef0123456789abcdef\"");
+        http.getResponseHeaders().add("Last-Modified", "Fri, 09 Oct 2026 
00:00:00 GMT");
+        http.getResponseHeaders().add("Content-Type", "text/plain");
+        http.getResponseHeaders().add("Content-Length", 
String.valueOf(data.length));
+    }
+
+    private void noSuchKey(HttpExchange http, String key) throws IOException {
+        error(http, 404, "NoSuchKey", "The specified key does not exist.", 
key);
+    }
+
+    private void error(HttpExchange http, int status, String code, String 
message, String key) throws IOException {
+        String xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Error><Code>" 
+ code + "</Code>"
+                     + "<Message>" + message + "</Message><Key>" + key + 
"</Key>"
+                     + "<BucketName>" + bucket + "</BucketName><Resource>/" + 
bucket + "/" + key + "</Resource>"
+                     + "<RequestId>1</RequestId><HostId>1</HostId></Error>";
+        respond(http, status, "application/xml", xml);
+    }
+
+    private static void respond(HttpExchange http, int status, String 
contentType, String body) throws IOException {
+        if (body == null) {
+            http.sendResponseHeaders(status, -1);
+            http.close();
+            return;
+        }
+        byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
+        http.getResponseHeaders().add("Content-Type", contentType);
+        http.sendResponseHeaders(status, bytes.length);
+        try (OutputStream os = http.getResponseBody()) {
+            os.write(bytes);
+        }
+    }
+
+    private static Map<String, String> query(HttpExchange http) {
+        Map<String, String> answer = new HashMap<>();
+        String q = http.getRequestURI().getRawQuery();
+        if (q != null) {
+            for (String part : q.split("&")) {
+                int eq = part.indexOf('=');
+                answer.put(decode(eq >= 0 ? part.substring(0, eq) : part), eq 
>= 0 ? decode(part.substring(eq + 1)) : "");
+            }
+        }
+        return answer;
+    }
+
+    private static String decode(String s) {
+        return URLDecoder.decode(s, StandardCharsets.UTF_8);
+    }
+}
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
index 096158878345..c43d1d8e7c24 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
@@ -80,6 +80,24 @@ Not changed: the HMAC key is still derived from the same key 
material as the cip
 would change the MAC written into the message and so could not be read by 
earlier versions; that is
 tracked separately.
 
+=== camel-aws2-s3 - a listed object that cannot be fetched is skipped
+
+When the consumer lists the bucket, a failure to get one of the listed objects 
(or to download its content) no longer
+fails the poll, as long as another object of that listing could be got: the 
object is skipped, the other listed
+objects are consumed in that poll, and the skipped object is tried again by a 
later poll. An object that no longer
+exists (for example deleted by another consumer after the listing) is skipped 
with a log line at `DEBUG`. Any other
+failure is given to the exception handler of the consumer, with the key in the 
message: by default that is a `WARN`
+with the stack trace, and with `bridgeErrorHandler=true` the route's error 
handler gets it. This happens at every poll
+that lists an object that can never be read (for example archived, or access 
denied), until it can be read or is
+removed from the bucket. Such a poll is a successful poll, so it does not 
count for `backoffErrorThreshold`.
+
+The poll still fails, as before, when none of the listed objects could be got 
and at least one get failed with an
+error other than that the object no longer exists (for example expired 
credentials, no permission to get objects,
+throttling): the failure of the first object is then the failure of the poll, 
and `backoffErrorThreshold` applies.
+In both cases the objects are no longer kept in the `inProgressRepository`; 
before, the object (or a new object with
+the same key) was skipped by all later polls until the endpoint was restarted. 
With the `fileName` option, a failed
+get still fails the poll.
+
 == Upgrading from 4.22.0 to 4.22.1
 
 === camel-hazelcast - ReplicatedHazelcastAggregationRepository now applies the 
default serialization filter

Reply via email to