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 40a9d1f44299 CAMEL-24863: a failure the circuit breaker's fallback 
recovered from is recorded as handled in the error registry
40a9d1f44299 is described below

commit 40a9d1f442990ae89231802817501c20b721f1cb
Author: Claus Ibsen <[email protected]>
AuthorDate: Mon Sep 21 12:04:51 2026 +0200

    CAMEL-24863: a failure the circuit breaker's fallback recovered from is 
recorded as handled in the error registry
    
    ## Description
    
    `camel get errors`, the error dev console and the camel-jbang-mcp errors 
tool listed every call that failed inside a `circuitBreaker` and was answered 
by its `onFallback` as an error that was not handled, one per call. An agent 
(or a person) reads that as nine failures of a route that in fact handled all 
nine.
    
    Traced against a `doTry`/`doCatch` around a `multicast` whose branch 
throws, which the registry records as handled, the difference is in the order 
of events, not in the registry:
    
    - The breaker runs the call on a correlated copy of the exchange with its 
own unit of work. When the call fails, `doneUow` on the copy (in the breaker's 
`finally`) fires the exchange-failed event before the fallback runs, and the 
registry records the copy's failure, not handled, correlated to the original. 
The breaker then runs the fallback on the original and sets the 
exception-caught property, but never says the failure was handled, so nothing 
follows. The registry's deduplication k [...]
    - With `doCatch` the copy is done after the failure propagated and the 
catch fired the handled event on the original, so the original's handled entry 
exists first and the copy's unhandled one is dropped.
    
    Two changes:
    
    - **camel-resilience4j**: the breaker emits the failure-handling and 
failure-handled events on the original around the fallback, the way 
`CatchProcessor` does (the handled event only when the fallback itself did not 
fail).
    - **camel-base-engine**: when the registry gets a handled failure for an 
original whose correlated copy is already recorded as not handled, it marks 
that entry handled (`BacklogErrorEventMessage.markHandled()`, new in the API, 
`@since 4.23`) instead of dropping the event, so the entry keeps the node and 
says the truth.
    
    Live check with a breaker route and a doTry/multicast route side by side, 
three calls each: before, breaker entries `handled=false` and multicast entries 
`handled=true`; after, all six handled, `camel get errors --handled=false` 
empty. Upgrade guide note added.
    
    ## Tests
    
    - `ResilienceFallbackErrorRegistryTest` (camel-resilience4j): two failed 
calls answered by the fallback give two registry entries, both handled, with 
the exception type; fails without the breaker change (verified by reverting it).
    - 
`ErrorRegistryDeduplicateTest.testCopyEntryIsMarkedHandledWhenTheOriginalRecovers`
 (camel-core): the doTry/multicast shape stays one handled entry; a guard for 
that path, which passed before as well since there the original's entry comes 
first.
    - The error registry tests of camel-core (18) and the fallback tests of 
camel-resilience4j (15) green.
---
 .../resilience4j/ResilienceProcessor.java          |  7 ++
 .../ResilienceFallbackErrorRegistryTest.java       | 79 ++++++++++++++++++++++
 .../apache/camel/spi/BacklogErrorEventMessage.java |  8 +++
 .../camel/impl/engine/DefaultErrorRegistry.java    | 13 +++-
 .../camel/impl/ErrorRegistryDeduplicateTest.java   | 25 +++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc    | 10 +++
 6 files changed, 141 insertions(+), 1 deletion(-)

diff --git 
a/components/camel-resilience4j/src/main/java/org/apache/camel/component/resilience4j/ResilienceProcessor.java
 
b/components/camel-resilience4j/src/main/java/org/apache/camel/component/resilience4j/ResilienceProcessor.java
index c4cd72ed7b1e..b5024f2371fe 100644
--- 
a/components/camel-resilience4j/src/main/java/org/apache/camel/component/resilience4j/ResilienceProcessor.java
+++ 
b/components/camel-resilience4j/src/main/java/org/apache/camel/component/resilience4j/ResilienceProcessor.java
@@ -68,6 +68,7 @@ import org.apache.camel.spi.ProcessorExchangeFactory;
 import org.apache.camel.spi.RouteIdAware;
 import org.apache.camel.spi.UnitOfWork;
 import org.apache.camel.support.AsyncProcessorConverterHelper;
+import org.apache.camel.support.EventHelper;
 import org.apache.camel.support.ExchangeHelper;
 import org.apache.camel.support.PluginHelper;
 import org.apache.camel.support.UnitOfWorkHelper;
@@ -1019,8 +1020,14 @@ public class ResilienceProcessor extends 
BaseProcessorSupport
                     LOG.trace("Processing exchange: {} using circuit breaker 
({}):{} with fallback: {}",
                             exchange.getExchangeId(), state, id, fallback);
                 }
