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 8c78228545ff CAMEL-26430: camel-core - RedeliveryErrorHandler: a 
redelivery whose processor throws completes the exchange instead of hanging 
(#28785)
8c78228545ff is described below

commit 8c78228545ff49ad5b1ebd93e63129a427a4f0e9
Author: allthingssecurity <[email protected]>
AuthorDate: Sun Nov 1 16:37:37 2026 +0530

    CAMEL-26430: camel-core - RedeliveryErrorHandler: a redelivery whose 
processor throws completes the exchange instead of hanging (#28785)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../errorhandler/RedeliveryErrorHandler.java       |  52 +++-
 .../RedeliveryProcessorThrowsPooledTest.java       |  38 +++
 .../RedeliveryProcessorThrowsTest.java             | 345 +++++++++++++++++++++
 3 files changed, 434 insertions(+), 1 deletion(-)

diff --git 
a/core/camel-core-processor/src/main/java/org/apache/camel/processor/errorhandler/RedeliveryErrorHandler.java
 
b/core/camel-core-processor/src/main/java/org/apache/camel/processor/errorhandler/RedeliveryErrorHandler.java
index b31ef2bfb3f5..bdab8527ecf0 100644
--- 
a/core/camel-core-processor/src/main/java/org/apache/camel/processor/errorhandler/RedeliveryErrorHandler.java
+++ 
b/core/camel-core-processor/src/main/java/org/apache/camel/processor/errorhandler/RedeliveryErrorHandler.java
@@ -22,6 +22,7 @@ import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.RejectedExecutionException;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
 
 import org.apache.camel.AsyncCallback;
@@ -1359,6 +1360,47 @@ public abstract class RedeliveryErrorHandler extends 
ErrorHandlerSupport
         }
 
         protected void redeliver() {
+            // the exchange is continued by whichever comes first: the 
callback given to the processor, or the takeover
+            // below when the redelivery throws before that callback is 
called. So it is continued exactly once
+            final AtomicBoolean continued = new AtomicBoolean();
+            // read while this redelivery still owns the task
+            final Exchange redelivered = exchange;
+            final String exchangeId = redelivered.getExchangeId();
+            try {
+                doRedeliver(continued, exchangeId);
+            } catch (Exception e) {
+                if (continued.get()) {
+                    // the callback was called before the exception was 
thrown, and it continued the exchange (this task
+                    // may already be released), so leave the exception to the 
reactive executor, which logs it, as before
+                    throw e;
+                }
+                // the redelivery threw before the callback was called, such 
as an AsyncProcessor that throws from
+                // process(exchange, callback) instead of setting the 
exception on the exchange. The reactive executor
+                // would drop the exception, and the exchange would never 
complete. Take over through the reactive
+                // executor, after the work already queued there, so a 
callback that the processor handed on and that is
+                // already queued there (such as the reply of a route behind a 
direct producer, whose steps are queued
+                // on the reactive executor) still comes first and continues 
the exchange, as before
+                reactiveExecutor.schedule(() -> 
takeOverFailedRedelivery(continued, redelivered, exchangeId, e));
+            }
+        }
+
+        private void takeOverFailedRedelivery(
+                AtomicBoolean continued, Exchange redelivered, String 
exchangeId, Exception cause) {
+            if (continued.compareAndSet(false, true)) {
+                // a new failure of the redelivery, so let the error handler 
handle it (redeliver again or give up), as
+                // when the processor sets the exception on the exchange or 
the onRedelivery processor fails
+                redelivered.setException(cause);
+                run();
+            } else {
+                // the queued callback continued the exchange (which may 
already be done and released), so this
+                // exception is only logged, as before
+                LOG.warn("Error executing the redelivery of exchangeId: {} by 
its processor {} due to {}. The exchange"
+                         + " continued, so this exception is ignored.",
+                        exchangeId, outputAsync, cause.getMessage(), cause);
+            }
+        }
+
+        private void doRedeliver(AtomicBoolean continued, String exchangeId) {
             // prepare for redelivery
             prepareExchangeForRedelivery();
 
@@ -1375,6 +1417,7 @@ public abstract class RedeliveryErrorHandler extends 
ErrorHandlerSupport
             if (exchange.isRouteStop()) {
                 // the on redelivery can mark that the exchange should stop 
and therefore not perform a redelivery
                 // and if so then we are done so continue callback
+                continued.set(true);
                 AsyncCallback cb = callback;
                 taskFactory.release(this);
                 reactiveExecutor.schedule(cb);
@@ -1393,8 +1436,15 @@ public abstract class RedeliveryErrorHandler extends 
ErrorHandlerSupport
 
             // process the exchange (also redelivery)
             processOutput(exchange, doneSync -> {
+                if (!continued.compareAndSet(false, true)) {
+                    // the processor threw before it called the callback, and 
the error handler took over that failure
+                    LOG.warn("The callback of the redelivery of exchangeId: {} 
was called after its processor {} had thrown an"
+                             + " exception (an AsyncProcessor must not throw 
from process). This callback is ignored.",
+                            exchangeId, outputAsync);
+                    return;
+                }
                 if (LOG.isTraceEnabled()) {
-                    LOG.trace("Redelivering exchangeId: {}", 
exchange.getExchangeId());
+                    LOG.trace("Redelivering exchangeId: {}", exchangeId);
                 }
 
                 // only process if the exchange hasn't failed
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/RedeliveryProcessorThrowsPooledTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/RedeliveryProcessorThrowsPooledTest.java
new file mode 100644
index 000000000000..927bb8136856
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/RedeliveryProcessorThrowsPooledTest.java
@@ -0,0 +1,38 @@
+/*
+ * 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.errorhandler;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.ExtendedCamelContext;
+import org.apache.camel.impl.engine.PooledExchangeFactory;
+import org.apache.camel.impl.engine.PooledProcessorExchangeFactory;
+
+/**
+ * {@link RedeliveryProcessorThrowsTest} with pooled exchanges, so the error 
handler uses pooled redelivery tasks, which
+ * are reset when they are released.
+ */
+class RedeliveryProcessorThrowsPooledTest extends 
RedeliveryProcessorThrowsTest {
+
+    @Override
+    protected CamelContext createCamelContext() throws Exception {
+        CamelContext camelContext = super.createCamelContext();
+        ExtendedCamelContext ecc = camelContext.getCamelContextExtension();
+        ecc.setExchangeFactory(new PooledExchangeFactory());
+        ecc.setProcessorExchangeFactory(new PooledProcessorExchangeFactory());
+        return camelContext;
+    }
+}
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/RedeliveryProcessorThrowsTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/RedeliveryProcessorThrowsTest.java
new file mode 100644
index 000000000000..d381e7f201d6
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/RedeliveryProcessorThrowsTest.java
@@ -0,0 +1,345 @@
+/*
+ * 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.errorhandler;
+
+import java.io.IOException;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.camel.AsyncCallback;
+import org.apache.camel.AsyncProcessor;
+import org.apache.camel.AsyncProducer;
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+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.AsyncProcessorSupport;
+import org.apache.camel.support.EventNotifierSupport;
+import org.junit.jupiter.api.Test;
+
+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.assertNull;
+
+/**
+ * An {@link AsyncProcessor} that throws from {@code process(exchange, 
callback)} on a redelivery, instead of setting
+ * the exception on the exchange: the exchange must complete (failed or 
handled) instead of hanging, for an immediate, a
+ * synchronous delayed, an asynchronous delayed and a transacted redelivery. 
Each send is bounded, so a hang fails the
+ * test.
+ */
+class RedeliveryProcessorThrowsTest extends ContextTestSupport {
+
+    private final AtomicInteger calls = new AtomicInteger();
+    private final AtomicInteger onRedeliveries = new AtomicInteger();
+
+    @Override
+    public boolean isUseRouteBuilder() {
+        return false;
+    }
+
+    /**
+     * The first call fails the exchange as an AsyncProcessor should (the 
exception on the exchange), the second call
+     * throws instead, and the later calls succeed.
+     */
+    private final class FailsThenThrows extends AsyncProcessorSupport {
+        @Override
+        public boolean process(Exchange exchange, AsyncCallback callback) {
+            int call = calls.incrementAndGet();
+            if (call == 1) {
+                exchange.setException(new IOException("first"));
+            } else if (call == 2) {
+                throw new IllegalStateException("second");
+            }
+            callback.done(true);
+            return true;
+        }
+    }
+
+    private void start(String errorHandler, AsyncProcessor processor) throws 
Exception {
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                switch (errorHandler) {
+                    case "immediate" -> 
errorHandler(defaultErrorHandler().maximumRedeliveries(1).redeliveryDelay(0));
+                    case "synchronousDelayed" ->
+                        
errorHandler(defaultErrorHandler().maximumRedeliveries(1).redeliveryDelay(10));
+                    case "asynchronousDelayed" -> 
errorHandler(defaultErrorHandler().maximumRedeliveries(1)
+                            .redeliveryDelay(10).asyncDelayedRedelivery());
+                    // the asynchronous redelivery then wakes up to check 
whether Camel is stopping before it redelivers
+                    case "asynchronousDelayedNotWhileStopping" -> 
errorHandler(defaultErrorHandler().maximumRedeliveries(1)
+                            
.redeliveryDelay(10).asyncDelayedRedelivery().allowRedeliveryWhileStopping(false));
+                    case "twoRedeliveries" -> 
errorHandler(defaultErrorHandler().maximumRedeliveries(2).redeliveryDelay(0));
+                    case "deadLetterChannel" ->
+                        
errorHandler(deadLetterChannel("mock:dead").maximumRedeliveries(1).redeliveryDelay(0));
+                    case "deadLetterChannelAsynchronousDelayed" -> 
errorHandler(deadLetterChannel("mock:dead")
+                            
.maximumRedeliveries(1).redeliveryDelay(10).asyncDelayedRedelivery());
+                    case "onRedelivery" -> 
errorHandler(defaultErrorHandler().maximumRedeliveries(2).redeliveryDelay(0)
+                            .onRedelivery(e -> 
onRedeliveries.incrementAndGet()));
+                    case "onException" -> {
+                        
onException(IOException.class).maximumRedeliveries(1).redeliveryDelay(0);
+                        
onException(IllegalStateException.class).handled(true).to("mock:handled");
+                    }
+                    default -> throw new 
IllegalArgumentException(errorHandler);
+                }
+                from("direct:start").process(processor).to("mock:result");
+            }
+        });
+        context.start();
+    }
+
+    private Exchange send() throws Exception {
+        return send(false);
+    }
+
+    private Exchange send(boolean transacted) throws Exception {
+        // bounded: on a hang the future does not complete and get times out
+        return template.asyncSend("direct:start", e -> {
+            e.getMessage().setBody("Hello");
+            e.getExchangeExtension().setTransacted(transacted);
+        }).get(10, TimeUnit.SECONDS);
+    }
+
+    private void assertFailedWithTheThrownException(Exchange out) {
+        assertInstanceOf(IllegalStateException.class, out.getException());
+        assertEquals("second", out.getException().getMessage());
+        assertEquals(2, calls.get(), "the processor was called for the 
delivery and the redelivery");
+        assertEquals(0, getMockEndpoint("mock:result").getReceivedCounter(), 
"mock:result");
+    }
+
+    @Test
+    void immediateRedelivery() throws Exception {
+        start("immediate", new FailsThenThrows());
+        assertFailedWithTheThrownException(send());
+    }
+
+    @Test
+    void synchronousDelayedRedelivery() throws Exception {
+        start("synchronousDelayed", new FailsThenThrows());
+        assertFailedWithTheThrownException(send());
+    }
+
+    @Test
+    void asynchronousDelayedRedelivery() throws Exception {
+        start("asynchronousDelayed", new FailsThenThrows());
+        assertFailedWithTheThrownException(send());
+    }
+
+    @Test
+    void asynchronousDelayedRedeliveryNotAllowedWhileStopping() throws 
Exception {
+        start("asynchronousDelayedNotWhileStopping", new FailsThenThrows());
+        assertFailedWithTheThrownException(send());
+    }
+
+    @Test
+    void transactedImmediateRedelivery() throws Exception {
+        // a transacted exchange runs on the reactive executor's queue, and 
its redelivery is always synchronous
+        start("immediate", new FailsThenThrows());
+        assertFailedWithTheThrownException(send(true));
+    }
+
+    @Test
+    void transactedSynchronousDelayedRedelivery() throws Exception {
+        start("synchronousDelayed", new FailsThenThrows());
+        assertFailedWithTheThrownException(send(true));
+    }
+
+    @Test
+    void thrownFailureIsRedelivered() throws Exception {
+        // the error handler handles the thrown failure as any failure of the 
processor: it redelivers again, and the
+        // third call succeeds
+        start("twoRedeliveries", new FailsThenThrows());
+        Exchange out = send();
+        assertNull(out.getException());
+        assertEquals(3, calls.get(), "calls");
+        assertEquals(1, getMockEndpoint("mock:result").getReceivedCounter(), 
"mock:result");
+        assertEquals(2, 
out.getMessage().getHeader(Exchange.REDELIVERY_COUNTER, Integer.class), 
"redelivery counter");
+    }
+
+    @Test
+    void deadLetterChannelGetsTheThrownFailure() throws Exception {
+        // with an asynchronous delayed redelivery
+        MockEndpoint dead = getMockEndpoint("mock:dead");
+        dead.expectedMessageCount(1);
+        start("deadLetterChannelAsynchronousDelayed", new FailsThenThrows());
+
+        Exchange out = send();
+        assertNull(out.getException(), "the dead letter channel handled the 
failure");
+        dead.assertIsSatisfied();
+        Exception caught = 
dead.getReceivedExchanges().get(0).getProperty(Exchange.EXCEPTION_CAUGHT, 
Exception.class);
+        assertInstanceOf(IllegalStateException.class, caught);
+        assertEquals("second", caught.getMessage());
+        assertEquals(1, dead.getReceivedCounter(), "mock:dead");
+        assertEquals(2, calls.get(), "calls");
+    }
+
+    @Test
+    void onRedeliveryRunsBeforeEachRedelivery() throws Exception {
+        // the thrown failure is redelivered like any other, so the 
onRedelivery processor runs before each redelivery
+        start("onRedelivery", new FailsThenThrows());
+        Exchange out = send();
+        assertNull(out.getException());
+        assertEquals(3, calls.get(), "calls");
+        assertEquals(2, onRedeliveries.get(), "onRedelivery calls");
+        assertEquals(1, getMockEndpoint("mock:result").getReceivedCounter(), 
"mock:result");
+    }
+
+    @Test
+    void onExceptionOfTheThrownFailureHandlesIt() throws Exception {
+        // the exception policy is chosen again for the thrown failure, as for 
any new failure
+        start("onException", new FailsThenThrows());
+
+        Exchange out = send();
+        assertNull(out.getException(), "the onException handled the thrown 
failure");
+        assertEquals(1, getMockEndpoint("mock:handled").getReceivedCounter(), 
"mock:handled");
+        assertEquals(2, calls.get(), "calls");
+    }
+
+    @Test
+    void controlCallbackCalledBeforeTheProcessorThrows() throws Exception {
+        // the processor calls the callback and then throws: the callback 
continued the exchange, so the exception is
+        // left to the reactive executor (which logs it), as before, and the 
exchange completes once
+        MockEndpoint result = getMockEndpoint("mock:result");
+        result.expectedMessageCount(1);
+        start("immediate", new AsyncProcessorSupport() {
+            @Override
+            public boolean process(Exchange exchange, AsyncCallback callback) {
+                int call = calls.incrementAndGet();
+                if (call == 1) {
+                    exchange.setException(new IOException("first"));
+                    callback.done(true);
+                    return true;
+                }
+                callback.done(true);
+                throw new IllegalStateException("after the callback");
+            }
+        });
+
+        Exchange out = send();
+        assertNull(out.getException());
+        result.assertIsSatisfied();
+        assertEquals(1, result.getReceivedCounter(), "mock:result");
+        assertEquals(2, calls.get(), "calls");
+    }
+
+    @Test
+    void controlQueuedCallbackBeforeTheProcessorThrows() throws Exception {
+        // the processor hands the callback to a direct route and then throws. 
The route runs on the reactive executor
+        // and calls the callback through it, before the error handler would 
take over the thrown failure, so it
+        // continues the exchange as before: the exchange completes with the 
reply of that route
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                
from("direct:inner").setBody(constant("inner")).setHeader("inner", 
constant("yes"));
+            }
+        });
+        AtomicReference<AsyncProducer> inner = new AtomicReference<>();
+        start("immediate", new AsyncProcessorSupport() {
+            @Override
+            public boolean process(Exchange exchange, AsyncCallback callback) {
+                int call = calls.incrementAndGet();
+                if (call == 1) {
+                    exchange.setException(new IOException("first"));
+                    callback.done(true);
+                    return true;
+                }
+                inner.get().process(exchange, callback);
+                throw new IllegalStateException("after the callback");
+            }
+        });
+        AsyncProducer producer = 
context.getEndpoint("direct:inner").createAsyncProducer();
+        producer.start();
+        inner.set(producer);
+        try {
+            Exchange out = send();
+            assertNull(out.getException());
+            assertEquals("inner", out.getMessage().getBody());
+            assertEquals("yes", out.getMessage().getHeader("inner"));
+            assertEquals(1, 
getMockEndpoint("mock:result").getReceivedCounter(), "mock:result");
+            assertEquals(2, calls.get(), "calls");
+        } finally {
+            producer.stop();
+        }
+    }
+
+    @Test
+    void callbackCalledAfterTheProcessorThrewIsIgnored() throws Exception {
+        // the processor hands the callback to another thread and then throws. 
The error handler cannot tell this from a
+        // callback that never comes, so it takes over the thrown failure 
(before, the exchange waited for the callback),
+        // and the late callback must not continue the exchange a second time
+        CountDownLatch release = new CountDownLatch(1);
+        AtomicReference<Thread> late = new AtomicReference<>();
+        AtomicReference<Throwable> lateFailure = new AtomicReference<>();
+        MockEndpoint dead = getMockEndpoint("mock:dead");
+        dead.expectedMessageCount(1);
+        // the exchange must be done once: a second continuation would 
complete its unit of work again
+        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;
+            }
+        });
+        start("deadLetterChannel", new AsyncProcessorSupport() {
+            @Override
+            public boolean process(Exchange exchange, AsyncCallback callback) {
+                int call = calls.incrementAndGet();
+                if (call == 1) {
+                    exchange.setException(new IOException("first"));
+                    callback.done(true);
+                    return true;
+                }
+                Thread thread = new Thread(() -> {
+                    try {
+                        if (release.await(10, TimeUnit.SECONDS)) {
+                            callback.done(false);
+                        }
+                    } catch (Throwable t) {
+                        lateFailure.set(t);
+                    }
+                }, "late-callback");
+                late.set(thread);
+                thread.start();
+                throw new IllegalStateException("second");
+            }
+        });
+
+        Exchange out = send();
+        assertNull(out.getException(), "the dead letter channel handled the 
thrown failure");
+        dead.assertIsSatisfied();
+
+        // now the processor calls the callback, after the exchange completed
+        release.countDown();
+        late.get().join(TimeUnit.SECONDS.toMillis(10));
+        assertFalse(late.get().isAlive(), "the late callback thread ended");
+        assertNull(lateFailure.get(), "the late callback failed");
+        assertEquals(1, done.get(), "the exchange was done once");
+        assertEquals(1, dead.getReceivedCounter(), "mock:dead");
+        assertEquals(0, getMockEndpoint("mock:result").getReceivedCounter(), 
"mock:result");
+        assertEquals(2, calls.get(), "calls");
+    }
+}

Reply via email to