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 a909f0224a98 CAMEL-26741: camel-core - an Error thrown by a plain 
Processor fails the exchange (#29525)
a909f0224a98 is described below

commit a909f0224a98023fd48c47bd97aa3e43ba5f85b0
Author: allthingssecurity <[email protected]>
AuthorDate: Wed Nov 11 12:20:27 2026 +0530

    CAMEL-26741: camel-core - an Error thrown by a plain Processor fails the 
exchange (#29525)
    
    * CAMEL-26741: camel-core - an Error thrown by a plain Processor fails the 
exchange
    * CAMEL-26741: upgrade guide - a test that asserts inside a processor and 
sends with template.send must check the returned exchange
    * CAMEL-26741: camel-core - leave a VirtualMachineError as before, and 
document which Errors fail the exchange
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../org/apache/camel/catalog/docs/sanitizer.adoc   |  12 +-
 .../component/bean/AbstractBeanProcessor.java      |   7 +-
 .../camel-sanitizer/src/main/docs/sanitizer.adoc   |  12 +-
 .../java/org/apache/camel/sanitizer/Sanitizer.java |  52 +---
 .../camel/sanitizer/SanitizerStepListener.java     |  13 -
 .../camel/processor/ProcessorThrowsErrorTest.java  | 327 +++++++++++++++++++++
 .../org/apache/camel/sanitizer/BrokenUserCode.java |   2 +-
 .../camel/sanitizer/SanitizerUserCodeTest.java     |  71 ++---
 .../support/AsyncProcessorConverterHelper.java     |   8 +-
 .../support/processor/DelegateSyncProcessor.java   |   9 +-
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  43 +++
 .../modules/ROOT/pages/error-handler.adoc          |  33 +++
 12 files changed, 463 insertions(+), 126 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/sanitizer.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/sanitizer.adoc
index 591e85cc05cd..555cac11156c 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/sanitizer.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/sanitizer.adoc
@@ -48,12 +48,12 @@ fails the exchange with what was thrown, and the callback 
and that failure race
 
 An exception that a processor throws on through an outer step is reported 
once, at the step that threw it first.
 
-An `Error` (such as an `AssertionError` of a test) that a plain `Processor` 
throws breaks the rule in Camel, not in the
-processor: the adapter Camel puts around a plain `Processor` 
(`DelegateSyncProcessor` for `process(...)`, the bridge of
-`AsyncProcessorConverterHelper`, and the bean component for a bean that is a 
`Processor`) calls the callback in
-`finally` and then lets the `Error` through. It is reported as 
`THREW_AFTER_CALLBACK` at that step, naming the
-processor, and the report says the callback was called by Camel's adapter and 
that the fix is in what threw the
-`Error`. Without stacks (`stackDepth=0`) the adapter cannot be seen, and the 
report is the one of an `AsyncProcessor`.
+A plain `Processor` does not break the rule when it throws: the adapter Camel 
puts around it (`DelegateSyncProcessor`
+for `process(...)`, the bridge of `AsyncProcessorConverterHelper`, and the 
bean component for a bean that is a
+`Processor`) sets what it throws on the exchange, also an `Error` such as an 
`AssertionError` of a test, and then calls
+the callback. The exchange fails, and nothing is reported. A 
`VirtualMachineError` (such as an `OutOfMemoryError` or
+a `StackOverflowError`) is the exception: the adapter does not catch it, so it 
is thrown on after the callback, as in
+earlier versions, and is reported as thrown after the callback 
(`THREW_AFTER_CALLBACK`).
 
 A report looks like this:
 
diff --git 
a/components/camel-bean/src/main/java/org/apache/camel/component/bean/AbstractBeanProcessor.java
 
b/components/camel-bean/src/main/java/org/apache/camel/component/bean/AbstractBeanProcessor.java
index 5b909e7a495f..4ab28bef031c 100644
--- 
a/components/camel-bean/src/main/java/org/apache/camel/component/bean/AbstractBeanProcessor.java
+++ 
b/components/camel-bean/src/main/java/org/apache/camel/component/bean/AbstractBeanProcessor.java
@@ -158,7 +158,12 @@ public abstract class AbstractBeanProcessor extends 
AsyncProcessorSupport {
         }
         try {
             target.process(exchange);
-        } catch (AssertionError | Exception e) {
+        } catch (VirtualMachineError e) {
+            // left as before (thrown on after the callback): the application 
must not handle a JVM-level failure such
+            // as an OutOfMemoryError with Camel's error handling
+            throw e;
+        } catch (Throwable e) {
+            // any other Throwable, as in DelegateSyncProcessor: the callback 
is called in finally
             exchange.setException(e);
         } finally {
             callback.done(true);
diff --git a/components/camel-sanitizer/src/main/docs/sanitizer.adoc 
b/components/camel-sanitizer/src/main/docs/sanitizer.adoc
index 591e85cc05cd..555cac11156c 100644
--- a/components/camel-sanitizer/src/main/docs/sanitizer.adoc
+++ b/components/camel-sanitizer/src/main/docs/sanitizer.adoc
@@ -48,12 +48,12 @@ fails the exchange with what was thrown, and the callback 
and that failure race
 
 An exception that a processor throws on through an outer step is reported 
once, at the step that threw it first.
 
-An `Error` (such as an `AssertionError` of a test) that a plain `Processor` 
throws breaks the rule in Camel, not in the
-processor: the adapter Camel puts around a plain `Processor` 
(`DelegateSyncProcessor` for `process(...)`, the bridge of
-`AsyncProcessorConverterHelper`, and the bean component for a bean that is a 
`Processor`) calls the callback in
-`finally` and then lets the `Error` through. It is reported as 
`THREW_AFTER_CALLBACK` at that step, naming the
-processor, and the report says the callback was called by Camel's adapter and 
that the fix is in what threw the
-`Error`. Without stacks (`stackDepth=0`) the adapter cannot be seen, and the 
report is the one of an `AsyncProcessor`.
+A plain `Processor` does not break the rule when it throws: the adapter Camel 
puts around it (`DelegateSyncProcessor`
+for `process(...)`, the bridge of `AsyncProcessorConverterHelper`, and the 
bean component for a bean that is a
+`Processor`) sets what it throws on the exchange, also an `Error` such as an 
`AssertionError` of a test, and then calls
+the callback. The exchange fails, and nothing is reported. A 
`VirtualMachineError` (such as an `OutOfMemoryError` or
+a `StackOverflowError`) is the exception: the adapter does not catch it, so it 
is thrown on after the callback, as in
+earlier versions, and is reported as thrown after the callback 
(`THREW_AFTER_CALLBACK`).
 
 A report looks like this:
 
diff --git 
a/components/camel-sanitizer/src/main/java/org/apache/camel/sanitizer/Sanitizer.java
 
b/components/camel-sanitizer/src/main/java/org/apache/camel/sanitizer/Sanitizer.java
index b1048e3a5e75..35a3e9a1ad41 100644
--- 
a/components/camel-sanitizer/src/main/java/org/apache/camel/sanitizer/Sanitizer.java
+++ 
b/components/camel-sanitizer/src/main/java/org/apache/camel/sanitizer/Sanitizer.java
@@ -23,7 +23,6 @@ import java.util.EnumMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ConcurrentLinkedQueue;
 import java.util.concurrent.ScheduledExecutorService;
@@ -56,7 +55,6 @@ import org.apache.camel.model.SplitDefinition;
 import org.apache.camel.model.ToDefinition;
 import org.apache.camel.model.ToDynamicDefinition;
 import org.apache.camel.spi.IdAware;
-import org.apache.camel.support.AsyncProcessorConverterHelper;
 import org.apache.camel.support.processor.DelegateAsyncProcessor;
 import org.apache.camel.support.processor.DelegateSyncProcessor;
 import org.apache.camel.util.URISupport;
@@ -248,20 +246,12 @@ public final class Sanitizer {
     }
 
     /**
-     * Reports a violation: counts it, logs it at WARN and charges it to the 
test session.
+     * Reports a violation: counts it, logs it at WARN and charges it to the 
test session. The first 1000 are kept for
+     * the report and logged; the others are counted and still charged to the 
test session, so a test that breaks the
+     * rule fails also when a long-lived context (such as a cached test 
context) passed the bound before it.
      */
     void record(
             Violation violation, String exchangeId, String routeId, String 
stepId, String details) {
-        record(violation, exchangeId, routeId, stepId, details, 
violation.getFix());
-    }
-
-    /**
-     * Reports a violation with its own usual fix. The first 1000 are kept for 
the report and logged; the others are
-     * counted and still charged to the test session, so a test that breaks 
the rule fails also when a long-lived
-     * context (such as a cached test context) passed the bound before it.
-     */
-    void record(
-            Violation violation, String exchangeId, String routeId, String 
stepId, String details, String fix) {
         counts.get(violation).increment();
         boolean keep = kept.incrementAndGet() <= MAX_KEPT;
         if (!keep && overflowLogged.compareAndSet(false, true)) {
@@ -277,7 +267,7 @@ public final class Sanitizer {
             @Override
             public SanitizerViolation get() {
                 if (built == null) {
-                    built = violationOf(violation, exchangeId, routeId, 
stepId, details, fix);
+                    built = violationOf(violation, exchangeId, routeId, 
stepId, details);
                 }
                 return built;
             }
@@ -294,7 +284,7 @@ public final class Sanitizer {
     }
 
     private SanitizerViolation violationOf(
-            Violation violation, String exchangeId, String routeId, String 
stepId, String details, String fix) {
+            Violation violation, String exchangeId, String routeId, String 
stepId, String details) {
         String processor = processorOf(routeId, stepId);
         StringBuilder sb = new StringBuilder(512);
         sb.append("Exchange sanitizer: 
").append(violation.getTitle()).append(" (R1)\n");
@@ -302,7 +292,7 @@ public final class Sanitizer {
         sb.append(details);
         sb.append("  The callback of process(exchange, callback) must be 
called exactly once, and process must not throw"
                   + " (see AsyncProcessor).\n");
-        sb.append("  Usual fix: ").append(fix).append('.');
+        sb.append("  Usual fix: ").append(violation.getFix()).append('.');
         return new SanitizerViolation(
                 violation, camelContext.getName(), exchangeId, routeId, 
stepId, processor, sb.toString());
     }
@@ -815,17 +805,6 @@ public final class Sanitizer {
      */
     static final class Trace {
 
-        /**
-         * The methods of Camel that call a plain {@link Processor} and then 
its callback in {@code finally}, catching
-         * only exceptions: an {@link Error} of the processor goes through 
after the callback (by name, as the bridge is
-         * a private nested class, binary name with {@code $}, and the bean 
one is in camel-bean; a rename must be
-         * followed here, which SanitizerUserCodeTest checks).
-         */
-        static final Set<String> SYNC_ADAPTERS = Set.of(
-                DelegateSyncProcessor.class.getName() + ".process",
-                AsyncProcessorConverterHelper.class.getName() + 
"$ProcessorToAsyncProcessorBridge.process",
-                
"org.apache.camel.component.bean.AbstractBeanProcessor.useCustomAdapter");
-
         private final String thread;
         private final StackTraceElement[] frames;
 
@@ -834,25 +813,6 @@ public final class Sanitizer {
             this.frames = frames;
         }
 
-        /**
-         * Whether the callback was called by an adapter Camel puts around a 
plain {@link Processor}, and not by the
-         * code of the processor: the first frame that is not a callback (a 
{@code done} method or a lambda of one) is
-         * one of {@link #SYNC_ADAPTERS}. Unknown (false) without stacks 
({@code stackDepth=0}).
-         */
-        boolean calledBySyncAdapter() {
-            if (frames == null) {
-                return false;
-            }
-            for (StackTraceElement f : frames) {
-                String method = f.getMethodName();
-                if ("done".equals(method) || method.startsWith("lambda$")) {
-                    continue;
-                }
-                return SYNC_ADAPTERS.contains(f.getClassName() + "." + method);
-            }
-            return false;
-        }
-
         void appendTo(StringBuilder sb, String what) {
             sb.append("  ").append(what).append(": thread 
").append(thread).append('\n');
             if (frames != null) {
diff --git 
a/components/camel-sanitizer/src/main/java/org/apache/camel/sanitizer/SanitizerStepListener.java
 
b/components/camel-sanitizer/src/main/java/org/apache/camel/sanitizer/SanitizerStepListener.java
index 904cd39977f3..34a37ead8dc2 100644
--- 
a/components/camel-sanitizer/src/main/java/org/apache/camel/sanitizer/SanitizerStepListener.java
+++ 
b/components/camel-sanitizer/src/main/java/org/apache/camel/sanitizer/SanitizerStepListener.java
@@ -81,12 +81,6 @@ final class SanitizerStepListener implements StepListener {
      */
     static final class StepCall {
 
-        // the usual fix when a plain Processor threw an Error after Camel's 
adapter called the callback
-        static final String SYNC_ADAPTER_FIX
-                = "fix what threw the Error. The processor is a plain 
Processor, so the callback is not its own: Camel's"
-                  + " adapter around it (such as DelegateSyncProcessor for 
process(...)) calls the callback in finally and"
-                  + " then lets the Error through";
-
         private final Sanitizer sanitizer;
         private final String exchangeId;
         private final String routeId;
@@ -187,13 +181,6 @@ final class SanitizerStepListener implements StepListener {
             if (first == null) {
                 trace.appendTo(sb, "process threw " + description);
                 sanitizer.record(Violation.THREW_BEFORE_CALLBACK, exchangeId, 
routeId, nodeId, sb.toString());
-            } else if (cause instanceof Error && first.calledBySyncAdapter()) {
-                // a plain Processor threw an Error: Camel's adapter called 
the callback in finally and let the Error
-                // through, so the adapter breaks the rule, not the processor
-                first.appendTo(sb, "the callback was called by Camel's adapter 
for a plain Processor");
-                trace.appendTo(sb, "then the Error of the processor went 
through the adapter: " + description);
-                sanitizer.record(Violation.THREW_AFTER_CALLBACK, exchangeId, 
routeId, nodeId, sb.toString(),
-                        SYNC_ADAPTER_FIX);
             } else {
                 first.appendTo(sb, "the callback was called");
                 trace.appendTo(sb, "then process threw " + description);
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/processor/ProcessorThrowsErrorTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/processor/ProcessorThrowsErrorTest.java
new file mode 100644
index 000000000000..99848ed31fab
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/processor/ProcessorThrowsErrorTest.java
@@ -0,0 +1,327 @@
+/*
+ * 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.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.camel.AsyncProcessor;
+import org.apache.camel.CamelExecutionException;
+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.support.AsyncProcessorConverterHelper;
+import org.apache.camel.support.DefaultEndpoint;
+import org.apache.camel.support.DefaultProducer;
+import org.apache.camel.support.processor.DelegateSyncProcessor;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * A plain {@link Processor} may throw an {@link Error}, such as an {@link 
AssertionError} of a test. The adapters that
+ * call it as an {@link AsyncProcessor} must then fail the exchange with it 
and call the callback once, as for an
+ * exception, instead of calling the callback and throwing the error on. A 
{@link VirtualMachineError} (such as an
+ * {@link OutOfMemoryError} or a {@link StackOverflowError}) is left as 
before: the adapters do not set it on the
+ * exchange, so Camel's error handling (onException, doCatch, the dead letter 
channel, redelivery) does not get it.
+ */
+public class ProcessorThrowsErrorTest extends ContextTestSupport {
+
+    private static final ProcessingError ERROR = new ProcessingError();
+    // an OutOfMemoryError that reaches JUnit aborts the whole test run, so it 
is only thrown where assertThrows expects it
+    private static final OutOfMemoryError OOM = new OutOfMemoryError("test");
+    private static final StackOverflowError SOE = new 
StackOverflowError("test");
+
+    private final AtomicInteger calls = new AtomicInteger();
+
+    @Test
+    public void processorThatThrowsAnErrorFailsTheExchange() throws Exception {
+        getMockEndpoint("mock:result").expectedMessageCount(0);
+
+        Exchange out = template.send("direct:process", e -> 
e.getMessage().setBody("Hello"));
+
+        assertFailedWithTheError(out.getException());
+        assertMockEndpointsSatisfied();
+        assertEquals(0, context.getInflightRepository().size(), "The exchange 
is still inflight");
+    }
+
+    @Test
+    public void errorHandlingAppliesToAnErrorOfAProcessor() throws Exception {
+        getMockEndpoint("mock:handled").expectedBodiesReceived("Hello");
+        getMockEndpoint("mock:result").expectedMessageCount(0);
+
+        Exchange out = template.send("direct:handled", e -> 
e.getMessage().setBody("Hello"));
+
+        assertFailedWithTheError(out.getProperty(Exchange.EXCEPTION_CAUGHT, 
Exception.class));
+        assertMockEndpointsSatisfied();
+        assertEquals(0, context.getInflightRepository().size(), "The exchange 
is still inflight");
+    }
+
+    @Test
+    public void doCatchCatchesAnErrorOfAProcessor() throws Exception {
+        getMockEndpoint("mock:caught").expectedBodiesReceived("Hello");
+        getMockEndpoint("mock:result").expectedMessageCount(0);
+
+        Exchange out = template.send("direct:try", e -> 
e.getMessage().setBody("Hello"));
+
+        assertNull(out.getException());
+        assertMockEndpointsSatisfied();
+        assertEquals(0, context.getInflightRepository().size(), "The exchange 
is still inflight");
+    }
+
+    @Test
+    public void beanThatIsAProcessorAndThrowsAnErrorFailsTheExchange() throws 
Exception {
+        getMockEndpoint("mock:result").expectedMessageCount(0);
+
+        Exchange out = template.send("direct:bean", e -> 
e.getMessage().setBody("Hello"));
+
+        assertFailedWithTheError(out.getException());
+        assertMockEndpointsSatisfied();
+        assertEquals(0, context.getInflightRepository().size(), "The exchange 
is still inflight");
+    }
+
+    @Test
+    public void delegateSyncProcessorCallsTheCallbackOnceWithTheError() {
+        assertCallbackCalledOnceWithTheError(new DelegateSyncProcessor(new 
ThrowingProcessor()));
+    }
+
+    @Test
+    public void convertedProcessorCallsTheCallbackOnceWithTheError() {
+        
assertCallbackCalledOnceWithTheError(AsyncProcessorConverterHelper.convert(new 
ThrowingProcessor()));
+    }
+
+    @Test
+    public void stackOverflowErrorOfAProcessorIsNotGivenToOnException() throws 
Exception {
+        getMockEndpoint("mock:handled").expectedMessageCount(0);
+
+        Exchange out = template.send("direct:vm-process", e -> 
e.getMessage().setBody("Hello"));
+
+        // as before: the error handler logs the Error and continues the 
exchange, without onException
+        assertNull(out.getException());
+        assertEquals(1, calls.get());
+        assertMockEndpointsSatisfied();
+    }
+
+    @Test
+    public void outOfMemoryErrorOfAProcessorIsNotGivenToDoCatch() throws 
Exception {
+        // as before, the exchange stays inflight, so do not wait for it when 
the context stops
+        shortShutdownTimeout();
+        getMockEndpoint("mock:caught").expectedMessageCount(0);
+
+        OutOfMemoryError thrown = assertThrows(OutOfMemoryError.class,
+                () -> template.send("direct:vm-try", e -> 
e.getMessage().setBody("Hello")));
+
+        assertSame(OOM, thrown);
+        assertMockEndpointsSatisfied();
+    }
+
+    @Test
+    public void 
stackOverflowErrorOfAProcessorIsNotRedeliveredNorGivenToTheDeadLetterChannel() 
throws Exception {
+        getMockEndpoint("mock:dead").expectedMessageCount(0);
+
+        Exchange out = template.send("direct:vm-dlc", e -> 
e.getMessage().setBody("Hello"));
+
+        assertNull(out.getException());
+        assertEquals(1, calls.get(), "The exchange was redelivered");
+        assertMockEndpointsSatisfied();
+    }
+
+    @Test
+    public void 
stackOverflowErrorOfABeanThatIsAProcessorIsNotGivenToOnException() throws 
Exception {
+        getMockEndpoint("mock:handled").expectedMessageCount(0);
+
+        Exchange out = template.send("direct:vm-bean", e -> 
e.getMessage().setBody("Hello"));
+
+        assertNull(out.getException());
+        assertEquals(1, calls.get());
+        assertMockEndpointsSatisfied();
+    }
+
+    @Test
+    public void stackOverflowErrorOfAProducerIsNotGivenToOnException() throws 
Exception {
+        getMockEndpoint("mock:handled").expectedMessageCount(0);
+
+        Exchange out = template.send("direct:vm-producer", e -> 
e.getMessage().setBody("Hello"));
+
+        assertNull(out.getException());
+        assertEquals(1, calls.get());
+        assertMockEndpointsSatisfied();
+    }
+
+    @Test
+    public void adaptersThrowAVirtualMachineErrorOnAfterTheCallback() {
+        assertVirtualMachineErrorThrownOnAfterTheCallback(new 
DelegateSyncProcessor(new ThrowingProcessor(OOM, calls)));
+        assertVirtualMachineErrorThrownOnAfterTheCallback(
+                AsyncProcessorConverterHelper.convert(new 
ThrowingProcessor(OOM, calls)));
+    }
+
+    private void 
assertVirtualMachineErrorThrownOnAfterTheCallback(AsyncProcessor processor) {
+        Exchange exchange = createExchangeWithBody("Hello");
+        List<Boolean> done = new ArrayList<>();
+
+        OutOfMemoryError thrown = assertThrows(OutOfMemoryError.class, () -> 
processor.process(exchange, done::add));
+
+        assertSame(OOM, thrown);
+        assertEquals(List.of(true), done);
+        assertNull(exchange.getException());
+    }
+
+    private void shortShutdownTimeout() {
+        context.getShutdownStrategy().setTimeUnit(TimeUnit.MILLISECONDS);
+        context.getShutdownStrategy().setTimeout(100);
+    }
+
+    private void assertCallbackCalledOnceWithTheError(AsyncProcessor 
processor) {
+        Exchange exchange = createExchangeWithBody("Hello");
+        List<Boolean> calls = new ArrayList<>();
+
+        boolean sync = processor.process(exchange, calls::add);
+
+        assertTrue(sync);
+        assertEquals(List.of(true), calls);
+        assertFailedWithTheError(exchange.getException());
+    }
+
+    private static void assertFailedWithTheError(Exception exception) {
+        // the exchange keeps an Error wrapped in a CamelExecutionException
+        assertIsInstanceOf(CamelExecutionException.class, exception);
+        assertSame(ERROR, exception.getCause());
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() throws Exception {
+                from("direct:process")
+                        .process(new ThrowingProcessor())
+                        .to("mock:result");
+
+                from("direct:handled")
+                        
.onException(ProcessingError.class).handled(true).to("mock:handled").end()
+                        .process(new ThrowingProcessor())
+                        .to("mock:result");
+
+                from("direct:try")
+                        .doTry()
+                            .process(new ThrowingProcessor())
+                            .to("mock:result")
+                        .doCatch(ProcessingError.class)
+                            .to("mock:caught")
+                        .end();
+
+                from("direct:bean")
+                        .bean(new ThrowingProcessor())
+                        .to("mock:result");
+
+                from("direct:vm-process")
+                        
.onException(Throwable.class).handled(true).to("mock:handled").end()
+                        .process(new ThrowingProcessor(SOE, calls));
+
+                from("direct:vm-try")
+                        .doTry()
+                            .process(new ThrowingProcessor(OOM, calls))
+                        .doCatch(Throwable.class)
+                            .to("mock:caught")
+                        .end();
+
+                from("direct:vm-dlc")
+                        
.errorHandler(deadLetterChannel("mock:dead").maximumRedeliveries(3).redeliveryDelay(0))
+                        .process(new ThrowingProcessor(SOE, calls));
+
+                from("direct:vm-bean")
+                        
.onException(Throwable.class).handled(true).to("mock:handled").end()
+                        .bean(new ThrowingProcessor(SOE, calls));
+
+                getContext().addEndpoint("throwing:producer", new 
ThrowingProducerEndpoint(calls));
+                from("direct:vm-producer")
+                        
.onException(Throwable.class).handled(true).to("mock:handled").end()
+                        .to("throwing:producer");
+            }
+        };
+    }
+
+    private static final class ThrowingProcessor implements Processor {
+        private final Error error;
+        private final AtomicInteger calls;
+
+        ThrowingProcessor() {
+            this(ERROR, new AtomicInteger());
+        }
+
+        ThrowingProcessor(Error error, AtomicInteger calls) {
+            this.error = error;
+            this.calls = calls;
+        }
+
+        @Override
+        public void process(Exchange exchange) {
+            calls.incrementAndGet();
+            throw error;
+        }
+    }
+
+    /**
+     * An endpoint whose producer is not an AsyncProcessor, so it is called 
through the bridge of
+     * AsyncProcessorConverterHelper.
+     */
+    private static final class ThrowingProducerEndpoint extends 
DefaultEndpoint {
+        private final AtomicInteger calls;
+
+        ThrowingProducerEndpoint(AtomicInteger calls) {
+            this.calls = calls;
+        }
+
+        @Override
+        public Producer createProducer() {
+            return new DefaultProducer(this) {
+                @Override
+                public void process(Exchange exchange) {
+                    calls.incrementAndGet();
+                    throw SOE;
+                }
+            };
+        }
+
+        @Override
+        public Consumer createConsumer(Processor processor) {
+            throw new UnsupportedOperationException("Producer only");
+        }
+
+        @Override
+        protected String createEndpointUri() {
+            return "throwing:producer";
+        }
+    }
+
+    private static final class ProcessingError extends Error {
+        private static final long serialVersionUID = 1L;
+
+        ProcessingError() {
+            super("Simulated error of a processor");
+        }
+    }
+}
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/sanitizer/BrokenUserCode.java 
b/core/camel-core/src/test/java/org/apache/camel/sanitizer/BrokenUserCode.java
index de36b657be9b..e8a80976313c 100644
--- 
a/core/camel-core/src/test/java/org/apache/camel/sanitizer/BrokenUserCode.java
+++ 
b/core/camel-core/src/test/java/org/apache/camel/sanitizer/BrokenUserCode.java
@@ -193,7 +193,7 @@ final class BrokenUserCode {
 
     /**
      * A plain Processor (not an AsyncProcessor) that throws an Error, as an 
assertion of a test in a processor does.
-     * Camel's adapter around it calls the callback, and lets the Error 
through.
+     * Camel's adapter around it sets the Error on the exchange and calls the 
callback, so it breaks no rule.
      */
     static final class ThrowsError implements Processor {
 
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/sanitizer/SanitizerUserCodeTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/sanitizer/SanitizerUserCodeTest.java
index 8ed6de2ba2f7..714b5e1f0658 100644
--- 
a/core/camel-core/src/test/java/org/apache/camel/sanitizer/SanitizerUserCodeTest.java
+++ 
b/core/camel-core/src/test/java/org/apache/camel/sanitizer/SanitizerUserCodeTest.java
@@ -17,11 +17,11 @@
 package org.apache.camel.sanitizer;
 
 import java.time.Duration;
-import java.util.Arrays;
 import java.util.List;
 import java.util.concurrent.TimeUnit;
 
 import org.apache.camel.CamelContext;
+import org.apache.camel.CamelExecutionException;
 import org.apache.camel.ContextTestSupport;
 import org.apache.camel.Exchange;
 import org.apache.camel.builder.RouteBuilder;
@@ -31,12 +31,15 @@ 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.assertInstanceOf;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 /**
  * Rule R1 on processors and strategies of a user that break the contract of 
AsyncProcessor: each test fails with a
- * report that names the step and the class, and says so with {@link 
ExpectSanitizerViolation}.
+ * report that names the step and the class, and says so with {@link 
ExpectSanitizerViolation}. The tests without it
+ * check what Camel handles so that no rule is broken, such as an Error (not a 
VirtualMachineError) of a plain
+ * Processor.
  */
 @CamelSanitizer
 class SanitizerUserCodeTest extends ContextTestSupport {
@@ -65,7 +68,7 @@ class SanitizerUserCodeTest extends ContextTestSupport {
                 // no id: the step has the id Camel generates, as process1
                 from("direct:autoId").routeId("autoId").process(new 
BrokenUserCode.ThrowsInProcess());
                 from("direct:plainError").process(new 
BrokenUserCode.ThrowsError()).id("plainError");
-                from("direct:beanError").bean(new 
BrokenUserCode.ThrowsError()).id("beanError");
+                from("direct:beanError").routeId("beanRoute").bean(new 
BrokenUserCode.ThrowsError()).id("beanError");
                 from("direct:late").process(throwsThenCallsBack).id("late");
                 from("direct:aggregateError")
                         .aggregate(constant(true), new 
BrokenUserCode.ErrorStrategy()).completionSize(2).id("aggregate")
@@ -160,57 +163,27 @@ class SanitizerUserCodeTest extends ContextTestSupport {
     }
 
     @Test
-    @ExpectSanitizerViolation(value = Violation.THREW_AFTER_CALLBACK, step = 
"plainError")
-    void anErrorOfAPlainProcessorIsReportedAsCamelsAdapter() {
-        sendAndCatchTheError("direct:plainError");
-
-        SanitizerViolation v = awaitOne(Violation.THREW_AFTER_CALLBACK);
-        assertEquals(BrokenUserCode.ThrowsError.class.getName(), 
v.getProcessor());
-        assertAdapterReport(v);
-    }
-
-    @Test
-    @ExpectSanitizerViolation(value = Violation.THREW_AFTER_CALLBACK, step = 
"beanError")
-    void aBeanIsNamedByItsClass() {
-        sendAndCatchTheError("direct:beanError");
-
-        SanitizerViolation v = awaitOne(Violation.THREW_AFTER_CALLBACK);
-        // the bean, not Camel's BeanProcessor that calls it
-        assertEquals(BrokenUserCode.ThrowsError.class.getName(), 
v.getProcessor());
-        assertAdapterReport(v);
+    void anErrorOfAPlainProcessorFailsTheExchange() {
+        // Camel's adapter around a plain Processor sets what it throws on the 
exchange, also an Error, and then calls the
+        // callback: no rule is broken
+        assertFailedWithTheError(template.send("direct:plainError", e -> 
e.getMessage().setBody("x")));
+        assertEquals(0, Sanitizer.get(context).getViolations().size());
     }
 
     @Test
-    void theAdaptersOfCamelAreFoundByTheirNames() throws Exception {
-        // the adapters are known by class and method name (one is a private 
nested class, one is in camel-bean): a
-        // rename or a move of one must be followed in 
Sanitizer.Trace.SYNC_ADAPTERS
-        for (String adapter : Sanitizer.Trace.SYNC_ADAPTERS) {
-            int dot = adapter.lastIndexOf('.');
-            Class<?> type = Class.forName(adapter.substring(0, dot));
-            String method = adapter.substring(dot + 1);
-            assertTrue(Arrays.stream(type.getDeclaredMethods()).anyMatch(m -> 
m.getName().equals(method)), adapter);
-        }
-    }
-
-    private void sendAndCatchTheError(String uri) {
-        // the exchange stays inflight after the Error went through, so the 
stop of the context does not wait the
-        // default 10 seconds for it
-        context.getShutdownStrategy().setTimeout(1);
-        try {
-            template.send(uri, e -> e.getMessage().setBody("x"));
-        } catch (BrokenUserCode.StrategyBug e) {
-            // the Error goes through Camel's adapter, after the callback; 
only the report matters here
-        }
+    void anErrorOfABeanThatIsAProcessorFailsTheExchange() {
+        assertFailedWithTheError(template.send("direct:beanError", e -> 
e.getMessage().setBody("x")));
+        assertEquals(0, Sanitizer.get(context).getViolations().size());
+        // a report at this step would name the bean, not Camel's 
BeanProcessor that calls it
+        assertEquals(BrokenUserCode.ThrowsError.class.getName(),
+                Sanitizer.get(context).processorOf("beanRoute", "beanError"));
     }
 
-    private static void assertAdapterReport(SanitizerViolation v) {
-        // the callback was called by Camel's adapter around the plain 
Processor, not by the processor: the report says
-        // so, and does not tell the author of the processor to stop throwing 
after the callback
-        assertTrue(v.getText().contains("the callback was called by Camel's 
adapter for a plain Processor"), v.getText());
-        
assertTrue(v.getText().contains(BrokenUserCode.StrategyBug.class.getName() + ": 
Forced by ThrowsError"),
-                v.getText());
-        assertTrue(v.getText().contains("Usual fix: " + 
SanitizerStepListener.StepCall.SYNC_ADAPTER_FIX), v.getText());
-        assertTrue(v.getText().contains("at " + 
BrokenUserCode.ThrowsError.class.getName() + ".process"), v.getText());
+    private static void assertFailedWithTheError(Exchange out) {
+        // the exchange keeps the Error wrapped in a CamelExecutionException
+        assertInstanceOf(CamelExecutionException.class, out.getException());
+        assertInstanceOf(BrokenUserCode.StrategyBug.class, 
out.getException().getCause());
+        assertEquals("Forced by ThrowsError", 
out.getException().getCause().getMessage());
     }
 
     @Test
diff --git 
a/core/camel-support/src/main/java/org/apache/camel/support/AsyncProcessorConverterHelper.java
 
b/core/camel-support/src/main/java/org/apache/camel/support/AsyncProcessorConverterHelper.java
index 455f582f3f65..4dd62586250b 100644
--- 
a/core/camel-support/src/main/java/org/apache/camel/support/AsyncProcessorConverterHelper.java
+++ 
b/core/camel-support/src/main/java/org/apache/camel/support/AsyncProcessorConverterHelper.java
@@ -63,8 +63,12 @@ public final class AsyncProcessorConverterHelper {
             }
             try {
                 processor.process(exchange);
-            } catch (Exception e) {
-                // must catch throwable so we catch all
+            } catch (VirtualMachineError e) {
+                // left as before (thrown on after the callback): the 
application must not handle a JVM-level failure
+                // such as an OutOfMemoryError with Camel's error handling
+                throw e;
+            } catch (Throwable e) {
+                // any other Throwable, as in DelegateSyncProcessor: the 
callback is called in finally
                 exchange.setException(e);
             } finally {
                 // we are bridging a sync processor as async so callback with 
true
diff --git 
a/core/camel-support/src/main/java/org/apache/camel/support/processor/DelegateSyncProcessor.java
 
b/core/camel-support/src/main/java/org/apache/camel/support/processor/DelegateSyncProcessor.java
index 92c2e1979f87..60341233bb97 100644
--- 
a/core/camel-support/src/main/java/org/apache/camel/support/processor/DelegateSyncProcessor.java
+++ 
b/core/camel-support/src/main/java/org/apache/camel/support/processor/DelegateSyncProcessor.java
@@ -65,8 +65,13 @@ public class DelegateSyncProcessor extends ServiceSupport
         // force calling the sync method
         try {
             processor.process(exchange);
-        } catch (Exception | LinkageError e) {
-            // we catch all exceptions and try to catch relatively manageable 
low-level errors
+        } catch (VirtualMachineError e) {
+            // left as before (thrown on after the callback): the application 
must not handle a JVM-level failure such
+            // as an OutOfMemoryError with Camel's error handling
+            throw e;
+        } catch (Throwable e) {
+            // any other Throwable, also an Error such as an AssertionError: 
the callback is called in finally, so an
+            // Error thrown on after it would not fail the exchange, and the 
exchange could stay inflight
             exchange.setException(e);
         } finally {
             // we are bridging a sync processor as async so callback with true
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 fcb047c9dc20..bb0dc8cbdd84 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
@@ -649,6 +649,49 @@ exception was dropped and that callback continued the 
exchange as a success. A s
 and logged at WARN; before, the exchange was continued a second time. There is 
no option for the old behaviour; fix the
 processor to set the exception on the exchange and call the callback, as an 
`AsyncProcessor` must.
 
+=== camel-core - an Error thrown by a Processor fails the exchange
+
+When a plain `Processor` (in `process(...)`, in `bean(...)` for a bean that is 
a `Processor`, or as a producer that is
+not an `AsyncProcessor`) throws an `Error` that is not a 
`VirtualMachineError`, such as an `AssertionError`, the
+exchange now fails with it, as for an exception. Before, only a `LinkageError` 
thrown in `process(...)` and an
+`AssertionError` thrown by a bean that is a `Processor` did so (as a bean 
method that throws any `Error` already does).
+Any other `Error` was thrown on after the processor's callback had been 
called: depending on the error handler, it was
+only logged and the next steps ran as if the processor had succeeded, or it 
reached the caller and the exchange stayed
+inflight until the context stopped (which then waited for its shutdown 
timeout).
+
+[cols="2,4",options="header"]
+|===
+|Thrown by the processor |What happens
+
+|An `Error` that is not a `VirtualMachineError`, such as `AssertionError`, 
`LinkageError`
+(`NoClassDefFoundError`, `ExceptionInInitializerError`) or a custom `Error`
+|The exchange fails with it (wrapped in a `CamelExecutionException`), and the 
error handler handles it as any other
+failure: redelivery, `onException`, `doCatch` and a dead letter channel get 
it. `onException(Exception.class)` and
+`doCatch(Exception.class)` also match it; to treat it differently, match it on 
its class, such as
+`onException(AssertionError.class)`
+
+|A `VirtualMachineError`, such as `OutOfMemoryError`, `StackOverflowError` or 
`InternalError`
+|Not changed by this entry: Camel's error handling does not get it (the JVM 
may no longer be in a consistent
+state, so the application should not handle it). It is not set on the 
exchange, but thrown on after the processor's
+callback was called. Depending on the error handler, the `Error` reaches the 
caller (and the exchange may stay
+inflight), or it is only logged and the route continues as if the processor 
had succeeded
+|===
+
+An application with strict reliability needs should not rely on Camel to react 
to a `VirtualMachineError`. For an
+`OutOfMemoryError` that the JVM raises, it can let the JVM stop with 
`-XX:+ExitOnOutOfMemoryError` or
+`-XX:+CrashOnOutOfMemoryError` (these options do not cover a 
`StackOverflowError`, or an `OutOfMemoryError` that code
+throws itself).
+
+`template.send(...)`, `template.request(...)` and `template.asyncSend(...)` 
return the exchange (or its future) with the
+exception and do not throw it. `sendBody(...)` and `requestBody(...)` throw a 
`CamelExecutionException` that holds the
+`Error`, unless the failure was handled (such as by a dead letter channel, 
`onException(...).handled(true)` or
+`doCatch`). A test that sends with `template.send(...)` and asserts only 
inside a processor
+must now check the exception of the returned exchange 
(`exchange.getException()`), or send with `sendBody(...)` or
+`requestBody(...)`.
+
+There is no option for the old behaviour. See also
+xref:error-handler.adoc#_an_error_thrown_by_a_processor[An Error thrown by a 
processor].
+
 === camel-core - variableReceive into a route or group variable stores the 
header variables in that route or group
 
 With `variableReceive` (`toV`, `enrich`, `pollEnrich`, `fromV` and so on) into 
a route variable or a group variable,
diff --git a/docs/user-manual/modules/ROOT/pages/error-handler.adoc 
b/docs/user-manual/modules/ROOT/pages/error-handler.adoc
index bdb39e90e4f7..b35127b3006e 100644
--- a/docs/user-manual/modules/ROOT/pages/error-handler.adoc
+++ b/docs/user-manual/modules/ROOT/pages/error-handler.adoc
@@ -182,6 +182,39 @@ Dead Letter Channel
 See xref:exception-clause.adoc[Exception Clause] documentation for
 additional documentation of the features above.
 
+== An Error thrown by a processor
+
+A plain `Processor` (in `process(...)`, in `bean(...)` for a bean that is a 
`Processor`, or a producer that is not an
+`AsyncProcessor`) may throw an `Error` instead of an exception:
+
+[cols="2,4",options="header"]
+|===
+|Thrown by the processor |What happens
+
+|An `Error` that is not a `VirtualMachineError`, such as `AssertionError`, 
`LinkageError`
+(`NoClassDefFoundError`, `ExceptionInInitializerError`) or a custom `Error`
+|The exchange fails with it (wrapped in a `CamelExecutionException`), and the 
error handler handles it as any other
+failure: redelivery, `onException`, `doCatch` and a dead letter channel get 
it. `onException(Exception.class)` and
+`doCatch(Exception.class)` also match it; to treat it differently, match it on 
its class, such as
+`onException(AssertionError.class)`
+
+|A `VirtualMachineError`, such as `OutOfMemoryError`, `StackOverflowError` or 
`InternalError`
+|Camel's error handling does not get it (the JVM may no longer be in a 
consistent state, so the application should not
+handle it). It is not set on the exchange, but thrown on after the processor's
+callback was called. Depending on the error handler, the `Error` reaches the 
caller (and the exchange may stay
+inflight), or it is only logged and the route continues as if the processor 
had succeeded
+|===
+
+An application with strict reliability needs should not rely on Camel to react 
to a `VirtualMachineError`. For an
+`OutOfMemoryError` that the JVM raises, it can let the JVM stop with 
`-XX:+ExitOnOutOfMemoryError` or
+`-XX:+CrashOnOutOfMemoryError` (these options do not cover a 
`StackOverflowError`, or an `OutOfMemoryError` that code
+throws itself).
+
+`template.send(...)`, `template.request(...)` and `template.asyncSend(...)` 
return the failed exchange (or its future)
+without throwing the exception; `sendBody(...)` and `requestBody(...)` throw a 
`CamelExecutionException` that holds the
+`Error`, unless the failure was handled (such as by a dead letter channel, 
`onException(...).handled(true)` or
+`doCatch`).
+
 == Scopes
 
 The error handler is scoped as either:

Reply via email to