Repository: resilience4j/resilience4j (Java)

Issue:
Issue with `recordExceptionPredicate` and `ignoreExceptionPredicate` for `WebClientResponseException` variants in Circuit Breaker configuration
#### **Description**:
We are facing an issue with the behavior of the `recordExceptionPredicate` and `ignoreExceptionPredicate` in our circuit breaker configuration for `WebClientResponseException` and its subclasses, particularly `BadRequest` and `NotFound`. The issue arises when specific subclasses of `WebClientResponseException`, such as `BadRequest`, are listed under `ignoreExceptions`, which results in conflicting behavior when evaluating exceptions. Specifically, the predicate for `recordExceptionPredicate` returns `true` for exceptions like `WebClientResponseException.BadRequest`, while it should be ignored according to the configuration.

The issue seems to be rooted in how the configuration differentiates between the parent class (`WebClientResponseException`) and its subclasses (`BadRequest`, `NotFound`, etc.), causing an overlap in the `recordExceptionPredicate` and `ignoreExceptionPredicate` behavior.

#### **Circuit Breaker Configuration**:
```yaml
resilience4j:
  circuitbreaker:
    circuitBreakerAspectOrder: 1
    instances:
      mlPlatformCircuitBreaker:
        waitDurationInOpenState: 300
        slidingWindowSize: 50
        minimumNumberOfCalls: 5
        slidingWindowType: COUNT_BASED
        failureRateThreshold: 50
        slowCallRateThreshold: 50
        slowCallDurationThreshold: 200
        recordExceptions:
          - org.springframework.web.reactive.function.client.WebClientRequestException
          - org.springframework.web.reactive.function.client.WebClientResponseException
          - java.util.concurrent.TimeoutException
          - java.net.UnknownHostException
        ignoreExceptions:
          - org.springframework.web.reactive.function.client.WebClientResponseException$BadRequest
          - org.springframework.web.reactive.function.client.WebClientResponseException$NotFound
          - java.net.ConnectException
```