+                // the failure is handled by the fallback: say so with the 
events a doCatch emits, so the error
+                // registry (camel get errors, the dev console) records a 
recovered failure as handled (CAMEL-24863)
+                
EventHelper.notifyExchangeFailureHandling(exchange.getContext(), exchange, 
fallback, false, null);
                 // process the fallback until its fully done
                 fallback.process(exchange);
+                if (exchange.getException() == null) {
+                    
EventHelper.notifyExchangeFailureHandled(exchange.getContext(), exchange, 
fallback, false, null);
+                }
             } catch (Throwable e) {
                 exchange.setException(e);
             }
diff --git 
a/components/camel-resilience4j/src/test/java/org/apache/camel/component/resilience4j/ResilienceFallbackErrorRegistryTest.java
 
b/components/camel-resilience4j/src/test/java/org/apache/camel/component/resilience4j/ResilienceFallbackErrorRegistryTest.java
new file mode 100644
index 000000000000..90ef425c51e1
--- /dev/null
+++ 
b/components/camel-resilience4j/src/test/java/org/apache/camel/component/resilience4j/ResilienceFallbackErrorRegistryTest.java
@@ -0,0 +1,79 @@
+/*
+ * 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.component.resilience4j;
+
+import java.util.Collection;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.spi.BacklogErrorEventMessage;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * CAMEL-24863: a failure the circuit breaker's fallback recovered from is an 
error that was handled, in the error
+ * registry (camel get errors, the dev console): the entry keeps the node that 
failed and says handled.
+ */
+public class ResilienceFallbackErrorRegistryTest extends CamelTestSupport {
+
+    @Override
+    protected CamelContext createCamelContext() throws Exception {
+        CamelContext context = super.createCamelContext();
+        context.getErrorRegistry().setEnabled(true);
+        return context;
+    }
+
+    @Test
+    public void testFallbackRecordsTheFailureAsHandled() throws Exception {
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("Fallback response", "Fallback response");
+
+        template.sendBody("direct:start", "Hello World");
+        template.sendBody("direct:start", "Hello World");
+
+        MockEndpoint.assertIsSatisfied(context);
+
+        Collection<BacklogErrorEventMessage> entries = 
context.getErrorRegistry().browse();
+        assertEquals(2, entries.size(),
+                "one registry entry per call: the copy's ExchangeFailedEvent 
and the original's ExchangeFailureHandledEvent merge into the same slot");
+        for (BacklogErrorEventMessage e : entries) {
+            assertTrue(e.isHandled(), "the fallback handled the failure: " + 
e);
+            assertEquals("java.lang.IllegalStateException", 
e.getExceptionType());
+        }
+    }
+
+    @Override
+    protected RoutesBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("direct:start")
+                        .circuitBreaker()
+                            .throwException(new 
IllegalStateException("Forced")).id("boom")
+                        .onFallback()
+                            .transform().constant("Fallback response")
+                        .end()
+                        .to("mock:result");
+            }
+        };
+    }
+}
diff --git 
a/core/camel-api/src/main/java/org/apache/camel/spi/BacklogErrorEventMessage.java
 
b/core/camel-api/src/main/java/org/apache/camel/spi/BacklogErrorEventMessage.java
index f7d64d643e69..9b1fee6dd0ac 100644
--- 
a/core/camel-api/src/main/java/org/apache/camel/spi/BacklogErrorEventMessage.java
+++ 
b/core/camel-api/src/main/java/org/apache/camel/spi/BacklogErrorEventMessage.java
@@ -77,6 +77,14 @@ public interface BacklogErrorEventMessage extends 
BacklogEventMessage {
      */
     boolean isHandled();
 
+    /**
+     * Marks the error as handled after the fact: the failure was recorded 
from a copy of the exchange (a circuit
+     * breaker's call, a multicast branch) before the original reported it as 
handled by its fallback or catch.
+     *
+     * @since 4.23
+     */
+    void markHandled();
+
     /**
      * The fully qualified class name of the exception (e.g. 
"java.lang.IllegalArgumentException").
      */
diff --git 
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultErrorRegistry.java
 
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultErrorRegistry.java
index ec3e9810524a..22b576b023e9 100644
--- 
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultErrorRegistry.java
+++ 
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultErrorRegistry.java
@@ -196,6 +196,12 @@ public class DefaultErrorRegistry extends 
EventNotifierSupport implements ErrorR
         } else {
             for (BacklogErrorEventMessage e : entries) {
                 if (exchangeId.equals(e.getExchangeId())) {
+                    // the copy's entry stays (it names the node), but the 
original reporting the failure as
+                    // handled (a circuit breaker's fallback, a doCatch around 
a multicast) means the exchange
+                    // recovered: the entry is an error that was handled, not 
an error (CAMEL-24863)
+                    if (handled && !e.isHandled()) {
+                        e.markHandled();
+                    }
                     return;
                 }
             }
