This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch CAMEL-26132-rss-clone-null-body in repository https://gitbox.apache.org/repos/asf/camel.git
commit 87ce37a2e4f0936931ce7de3688c26fd4dcce3d0 Author: Claus Ibsen <[email protected]> AuthorDate: Mon Nov 2 16:57:28 2026 +0100 CAMEL-26132: camel-rss - Fail the exchange when the feed cannot be cloned for a split entry With splitEntries=true, RssEndpoint.createExchange clones the SyndFeed to hold the one entry. When the clone failed with CloneNotSupportedException it only logged at DEBUG and sent the exchange with a null body, so the entry was lost without a visible error. Now it logs at WARN, keeps the SyndEntry as the body and sets a RuntimeCamelException on the exchange, so the route's error handler (for example a dead letter channel) receives the entry. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../apache/camel/catalog/docs/rss-component.adoc | 5 + .../camel-rss/src/main/docs/rss-component.adoc | 5 + .../apache/camel/component/rss/RssEndpoint.java | 12 +- .../component/rss/RssEntryCloneFailureTest.java | 136 +++++++++++++++++++++ 4 files changed, 153 insertions(+), 5 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/rss-component.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/rss-component.adoc index 3d7fdeb1d784..b7fb1f079906 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/rss-component.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/rss-component.adoc @@ -62,6 +62,11 @@ a `SyndFeed` with one `SyndEntry` or the whole `SyndFeed` with all its `SyndEntr |`splitEntries` |`false` |The entire list of entries from the current feed is set in the exchange. |======================================================================= +With `splitEntries=true`, Camel copies the feed (with ROME's `clone`) to hold the one entry. +If the feed cannot be copied, the exchange fails with a `RuntimeCamelException` (caused by the +`CloneNotSupportedException`) and the `SyndEntry` as the body, so the route's error handler, +such as a dead letter channel, receives the entry. + == Example If the URL for the RSS feed uses query parameters, this component will diff --git a/components/camel-rss/src/main/docs/rss-component.adoc b/components/camel-rss/src/main/docs/rss-component.adoc index 3d7fdeb1d784..b7fb1f079906 100644 --- a/components/camel-rss/src/main/docs/rss-component.adoc +++ b/components/camel-rss/src/main/docs/rss-component.adoc @@ -62,6 +62,11 @@ a `SyndFeed` with one `SyndEntry` or the whole `SyndFeed` with all its `SyndEntr |`splitEntries` |`false` |The entire list of entries from the current feed is set in the exchange. |======================================================================= +With `splitEntries=true`, Camel copies the feed (with ROME's `clone`) to hold the one entry. +If the feed cannot be copied, the exchange fails with a `RuntimeCamelException` (caused by the +`CloneNotSupportedException`) and the `SyndEntry` as the body, so the route's error handler, +such as a dead letter channel, receives the entry. + == Example If the URL for the RSS feed uses query parameters, this component will diff --git a/components/camel-rss/src/main/java/org/apache/camel/component/rss/RssEndpoint.java b/components/camel-rss/src/main/java/org/apache/camel/component/rss/RssEndpoint.java index 922204db4b5c..f3f90398e850 100644 --- a/components/camel-rss/src/main/java/org/apache/camel/component/rss/RssEndpoint.java +++ b/components/camel-rss/src/main/java/org/apache/camel/component/rss/RssEndpoint.java @@ -24,6 +24,7 @@ import org.apache.camel.Category; import org.apache.camel.Exchange; import org.apache.camel.Processor; import org.apache.camel.Producer; +import org.apache.camel.RuntimeCamelException; import org.apache.camel.component.feed.FeedComponent; import org.apache.camel.component.feed.FeedEndpoint; import org.apache.camel.component.feed.FeedPollingConsumer; @@ -81,15 +82,16 @@ public class RssEndpoint extends FeedEndpoint implements EndpointServiceLocation @Override public Exchange createExchange(Object feed, Object entry) { Exchange exchange = createExchangeWithFeedHeader(feed, RssConstants.RSS_FEED); - SyndFeed newFeed; try { - newFeed = (SyndFeed) ((SyndFeed) feed).clone(); + SyndFeed newFeed = (SyndFeed) ((SyndFeed) feed).clone(); newFeed.setEntries(Arrays.asList((SyndEntry) entry)); + exchange.getIn().setBody(newFeed); } catch (CloneNotSupportedException e) { - LOG.debug("Could not create a new feed. This exception will be ignored.", e); - newFeed = null; + // keep the entry as the body so the error handler (such as a dead letter channel) does not lose it + LOG.warn("Could not clone the feed to hold the entry. The exchange is failed with the entry as its body.", e); + exchange.getIn().setBody(entry); + exchange.setException(new RuntimeCamelException("Could not clone the feed to hold the entry", e)); } - exchange.getIn().setBody(newFeed); return exchange; } diff --git a/components/camel-rss/src/test/java/org/apache/camel/component/rss/RssEntryCloneFailureTest.java b/components/camel-rss/src/test/java/org/apache/camel/component/rss/RssEntryCloneFailureTest.java new file mode 100644 index 000000000000..484ca53869ba --- /dev/null +++ b/components/camel-rss/src/test/java/org/apache/camel/component/rss/RssEntryCloneFailureTest.java @@ -0,0 +1,136 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rss; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import com.rometools.rome.feed.synd.SyndEntry; +import com.rometools.rome.feed.synd.SyndEntryImpl; +import com.rometools.rome.feed.synd.SyndFeed; +import com.rometools.rome.feed.synd.SyndFeedImpl; +import org.apache.camel.Exchange; +import org.apache.camel.Processor; +import org.apache.camel.RuntimeCamelException; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.feed.FeedEndpoint; +import org.apache.camel.component.feed.FeedPollingConsumer; +import org.apache.camel.component.mock.MockEndpoint; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertSame; + +/** + * When the feed cannot be cloned, the split consumer must fail the exchange instead of sending a null body. + */ +public class RssEntryCloneFailureTest extends CamelTestSupport { + + @Test + public void testCloneFailureFailsTheExchange() throws Exception { + MockEndpoint result = getMockEndpoint("mock:result"); + result.expectedMessageCount(0); + MockEndpoint dead = getMockEndpoint("mock:dead"); + dead.expectedMessageCount(2); + + MockEndpoint.assertIsSatisfied(context); + + for (Exchange exchange : dead.getReceivedExchanges()) { + Throwable caught = exchange.getProperty(Exchange.EXCEPTION_CAUGHT, Throwable.class); + assertInstanceOf(RuntimeCamelException.class, caught); + assertInstanceOf(CloneNotSupportedException.class, caught.getCause()); + } + Set<String> titles = dead.getReceivedExchanges().stream() + .map(e -> e.getIn().getBody(SyndEntry.class).getTitle()) + .collect(Collectors.toSet()); + assertEquals(Set.of("Entry 1", "Entry 2"), titles); + } + + @Test + public void testCreateExchangeKeepsEntryAndSetsException() { + RssEndpoint endpoint = context.getEndpoint("rss:file:src/test/data/rss20.xml", RssEndpoint.class); + SyndFeed feed = createNonCloneableFeed(); + SyndEntry entry = feed.getEntries().get(0); + + Exchange exchange = endpoint.createExchange(feed, entry); + + assertSame(entry, exchange.getIn().getBody()); + assertInstanceOf(RuntimeCamelException.class, exchange.getException()); + assertInstanceOf(CloneNotSupportedException.class, exchange.getException().getCause()); + assertSame(feed, exchange.getIn().getHeader(RssConstants.RSS_FEED)); + } + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + public void configure() throws Exception { + errorHandler(deadLetterChannel("mock:dead")); + + RssEndpoint rss = new NonCloneableFeedEndpoint(); + rss.setCamelContext(context); + rss.setFeedUri("file:src/test/data/rss20.xml"); + rss.setSplitEntries(true); + rss.setDelay(60000); + rss.setThrottleEntries(false); + context.addEndpoint("myrss", rss); + + from("myrss").to("mock:result"); + } + }; + } + + private static SyndFeed createNonCloneableFeed() { + SyndFeed feed = new NonCloneableFeed(); + feed.setFeedType("rss_2.0"); + feed.setTitle("Feed"); + List<SyndEntry> entries = new ArrayList<>(); + for (int i = 1; i <= 2; i++) { + SyndEntry entry = new SyndEntryImpl(); + entry.setTitle("Entry " + i); + entries.add(entry); + } + feed.setEntries(entries); + return feed; + } + + private static class NonCloneableFeed extends SyndFeedImpl { + @Override + public Object clone() throws CloneNotSupportedException { + throw new CloneNotSupportedException("Cannot clone this feed"); + } + } + + private static class NonCloneableFeedEndpoint extends RssEndpoint { + @Override + protected FeedPollingConsumer createEntryPollingConsumer( + FeedEndpoint feedEndpoint, Processor processor, boolean throttleEntries) + throws Exception { + RssEntryPollingConsumer answer = new RssEntryPollingConsumer(this, processor, throttleEntries) { + @Override + protected Object createFeed() { + return createNonCloneableFeed(); + } + }; + configureConsumer(answer); + return answer; + } + } +}
