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 5551dee680b2 CAMEL-26446: camel-core - RedeliveryErrorHandler: a throw 
on the first delivery goes through redelivery, the dead letter channel and 
onException, and continues the exchange once (#29040)
5551dee680b2 is described below

commit 5551dee680b2136f728980456f8abb23856b740d
Author: allthingssecurity <[email protected]>
AuthorDate: Tue Nov 3 14:17:14 2026 +0530

    CAMEL-26446: camel-core - RedeliveryErrorHandler: a throw on the first 
delivery goes through redelivery, the dead letter channel and onException, and 
continues the exchange once (#29040)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../errorhandler/RedeliveryErrorHandler.java       | 112 +++++-
 ...veryFirstDeliveryProcessorThrowsPooledTest.java |  38 ++
 ...RedeliveryFirstDeliveryProcessorThrowsTest.java | 407 +++++++++++++++++++++
 ...eryFirstDeliveryProcessorThrowsTestSupport.java |  78 ++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  19 +
 5 files changed, 640 insertions(+), 14 deletions(-)

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 9f7f7792b095..5c05406e6a86 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
@@ -1173,12 +1173,23 @@ public abstract class RedeliveryErrorHandler extends 
ErrorHandlerSupport
      * Task to perform calling the processor and handling redelivery if it 
fails (more advanced than ProcessTask)
      */
     protected class RedeliveryTask implements PooledExchangeTask {
+        private static final AtomicIntegerFieldUpdater<RedeliveryTask> AWAITED
+                = AtomicIntegerFieldUpdater.newUpdater(RedeliveryTask.class, 
"awaited");
+
         // state
         private Message original;
         private Exchange exchange;
         private AsyncCallback callback;
         private int redeliveryCounter;
         private long redeliveryDelay;
+        // the number of the first delivery whose callback is still to come, 
or 0 when none. The callback and the
+        // takeover of a first delivery that throws before it calls the 
callback compete for it (compareAndSet), so the
+        // exchange is continued exactly once. A field, not an object per 
delivery, as the first delivery runs for every
+        // step of a route whose error handler can redeliver
+        private volatile int awaited;
+        // numbers the first deliveries of this task (it is reused when tasks 
are pooled), so a callback of an earlier
+        // exchange never matches
+        private int deliveries;
 
         // default behavior which can be overloaded on a per exception basis
         private Predicate retryWhilePredicate;
@@ -1326,17 +1337,88 @@ public abstract class RedeliveryErrorHandler extends 
ErrorHandlerSupport
                 }
             } else {
                 // Simple delivery
-                processOutput(exchange, doneSync -> {
-                    // only continue with callback if we are done
-                    if (isDone(exchange)) {
-                        AsyncCallback cb = callback;
-                        taskFactory.release(this);
-                        reactiveExecutor.schedule(cb);
-                    } else {
-                        // error occurred so loop back around and call 
ourselves
-                        reactiveExecutor.schedule(this);
-                    }
-                });
+                deliver();
+            }
+        }
+
+        /**
+         * The first delivery. When the processor throws before it calls the 
callback, such as an AsyncProcessor that
+         * throws from process(exchange, callback) instead of setting the 
exception on the exchange, the error handler
+         * takes over that failure as when the processor sets it on the 
exchange (redelivery, the dead letter channel
+         * and onException apply), as a redelivery that throws does.
+         */
+        private void deliver() {
+            // read while this task still owns it: once the callback is 
called, the task may be released and reused
+            final Exchange delivered = exchange;
+            int number = ++deliveries;
+            if (number == 0) {
+                number = ++deliveries;
+            }
+            final int delivery = number;
+            // an ordered write is enough: the callback reaches another thread 
only through a hand-off that publishes it
+            AWAITED.lazySet(this, delivery);
+            try {
+                processOutput(delivered, doneSync -> onDelivered(delivery));
+            } catch (Throwable e) {
+                // a Throwable, not only an Exception: an Error (such as 
NoClassDefFoundError) thrown before the callback
+                // is called would also leave the exchange hanging, as the 
reactive executor catches only exceptions
+                if (awaited != delivery) {
+                    // the callback was called before the exception was 
thrown, and it continued the exchange (this task
+                    // may already be released), so only log the exception: 
the exchange must not be continued again.
+                    // This log does not read the exchange id: it is created 
on first use, and the exchange may be in use
+                    // on another thread (or, pooled, by another exchange)
+                    LOG.warn("Error executing the first delivery by the 
processor {} due to {}. The exchange continued, so"
+                             + " this exception is ignored.",
+                            outputAsync, e.getMessage(), e);
+                    return;
+                }
+                // take over through the reactive executor, after the work 
already queued there, so a callback that the
+                // processor handed on and that this queued work calls (such 
as the reply of a route behind a direct
+                // producer whose steps each schedule the next one there, as 
with its default error handler) still
+                // comes first and continues the exchange. A callback that 
only work queued after the takeover calls
+                // (such as a route with noErrorHandler()) loses to it
+                final String exchangeId = delivered.getExchangeId();
+                reactiveExecutor.schedule(() -> 
takeOverFailedDelivery(delivery, delivered, exchangeId, e));
+            }
+        }
+
+        private void onDelivered(int delivery) {
+            if (!AWAITED.compareAndSet(this, delivery, 0)) {
+                // the processor threw before it called the callback, and the 
error handler took over that failure, or
+                // the processor called the callback a second time
+                LOG.warn("The callback of the first delivery by the processor 
{} was called when it was no longer awaited"
+                         + " (after the processor had thrown an exception, 
which the error handler handled instead, or"
+                         + " more than once; an AsyncProcessor must not throw 
from process). This callback is ignored.",
+                        outputAsync);
+                return;
+            }
+            continueAfterDelivery();
+        }
+
+        private void takeOverFailedDelivery(int delivery, Exchange delivered, 
String exchangeId, Throwable cause) {
+            if (AWAITED.compareAndSet(this, delivery, 0)) {
+                // a failure of the processor, so continue as when the 
processor sets the exception on the exchange and
+                // calls the callback: the error handler handles it 
(redeliver, onException or the dead letter channel)
+                delivered.setException(cause);
+                continueAfterDelivery();
+            } else {
+                // the queued callback continued the exchange (which may 
already be done and released), so this
+                // exception is only logged
+                LOG.warn("Error executing the first delivery of exchangeId: {} 
by the processor {} due to {}. The exchange"
+                         + " continued, so this exception is ignored.",
+                        exchangeId, outputAsync, cause.getMessage(), cause);
+            }
+        }
+
+        private void continueAfterDelivery() {
+            // only continue with callback if we are done
+            if (isDone(exchange)) {
+                AsyncCallback cb = callback;
+                taskFactory.release(this);
+                reactiveExecutor.schedule(cb);
+            } else {
+                // error occurred so loop back around and call ourselves
+                reactiveExecutor.schedule(this);
             }
         }
 
