How do I test Spring Cloud's circuit breaker filter?

62 views Asked by At

Here's an MRE. I took some time, wrote it from scratch, and tried to make it small

package com.example.gatewaydemo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class GatewaydemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(GatewaydemoApplication.class, args);
    }

}
package com.example.gatewaydemo.config;

import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import io.github.resilience4j.timelimiter.TimeLimiterConfig;
import org.springframework.cloud.circuitbreaker.resilience4j.ReactiveResilience4JCircuitBreakerFactory;
import org.springframework.cloud.circuitbreaker.resilience4j.Resilience4JConfigBuilder;
import org.springframework.cloud.client.circuitbreaker.Customizer;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.OrderedGatewayFilter;
import org.springframework.cloud.gateway.filter.factory.SpringCloudCircuitBreakerFilterFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerResponse;

@Configuration
public class MyCircuitBreakerConfig {
    private final ServerProperties serverProperties;

    public MyCircuitBreakerConfig(ServerProperties serverProperties) {
        this.serverProperties = serverProperties;
    }

    @Bean
    public Customizer<ReactiveResilience4JCircuitBreakerFactory> circuitBreakerCustomizer() {
        return factory -> factory.configureDefault(id -> new Resilience4JConfigBuilder(id)
                .circuitBreakerConfig(CircuitBreakerConfig.ofDefaults())
                .timeLimiterConfig(TimeLimiterConfig.custom()
                        .timeoutDuration(serverProperties.getTimeout())
                        .build())
                .build());
    }

    @Bean
    // this is what I want to test
    public GatewayFilter circuitBreakerFilter(SpringCloudCircuitBreakerFilterFactory filterFactory) {
        return wrapInOrderedGatewayFilter(
                filterFactory.apply("route-id",
                        config -> config.setFallbackUri("/fallback")
                ));
    }

    // sometimes filters don't get applied unless wrapped in OrderedGatewayFilter
    private static GatewayFilter wrapInOrderedGatewayFilter(GatewayFilter wrappee) {
        return new OrderedGatewayFilter(wrappee, 0);
    }

    @Bean
    public RouterFunction<ServerResponse> fallbackRouterFunction() {
        return RouterFunctions.route()
                .GET("/fallback", serverRequest -> ServerResponse.status(HttpStatus.GATEWAY_TIMEOUT)
                        .bodyValue("Timeout!"))
                .build();
    }
}
package com.example.gatewaydemo.config;

import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;

import java.time.Duration;

@ConfigurationProperties(prefix = "my-app")
@Getter
@Setter
public class ServerProperties {
    private Duration timeout;
}
# application.yml

my-app:
  timeout: 5s
<?xml version="1.0" encoding="UTF-8"?>
<!--suppress VulnerableLibrariesLocal -->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.1.5</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>gatewaydemo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>gatewaydemo</name>
    <description>gatewaydemo</description>
    <properties>
        <java.version>17</java.version>
        <spring-cloud.version>2022.0.4</spring-cloud.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-gateway</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-circuitbreaker-reactor-resilience4j</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>io.projectreactor</groupId>
            <artifactId>reactor-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

Here's my attempt at testing it:

package com.example.gatewaydemo;

import com.example.gatewaydemo.config.MyCircuitBreakerConfig;
import com.example.gatewaydemo.config.ServerProperties;
import io.github.resilience4j.springboot3.circuitbreaker.autoconfigure.CircuitBreakerAutoConfiguration;
import io.github.resilience4j.springboot3.timelimiter.autoconfigure.TimeLimiterConfigurationOnMissingBean;
import io.github.resilience4j.springboot3.timelimiter.autoconfigure.TimeLimiterProperties;
import lombok.SneakyThrows;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cloud.circuitbreaker.resilience4j.ReactiveResilience4JAutoConfiguration;
import org.springframework.cloud.circuitbreaker.resilience4j.Resilience4JConfigurationProperties;
import org.springframework.cloud.gateway.config.GatewayResilience4JCircuitBreakerAutoConfiguration;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.factory.SpringCloudCircuitBreakerFilterFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.http.server.reactive.MockServerHttpResponse;
import org.springframework.mock.web.server.MockServerWebExchange;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;

import java.time.Duration;

import static com.example.gatewaydemo.CircuitBreakerTest.CircuitBreakerTestConfig;

@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = CircuitBreakerTestConfig.class)
public class CircuitBreakerTest {
    static final Duration TIMEOUT = Duration.ofMillis(100);
    @Autowired
    SpringCloudCircuitBreakerFilterFactory filterFactory;
    @Autowired
    ServerProperties serverProperties;

    @Test
    void circuitBreakerGatewayFilter_redirectsToFallbackOnSlowResponses() {
        MyCircuitBreakerConfig myCircuitBreakerConfig = new MyCircuitBreakerConfig(serverProperties);
        GatewayFilter circuitBreakerFilter = myCircuitBreakerConfig.circuitBreakerFilter(filterFactory);

        MockServerWebExchange exchange =
                MockServerWebExchange.builder(MockServerHttpRequest.get("/").build()).build();
        long marginOfError = 100;
        GatewayFilterChain slowChain = e -> {
            sleep(TIMEOUT.plusMillis(marginOfError));
            return e.getResponse().writeWith(Mono.just(DefaultDataBufferFactory.sharedInstance.wrap(
                    "If you read this in the response body, the circuit breaker didn't work".getBytes()
            )));
        };
        StepVerifier.create(circuitBreakerFilter.filter(exchange, slowChain))
                .verifyComplete();
        // I guess it should have the right status code by now?
        MockServerHttpResponse response = exchange.getResponse();
        debuggerStopper(); // no!
    }

