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 e8a5c53c89d3 CAMEL-26601: camel-core - Recipient list in a doTry: a
producer that throws fails the exchange instead of hanging (#29295)
e8a5c53c89d3 is described below
commit e8a5c53c89d327833617f116fed2c577c9e36f60
Author: allthingssecurity <[email protected]>
AuthorDate: Fri Nov 6 13:06:27 2026 +0530
CAMEL-26601: camel-core - Recipient list in a doTry: a producer that throws
fails the exchange instead of hanging (#29295)
Inside a doTry, the recipient list now guards the unit-of-work processor of
each recipient, so a producer that throws before calling the callback fails the
exchange instead of leaving it hanging.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../camel/impl/engine/CamelInternalProcessor.java | 13 +-
.../apache/camel/processor/MulticastProcessor.java | 6 +
.../RecipientListProducerThrowsInDoTryTest.java | 287 +++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 4 +-
4 files changed, 306 insertions(+), 4 deletions(-)
diff --git
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/CamelInternalProcessor.java
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/CamelInternalProcessor.java
index 0fa6cd81b25a..3718aee4f3c3 100644
---
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/CamelInternalProcessor.java
+++
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/CamelInternalProcessor.java
@@ -131,8 +131,8 @@ public class CamelInternalProcessor extends
DelegateAsyncProcessor implements In
// the id of the step, when this is the channel of a step that asks the
step hook itself, as it has no error handler
// that does (set by DefaultChannel)
String stepHookNodeId;
- // whether the processor is the step itself, with no error handler around
it (set by DefaultChannel, such as for the
- // steps of a doTry or an onException): this then catches an exception
that the processor throws instead of
+ // whether the processor has no error handler around it (set by
DefaultChannel for a step without an error handler,
+ // such as the steps of a doTry or an onException, or through
setCatchProcessorThrows): this then catches an exception that the processor
throws instead of
// calling the callback, which would otherwise be dropped by the reactive
executor and leave the exchange hanging
boolean catchProcessorThrows;
@@ -225,6 +225,15 @@ public class CamelInternalProcessor extends
DelegateAsyncProcessor implements In
addAdvice(CamelInternalProcessor.wrap(processor));
}
+ /**
+ * Whether to catch an exception that the wrapped processor throws from
{@code process(exchange, callback)}, instead
+ * of setting it on the exchange, and fail the exchange with it. For a
processor with no error handler around it, as
+ * {@link DefaultChannel} does for a step without an error handler. Must
be set before this processor is used.
+ */
+ public void setCatchProcessorThrows(boolean catchProcessorThrows) {
+ this.catchProcessorThrows = catchProcessorThrows;
+ }
+
@Override
public void setRouteOnAdvices(Route route) {
RoutePolicyAdvice task = getAdvice(RoutePolicyAdvice.class);
diff --git
a/core/camel-core-processor/src/main/java/org/apache/camel/processor/MulticastProcessor.java
b/core/camel-core-processor/src/main/java/org/apache/camel/processor/MulticastProcessor.java
index 72a7b82882e1..78eb0db6132e 100644
---
a/core/camel-core-processor/src/main/java/org/apache/camel/processor/MulticastProcessor.java
+++
b/core/camel-core-processor/src/main/java/org/apache/camel/processor/MulticastProcessor.java
@@ -52,6 +52,7 @@ import org.apache.camel.Route;
import org.apache.camel.RuntimeCamelException;
import org.apache.camel.StreamCache;
import org.apache.camel.Traceable;
+import org.apache.camel.impl.engine.CamelInternalProcessor;
import org.apache.camel.processor.aggregate.ShareUnitOfWorkAggregationStrategy;
import org.apache.camel.processor.aggregate.UseOriginalAggregationStrategy;
import org.apache.camel.processor.errorhandler.ErrorHandlerSupport;
@@ -1311,6 +1312,11 @@ public class MulticastProcessor extends
BaseProcessorSupport
} else {
// and wrap in unit of work processor so the copy exchange also
can run under UoW
answer = createUnitOfWorkProcessor(route, withSendHook(processor),
exchange);
+ if (answer instanceof CamelInternalProcessor internal) {
+ // no error handler around the output (such as in a doTry): a
recipient producer that throws from
+ // process(exchange, callback) fails the copy instead of
hanging the recipient list, as in a channel
+ internal.setCatchProcessorThrows(true);
+ }
}
return answer;
diff --git
a/core/camel-core/src/test/java/org/apache/camel/processor/RecipientListProducerThrowsInDoTryTest.java
b/core/camel-core/src/test/java/org/apache/camel/processor/RecipientListProducerThrowsInDoTryTest.java
new file mode 100644
index 000000000000..ff1231bb20d7
--- /dev/null
+++
b/core/camel-core/src/test/java/org/apache/camel/processor/RecipientListProducerThrowsInDoTryTest.java
@@ -0,0 +1,287 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.processor;
+
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.camel.AsyncCallback;
+import org.apache.camel.Consumer;
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Exchange;
+import org.apache.camel.Processor;
+import org.apache.camel.Producer;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.model.RecipientListDefinition;
+import org.apache.camel.spi.CamelEvent;
+import org.apache.camel.spi.CamelEvent.ExchangeCompletedEvent;
+import org.apache.camel.spi.CamelEvent.ExchangeFailedEvent;
+import org.apache.camel.support.DefaultAsyncProducer;
+import org.apache.camel.support.DefaultEndpoint;
+import org.apache.camel.support.EventNotifierSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * A recipient of a recipient list in a doTry has no error handler around its
producer. When the producer throws from
+ * {@code process(exchange, callback)} instead of setting the exception on the
exchange, the exception reached the task
+ * of the recipient, which the reactive executor runs and which dropped it:
the recipient list waited for good. The
+ * exchange must go on as when the producer sets the exception, and be
continued exactly once. Each send is bounded, so
+ * a hang fails the test.
+ */
+class RecipientListProducerThrowsInDoTryTest extends ContextTestSupport {
+
+ private final AtomicInteger calls = new AtomicInteger();
+
+ @Override
+ public boolean isUseRouteBuilder() {
+ return false;
+ }
+
+ /**
+ * What the producer does with the exchange and its callback.
+ */
+ private interface Behaviour {
+ boolean process(Exchange exchange, AsyncCallback callback);
+ }
+
+ private final class TestEndpoint extends DefaultEndpoint {
+ private final Behaviour behaviour;
+
+ private TestEndpoint(String uri, Behaviour behaviour) {
+ this.behaviour = behaviour;
+ setEndpointUri(uri);
+ }
+
+ @Override
+ public Producer createProducer() {
+ return new DefaultAsyncProducer(this) {
+ @Override
+ public boolean process(Exchange exchange, AsyncCallback
callback) {
+ calls.incrementAndGet();
+ return behaviour.process(exchange, callback);
+ }
+ };
+ }
+
+ @Override
+ public Consumer createConsumer(Processor processor) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ protected String createEndpointUri() {
+ return getEndpointUri();
+ }
+ }
+
+ private static boolean sets(Exchange exchange, AsyncCallback callback) {
+ exchange.setException(new IllegalStateException("failed"));
+ callback.done(true);
+ return true;
+ }
+
+ private static boolean throwsFailure(Exchange exchange, AsyncCallback
callback) {
+ throw new IllegalStateException("failed");
+ }
+
+ private interface Options {
+ void apply(RecipientListDefinition<?> recipientList);
+ }
+
+ /**
+ * The routes direct:{id} with a recipient list of test:{id} in a doTry,
to mock:{id}-result, and a doCatch to
+ * mock:{id}-caught.
+ */
+ private void addRoute(RouteBuilder b, String id, Options options) {
+ RecipientListDefinition<?> recipientList = b.from("direct:" + id)
+ .doTry()
+ .recipientList(b.constant("test:" + id));
+ options.apply(recipientList);
+ recipientList.end()
+ .to("mock:" + id + "-result")
+ .endDoTry()
+ .doCatch(IllegalStateException.class)
+ .to("mock:" + id + "-caught")
+ .end();
+ }
+
+ private void start(Options options, Behaviour behaviour) throws Exception {
+ context.addEndpoint("test:sets", new TestEndpoint("test:sets",
RecipientListProducerThrowsInDoTryTest::sets));
+ context.addEndpoint("test:start", new TestEndpoint("test:start",
behaviour));
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ addRoute(this, "sets", options);
+ addRoute(this, "start", options);
+ }
+ });
+ context.start();
+ }
+
+ private AtomicInteger countDoneExchanges() {
+ AtomicInteger done = new AtomicInteger();
+ context.getManagementStrategy().addEventNotifier(new
EventNotifierSupport() {
+ @Override
+ public void notify(CamelEvent event) {
+ done.incrementAndGet();
+ }
+
+ @Override
+ public boolean isEnabled(CamelEvent event) {
+ return event instanceof ExchangeCompletedEvent || event
instanceof ExchangeFailedEvent;
+ }
+ });
+ return done;
+ }
+
+ private String outcome(Exchange out, String id) {
+ Exception caught = out.getProperty(Exchange.EXCEPTION_CAUGHT,
Exception.class);
+ return "exception=" + describe(out.getException()) + " caught=" +
describe(caught)
+ + " result=" + getMockEndpoint("mock:" + id +
"-result").getReceivedCounter()
+ + " caughtMock=" + getMockEndpoint("mock:" + id +
"-caught").getReceivedCounter();
+ }
+
+ private static String describe(Throwable t) {
+ // the message of a CamelExchangeException ends with the exchange,
which differs between the routes
+ return t == null ? "none" : t.getClass().getSimpleName() + ":" +
t.getMessage().replaceAll("\\. Exchange\\[.*", "");
+ }
+
+ /**
+ * The route whose producer sets the exception (direct:sets) and the one
whose producer behaves as given
+ * (direct:start) must do the same, and be done as many times.
+ */
+ private void assertSameAsSettingTheException(Options options, Behaviour
behaviour, String expected)
+ throws Exception {
+ start(options, behaviour);
+ AtomicInteger done = countDoneExchanges();
+ Exchange reference = template.asyncSend("direct:sets", e ->
e.getMessage().setBody("Hello")).get(10,
+ TimeUnit.SECONDS);
+ assertEquals(expected, outcome(reference, "sets"), "the producer that
sets the exception");
+ await().atMost(10, TimeUnit.SECONDS).until(() -> done.get() >= 1);
+ int referenceDone = done.getAndSet(0);
+ calls.set(0);
+
+ Exchange out = template.asyncSend("direct:start", e ->
e.getMessage().setBody("Hello")).get(10, TimeUnit.SECONDS);
+ assertEquals(expected, outcome(out, "start"), "the route whose
producer does not set the exception");
+ await().atMost(10, TimeUnit.SECONDS).until(() -> done.get() >=
referenceDone);
+ // wait until nothing is in flight, so a second continuation that
comes later is counted as well
+ await().atMost(10, TimeUnit.SECONDS).until(() ->
context.getInflightRepository().size() == 0);
+ assertEquals(1, calls.get(), "the producer is called once");
+ // and send one more exchange, so a second continuation queued before
it is counted as well
+ template.asyncSend("direct:sets", e ->
e.getMessage().setBody("Hello")).get(10, TimeUnit.SECONDS);
+ await().atMost(10, TimeUnit.SECONDS).until(() -> done.get() >= 2 *
referenceDone);
+ assertEquals(2 * referenceDone, done.get(),
+ "the exchanges done, as with the producer that sets the
exception (and once more for the later exchange)");
+ }
+
+ private static final String CAUGHT
+ = "exception=none caught=IllegalStateException:failed result=0
caughtMock=1";
+
+ private static final String CAUGHT_STOP_ON_EXCEPTION
+ = "exception=none caught=CamelExchangeException:Multicast
processing failed for number 0 result=0 caughtMock=1";
+
+ @Test
+ void producerThrows() throws Exception {
+ assertSameAsSettingTheException(r -> {
+ }, RecipientListProducerThrowsInDoTryTest::throwsFailure, CAUGHT);
+ }
+
+ @Test
+ void producerThrowsSynchronous() throws Exception {
+ assertSameAsSettingTheException(r -> r.synchronous(),
RecipientListProducerThrowsInDoTryTest::throwsFailure,
+ CAUGHT);
+ }
+
+ @Test
+ void producerThrowsParallel() throws Exception {
+ assertSameAsSettingTheException(r -> r.parallelProcessing(),
RecipientListProducerThrowsInDoTryTest::throwsFailure,
+ CAUGHT);
+ }
+
+ @Test
+ void producerThrowsStopOnException() throws Exception {
+ assertSameAsSettingTheException(r -> r.stopOnException(),
RecipientListProducerThrowsInDoTryTest::throwsFailure,
+ CAUGHT_STOP_ON_EXCEPTION);
+ }
+
+ @Test
+ void producerThrowsParallelTimeout() throws Exception {
+ // with a timeout the recipient list did not hang, but returned after
the timeout without the failure
+ assertSameAsSettingTheException(r ->
r.parallelProcessing().timeout(5000),
+ RecipientListProducerThrowsInDoTryTest::throwsFailure, CAUGHT);
+ }
+
+ @Test
+ void producerThrowsShareUnitOfWork() throws Exception {
+ assertSameAsSettingTheException(r -> r.shareUnitOfWork(),
RecipientListProducerThrowsInDoTryTest::throwsFailure,
+ CAUGHT);
+ }
+
+ @Test
+ void producerCallsBackThenThrows() throws Exception {
+ // the callback came first and continued the exchange: the exception
is only logged, as before (control: same
+ // on main)
+ assertSameAsSettingTheException(r -> {
+ }, (exchange, callback) -> {
+ exchange.setException(new IllegalStateException("failed"));
+ callback.done(true);
+ throw new IllegalStateException("thrown after the callback");
+ }, CAUGHT);
+ }
+
+ @Test
+ void producerHandsOnCallbackThenThrows() throws Exception {
+ // the callback was queued on the reactive executor before the
producer threw: it comes first and continues the
+ // exchange once, the takeover is only logged (control: same on main;
a takeover without the guard would
+ // continue the exchange twice)
+ assertSameAsSettingTheException(r -> {
+ }, (exchange, callback) -> {
+ exchange.setException(new IllegalStateException("failed"));
+
exchange.getContext().getCamelContextExtension().getReactiveExecutor().schedule(()
-> callback.done(false));
+ throw new IllegalStateException("thrown after handing on the
callback");
+ }, CAUGHT);
+ }
+
+ @Test
+ void sedaQueueFull() throws Exception {
+ // a real producer that throws from process(exchange, callback): the
seda producer throws IllegalStateException
+ // "Queue full" when its bounded queue is full (the default, neither
blockWhenFull nor discardWhenFull)
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:seda")
+ .doTry()
+ .recipientList(constant("seda:full?size=1"))
+ .end()
+ .to("mock:seda-result")
+ .endDoTry()
+ .doCatch(IllegalStateException.class)
+ .to("mock:seda-caught")
+ .end();
+ }
+ });
+ context.start();
+ // fills the queue (no consumer)
+ template.sendBody("seda:full?size=1", "first");
+
+ Exchange out = template.asyncSend("direct:seda", e ->
e.getMessage().setBody("Hello")).get(10, TimeUnit.SECONDS);
+ assertEquals("exception=none caught=IllegalStateException:Queue full
result=0 caughtMock=1", outcome(out, "seda"));
+ }
+}
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 3e0e95612b3f..41a3b204fe36 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
@@ -479,8 +479,8 @@ option brings back the redelivery of a whole routing slip
or dynamic router.
=== camel-core - an AsyncProcessor that throws in a step without an error
handler fails the exchange
A step that has no error handler of its own (the steps of `doTry`, `doCatch`,
`doFinally` and `onException`, also when
-nested in another EIP inside them, the outputs of a multicast, the steps of a
circuit breaker, and a step with
-`inheritErrorHandler(false)`) now handles a processor that throws from
`process(exchange, callback)` instead of
+nested in another EIP inside them, the outputs of a multicast, the recipients
of a recipient list inside a `doTry`,
+the steps of a circuit breaker, and a step with `inheritErrorHandler(false)`)
now handles a processor that throws from `process(exchange, callback)` instead
of
setting the exception on the exchange as if it had set it: the exchange fails
with that exception, so the `doCatch`
catches it, and the output of a multicast is redelivered by the error handler
of the route as for any other failure.
Before, the exchange hung.