@@ -1462,10 +1544,12 @@ public abstract class RedeliveryErrorHandler extends 
ErrorHandlerSupport
             final String exchangeId = redelivered.getExchangeId();
             try {
                 doRedeliver(continued, exchangeId);
-            } catch (Exception e) {
+            } catch (Throwable e) {
+                // a Throwable, not only an Exception, as in deliver()
                 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
+                    // may already be released), so leave the exception to the 
reactive executor, as before (a precise
+                    // rethrow: only unchecked exceptions and errors reach 
here)
                     throw e;
                 }
                 // the redelivery threw before the callback was called, such 
as an AsyncProcessor that throws from
@@ -1479,7 +1563,7 @@ public abstract class RedeliveryErrorHandler extends 
ErrorHandlerSupport
         }
 
         private void takeOverFailedRedelivery(
-                AtomicBoolean continued, Exchange redelivered, String 
exchangeId, Exception cause) {
+                AtomicBoolean continued, Exchange redelivered, String 
exchangeId, Throwable 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
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/RedeliveryFirstDeliveryProcessorThrowsPooledTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/RedeliveryFirstDeliveryProcessorThrowsPooledTest.java
new file mode 100644
index 000000000000..51a8bc3fcad0
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/RedeliveryFirstDeliveryProcessorThrowsPooledTest.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;
+
+/**
+ * The tests of {@link RedeliveryFirstDeliveryProcessorThrowsTestSupport} with 
pooled exchanges, so the error handler
+ * uses pooled redelivery tasks, which are reset when they are released.
+ */
+class RedeliveryFirstDeliveryProcessorThrowsPooledTest extends 
RedeliveryFirstDeliveryProcessorThrowsTestSupport {
+
+    @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/RedeliveryFirstDeliveryProcessorThrowsTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/RedeliveryFirstDeliveryProcessorThrowsTest.java
new file mode 100644
index 000000000000..cb57a79fa28c
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/RedeliveryFirstDeliveryProcessorThrowsTest.java
@@ -0,0 +1,407 @@
+/*
+ * 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.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+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.Exchange;
+import org.apache.camel.Processor;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.support.AsyncProcessorSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.awaitility.Awaitility.await;
+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;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * An {@link AsyncProcessor} that throws from {@code process(exchange, 
callback)} on the first delivery, instead of
+ * setting the exception on the exchange, in a route whose error handler can 
redeliver, has exception policies or a dead
+ * letter channel: the error handler must handle the thrown failure as one set 
on the exchange (redelivery, the dead
+ * letter channel and onException apply, as for a redelivery that throws), and 
continue the exchange exactly once. Each
+ * send is bounded, so a hang fails the test.
+ */
+class RedeliveryFirstDeliveryProcessorThrowsTest extends 
RedeliveryFirstDeliveryProcessorThrowsTestSupport {
+
+    /**
+     * Throws on the first call, and succeeds on the later calls.
+     */
+    private final class ThrowsFirst extends AsyncProcessorSupport {
+        @Override
+        public boolean process(Exchange exchange, AsyncCallback callback) {
+            if (calls.incrementAndGet() == 1) {
+                throw new IllegalStateException("thrown");
+            }
+            callback.done(true);
+            return true;
+        }
+    }
+
+    /**
+     * Throws on the first call, and fails the exchange as an AsyncProcessor 
should (the exception on the exchange) on
+     * the later calls.
+     */
+    private final class ThrowsThenFails extends AsyncProcessorSupport {
+        @Override
+        public boolean process(Exchange exchange, AsyncCallback callback) {
+            int call = calls.incrementAndGet();
+            if (call == 1) {
+                throw new IllegalStateException("thrown");
+            }
+            exchange.setException(new IllegalStateException("failed " + call));
+            callback.done(true);
+            return true;
+        }
+    }
+
+    private void start(String errorHandler, Processor 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());
+                    case "deadLetterChannel" -> 
errorHandler(deadLetterChannel("mock:dead"));
+                    case "deadLetterChannelRedelivery" ->
+                        
errorHandler(deadLetterChannel("mock:dead").maximumRedeliveries(1).redeliveryDelay(0));
+                    case "deadLetterChannelRedeliveryOnExceptionWithout" -> {
+                        
errorHandler(deadLetterChannel("mock:dead").maximumRedeliveries(1).redeliveryDelay(0));
+                        
onException(IllegalStateException.class).maximumRedeliveries(0);
+                    }
+                    case "onException" -> 
onException(IllegalStateException.class).handled(true).to("mock:handled");
+                    default -> throw new 
IllegalArgumentException(errorHandler);
+                }
+                from("direct:start").process(processor).to("mock:result");
+            }
+        });
+        context.start();
+    }
+
+    private void assertRedelivered(Exchange out) {
+        assertNull(out.getException());
+        assertEquals(2, calls.get(), "the processor was called for the 
delivery and the redelivery");
+        assertEquals(1, 
out.getMessage().getHeader(Exchange.REDELIVERY_COUNTER, Integer.class), 
"redelivery counter");
+        assertEquals(1, getMockEndpoint("mock:result").getReceivedCounter(), 
"mock:result");
+    }
+
+    private void assertDeadLetterChannelGotTheThrownFailure(Exchange out, 
MockEndpoint dead) throws Exception {
+        assertNull(out.getException(), "the dead letter channel handled the 
failure");
+        dead.assertIsSatisfied();
+        assertEquals(1, dead.getReceivedCounter(), "mock:dead");
+        Exception caught = 
dead.getReceivedExchanges().get(0).getProperty(Exchange.EXCEPTION_CAUGHT, 
Exception.class);
+        assertInstanceOf(IllegalStateException.class, caught);
+        assertEquals("thrown", caught.getMessage());
+        assertEquals(0, getMockEndpoint("mock:result").getReceivedCounter(), 
"mock:result");
+    }
+
+    @Test
+    void thrownFailureIsRedelivered() throws Exception {
+        start("immediate", new ThrowsFirst());
+        assertRedelivered(send());
+    }
+
+    @Test
+    void thrownFailureIsRedeliveredAfterASynchronousDelay() throws Exception {
+        start("synchronousDelayed", new ThrowsFirst());
+        assertRedelivered(send());
+    }
+
+    @Test
+    void thrownFailureIsRedeliveredAfterAnAsynchronousDelay() throws Exception 
{
+        start("asynchronousDelayed", new ThrowsFirst());
+        assertRedelivered(send());
+    }
+
+    @Test
+    void transactedThrownFailureIsRedelivered() throws Exception {
+        // a transacted exchange runs on the reactive executor's queue, and 
its redelivery is always synchronous
+        start("synchronousDelayed", new ThrowsFirst());
+        assertRedelivered(send(true));
+    }
+
+    @Test
+    void exhaustedFailsWithTheFailureOfTheRedelivery() throws Exception {
+        start("immediate", new ThrowsThenFails());
+        Exchange out = send();
+        assertInstanceOf(IllegalStateException.class, out.getException());
+        assertEquals("failed 2", 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 throwsOnEveryAttempt() throws Exception {
+        // the redelivery that throws is handled as since CAMEL-26430: the 
exchange fails with its exception
+        start("immediate", new AsyncProcessorSupport() {
+            @Override
+            public boolean process(Exchange exchange, AsyncCallback callback) {
+                throw new IllegalStateException("thrown " + 
calls.incrementAndGet());
+            }
+        });
+        Exchange out = send();
+        assertInstanceOf(IllegalStateException.class, out.getException());
+        assertEquals("thrown 2", 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 errorThrownOnEveryAttempt() throws Exception {
+        // an Error, not an Exception, thrown on the first delivery and on the 
redelivery: before, the reactive executor
+        // let it through (it catches only exceptions), and the exchange never 
completed
+        start("immediate", new AsyncProcessorSupport() {
+            @Override
+            public boolean process(Exchange exchange, AsyncCallback callback) {
+                throw new NoClassDefFoundError("thrown " + 
calls.incrementAndGet());
+            }
+        });
+        Exchange out = send();
+        assertInstanceOf(NoClassDefFoundError.class, 
out.getException().getCause());
+        assertEquals("thrown 2", out.getException().getCause().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 deadLetterChannelGetsTheFailureOfTheRedelivery() throws Exception {
+        MockEndpoint dead = getMockEndpoint("mock:dead");
+        dead.expectedMessageCount(1);
+        start("deadLetterChannelRedelivery", new ThrowsThenFails());
+        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);
+        assertEquals("failed 2", caught.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 notRedeliveredWithAnOnExceptionWithoutRedeliveries() throws Exception 
{
+        // the way to not redeliver such a failure that the upgrade guide 
gives: the dead letter channel still gets it
+        MockEndpoint dead = getMockEndpoint("mock:dead");
+        dead.expectedMessageCount(1);
+        start("deadLetterChannelRedeliveryOnExceptionWithout", new 
ThrowsFirst());
+        assertDeadLetterChannelGotTheThrownFailure(send(), dead);
+        assertEquals(1, calls.get(), "calls");
+    }
+
+    @Test
+    void deadLetterChannelGetsTheThrownFailure() throws Exception {
+        MockEndpoint dead = getMockEndpoint("mock:dead");
+        dead.expectedMessageCount(1);
+        start("deadLetterChannel", new ThrowsFirst());
+        assertDeadLetterChannelGotTheThrownFailure(send(), dead);
+        assertEquals(1, calls.get(), "calls");
+    }
+
+    @Test
+    void onExceptionOfTheThrownFailureHandlesIt() throws Exception {
+        start("onException", new ThrowsFirst());
+        Exchange out = send();
+        assertNull(out.getException(), "the onException handled the thrown 
failure");
+        assertEquals(1, getMockEndpoint("mock:handled").getReceivedCounter(), 
"mock:handled");
+        assertEquals(0, getMockEndpoint("mock:result").getReceivedCounter(), 
"mock:result");
+        assertEquals(1, calls.get(), "calls");
+    }
+
+    @Test
+    void nextExchangeIsProcessed() throws Exception {
+        MockEndpoint dead = getMockEndpoint("mock:dead");
+        dead.expectedMessageCount(1);
+        start("deadLetterChannel", new ThrowsFirst());
+        assertDeadLetterChannelGotTheThrownFailure(send(), dead);
+
+        Exchange out = send();
+        assertNull(out.getException());
+        assertEquals(1, getMockEndpoint("mock:result").getReceivedCounter(), 
"mock:result");
+        assertEquals(2, calls.get(), "calls");
+    }
+
+    @Test
+    void controlFailureSetOnTheExchange() throws Exception {
+        // control, as before: a processor that sets the exception on the 
exchange and calls the callback, as an
+        // AsyncProcessor should, goes to the dead letter channel
+        MockEndpoint dead = getMockEndpoint("mock:dead");
+        dead.expectedMessageCount(1);
+        start("deadLetterChannel", new AsyncProcessorSupport() {
+            @Override
+            public boolean process(Exchange exchange, AsyncCallback callback) {
+                calls.incrementAndGet();
+                exchange.setException(new IllegalStateException("thrown"));
+                callback.done(true);
+                return true;
+            }
+        });
+        assertDeadLetterChannelGotTheThrownFailure(send(), dead);
+        assertEquals(1, calls.get(), "calls");
+    }
+
+    @Test
+    void controlPlainProcessorThrows() throws Exception {
+        // control, as before: the converter of a plain Processor to an 
AsyncProcessor catches the exception and sets it
+        // on the exchange, so the dead letter channel gets it
+        MockEndpoint dead = getMockEndpoint("mock:dead");
+        dead.expectedMessageCount(1);
+        start("deadLetterChannel", exchange -> {
+            calls.incrementAndGet();
+            throw new IllegalStateException("thrown");
+        });
+        assertDeadLetterChannelGotTheThrownFailure(send(), dead);
+        assertEquals(1, calls.get(), "calls");
+    }
+
+    @Test
+    void callbackOfARouteBeforeTheProcessorThrowsContinuesTheExchange() throws 
Exception {
+        // the processor hands the exchange and the callback to a direct route 
(with the default error handler) and then
+        // throws. Each step of that route runs on the reactive executor ahead 
of the takeover queued there and the route
+        // calls the callback through it, so it continues the exchange: the 
exchange completes with the reply of that
+        // route, once. (A route with noErrorHandler() would lose to the 
takeover, and its callback be ignored.)
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                
from("direct:inner").setBody(constant("inner")).setHeader("inner", 
constant("yes"));
+            }
+        });
+        AtomicReference<AsyncProducer> producer = new AtomicReference<>();
+        start("deadLetterChannel", new AsyncProcessorSupport() {
+            @Override
+            public boolean process(Exchange exchange, AsyncCallback callback) {
+                calls.incrementAndGet();
+                producer.get().process(exchange, callback);
+                throw new IllegalStateException("after the callback was handed 
to a route");
+            }
+        });
+        AsyncProducer direct = 
context.getEndpoint("direct:inner").createAsyncProducer();
+        direct.start();
+        producer.set(direct);
+        AtomicInteger done = countDoneExchanges();
+        try {
+            Exchange out = sendOnThisThread();
+            assertNull(out.getException());
+            assertEquals("inner", out.getMessage().getBody());
+            assertEquals("yes", out.getMessage().getHeader("inner"));
+            assertEquals(1, 
getMockEndpoint("mock:result").getReceivedCounter(), "mock:result");
+            assertEquals(0, getMockEndpoint("mock:dead").getReceivedCounter(), 
"mock:dead");
+            assertEquals(1, done.get(), "the exchange was done once");
+            assertEquals(1, calls.get(), "calls");
+        } finally {
+            direct.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, 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);
+        AtomicInteger done = countDoneExchanges();
+        start("deadLetterChannel", new AsyncProcessorSupport() {
+            @Override
+            public boolean process(Exchange exchange, AsyncCallback callback) {
+                calls.incrementAndGet();
+                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("thrown");
+            }
+        });
+
+        assertDeadLetterChannelGotTheThrownFailure(send(), dead);
+
+        // 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(1, calls.get(), "calls");
+    }
+
+    @Test
+    void callbackAndThrowAtTheSameTimeContinueEachExchangeOnce() throws 
Exception {
+        // the processor hands the callback to another thread, which calls it 
at once, and throws: the callback and the
+        // takeover race, and whichever wins, each exchange is continued once 
(to mock:result, or with the thrown
+        // failure to the dead letter channel)
+        int count = 50;
+        ExecutorService callbacks = Executors.newFixedThreadPool(2);
+        try {
+            start("deadLetterChannel", new AsyncProcessorSupport() {
+                @Override
+                public boolean process(Exchange exchange, AsyncCallback 
callback) {
+                    calls.incrementAndGet();
+                    callbacks.execute(() -> callback.done(false));
+                    throw new IllegalStateException("thrown");
+                }
+            });
+            AtomicInteger done = countDoneExchanges();
+
+            List<CompletableFuture<Exchange>> sends = new ArrayList<>();
+            for (int i = 0; i < count; i++) {
+                sends.add(asyncSend("direct:start", false));
+            }
+            for (CompletableFuture<Exchange> send : sends) {
+                assertNull(send.get(20, TimeUnit.SECONDS).getException());
+            }
+            await().atMost(10, TimeUnit.SECONDS).until(() -> done.get() >= 
count);
+            // the callbacks that lost have run too
+            callbacks.shutdown();
+            assertTrue(callbacks.awaitTermination(10, TimeUnit.SECONDS), "the 
callbacks ran");
+
+            assertEquals(count, done.get(), "each exchange was done once");
+            assertEquals(count, 
getMockEndpoint("mock:result").getReceivedCounter()
+                                + 
getMockEndpoint("mock:dead").getReceivedCounter(),
+                    "mock:result + mock:dead");
+            assertEquals(count, calls.get(), "calls");
+        } finally {
+            callbacks.shutdownNow();
+        }
+    }
+}
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/RedeliveryFirstDeliveryProcessorThrowsTestSupport.java
 
b/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/RedeliveryFirstDeliveryProcessorThrowsTestSupport.java
new file mode 100644
index 000000000000..61fef9f2f378
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/RedeliveryFirstDeliveryProcessorThrowsTestSupport.java
@@ -0,0 +1,78 @@
+/*
+ * 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.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.camel.AsyncCallback;
+import org.apache.camel.Exchange;
+import org.apache.camel.Processor;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.support.AsyncProcessorSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * Shared by {@link RedeliveryFirstDeliveryProcessorThrowsTest} and
+ * {@link RedeliveryFirstDeliveryProcessorThrowsPooledTest}: the tests that 
depend on whether the redelivery tasks are
+ * pooled. The route has an onException, so its error handler runs the step 
with its redelivery task, and the tests
+ * inherited from {@link SimpleTaskProcessorThrowsTestSupport} (the late 
callback with the next exchange, the double
+ * callback) run on the first delivery of that task.
+ */
+abstract class RedeliveryFirstDeliveryProcessorThrowsTestSupport extends 
SimpleTaskProcessorThrowsTestSupport {
+
+    @Override
+    protected void start(Processor processor) throws Exception {
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                // not handled and no redelivery: the exchange fails with the 
thrown exception, as with the simple task
+                
onException(IllegalStateException.class).maximumRedeliveries(0);
+                from("direct:start").process(processor).to("mock:result");
+            }
+        });
+        context.start();
+    }
+
+    @Test
+    void callbackCalledBeforeTheProcessorThrowsContinuesTheExchangeOnce() 
throws Exception {
+        // the processor calls the callback and then throws: the callback 
continued the exchange, so the exception is
+        // only logged, and the exchange completes once (before, the catch of 
the task failed the exchange and completed
+        // it a second time)
+        MockEndpoint result = getMockEndpoint("mock:result");
+        result.expectedMessageCount(1);
+        start(new AsyncProcessorSupport() {
+            @Override
+            public boolean process(Exchange exchange, AsyncCallback callback) {
+                calls.incrementAndGet();
+                callback.done(true);
+                throw new IllegalStateException("after the callback");
+            }
+        });
+        AtomicInteger done = countDoneExchanges();
+
+        Exchange out = sendOnThisThread();
+        assertNull(out.getException());
+        result.assertIsSatisfied();
+        assertEquals(1, result.getReceivedCounter(), "mock:result");
+        assertEquals(1, done.get(), "the exchange was done once");
+        assertEquals(1, calls.get(), "calls");
+    }
+}
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 5d88bf55901e..ecaafe8c6090 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
@@ -206,6 +206,25 @@ slip) did not send its message. A route called from such a 
`doFinally` that fail
 own error handler, as anywhere else; before, its error handler left the 
failure alone and the exchange failed with that
 exception. As before, the route does not continue after the `doTry` once a 
called route handled the failure.
 
+=== camel-core - an AsyncProcessor that throws on the first attempt goes 
through the error handler
+
+When the error handler of a step can redeliver, has an `onException` or an 
`onPrepareFailure` processor, or is a dead
+letter channel, and the processor of the step (such as a custom 
`AsyncProcessor` used with `process(...)`; an endpoint
+in `to(...)` is not affected) throws from `process(exchange, callback)` on the 
first attempt instead of setting the
+exception on the exchange, the error handler now handles that failure as any 
other failure of the step: it redelivers
+it, and when the redeliveries are exhausted, the matching `onException` or the 
dead letter channel handles it. This is
+what it already did when the processor set the same exception on the exchange, 
or when a plain `Processor` threw it.
+Before, the exchange failed at once with the thrown exception. When such a 
processor calls its callback and then
+throws, the exception is now only logged at WARN; when it handed its callback 
to another thread before it threw, and
+that thread calls it after the error handler took over the failure, or when it 
calls its callback twice, that call is
+now ignored and logged at WARN. Before, the exchange was continued a second 
time in these cases.
+
+To not redeliver such a failure, add an `onException` for its exception type 
with `maximumRedeliveries(0)`; as for the
+same failure set on the exchange, the dead letter channel and the 
`onException` still handle it. There is no option to
+let such a failure skip the dead letter channel or `onException` as before; a 
plain `Processor` that throws gets the
+same handling. Better, fix the processor to set the exception on the exchange 
and call the callback, as an
+`AsyncProcessor` must.
+
 === camel-core - ThrottlingExceptionRoutePolicy counts the failures within the 
failure window
 
 The circuit of `ThrottlingExceptionRoutePolicy` now opens when 
`failureThreshold` failures occurred within the last

Reply via email to