    @SneakyThrows
    private static void sleep(Duration sleepDuration) {
        Thread.sleep(sleepDuration.toMillis());
    }

    private void debuggerStopper() {}

    @Configuration
    @EnableConfigurationProperties({
            TimeLimiterProperties.class,
            Resilience4JConfigurationProperties.class
    })
    @Import({TimeLimiterConfigurationOnMissingBean.class,
            CircuitBreakerAutoConfiguration.class,
            ReactiveResilience4JAutoConfiguration.class,
            GatewayResilience4JCircuitBreakerAutoConfiguration.class,
            MyCircuitBreakerConfig.class})
    static class CircuitBreakerTestConfig {
        @Bean
        ServerProperties serverProperties() {
            ServerProperties gatewayMeta = new ServerProperties();
            gatewayMeta.setTimeout(TIMEOUT);
            return gatewayMeta;
        }
    }
}

I want to point out that a similar circuit breaker does work in "production" so my configuration is very likely to be correct. It's the test that's got to be wrong

The redirect apparently never happens: a breakpoint put at the HandlerFunction lambda never gets hit. In fact, it's considered a success

// SpringCloudCircuitBreakerFilterFactory

    @Override
    public GatewayFilter apply(Config config) {
        ReactiveCircuitBreaker cb = reactiveCircuitBreakerFactory.create(config.getId());
        Set<HttpStatus> statuses = config.getStatusCodes().stream().map(HttpStatusHolder::parse)
                .filter(statusHolder -> statusHolder.getHttpStatus() != null).map(HttpStatusHolder::getHttpStatus)
                .collect(Collectors.toSet());

        return new GatewayFilter() {
            @Override
            public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
                // a breakpoint at the doOnSuccess lambda is hit
                return cb.run(chain.filter(exchange).doOnSuccess(v -> {
                    if (statuses.contains(exchange.getResponse().getStatusCode())) {

So how do I test my circuit breaker filter?

1

There are 1 answers

0
Powet On

I fixed it. The key was to replace Thread.sleep() with Mono.delay()

GatewayFilterChain slowChain = e -> Mono.delay(TIMEOUT.plusMillis(marginOfError)).then(Mono.empty());

and to enable WebFlux (so that the RouterFunction was picked up)

@EnableWebFlux
static class CircuitBreakerTestConfig {

Here's the full working version (I didn't change other files):

package com.example.gatewaydemo.config;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.factory.SpringCloudCircuitBreakerFilterFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.http.server.reactive.MockServerHttpResponse;
import org.springframework.mock.web.server.MockServerWebExchange;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.web.reactive.config.EnableWebFlux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;

import java.time.Duration;

import static com.example.gatewaydemo.config.CircuitBreakerTest.CircuitBreakerTestConfig;
import static org.assertj.core.api.Assertions.assertThat;

@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = CircuitBreakerTestConfig.class)
public class CircuitBreakerTest {
    static final Duration TIMEOUT = Duration.ofMillis(100);

    @Autowired
    SpringCloudCircuitBreakerFilterFactory filterFactory;
    
    @Autowired
    ServerProperties serverProperties;

    @Test
    void circuitBreakerGatewayFilter_redirectsToFallbackOnSlowResponses() {
        MyCircuitBreakerConfig myCircuitBreakerConfig = new MyCircuitBreakerConfig(serverProperties);
        GatewayFilter circuitBreakerFilter = myCircuitBreakerConfig.circuitBreakerFilter(filterFactory);

        MockServerWebExchange exchange = MockServerWebExchange.builder(MockServerHttpRequest.get("/")).build();
        
        long marginOfError = 100;
        GatewayFilterChain slowChain = e -> Mono.delay(TIMEOUT.plusMillis(marginOfError)).then(Mono.empty());
        
        StepVerifier.create(circuitBreakerFilter.filter(exchange, slowChain))
            .verifyComplete();
        MockServerHttpResponse response = exchange.getResponse();

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.GATEWAY_TIMEOUT);
        StepVerifier.create(response.getBodyAsString())
            .assertNext(responseBodyValue ->
        assertThat(responseBodyValue).containsIgnoringCase("timeout"))
            .verifyComplete();
    }

    @Configuration
    @EnableCircuitBreaker
    @EnableWebFlux
    static class CircuitBreakerTestConfig {
        @Bean
        ServerProperties serverProperties() {
            ServerProperties gatewayMeta = new ServerProperties();
            gatewayMeta.setTimeout(TIMEOUT);
            return gatewayMeta;
        }
    }
}
package com.example.gatewaydemo.config;

import io.github.resilience4j.springboot3.circuitbreaker.autoconfigure.CircuitBreakerAutoConfiguration;
import io.github.resilience4j.springboot3.timelimiter.autoconfigure.TimeLimiterConfigurationOnMissingBean;
import io.github.resilience4j.springboot3.timelimiter.autoconfigure.TimeLimiterProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cloud.circuitbreaker.resilience4j.ReactiveResilience4JAutoConfiguration;
import org.springframework.cloud.circuitbreaker.resilience4j.Resilience4JConfigurationProperties;
import org.springframework.cloud.gateway.config.GatewayResilience4JCircuitBreakerAutoConfiguration;
import org.springframework.context.annotation.Import;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@EnableConfigurationProperties({
        TimeLimiterProperties.class,
        Resilience4JConfigurationProperties.class
})
@Import({TimeLimiterConfigurationOnMissingBean.class,
        CircuitBreakerAutoConfiguration.class,
        ReactiveResilience4JAutoConfiguration.class,
        GatewayResilience4JCircuitBreakerAutoConfiguration.class,
        MyCircuitBreakerConfig.class})
// just for better readability
public @interface EnableCircuitBreaker {
}