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?
I fixed it. The key was to replace
Thread.sleep()withMono.delay()and to enable WebFlux (so that the
RouterFunctionwas picked up)Here's the full working version (I didn't change other files):