Skip to content

CAMEL-26422: camel-resilience4j, camel-microprofile-fault-tolerance - a circuit breaker no longer ends the enclosing doTry - #28780

Merged
davsclaus merged 2 commits into
apache:mainfrom
allthingssecurity:codex-resilience-try-scope-1029-v2
Nov 1, 2026
Merged

davsclaus merged 2 commits into
apache:mainfrom
allthingssecurity:codex-resilience-try-scope-1029-v2

Conversation

@allthingssecurity

@allthingssecurity allthingssecurity commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Description

CAMEL-26422

A circuit breaker (camel-resilience4j and camel-microprofile-fault-tolerance) inside a doTry ends that doTry for the steps after it:

  • after a fallback that recovered from a failure, no doCatch of the doTry catches a later failure in the same doTry, whatever the failing step is. The failure is thrown from the doTry;
  • after the circuit breaker is done, a later routing slip, dynamic router, multicast or recipient list of the same doTry uses the route's error handler (redelivery and the dead letter channel) instead of the doCatch.
from("direct:start").doTry()
    .circuitBreaker().throwException(new IllegalArgumentException("Recover me"))
        .onFallback().setBody(constant("Recovered")).end()
    .throwException(new IllegalStateException("later failure"))
    .doCatch(IllegalStateException.class).to("mock:caught").end();
// mock:caught gets nothing, the exchange fails with the IllegalStateException