#### **Test Code**:
```java
  private record CircuitBreakerTestConfig(Long waitDurationInOpenState,
                                            Integer slidingWindowSize,
                                            Integer minimumNumberOfCalls,
                                            String slidingWindowType,
                                            Float failureRateThreshold,
                                            Float slowCallRateThreshold,
                                            Long slowCallDurationThreshold,
                                            List<Class<? extends Throwable>> recordExceptions,
                                            List<Class<? extends Throwable>> ignoreExceptions) {}

  private record CircuitBreakerTestParams(String breakerName,
                                          String testUrl,
                                          String successBody,
                                          String className,
                                          String methodName,
                                          Object[] methodArgs,
                                          CircuitBreakerTestConfig testConfig) {}

  private static Stream<Arguments> circuitBreakerTestParams() {
      return Stream.of(
              Arguments.of(new CircuitBreakerTestParams(
                      "mlPlatformCircuitBreaker",
                      "/v2/models/mealpreptimepredictionlgb/versions/1/infer",
                      "{\"outputs\": [{\"data\": [1.23]}]}",
                      "com.trendyol.orange.deliverystoreanalyticsapi.infrastructure.out.rest.mlops.MLOpsAdapter",
                      "getMealPrepTimePrediction",
                      new Object[]{List.of(1.0, 2.0, 3.0)},
                      new CircuitBreakerTestConfig(
                              300L, 50, 5, "COUNT_BASED",
                              50.0f, 50.0f, 200L,
                              List.of(
                                      org.springframework.web.reactive.function.client.WebClientRequestException.class,
                                      org.springframework.web.reactive.function.client.WebClientResponseException.class,
                                      java.util.concurrent.TimeoutException.class,
                                      java.net.UnknownHostException.class,
                                      java.net.ConnectException.class
                              ),
                              List.of(
                                      org.springframework.web.reactive.function.client.WebClientResponseException.BadRequest.class,
                                      org.springframework.web.reactive.function.client.WebClientResponseException.NotFound.class
                              )
                      )
              ))
      );
  }

@ParameterizedTest
@MethodSource("circuitBreakerTestParams")
void should_circuit_breaker_config_set_correct(CircuitBreakerTestParams params) {
    CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(params.breakerName());
    circuitBreaker.reset();

    long waitDurationMs = circuitBreaker.getCircuitBreakerConfig().getWaitIntervalFunctionInOpenState().apply(1);
    int slidingWindowSize = circuitBreaker.getCircuitBreakerConfig().getSlidingWindowSize();
    String slidingWindowType = circuitBreaker.getCircuitBreakerConfig().getSlidingWindowType().toString();
    float failureRateThreshold = circuitBreaker.getCircuitBreakerConfig().getFailureRateThreshold();
    float slowCallRateThreshold = circuitBreaker.getCircuitBreakerConfig().getSlowCallRateThreshold();
    int minimumNumberOfCalls = circuitBreaker.getCircuitBreakerConfig().getMinimumNumberOfCalls();
    Long slowCallDurationThreshold = circuitBreaker.getCircuitBreakerConfig().getSlowCallDurationThreshold().toMillis();

    assertEquals(params.testConfig().waitDurationInOpenState(), waitDurationMs);
    assertEquals(params.testConfig().slidingWindowSize(), slidingWindowSize);
    assertEquals(params.testConfig().slidingWindowType(), slidingWindowType);
    assertEquals(params.testConfig().failureRateThreshold(), failureRateThreshold);
    assertEquals(params.testConfig().failureRateThreshold(), failureRateThreshold);
    assertEquals(params.testConfig().slowCallRateThreshold(), slowCallRateThreshold);
    assertEquals(params.testConfig().minimumNumberOfCalls(), minimumNumberOfCalls);
    assertEquals(params.testConfig().slowCallDurationThreshold(), slowCallDurationThreshold);

    Predicate<Throwable> recordExceptionPredicate = circuitBreaker.getCircuitBreakerConfig().getRecordExceptionPredicate();
    Predicate<Throwable> ignoreExceptionPredicate = circuitBreaker.getCircuitBreakerConfig().getIgnoreExceptionPredicate();

    List<Class<? extends Throwable>> testExceptions = List.of(
            org.springframework.web.reactive.function.client.WebClientRequestException.class,
            org.springframework.web.reactive.function.client.WebClientResponseException.class,
            org.springframework.web.reactive.function.client.WebClientResponseException.BadRequest.class,
            org.springframework.web.reactive.function.client.WebClientResponseException.NotFound.class,
            java.util.concurrent.TimeoutException.class,
            java.net.UnknownHostException.class,
            java.net.ConnectException.class
    );

    for (Class<? extends Throwable> exceptionClass : testExceptions) {
        Throwable exceptionInstance = createExceptionInstance(exceptionClass);
        boolean isRecorded = recordExceptionPredicate.test(exceptionInstance);
        boolean isIgnored = ignoreExceptionPredicate.test(exceptionInstance);

        if (params.testConfig().ignoreExceptions().contains(exceptionClass)) {
            assertTrue(isIgnored, "Expected exception " + exceptionClass.getSimpleName() + " to be ignored");
            assertFalse(isRecorded, "Expected exception " + exceptionClass.getSimpleName() + " to be ignored");
        } else {
            assertTrue(isRecorded, "Expected exception " + exceptionClass.getSimpleName() + " to be recorded");
            assertFalse(isIgnored, "Expected exception " + exceptionClass.getSimpleName() + " to be recorded");
        }
    }
}
```

When I remove this line of code, the test is passing.
```java 
assertFalse(isRecorded, "Expected exception " + exceptionClass.getSimpleName() + " to be ignored");
```

#### **Observed Issue**:
- **Expected**: When an exception, such as `WebClientResponseException.BadRequest`, is specified under `ignoreExceptions`, it should be ignored by the `ignoreExceptionPredicate` and should not trigger the `recordExceptionPredicate`.
- **Actual**: The `recordExceptionPredicate` returns `true` for `WebClientResponseException.BadRequest` even though it should be ignored. Other exceptions listed under `ignoreExceptions`, such as `java.net.ConnectException`, are correctly ignored.

We use spring boot 3.1.2 and resilience4j 2.0.2


Discussion: <!-- Greeting -->
Hey there, @enesoral! I'm here to help you with any bugs, questions, or contributions. Let me know how I can assist you while we wait for a human maintainer.