@@ -449,7 +455,7 @@ public class DefaultErrorRegistry extends 
EventNotifierSupport implements ErrorR
         private final String threadName;
         private final JsonObject data;
         private final Throwable exception;
-        private final boolean handled;
+        private volatile boolean handled;
         private final String[] messageHistory;
 
         private volatile String dataAsJson;
@@ -583,6 +589,11 @@ public class DefaultErrorRegistry extends 
EventNotifierSupport implements ErrorR
             return handled;
         }
 
+        @Override
+        public void markHandled() {
+            this.handled = true;
+        }
+
         @Override
         public String getExceptionType() {
             return exception.getClass().getName();
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/impl/ErrorRegistryDeduplicateTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/impl/ErrorRegistryDeduplicateTest.java
index 97fa3158b3b9..af8e8548e61a 100644
--- 
a/core/camel-core/src/test/java/org/apache/camel/impl/ErrorRegistryDeduplicateTest.java
+++ 
b/core/camel-core/src/test/java/org/apache/camel/impl/ErrorRegistryDeduplicateTest.java
@@ -49,6 +49,20 @@ public class ErrorRegistryDeduplicateTest extends 
ContextTestSupport {
         assertEquals(true, entries.iterator().next().isHandled());
     }
 
+    /**
+     * CAMEL-24863: a copy fails first (recorded as not handled, it names the 
node), then the original reports the
+     * failure as handled (a doCatch around a multicast): the copy's entry 
stays and is marked handled.
+     */
+    @Test
+    public void testCopyEntryIsMarkedHandledWhenTheOriginalRecovers() throws 
Exception {
+        getMockEndpoint("mock:caught").expectedMessageCount(1);
+        template.sendBody("direct:copies", "Hello");
+        assertMockEndpointsSatisfied();
+        Collection<BacklogErrorEventMessage> entries = 
context.getErrorRegistry().browse();
+        assertEquals(1, entries.size(), entries.toString());
+        assertEquals(true, entries.iterator().next().isHandled(), "the doCatch 
handled the copy's failure");
+    }
+
     @Override
     protected RouteBuilder createRouteBuilder() {
         return new RouteBuilder() {
@@ -65,6 +79,17 @@ public class ErrorRegistryDeduplicateTest extends 
ContextTestSupport {
                 from("direct:sub").routeId("sub")
                         
.errorHandler(deadLetterChannel("mock:dead").maximumRedeliveries(0))
                         .throwException(new IllegalArgumentException("Forced 
error"));
+                // a multicast copy fails (its own unit of work reports the 
failure first), the doTry on the
+                // original catches it
+                from("direct:copies").routeId("copies")
+                        .doTry()
+                            .multicast().to("direct:boom", "log:other").end()
+                        .endDoTry().doCatch(Exception.class)
+                            .to("mock:caught")
+                        .end();
+                from("direct:boom").routeId("boom")
+                        .errorHandler(noErrorHandler())
+                        .throwException(new IllegalStateException("boom"));
             }
         };
     }
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index c1176d685d12..3068b1a2da1f 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -490,6 +490,16 @@ watched one is now registered as it appears, with the 
files already in it reload
 compile work directory (`camel.main.compileWorkDir`, `.camel-jbang/compile` 
for the CLI), where the runtime writes the
 class files it compiles, so a compiled source no longer triggers a reload of 
its own.
 
+=== camel-core, camel-resilience4j - a failure the circuit breaker's fallback 
recovered from is recorded as handled
+
+The error registry (`camel get errors`, the error dev console) recorded a call 
that failed inside a
+`circuitBreaker` and was answered by its `onFallback` as an error that was not 
handled, one per call, since the
+breaker runs the call on a copy of the exchange whose failure is reported 
before the fallback runs, and the breaker
+did not report the recovery. The breaker now emits the failure-handling and 
failure-handled events around the
+fallback, as `doCatch` does, and the registry marks the copy's entry as 
handled when the original reports the
+failure as handled, keeping the entry that names the node that failed. `camel 
get errors --handled=false` no longer
+lists such calls. The `handled` field of the entries is unchanged in shape.
+
 === camel-core - the type of a bean created by a script or a builder is 
optional
 
 The `type` (class name) of a bean definition — `bean` under `beans`, 
`templateBean` of a route

Reply via email to