Gonzalo Martinez
Gonzalo Martinez

Reputation: 89

Can't use a ControllerAdvice with ExceptionHandlers while using Circuit Breaker from resilience4j

So, I was doing a simple spring boot project using resilience4j's tools, like @RateLimiter, @CircuitBreaker and @Retry. All was working fine, even with personalized exceptions, because they were caught within my service.

Now I want my service to simply throw exceptions, and manage the handling in a @RestControllerAdvice with @ExceptionHandler(s), instead of catching the exceptions and managing them within the service, as I see many people do it like that. It also makes sense, because that way, the service only does strictly what it needs to do, and another class can manage the exceptions.

The problem is that, from what I've seen (not much), all these people that do that don't use @CircuitBreaker too. To be more specific, the problem is that the exceptions are caught first by the Circuit Breaker's fallback method instead of the Exception Handlers.

Here's some code:

@CircuitBreaker(name = "clientCircuitBreaker", fallbackMethod = "fallbackCircuitBreaker")
@Retry(name="clientRetry")
@RateLimiter(name="clientRateLimiter", fallbackMethod = "fallbackRateLimiter")
@GetMapping
public ResponseEntity<?> getClient(@RequestParam long doc) {
    return this.clientService.getClient(doc);
}

public ResponseEntity<Object> fallbackCircuitBreaker(Throwable exception) {
    return this.clientService.fallbackCircuitBreaker(exception);
}

//...

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(IErrorException.class)
    public ResponseEntity<ErrorModel> personalizedException(IErrorException ex) {
        return ResponseEntity.status(404).body(ex.getErrorModel());
    }

    //...
}

I've also tried changing the type of the @ExceptionHandler to a more specific one (and make it throw that exception), because that right there is an interface/abstract class, but nothing. I also tried using the configuration ignoreExceptions(..) for the Circuit Breaker in a java configuration file, ignoring 'IErrorException.class', but nothing.

Any idea? Or should I give up this line of thought, and just manage exceptions inside of the service?

Upvotes: 1

Views: 1851

Answers (3)

Gonzalo Martinez
Gonzalo Martinez

Reputation: 89

Update: I've found a better solution.

So, it's been a while. I've been running with the above answer, but I've come to understand that it's a bit lacking. The reason is that, if you now need to ignore some new exceptions, that aren't a subclass of IErrorException (example: internal database related exceptions), you have to add a new method for each one, which is trash.