Cause: the circuit breaker runs its steps as if in a doTry, so it sets the exchange property TryRouteBlock while they run, and it removed the property when it was done, also when it was itself in a doTry. CAMEL-25114 lists this under "Not changed". The fallback sets CamelExceptionHandled (as a doCatch does) and leaves it set; CatchProcessor skips its clause when that property is set. The PR of CAMEL-26332 (#28755) names this as a follow-up.

Changes:

  • Both circuit breaker processors keep the values of TryRouteBlock and CamelExceptionHandled of the enclosing scope when they start and set them again when they are done, as TryProcessor does for a nested doTry; when the enclosing scope had no value, the property is removed. Resilience4j does it in the callback, so also when it completes asynchronously; the callback is still called once on every path (the pooled tasks do not use it).
  • After a fallback outside a doTry, CamelExceptionHandled is no longer left set on the exchange. Only the doCatch (and its debugger hook) read it; it is not documented.
  • A fallback that fails inside a doTry is now caught by the doCatch, as a circuit breaker without a fallback is. Before, the fallback had set CamelExceptionHandled before it ran, so the doCatch skipped its failure and it was thrown from the doTry.
  • Upgrade guide (4.24): such failures now go to the doCatch, also a failure of the fallback itself; to have those steps use the route's error handler, move them out of the doTry.

Tests:

Backport: no, failures now go to the doCatch instead of the route's error handler. 4.22 has the same behaviour.

Target

  • I checked that the commit is targeting the correct branch (Camel 4 uses the main branch)

Tracking

  • If this is a large change, bug fix, or code improvement, I checked there is a JIRA issue filed for the change (usually before you start working on it): CAMEL-26422.

Apache Camel coding standards and style

  • I checked that each commit in the pull request has a meaningful subject line and body.
  • I have run mvn clean install -DskipTests locally from root folder and I have committed all auto-generated changes.

Claude Code on behalf of allthingssecurity

🤖 Generated with Claude Code

… a circuit breaker no longer ends the enclosing doTry

A circuit breaker runs its steps as if in a doTry: it sets the exchange
property TryRouteBlock while they run. When it was done it removed the
property, also when the circuit breaker itself was in the steps of a doTry,
so a later routing slip, dynamic router, multicast or recipient list of that
doTry used the error handler of the route (redelivery, dead letter channel)
instead of the doCatch. Also, a fallback that recovered from a failure sets
the property CamelExceptionHandled (as a doCatch does), and it stayed set, so
no doCatch of the enclosing doTry caught a later failure in the same doTry:
CatchProcessor skips its clause when that property is set. The failure was
thrown from the doTry.

Both circuit breakers now keep the values of TryRouteBlock and
CamelExceptionHandled of the enclosing scope and set them again when they
are done, as a nested doTry does (CAMEL-25114, which listed the circuit
breakers as not changed), also when Resilience4j completes asynchronously.

Co-Authored-By: GPT-6 <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

🌟 Thank you for your contribution to the Apache Camel project! 🌟
🤖 CI automation will test this PR automatically.

🐫 Apache Camel Committers, please review the following items:

  • First-time contributors require MANUAL approval for the GitHub Actions to run
  • You can use the command /component-test (camel-)component-name1 (camel-)component-name2.. to request a test from the test bot although they are normally detected and executed by CI.
  • You can label PRs using skip-tests and test-dependents to fine-tune the checks executed by this PR.
  • Build and test logs are available in the summary page. Only Apache Camel committers have access to the summary.

⚠️ Be careful when sharing logs. Review their contents before sharing them publicly.

@github-actions

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

🧪 CI tested the following changed modules:

  • components/camel-microprofile/camel-microprofile-fault-tolerance
  • components/camel-resilience4j
  • docs

ℹ️ Dependent modules were not tested because the total number of affected modules exceeded the threshold (50). Use the test-dependents label to force testing all dependents.


🔬 Scalpel shadow comparison — Scalpel: 42 of 713 tested, 26 compile-only — current: 42 all tested

Maveniverse Scalpel detected 42 affected modules (current approach: 42).

Skip-tests mode would test 42 modules (3 direct + 40 downstream), skip tests for 26 (generated code, meta-modules)

Modules Scalpel would test (42)
  • camel-a2a ← downstream of org.apache.camel:camel-platform-http-vertx
  • camel-clickup ← downstream of org.apache.camel:camel-netty-http
  • camel-java-joor-dsl ← downstream of org.apache.camel:camel-telegram
  • camel-jbang-mcp ← downstream of org.apache.camel:camel-catalog
  • camel-jbang-plugin-mcp ← downstream of org.apache.camel:camel-jbang-core
  • camel-jbang-plugin-tui ← downstream of org.apache.camel:camel-catalog
  • camel-jbang-plugin-validate ← downstream of org.apache.camel:camel-yaml-dsl-validator
  • camel-jbang-run ← downstream of org.apache.camel:camel-catalog
  • camel-jsonpath ← downstream of org.apache.camel:camel-platform-http-vertx
  • camel-kafka ← downstream of org.apache.camel:camel-resilience4j
  • camel-knative-http ← downstream of org.apache.camel:camel-platform-http-vertx
  • camel-launcher-container ← downstream of org.apache.camel:camel-launcher
  • camel-mcp-server ← downstream of org.apache.camel:camel-platform-http-vertx
  • camel-micrometer-prometheus ← downstream of org.apache.camel:camel-platform-http-main
  • camel-microprofile-fault-tolerance ← components/camel-microprofile/camel-microprofile-fault-tolerance/src/main/java/org/apache/camel/component/microprofile/faulttolerance/FaultToleranceProcessor.java, components/camel-microprofile/camel-microprofile-fault-tolerance/src/test/java/org/apache/camel/component/microprofile/faulttolerance/FaultToleranceOuterTryScopeTest.java
  • camel-netty-http ← downstream of org.apache.camel:camel-resilience4j
  • camel-oauth ← downstream of org.apache.camel:camel-netty-http
  • camel-observability-services ← downstream of org.apache.camel:camel-micrometer-prometheus
  • camel-openai ← downstream of org.apache.camel:camel-platform-http-vertx
  • camel-openapi-validator ← downstream of org.apache.camel:camel-rest-openapi
  • camel-platform-http-main ← downstream of org.apache.camel:camel-platform-http-vertx
  • camel-platform-http-vertx ← downstream of org.apache.camel:camel-rest-openapi
  • camel-resilience4j ← components/camel-resilience4j/src/main/java/org/apache/camel/component/resilience4j/ResilienceProcessor.java, components/camel-resilience4j/src/test/java/org/apache/camel/component/resilience4j/ResilienceOuterTryScopeTest.java
  • camel-resilience4j-micrometer ← downstream of org.apache.camel:camel-resilience4j
  • camel-rest-openapi ← downstream of org.apache.camel:camel-netty-http
  • camel-semantic ← downstream of org.apache.camel:camel-xml-io-dsl
  • camel-semantic-openai ← downstream of org.apache.camel:camel-semantic
  • camel-semantic-typesafe-ai ← downstream of org.apache.camel:camel-xml-io-dsl
  • camel-semantic-wolf-defender ← downstream of org.apache.camel:camel-semantic
  • camel-state-store ← downstream of org.apache.camel:camel-test-main-junit5
  • camel-telegram ← downstream of org.apache.camel:camel-netty-http
  • camel-test-main-junit5 ← downstream of org.apache.camel:camel-xml-io-dsl
  • camel-test-main-junit6 ← downstream of org.apache.camel:camel-xml-io-dsl
  • camel-typesafe-ai ← downstream of org.apache.camel:camel-xml-io-dsl
  • camel-webhook ← downstream of org.apache.camel:camel-netty-http
  • camel-whatsapp ← downstream of org.apache.camel:camel-netty-http
  • camel-xml-io-dsl ← downstream of org.apache.camel:camel-telegram
  • camel-xml-jaxb-dsl ← downstream of org.apache.camel:camel-telegram
  • camel-xml-jaxb-dsl-test-definition ← downstream of org.apache.camel:camel-telegram
  • camel-xml-jaxb-dsl-test-spring ← downstream of org.apache.camel:camel-telegram
  • camel-yaml-dsl-validator ← downstream of org.apache.camel:camel-catalog
  • camel-yaml-dsl-validator-maven-plugin ← downstream of org.apache.camel:camel-yaml-dsl-validator
Modules with tests skipped (26)
  • apache-camel
  • camel-allcomponents
  • camel-catalog
  • camel-catalog-console
  • camel-catalog-maven
  • camel-catalog-suggest
  • camel-componentdsl
  • camel-endpointdsl
  • camel-endpointdsl-support
  • camel-itest
  • camel-jbang-core
  • camel-jbang-it
  • camel-jbang-main
  • camel-jbang-plugin-edit
  • camel-jbang-plugin-generate
  • camel-jbang-plugin-kubernetes
  • camel-jbang-plugin-test
  • camel-kamelet-main
  • camel-launcher
  • camel-report-maven-plugin
  • camel-route-parser
  • camel-yaml-dsl
  • camel-yaml-dsl-deserializers
  • camel-yaml-dsl-maven-plugin
  • coverage
  • dummy-component

ℹ️ Shadow mode — Scalpel observes but does not affect test execution. Learn more

Build reactor — dependencies compiled but only changed modules were tested (3 modules, 1m 33s total)

Total reactor time: 1m 33s

Module Duration Status
Camel :: Resilience4j 46.7s SUCCESS
Camel :: MicroProfile :: Fault Tolerance 32.2s SUCCESS
Camel :: Docs 13.9s SUCCESS

Top 20 slowest modules:

  • Camel :: Resilience4j (46.7s)
  • Camel :: MicroProfile :: Fault Tolerance (32.2s)
  • Camel :: Docs (13.9s)

⚙️ View full build and test results

… review: more tests, remove the property when the scope had none

- A fallback that fails inside a doTry is now caught by the doCatch: a test in
  both test classes, and a sentence in the upgrade guide.
- The doCatch control test puts a failing step after the circuit breaker inside
  the doCatch, so it fails when the restore of CamelExceptionHandled is
  replaced by a remove (the earlier version passed either way).
- An asynchronous variant of the recovered fallback test (Resilience4j), which
  covers the restore in the asynchronous completion.
- When the enclosing scope had no value, the property is removed instead of set
  to null, as TryProcessor does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@davsclaus davsclaus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TL;DR: The fix is correct, and I'd approve it after two small additions. Thanks @allthingssecurity: saving and restoring TryRouteBlock and CamelExceptionHandled the way TryProcessor does for a nested doTry is the right model. It also fixes one more case that is not tested or documented yet (finding 1), and the doCatch control test passes whether or not the restore is there (finding 2).

What I checked:

  • The bug is real: TryProcessor sets TryRouteBlock once for the try pipeline (TryProcessor.java:128), and the fallback's CamelExceptionHandled=true made CatchProcessor.java:135 skip every doCatch. CAMEL-25114 only deferred this, so nothing deliberate is reverted.
  • Resilience4j: restoreScope (ResilienceProcessor.java:569-576) runs exactly once on the sync, async and setup-failure paths; MicroProfile FT is sync with one exit. Circuit breaker state, counters and the fallback are unchanged.
  • I ran scratch tests in camel-resilience4j: the PR's 6 tests pass, and the cases in findings 1-3 fail on main and pass with the PR.
  • No shared code with #28779, #28785 or #28786.

Findings:

  1. A failing fallback inside a doTry is now caught by the doCatch. This change has no test and no upgrade-guide entry. On main, the fallback sets CamelExceptionHandled=true before it runs (ResilienceProcessor.java:1082, FaultToleranceProcessor.java:623). When the fallback then fails, the doCatch is skipped and the exception leaves the doTry. With this PR, the restore clears that flag, so the doCatch catches the failure. That is the right behaviour, and it matches a circuit breaker without a fallback. I confirmed it with this route (it fails on main and passes here):

    from("direct:failingFallback").doTry()
        .circuitBreaker().throwException(new IllegalArgumentException("Recover me"))
            .onFallback().throwException(new IllegalStateException("fallback fails")).end()
        .endDoTry()
        .doCatch(IllegalStateException.class).to("mock:caught").end();

    Suggestion: add this case to both test classes, and one sentence to camel-4x-upgrade-guide-4_24.adoc:343-351, for example: "A failure of the fallback itself is now also caught by the doCatch (before, it was thrown from the doTry)."

  2. circuitBreakerInDoCatchKeepsTheFailureHandled passes even without the restore. See ResilienceOuterTryScopeTest.java:57 and :114, and FaultToleranceOuterTryScopeTest.java:52 and :104. After the first doCatch the exchange has no exception, so the second doCatch skips it anyway: catches(exchange, null) returns null. I changed the restore at ResilienceProcessor.java:574 to removeProperty(...), and all 6 tests still passed. A version that does fail with that change puts a failing step after the circuit breaker, inside the doCatch:

    from("direct:inCatch").doTry()
        .throwException(new IllegalStateException("First failure"))
        .doCatch(IllegalStateException.class).to("mock:first")
            .circuitBreaker().to("mock:inside").end()
            .throwException(new IllegalArgumentException("in catch")).endDoTry()
        .doCatch(Exception.class).to("mock:second").end();
    // mock:second expects 0, and out.getException() is the IllegalArgumentException
  3. (minor) The asynchronous test only covers the success path. direct:async (ResilienceOuterTryScopeTest.java:99) never runs the fallback, but the async fallback path is where CamelExceptionHandled is restored from whenComplete. An async variant of direct:afterFallback (resilience4jConfiguration().asynchronous(true).end()) fails on main and passes with the PR, so it would cover that path.

  4. (minor, optional) When the enclosing scope had no value, both processors call setProperty(key, null) (ResilienceProcessor.java:573-574, FaultToleranceProcessor.java:347-348), where TryProcessor.java:141-145 removes the property. It works the same, because getInternalProperties() skips null values. Matching TryProcessor would make it easier for a reader to compare the two.


Claude Code on behalf of Claus Ibsen. This review was generated by an AI agent and may contain inaccuracies; please verify all suggestions before applying.

@davsclaus davsclaus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TL;DR: Approve. Thanks @allthingssecurity, 4048257 addresses all four points: the failing-fallback case is tested in both modules and has its upgrade-guide sentence, circuitBreakerInDoCatchKeepsTheFailureHandled now throws after the circuit breaker inside the doCatch so it fails without the restore, the asynchronous fallback path is covered, and restoreScopeProperty removes the property when the enclosing scope had none, as TryProcessor does. CI is green.


Claude Code on behalf of Claus Ibsen. This review was generated by an AI agent and may contain inaccuracies; please verify all suggestions before applying.

@allthingssecurity

Copy link
Copy Markdown
Contributor Author

Thanks for the review. All four points are in 4048257.

  1. A failing fallback inside a doTry: new test failingFallbackIsCaught in both ResilienceOuterTryScopeTest and FaultToleranceOuterTryScopeTest, with your route. It fails with the processors of main and passes here. The upgrade guide entry has your sentence.
  2. circuitBreakerInDoCatchKeepsTheFailureHandled now uses your route: a failing step after the circuit breaker inside the doCatch. It expects mock:second 0 and the IllegalArgumentException on the exchange. With the restore changed to removeProperty(EXCEPTION_HANDLED) it fails in both classes (mock:second gets 1).
  3. New asynchronousLaterFailureAfterRecoveredFallbackIsCaught (Resilience4j), the async variant of direct:afterFallback. It fails on main and passes here.
  4. Both processors now remove the property when the enclosing scope had none, through a small restoreScopeProperty helper, as TryProcessor does.

From your review of #28779, finding 2: the routing slip test for a route called from circuitBreaker() went into #28779 (ResilienceCalledRouteRoutingSlipTest in camel-resilience4j) instead of here. It needs the #28779 change. On this branch alone the whole slip is redelivered, so the first step gets 3 messages and the test fails.

Tests: both classes 20 times in one JVM (280 tests); camel-resilience4j (93) and camel-microprofile-fault-tolerance (43), all tests, 0 failures. The description is updated.

Claude Code on behalf of allthingssecurity

@davsclaus davsclaus added this to the 4.24.0 milestone Nov 1, 2026
@davsclaus davsclaus added the bug Something isn't working label Nov 1, 2026
@davsclaus
davsclaus merged commit 930d4d3 into apache:main Nov 1, 2026
7 checks passed

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. The fix correctly models TryProcessor's scope-save/restore semantics for both circuit breaker implementations.

Verified:

  • restoreScopeProperty calls removeProperty(key) when the enclosing scope had no value (null) and setProperty(key, value) otherwise — identical behavior to TryProcessor for TRY_ROUTE_BLOCK; and functionally equivalent to TryProcessor's unconditional setProperty(key, null) for EXCEPTION_HANDLED, since all consumers (CatchProcessor:135, CatchDefinition:125) test getProperty() != null, which returns false in both cases.
  • ResilienceProcessor: the restoreScope wrapper callback is passed to both processAsync and processSync, so all exit paths (sync success, async whenComplete, setup-failure fast-path) restore the properties exactly once. No stale removeProperty(TRY_ROUTE_BLOCK) calls remain.
  • FaultToleranceProcessor: sync-only; restore runs at line 347-348, before callback.done(true), covering all exit paths within the try/finally block.
  • Async lambda captures are effectively final; the exchange handoff model guarantees single-thread access at each point, so no race.
  • davsclaus's four findings (failing-fallback test, doCatch control test, async fallback path test, setProperty(null) vs removeProperty) are all already addressed or non-issues in the current version of the PR.
  • Pre-fix test validation: 10 of the 14 tests fail on main per the PR description; the two control tests (outsideTryUsesGlobalErrorHandler, circuitBreakerInDoCatchKeepsTheFailureHandled) pass on main. Static trace confirms each failing test exercises the exact properties restored by this fix.

This review was generated by an AI agent, Hermès on behalf of @gnodet.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working components docs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants