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 0c158cbdb6f7 CAMEL-26461: camel-core - NoErrorHandler: an
AsyncProcessor that throws fails the exchange instead of hanging (#29041)
0c158cbdb6f7 is described below
commit 0c158cbdb6f77e826782695164ba468bd8f753c5
Author: allthingssecurity <[email protected]>
AuthorDate: Tue Nov 3 16:08:54 2026 +0530
CAMEL-26461: camel-core - NoErrorHandler: an AsyncProcessor that throws
fails the exchange instead of hanging (#29041)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../processor/errorhandler/NoErrorHandler.java | 80 +++-
.../NoErrorHandlerProcessorThrowsTest.java | 418 +++++++++++++++++++++
2 files changed, 493 insertions(+), 5 deletions(-)
diff --git
a/core/camel-core-processor/src/main/java/org/apache/camel/processor/errorhandler/NoErrorHandler.java
b/core/camel-core-processor/src/main/java/org/apache/camel/processor/errorhandler/NoErrorHandler.java
index 318615028678..fae0dfb60b61 100644
---
a/core/camel-core-processor/src/main/java/org/apache/camel/processor/errorhandler/NoErrorHandler.java
+++
b/core/camel-core-processor/src/main/java/org/apache/camel/processor/errorhandler/NoErrorHandler.java
@@ -17,6 +17,7 @@
package org.apache.camel.processor.errorhandler;
import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicIntegerFieldUpdater;
import org.apache.camel.AsyncCallback;
import org.apache.camel.AsyncProcessor;
@@ -27,9 +28,13 @@ import
org.apache.camel.support.AsyncCallbackToCompletableFutureAdapter;
import org.apache.camel.support.AsyncProcessorConverterHelper;
import org.apache.camel.support.AsyncProcessorHelper;
import org.apache.camel.support.service.ServiceHelper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
public class NoErrorHandler extends ErrorHandlerSupport implements
AsyncProcessor {
+ private static final Logger LOG =
LoggerFactory.getLogger(NoErrorHandler.class);
+
private final AsyncProcessor output;
public NoErrorHandler(Processor processor) {
@@ -43,13 +48,78 @@ public class NoErrorHandler extends ErrorHandlerSupport
implements AsyncProcesso
@Override
public boolean process(Exchange exchange, final AsyncCallback callback) {
- return output.process(exchange, new AsyncCallback() {
- @Override
- public void done(boolean doneSync) {
+ final Continuation continuation = new Continuation(output, exchange,
callback);
+ try {
+ return output.process(exchange, continuation);
+ } 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 (continuation.continued != 0) {
+ // the callback was called before the exception was thrown,
and it continued the exchange: leave the
+ // exception to the caller, as before (a precise rethrow: only
unchecked exceptions and errors reach here)
+ throw e;
+ }
+ // the processor threw before it called the callback, such as an
AsyncProcessor that throws from
+ // process(exchange, callback) instead of setting the exception on
the exchange: the exception would be
+ // dropped by the reactive executor and the exchange would never
complete. Fail the exchange through the
+ // reactive executor, after the work already queued there, so a
callback that the processor handed on and
+ // that this queued work calls still comes first and continues the
exchange
+ final String exchangeId = exchange.getExchangeId();
+
exchange.getContext().getCamelContextExtension().getReactiveExecutor()
+ .schedule(() -> continuation.takeOver(exchangeId, e));
+ return false;
+ }
+ }
+
+ /**
+ * The callback given to the processor. It and the takeover of a processor
that throws before it calls the callback
+ * compete for continuing the exchange (compareAndSet), so the exchange is
continued exactly once.
+ */
+ private static final class Continuation implements AsyncCallback {
+ private static final AtomicIntegerFieldUpdater<Continuation> CONTINUED
+ = AtomicIntegerFieldUpdater.newUpdater(Continuation.class,
"continued");
+
+ private final AsyncProcessor output;
+ private final Exchange exchange;
+ private final AsyncCallback callback;
+ // 1 once the exchange is continued (a field, not an object, as this
callback is created for every step)
+ private volatile int continued;
+
+ Continuation(AsyncProcessor output, Exchange exchange, AsyncCallback
callback) {
+ this.output = output;
+ this.exchange = exchange;
+ this.callback = callback;
+ }
+
+ @Override
+ public void done(boolean doneSync) {
+ if (!CONTINUED.compareAndSet(this, 0, 1)) {
+ // this log does not read the exchange id: it is created on
first use, and the exchange may be in use
+ // on another thread
+ LOG.warn("The callback of {} was called when it was no longer
awaited (after the processor had thrown an"
+ + " exception, which failed the exchange instead, or
more than once; an AsyncProcessor must not"
+ + " throw from process). This callback is ignored.",
+ output);
+ return;
+ }
+ exchange.getExchangeExtension().setRedeliveryExhausted(false);
+ callback.done(doneSync);
+ }
+
+ void takeOver(String exchangeId, Throwable cause) {
+ if (CONTINUED.compareAndSet(this, 0, 1)) {
+ // fail the exchange, as when the processor sets the exception
on the exchange
+ exchange.setException(cause);
exchange.getExchangeExtension().setRedeliveryExhausted(false);
- callback.done(doneSync);
+ callback.done(false);
+ } else {
+ // the queued callback continued the exchange (which may
already be done), so this exception is only
+ // logged
+ LOG.warn("Error processing exchangeId: {} by {} due to {}. The
exchange continued, so this exception is"
+ + " ignored.",
+ exchangeId, output, cause.getMessage(), cause);
}
- });
+ }
}
@Override
diff --git
a/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/NoErrorHandlerProcessorThrowsTest.java
b/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/NoErrorHandlerProcessorThrowsTest.java
new file mode 100644
index 000000000000..7ed348983700
--- /dev/null
+++
b/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/NoErrorHandlerProcessorThrowsTest.java
@@ -0,0 +1,418 @@
+/*
+ * 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.Map;
+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.CamelExchangeException;
+import org.apache.camel.Consumer;
+import org.apache.camel.Endpoint;
+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.component.mock.MockEndpoint;
+import org.apache.camel.support.AsyncProcessorSupport;
+import org.apache.camel.support.DefaultAsyncProducer;
+import org.apache.camel.support.DefaultComponent;
+import org.apache.camel.support.DefaultEndpoint;
+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)}, instead of setting the exception on
+ * the exchange, in a route with {@code noErrorHandler()}: the exchange must
complete with the thrown exception instead
+ * of hanging, and be continued exactly once. Each send is bounded, so a hang
fails the test. The helpers, and the tests
+ * of a late callback with the next exchange and of a double callback, are
those of
+ * {@link SimpleTaskProcessorThrowsTestSupport}, with the route of {@link
#start(Processor)}.
+ */
+class NoErrorHandlerProcessorThrowsTest extends
SimpleTaskProcessorThrowsTestSupport {
+
+ /**
+ * 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;
+ }
+ }
+
+ @Override
+ protected void start(Processor processor) throws Exception {
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+
from("direct:start").errorHandler(noErrorHandler()).process(processor).to("mock:result");
+ }
+ });
+ context.start();
+ }
+
+ @Test
+ void noErrorHandler() throws Exception {
+ start(new ThrowsFirst());
+ assertFailedWithTheThrownException(send());
+ }
+
+ @Test
+ void processorThrowsError() throws Exception {
+ // an Error, not an Exception: before, the reactive executor let it
through (it catches only exceptions), and the
+ // exchange never completed
+ start(new AsyncProcessorSupport() {
+ @Override
+ public boolean process(Exchange exchange, AsyncCallback callback) {
+ calls.incrementAndGet();
+ throw new NoClassDefFoundError("thrown");
+ }
+ });
+ Exchange out = send();
+ assertInstanceOf(NoClassDefFoundError.class,
out.getException().getCause());
+ assertEquals("thrown", out.getException().getCause().getMessage());
+ assertEquals(1, calls.get(), "calls");
+ assertEquals(0, getMockEndpoint("mock:result").getReceivedCounter(),
"mock:result");
+ }
+
+ @Test
+ void afterAnAsynchronousStep() throws Exception {
+ // the step runs on the thread of the asynchronous delay, not on the
caller's
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+
from("direct:start").errorHandler(noErrorHandler()).delay(10).asyncDelayed().process(new
ThrowsFirst())
+ .to("mock:result");
+ }
+ });
+ context.start();
+ assertFailedWithTheThrownException(send());
+ }
+
+ @Test
+ void controlTransacted() throws Exception {
+ // control: a transacted exchange runs on the reactive executor's
queue, and the exchange fails with the thrown
+ // exception on main too
+ start(new ThrowsFirst());
+ assertFailedWithTheThrownException(send(true));
+ }
+
+ @Test
+ void nextExchangeIsProcessed() throws Exception {
+ start(new ThrowsFirst());
+ assertFailedWithTheThrownException(send());
+
+ Exchange out = send();
+ assertNull(out.getException());
+ assertEquals(1, getMockEndpoint("mock:result").getReceivedCounter(),
"mock:result");
+ assertEquals(2, 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
+ start(exchange -> {
+ calls.incrementAndGet();
+ throw new IllegalStateException("thrown");
+ });
+ assertFailedWithTheThrownException(send());
+ }
+
+ @Test
+ void controlCallbackCalledBeforeTheProcessorThrows() throws Exception {
+ // control, as before: 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), and
the exchange completes once
+ 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");
+ }
+
+ @Test
+ void controlTransactedCallbackCalledBeforeTheProcessorThrows() throws
Exception {
+ // control, as before: for a transacted exchange the exception thrown
after the callback reaches the caller, which
+ // fails the exchange (so a transaction rolls back)
+ start(new AsyncProcessorSupport() {
+ @Override
+ public boolean process(Exchange exchange, AsyncCallback callback) {
+ calls.incrementAndGet();
+ callback.done(true);
+ throw new IllegalStateException("after the callback");
+ }
+ });
+
+ Exchange out = send(true);
+ assertInstanceOf(IllegalStateException.class, out.getException());
+ assertEquals("after the callback", out.getException().getMessage());
+ assertEquals(1, calls.get(), "calls");
+ }
+
+ @Test
+ void controlCallbackQueuedBeforeTheProcessorThrows() throws Exception {
+ // control, as before: the processor queues the call of the callback
on the reactive executor and then throws.
+ // The thrown failure is taken over through the reactive executor,
after the work already queued there, so the
+ // queued callback comes first and continues the exchange
+ start(new AsyncProcessorSupport() {
+ @Override
+ public boolean process(Exchange exchange, AsyncCallback callback) {
+ calls.incrementAndGet();
+
exchange.getContext().getCamelContextExtension().getReactiveExecutor().schedule(()
-> {
+ exchange.getMessage().setBody("queued");
+ callback.done(false);
+ });
+ throw new IllegalStateException("after the callback was
queued");
+ }
+ });
+ AtomicInteger done = countDoneExchanges();
+
+ Exchange out = sendOnThisThread();
+ assertNull(out.getException());
+ assertEquals("queued", out.getMessage().getBody());
+ assertEquals(1, getMockEndpoint("mock:result").getReceivedCounter(),
"mock:result");
+ assertEquals(1, done.get(), "the exchange was done once");
+ assertEquals(1, calls.get(), "calls");
+ }
+
+ @Test
+ void controlCallbackOfARouteBeforeTheProcessorThrows() throws Exception {
+ // control, as before: 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 ahead
of the takeover queued on the reactive
+ // executor, and the route calls the callback through it, so the route
continues the exchange: the exchange
+ // completes with the reply of that route, once. (A called route with
noErrorHandler() loses to the takeover,
+ // even with one step: its steps do not go through the reactive
executor ahead of the takeover; the exchange
+ // then fails with the thrown exception and the reply of that route is
ignored with a WARN.)
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+
from("direct:inner").setBody(constant("inner")).setHeader("inner",
constant("yes"));
+ }
+ });
+ AtomicReference<AsyncProducer> producer = new AtomicReference<>();
+ start(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(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.
NoErrorHandler cannot tell this from a
+ // callback that never comes, so it fails the exchange with the thrown
exception (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<>();
+ AtomicInteger done = countDoneExchanges();
+ start(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");
+ }
+ });
+
+ assertFailedWithTheThrownException(send());
+
+ // 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(0, getMockEndpoint("mock:result").getReceivedCounter(),
"mock:result");
+ }
+
+ @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
(as a success or with the thrown failure)
+ int count = 50;
+ ExecutorService callbacks = Executors.newFixedThreadPool(2);
+ try {
+ start(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));
+ }
+ int succeeded = 0;
+ for (CompletableFuture<Exchange> send : sends) {
+ Exchange out = send.get(20, TimeUnit.SECONDS);
+ if (out.getException() == null) {
+ succeeded++;
+ } else {
+ assertEquals("thrown", out.getException().getMessage());
+ }
+ }
+ 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(succeeded,
getMockEndpoint("mock:result").getReceivedCounter(), "mock:result");
+ assertEquals(count, calls.get(), "calls");
+ } finally {
+ callbacks.shutdownNow();
+ }
+ }
+
+ /**
+ * An endpoint whose producer is an AsyncProcessor that throws from
process(exchange, callback).
+ */
+ private void addThrowingEndpoint() {
+ context.addComponent("throwing", new DefaultComponent() {
+ @Override
+ protected Endpoint createEndpoint(String uri, String remaining,
Map<String, Object> parameters) {
+ return new DefaultEndpoint(uri, this) {
+ @Override
+ public Producer createProducer() {
+ return new DefaultAsyncProducer(this) {
+ @Override
+ public boolean process(Exchange exchange,
AsyncCallback callback) {
+ calls.incrementAndGet();
+ throw new IllegalStateException("thrown");
+ }
+ };
+ }
+
+ @Override
+ public Consumer createConsumer(Processor processor) {
+ throw new UnsupportedOperationException();
+ }
+ };
+ }
+ });
+ }
+
+ @Test
+ void recipientListWithARecipientThatThrows() throws Exception {
+ // a recipient list wraps each producer in a clone of the error
handler of the route: the recipient that throws
+ // fails, and without stopOnException the next recipient still gets
the message
+ addThrowingEndpoint();
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+
from("direct:start").errorHandler(noErrorHandler()).recipientList(constant("throwing:a,mock:b")).end()
+ .to("mock:result");
+ }
+ });
+ context.start();
+
+ Exchange out = send();
+ assertEquals(1, calls.get(), "calls");
+ assertEquals(1, getMockEndpoint("mock:b").getReceivedCounter(),
"mock:b");
+ // the failure of the recipient fails the exchange at the end of the
recipient list
+ assertInstanceOf(IllegalStateException.class, out.getException());
+ assertEquals("thrown", out.getException().getMessage());
+ assertEquals(0, getMockEndpoint("mock:result").getReceivedCounter(),
"mock:result");
+ }
+
+ @Test
+ void recipientListStopOnExceptionWithARecipientThatThrows() throws
Exception {
+ addThrowingEndpoint();
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+
from("direct:start").errorHandler(noErrorHandler()).recipientList(constant("throwing:a,mock:b"))
+ .stopOnException().end()
+ .to("mock:result");
+ }
+ });
+ context.start();
+
+ Exchange out = send();
+ assertInstanceOf(CamelExchangeException.class, out.getException());
+ assertInstanceOf(IllegalStateException.class,
out.getException().getCause());
+ assertEquals(1, calls.get(), "calls");
+ assertEquals(0, getMockEndpoint("mock:b").getReceivedCounter(),
"mock:b");
+ assertEquals(0, getMockEndpoint("mock:result").getReceivedCounter(),
"mock:result");
+ }
+}