So I was thinking about how this normally works (when I don't add any fallback method and I configure the exceptions to be ignored in the application.yml/properties). In that case you just add a list of exceptions you want to ignore and that's that. So my new solution follows that idea, though to be honest I still feel like it could be improved.

@RequestMapping("/api/${env.version}/clients")
@RestController
public class ClientController implements IClientController {

    private final ClientService clientService;
    private final CircuitBreakerRegistry circuitBreakerRegistry;

    @Autowired
    public ClientController(ClientService clientService, CircuitBreakerRegistry circuitBreakerRegistry) {
        this.clientService = clientService;
        this.circuitBreakerRegistry = circuitBreakerRegistry;
    }

    @CircuitBreaker(name = "circuitBreaker", fallbackMethod = "fallbackCircuitBreaker")
    @Retry(name = "retry")
    @RateLimiter(name = "rateLimiter", fallbackMethod = "fallbackRateLimiter")
    @GetMapping
    @ResponseStatus(HttpStatus.OK)
    @ResponseBody
    public ClientModel getClient(@RequestParam long doc) {
        return clientService.getClient(doc);
    }

    // fallbacks

    public ResponseModel fallbackCircuitBreaker(long doc, Exception exception) throws Exception {
        ArrayList<Class> ignoredExceptions = new ArrayList<>();
        ignoredExceptions.add(DataAccessException.class);
        ignoredExceptions.add(IErrorException.class);
        ignoredExceptions.add(RateLimiterException.class);

        if (ignoredExceptions.stream().anyMatch(ex -> ex.isAssignableFrom(exception.getClass()))) {
            circuitBreakerRegistry.circuitBreaker("circuitBreaker").transitionToClosedState();
            throw exception;
        }

        throw new CircuitBreakerException();
    }

    public ClientModel fallbackRateLimiter(long doc, RequestNotPermitted exception) {
        throw new RateLimiterException();
    }

}

Thus, if you now need to ignore a new exception, you just copy the last ignoredExceptions.add(...) line and just change the exception to the one you want. Just like that it works.

Like I also said, I think there's still room for improvement here, like maybe using the properties from appliaction.yml/properties that are normally used to ignore exceptions for the circuit breaker (that don't work here, because I'm setting fallbaks) by catching them with @Value as a list inside the controller or something, thus never even needing to change the code again, just the appliaction.yml/properties. But we'll see.

For just a few exceptions, the previous answer was good enough, but for more (3 or more in my opinion), I think this is better.

Upvotes: 0

Gonzalo Martinez
Gonzalo Martinez

Reputation: 89

Update: this is my solution for now

@RequestMapping("/api/${env.version}/clients")
@RestController
public class ClientController implements IClientController {

    private final ClientService clientService;
    private final CircuitBreakerRegistry circuitBreakerRegistry;

    @Autowired
    public ClientController(ClientService clientService, CircuitBreakerRegistry circuitBreakerRegistry) {
        this.clientService = clientService;
        this.circuitBreakerRegistry = circuitBreakerRegistry;
    }

    @CircuitBreaker(name = "clientCircuitBreaker", fallbackMethod = "fallbackCircuitBreaker")
    @Retry(name = "clientRetry")
    @RateLimiter(name = "clientRateLimiter", fallbackMethod = "fallbackRateLimiter")
    @GetMapping
    @ResponseStatus(HttpStatus.OK)
    @ResponseBody
    public ClientModel getClient(@RequestParam long doc) {
        return clientService.getClient(doc);
    }

    // fallbacks

    public ClientModel fallbackCircuitBreaker(long doc, IErrorException exception) {
        circuitBreakerRegistry.circuitBreaker("clientCircuitBreaker").transitionToClosedState();
        throw exception;
    }

    public ClientModel fallbackCircuitBreaker(long doc, Throwable exception) {
        throw new CircuitBreakerException();
    }

    public ClientModel fallbackRateLimiter(long doc, RequestNotPermitted exception) {
        throw new RateLimiterException();
    }

}

This is the best I can get without being able to use ignoreExceptions. About that, I confirmed that ignoreExceptions for Retry works, but ignoreExceptions for Circuit Breaker doesn't.

I thought that the fact that I'm using a fallback method for Circuit Breaker and not for Retry could have something to do with it (maybe ???). This solution and what I just said come from here. Although, it's not ideal, because what I'm doing when catching the IErrorException exception is just throwing it again and just ignoring the intervention of the Circuit Breaker.

Upvotes: 0

Gonzalo Martinez
Gonzalo Martinez

Reputation: 89

Quick note: the annotations are executed in the inverse order that they appear, meaning @RateLimiter, then @Retry, then @CircuitBreaker. The circuit breaker, therefore, is the last failsafe to catch any unexpected exceptions.

Hi, I'm the one that asked the question, and I have found a temporary (and rather sus in my opinion) solution.

It goes like this:

public ResponseEntity<Object> fallbackCircuitBreaker(Throwable exception) throws Throwable {
        if(!(exception instanceof IErrorException)) {
            return this.clienteService.fallbackCircuitBreaker(exception);
        } else {
            circuitBreakerRegistry.circuitBreaker("clientCircuitBreaker").transitionToClosedState();
            throw exception;
        }
    }

Like I said, it solves it because, even though the circuit breaker still catches it, if it's a personalized exception, it re-throws it, and it's then caught by the exception handlers. The problem is that it does still trigger the circuit breaker, so, if it was a personalized exception, I also force a transition to closed state to "make it so nothing happened". Suspicious, as I said before.

Something of note is that I also have a @Retry like it shows in my controller, so the above solution worked for the circuit breaker, but it still retried a couple of times when throwing a personalized exception. To solve this, I ventured to use .ignoreExceptions(IErrorException.class) in the retry configuration, which didn't work in the circuit breaker configuration last time I chequed. But surprisingly, it worked.

Thus, I reached the intended behaviour. It works just like catching my personalized exceptions in the service and returning a proper reponse, just that it's better distributed now. However, I feel like it's not the best option. So I ask: does something like .ignoreExceptions(IErrorException.class) work for you in your circuit breaker configuration?

Here are my dependencies and configs (relevant to this):

@Configuration
public class CircuitBreakerConfiguration {

    @Primary
    @Bean
    public CircuitBreakerProperties circuitBreakerProperties() {
        CircuitBreakerProperties circuitBreakerProperties = new CircuitBreakerProperties();
        circuitBreakerProperties.setCircuitBreakerAspectOrder(1);
        return circuitBreakerProperties;
    }

    @Bean
    public CircuitBreakerRegistry circuitBreakerRegistry() {
        CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.custom()
            .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
            .slidingWindowSize(5)
            .failureRateThreshold(40)
            .slowCallRateThreshold(40)
            .permittedNumberOfCallsInHalfOpenState(1)
            .maxWaitDurationInHalfOpenState(Duration.ofSeconds(10))
            .waitDurationInOpenState(Duration.ofSeconds(10))
            .writableStackTraceEnabled(true)
            .minimumNumberOfCalls(5)
            .automaticTransitionFromOpenToHalfOpenEnabled(true)
                .ignoreExceptions(IErrorException.class) // last time I chequed, it did nothing
            .build();
   
        return CircuitBreakerRegistry.of(circuitBreakerConfig);
    }

}
@Configuration
public class RetryConfiguration {

    @Primary
    @Bean
    public RetryProperties retryProperties() {
        RetryProperties retryProperties = new RetryProperties();
        retryProperties.setRetryAspectOrder(2);
        return retryProperties;
    }

    @Bean
    public RetryRegistry retryRegistry() {
        RetryConfig retryConfig = RetryConfig.custom()
            .maxAttempts(3)
            .waitDuration(Duration.ofSeconds(1))
                .ignoreExceptions(IErrorException.class) // works
            .build();

        return RetryRegistry.of(retryConfig);
    }

}

      <!-- ... -->
      <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.1.2</version>
        <relativePath/> <!-- lookup parent from repository -->
      </parent>
      <!-- ... -->

      <dependencies>

        <!--Resilience4j (Circuit Breaker, Rate Limiter y Retry)-->

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>

        <dependency>
            <groupId>io.github.resilience4j</groupId>
            <artifactId>resilience4j-spring-boot3</artifactId>
            <version>2.1.0</version>
        </dependency>

        <!-- ... -->

      <dependencies>

      <!-- ... -->

Upvotes: 1

Related Questions