<!-- Answer -->
In Resilience4j, when an exception is both in the `recordExceptions` and `ignoreExceptions` lists, the exception will be ignored. Ignoring an exception has priority over recording it <sup>[[1]](https://github.com/resilience4j/resilience4j/blob/master/resilience4j-circuitbreaker/src/main/java/io/github/resilience4j/circuitbreaker/CircuitBreakerConfig.java)</sup>.

In your test, the line `assertFalse(isRecorded, "Expected exception " + exceptionClass.getSimpleName() + " to be ignored");` is failing because the `recordExceptionPredicate` is returning `true` for `WebClientResponseException.BadRequest`, which should not happen since it is listed in `ignoreExceptions`.

To resolve this, ensure that your `ignoreExceptionPredicate` is correctly configured to return `true` for `WebClientResponseException.BadRequest`. This should automatically prevent the `recordExceptionPredicate` from returning `true` for the same exception, as ignoring takes precedence.

Double-check your configuration and predicates to ensure they align with this behavior. If the issue persists, it might be worth reviewing the implementation of your predicates to ensure they are correctly evaluating the exceptions based on your configuration.


<!-- Dosu Comment Footer -->
*To continue talking to [Dosu](https://dosu.dev), mention @dosu.*

---
<sup>Help Dosu learn! Give it feedback: [Great Response](https://app.dosu.dev/response-feedback/5dee8a3f-0ccc-4b1b-acb5-6dca04640c5f?feedback_type=great_response) | [Irrelevant Answer](https://app.dosu.dev/response-feedback/5dee8a3f-0ccc-4b1b-acb5-6dca04640c5f?feedback_type=irrelevant_answer) | [Incorrect Sources](https://app.dosu.dev/response-feedback/5dee8a3f-0ccc-4b1b-acb5-6dca04640c5f?feedback_type=incorrect_sources) | [Too Verbose](https://app.dosu.dev/response-feedback/5dee8a3f-0ccc-4b1b-acb5-6dca04640c5f?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/5dee8a3f-0ccc-4b1b-acb5-6dca04640c5f?feedback_type=hallucination) | [Bug Report](https://app.dosu.dev/response-feedback/5dee8a3f-0ccc-4b1b-acb5-6dca04640c5f?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/5dee8a3f-0ccc-4b1b-acb5-6dca04640c5f?feedback_type=other)</sup>




Fix (patch):
diff --git a/resilience4j-circuitbreaker/src/main/java/io/github/resilience4j/circuitbreaker/CircuitBreakerConfig.java b/resilience4j-circuitbreaker/src/main/java/io/github/resilience4j/circuitbreaker/CircuitBreakerConfig.java
index 43cf6d9e3..829df5b95 100644
--- a/resilience4j-circuitbreaker/src/main/java/io/github/resilience4j/circuitbreaker/CircuitBreakerConfig.java
+++ b/resilience4j-circuitbreaker/src/main/java/io/github/resilience4j/circuitbreaker/CircuitBreakerConfig.java
@@ -97,6 +97,7 @@ public class CircuitBreakerConfig implements Serializable {
         .ofSeconds(DEFAULT_WAIT_DURATION_IN_HALF_OPEN_STATE);
     private State transitionToStateAfterWaitDuration = DEFAULT_TRANSITION_TO_STATE_AFTER_WAIT_DURATION;
     private transient Clock clock = DEFAULT_CLOCK;
+    private boolean ignoreExceptionsPrecedenceEnabled = false;
 
     private CircuitBreakerConfig() {
     }
@@ -216,6 +217,10 @@ public Clock getClock() {
         return clock;
     }
 
+    public boolean isIgnoreExceptionsPrecedenceEnabled() {
+        return ignoreExceptionsPrecedenceEnabled;
+    }
+
     public enum SlidingWindowType {
         TIME_BASED, COUNT_BASED
     }
@@ -275,6 +280,8 @@ public String toString() {
         circuitBreakerConfig.append(slowCallRateThreshold);
         circuitBreakerConfig.append(", slowCallDurationThreshold=");
         circuitBreakerConfig.append(slowCallDurationThreshold);
+        circuitBreakerConfig.append(", ignoreExceptionsPrecedenceEnabled=");
+        circuitBreakerConfig.append(ignoreExceptionsPrecedenceEnabled);
         circuitBreakerConfig.append("}");
         return circuitBreakerConfig.toString();
     }
@@ -335,7 +342,7 @@ public Instant getWaitUntil() {
         }
     }
 
-	public static class Builder {
+    public static class Builder {
 
         @Nullable
         private Predicate<Throwable> recordExceptionPredicate;
@@ -375,6 +382,7 @@ public static class Builder {
         private State transitionToStateAfterWaitDuration = DEFAULT_TRANSITION_TO_STATE_AFTER_WAIT_DURATION;
         private byte createWaitIntervalFunctionCounter = 0;
         private Clock clock = DEFAULT_CLOCK;
+        private boolean ignoreExceptionsPrecedenceEnabled = false;
 
 
         public Builder(CircuitBreakerConfig baseConfig) {
@@ -401,6 +409,7 @@ public Builder(CircuitBreakerConfig baseConfig) {
             this.writableStackTraceEnabled = baseConfig.writableStackTraceEnabled;
             this.recordResultPredicate = baseConfig.recordResultPredicate;
             this.clock = baseConfig.clock;
+            this.ignoreExceptionsPrecedenceEnabled = baseConfig.ignoreExceptionsPrecedenceEnabled;
         }
 
         public Builder() {
@@ -910,6 +919,33 @@ public Builder clock(Clock clock) {
             return this;
         }
 
+        /**
+         * Enables ignoreExceptions to take precedence over recordExceptions.
+         * When enabled, if an exception matches both recordExceptions and ignoreExceptions,
+         * it will be ignored. When disabled (default), the legacy behavior is used where
+         * recordExceptions takes precedence for backward compatibility.
+         *
+         * @param enableIgnoreExceptionsPrecedence true to enable new behavior, false for legacy behavior (default)
+         * @return the CircuitBreakerConfig.Builder
+         */
+        public Builder ignoreExceptionsPrecedenceEnabled(boolean enableIgnoreExceptionsPrecedence) {
+            this.ignoreExceptionsPrecedenceEnabled = enableIgnoreExceptionsPrecedence;
+            return this;
+        }
+
+        /**
+         * Enables ignoreExceptions to take precedence over recordExceptions.
+         * This changes the default behavior where recordExceptions takes precedence.
+         * When enabled, exceptions that match both recordExceptions and ignoreExceptions
+         * will be ignored.
+         *
+         * @return the CircuitBreakerConfig.Builder
+         */
+        public Builder enableIgnoreExceptionsPrecedence() {
+            this.ignoreExceptionsPrecedenceEnabled = true;
+            return this;
+        }
+
         /**
          * Builds a CircuitBreakerConfig
          *
@@ -937,6 +973,7 @@ public CircuitBreakerConfig build() {
             config.writableStackTraceEnabled = writableStackTraceEnabled;
             config.recordExceptionPredicate = createRecordExceptionPredicate();
             config.ignoreExceptionPredicate = createIgnoreFailurePredicate();
+            config.ignoreExceptionsPrecedenceEnabled = ignoreExceptionsPrecedenceEnabled;
             config.currentTimestampFunction = currentTimestampFunction;
             config.timestampUnit = timestampUnit;
             config.recordResultPredicate = recordResultPredicate;
@@ -950,8 +987,16 @@ private Predicate<Throwable> createIgnoreFailurePredicate() {
         }
 
         private Predicate<Throwable> createRecordExceptionPredicate() {
-            return PredicateCreator.createExceptionsPredicate(recordExceptionPredicate, recordExceptions)
-                .orElse(DEFAULT_RECORD_EXCEPTION_PREDICATE);
+            Predicate<Throwable> baseRecordExceptionPredicate = PredicateCreator.createExceptionsPredicate(recordExceptionPredicate, recordExceptions)
+                    .orElse(DEFAULT_RECORD_EXCEPTION_PREDICATE);
+
+
+            if (ignoreExceptionsPrecedenceEnabled) {
+                return throwable ->
+                        !createIgnoreFailurePredicate().test(throwable) &&
+                                baseRecordExceptionPredicate.test(throwable);
+            }
+            return baseRecordExceptionPredicate;
         }
 
         private IntervalFunction validateWaitIntervalFunctionInOpenState() {