11

Spring Cloud Feign traffic cut-off with Resilience4J TimeLimiter

 2 years ago
source link: https://arnoldgalovics.com/spring-cloud-feign-traffic-cut-off-with-resilience4j-timelimiter/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Spring Cloud Feign traffic cut-off with Resilience4J TimeLimiter

Most probably you’ve at least heard about circuit breaking if you’ve dealt with microservices.

HTTP is still a common protocol to choose when setting up the communication channel between your microservices and while it’s awesome, there are definitely some downsides.

For example since it’s a synchronous request/response based protocol, after sending a request to the other end, you gotta wait for the reply to come back. And sometimes that could take an awful amount of time.

Let’s take an example architecture:

The

online-store-service
online-store-service exposes APIs to the public world like creating and buying products. For every request that comes in, the user’s session has to be validated. The validation happens using the
user-session-service
user-session-service

. It has a single API that will respond whether the given session is valid or not.

What if the

user-session-service
user-session-service

gets overloaded with tons of requests and unable to cope with the load in a reasonable time?

That slowness will cascade to the exposed APIs provided by the

online-store-service
online-store-service

. And what if there’s an SLA for those public APIs, like responding within 1 second, let it be a positive response or an error one.

We’ll check how to make sure that a Feign client call gets cut off after a specified amount of time.

The Feign client

Let’s create a simple Feign client for the use-case above:

@FeignClient(name = "user-session-service")
public interface UserSessionClient {
@GetMapping("/user-sessions/validate")
UserSessionValidatorResponse validateSession(@RequestParam UUID sessionId);
@FeignClient(name = "user-session-service")
public interface UserSessionClient {
    @GetMapping("/user-sessions/validate")
    UserSessionValidatorResponse validateSession(@RequestParam UUID sessionId);
}

Nothing special, one API with one query parameter,

sessionId
sessionId

.

Let’s say, the online-store-service exposes a

POST /products
POST /products API that triggers the session validation. After the session is validated and decided to be valid, it’s going to forward the request to the
inventory-service
inventory-service

to create the actual product.

If the Feign API call for session validation takes X seconds. And the product creation with the

inventory-service
inventory-service takes Y seconds. The overall response time will be X + Y + Z seconds where Z stands for the extra time taken by the
online-store-service
online-store-service

.

A very quick example on this. If the session validation takes 8 seconds and the product creation in the

inventory-service
inventory-service takes 50 ms and the extra added time by the
online-store-service
online-store-service

is 10 ms, then the overall response time will be 8060 ms, a tiny bit more than 8 seconds.

And whatever happens with the session validation in terms of response time, it’s going to cascade into the

online-store-service
online-store-service‘s response time even though the
online-store-service
online-store-service

has an SLA of let’s say 5 seconds. Not good.

To ensure we’re meeting the specified response time – and don’t kill me because I said 5 seconds, I know it’s a lot but honestly a lot of APIs can’t even meet that – we’ll use Resilience4J for the job.

Configuring a Resilience4J TimeLimiter

We’ll use a TimeLimiter from the Resilience4J portfolio.

Let’s add the Resilience4J package to the

build.gradle
build.gradle/
pom.xml
pom.xml

:

// omitted
set('springCloudVersion', "2020.0.4")
dependencies {
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j'
// omitted for simplicity
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
// omitted
// omitted

ext {
	set('springCloudVersion', "2020.0.4")
}

dependencies {
	implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
	implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j'
        // omitted for simplicity
}

dependencyManagement {
	imports {
		mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
	}
}

// omitted

Then, let’s go to the

application.properties
application.properties

of the app and enable Feign circuit breaking (although we’ll not use circuit breaking here):

feign.circuitbreaker.enabled=true
feign.circuitbreaker.enabled=true

When you enable the circuit breaker with Resilience4J there will be a default TimeLimiter configured which I’ll explain in a second, but before doing that, let’s talk a second about the available parameters for a TimeLimiter.

There’s the most critical parameter,

timeoutDuration
timeoutDuration

. It’s configuring how much time you want to give to your client invocations. If it’s set to 1 second, then that’s gonna be the available time window for execution. After 1 second, it’ll cut the traffic off.

The other one is the

cancelRunningFuture
cancelRunningFuture

parameter. It controls whether upon a timeout, the timed out execution shall be cancelled or not.

Back to the default TimeLimiter. It’s configured with a 1 second

timeoutDuration
timeoutDuration and true for
cancelRunningFuture
cancelRunningFuture

.

Let’s create a new

@Configuration
@Configuration class and we’ll use a
Customizer
Customizer

.

@Configuration
public class FeignConfiguration implements FeignFormatterRegistrar {
@Bean
public Customizer<Resilience4JCircuitBreakerFactory> circuitBreakerFactoryCustomizer() {
@Configuration
public class FeignConfiguration implements FeignFormatterRegistrar {
    @Bean
    public Customizer<Resilience4JCircuitBreakerFactory> circuitBreakerFactoryCustomizer() {
        
    }
}

Then let’s go ahead and get a custom TimeLimiter config.

@Configuration
public class FeignConfiguration {
@Bean
public Customizer<Resilience4JCircuitBreakerFactory> circuitBreakerFactoryCustomizer() {
TimeLimiterConfig timeLimiterConfig = TimeLimiterConfig.custom().timeoutDuration(Duration.ofSeconds(5)).build();
return resilience4JCircuitBreakerFactory -> resilience4JCircuitBreakerFactory.configure(builder ->
builder.timeLimiterConfig(timeLimiterConfig), "UserSessionClient#validateSession(UUID)");
@Configuration
public class FeignConfiguration {
    @Bean
    public Customizer<Resilience4JCircuitBreakerFactory> circuitBreakerFactoryCustomizer() {
        TimeLimiterConfig timeLimiterConfig = TimeLimiterConfig.custom().timeoutDuration(Duration.ofSeconds(5)).build();
        return resilience4JCircuitBreakerFactory -> resilience4JCircuitBreakerFactory.configure(builder ->
                builder.timeLimiterConfig(timeLimiterConfig), "UserSessionClient#validateSession(UUID)");
    }
}

This config will cut off the traffic after 5 seconds.

Also, let’s talk a second about the configuration for the specific Feign client using the textual name of the client,

UserSessionClient#validateSession(UUID)
UserSessionClient#validateSession(UUID)

.

By default, they are named after the Feign client interface name and the respective method signature. It’s generated with the

Feign#configKey
Feign#configKey

method.

Due to a bug in the currently released Spring Cloud OpenFeign version the naming doesn’t work properly. Instead of the name above, it generates

HardCodedTarget#validateSession(UUID)
HardCodedTarget#validateSession(UUID)

. It’s already fixed by this commit though.

Anyway, while that’s released, we can provide a custom version of name generator that’s doing exactly that.

@Configuration
public class FeignConfiguration {
@Bean
public Customizer<Resilience4JCircuitBreakerFactory> circuitBreakerFactoryCustomizer() {
TimeLimiterConfig timeLimiterConfig = TimeLimiterConfig.custom().timeoutDuration(Duration.ofSeconds(5)).build();
return resilience4JCircuitBreakerFactory -> resilience4JCircuitBreakerFactory.configure(builder ->
builder.timeLimiterConfig(timeLimiterConfig), "UserSessionClient#validateSession(UUID)");
@Bean
public CircuitBreakerNameResolver circuitBreakerNameResolver() {
return (feignClientName, target, method) -> Feign.configKey(target.type(), method);
@Configuration
public class FeignConfiguration {
    @Bean
    public Customizer<Resilience4JCircuitBreakerFactory> circuitBreakerFactoryCustomizer() {
        TimeLimiterConfig timeLimiterConfig = TimeLimiterConfig.custom().timeoutDuration(Duration.ofSeconds(5)).build();
        return resilience4JCircuitBreakerFactory -> resilience4JCircuitBreakerFactory.configure(builder ->
                builder.timeLimiterConfig(timeLimiterConfig), "UserSessionClient#validateSession(UUID)");
    }

    @Bean
    public CircuitBreakerNameResolver circuitBreakerNameResolver() {
        return (feignClientName, target, method) -> Feign.configKey(target.type(), method);
    }
}

Testing the Resilicence4J TimeLimiter with the Feign client

Now, if we invoke the Feign client and the execution time exceeds the 5 seconds, we’ll get a similar exception message.

No fallback available.
org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException: No fallback available.
at app//org.springframework.cloud.client.circuitbreaker.CircuitBreaker.lambda$run$0(CircuitBreaker.java:31)
at app//io.vavr.control.Try.lambda$recover$6ea7267f$1(Try.java:949)
at app//io.vavr.control.Try.of(Try.java:75)
at app//io.vavr.control.Try.recover(Try.java:949)
at app//worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
Caused by: java.util.concurrent.TimeoutException: TimeLimiter 'UserSessionClient#validateSession(UUID)' recorded a timeout exception.
at java.base/java.util.concurrent.FutureTask.get(FutureTask.java:204)
at io.github.resilience4j.timelimiter.internal.TimeLimiterImpl.lambda$decorateFutureSupplier$0(TimeLimiterImpl.java:46)
at io.github.resilience4j.circuitbreaker.CircuitBreaker.lambda$decorateCallable$3(CircuitBreaker.java:171)
at io.vavr.control.Try.of(Try.java:75)
... 89 more
No fallback available.
org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException: No fallback available.
	at app//org.springframework.cloud.client.circuitbreaker.CircuitBreaker.lambda$run$0(CircuitBreaker.java:31)
	at app//io.vavr.control.Try.lambda$recover$6ea7267f$1(Try.java:949)
	at app//io.vavr.control.Try.of(Try.java:75)
	at app//io.vavr.control.Try.recover(Try.java:949)
	...
	at app//worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
Caused by: java.util.concurrent.TimeoutException: TimeLimiter 'UserSessionClient#validateSession(UUID)' recorded a timeout exception.
	at java.base/java.util.concurrent.FutureTask.get(FutureTask.java:204)
	at io.github.resilience4j.timelimiter.internal.TimeLimiterImpl.lambda$decorateFutureSupplier$0(TimeLimiterImpl.java:46)
	at io.github.resilience4j.circuitbreaker.CircuitBreaker.lambda$decorateCallable$3(CircuitBreaker.java:171)
	at io.vavr.control.Try.of(Try.java:75)
	... 89 more

Good, now let’s create an integration test for this. We want this automated. We don’t want to test every timing scenario manually, it’s just unreliable.

I’ll reuse the TestServiceInstaceListSupplier from my other article. If you haven’t read it, check it out.

Let’s start with a simple test:

@SpringBootTest({"server.port:0", "eureka.client.enabled:false"})
public class CircuitBreakerTest {
@TestConfiguration
public static class TestConfig {
@Bean
public ServiceInstanceListSupplier serviceInstanceListSupplier() {
return new TestServiceInstanceListSupplier("user-session-service", 8082);
@RegisterExtension
static WireMockExtension USER_SESSION_SERVICE = WireMockExtension.newInstance()
.options(WireMockConfiguration.wireMockConfig().port(8082))
.build();
@Autowired
private UserSessionClient userSessionClient;
@Test
public void testTimeLimiterWorks() throws Exception {
String responseBody = "{ \"sessionId\": \"828bc3cb-52f0-482b-8247-d3db5c87c941\", \"valid\": true}";
String uuidString = "828bc3cb-52f0-482b-8247-d3db5c87c941";
UUID uuid = UUID.fromString(uuidString);
USER_SESSION_SERVICE.stubFor(get(urlPathEqualTo("/user-sessions/validate"))
.withQueryParam("sessionId", equalTo(uuidString))
.willReturn(aResponse().withBody(responseBody).withHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE).withFixedDelay(7000)));
@SpringBootTest({"server.port:0", "eureka.client.enabled:false"})
public class CircuitBreakerTest {
    @TestConfiguration
    public static class TestConfig {
        @Bean
        public ServiceInstanceListSupplier serviceInstanceListSupplier() {
            return new TestServiceInstanceListSupplier("user-session-service", 8082);
        }
    }

    @RegisterExtension
    static WireMockExtension USER_SESSION_SERVICE = WireMockExtension.newInstance()
            .options(WireMockConfiguration.wireMockConfig().port(8082))
            .build();

    @Autowired
    private UserSessionClient userSessionClient;

    @Test
    public void testTimeLimiterWorks() throws Exception {
        String responseBody = "{ \"sessionId\": \"828bc3cb-52f0-482b-8247-d3db5c87c941\", \"valid\": true}";

        String uuidString = "828bc3cb-52f0-482b-8247-d3db5c87c941";
        UUID uuid = UUID.fromString(uuidString);

        USER_SESSION_SERVICE.stubFor(get(urlPathEqualTo("/user-sessions/validate"))
                .withQueryParam("sessionId", equalTo(uuidString))
                .willReturn(aResponse().withBody(responseBody).withHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE).withFixedDelay(7000)));
    }

}

First let’s create a Spring Boot test, starting up on random port and disabling Eureka. Also, start up a WireMock server on port 8082.

Then in the test case, let’s do the stubbing. The important part is to specify the response to be returned with a fixed delay of 7 seconds, using the

withFixedDelay
withFixedDelay

method.

After that, let’s call the Feign client.

@SpringBootTest({"server.port:0", "eureka.client.enabled:false"})
public class CircuitBreakerTest {
@TestConfiguration
public static class TestConfig {
@Bean
public ServiceInstanceListSupplier serviceInstanceListSupplier() {
return new TestServiceInstanceListSupplier("user-session-service", 8082);
@RegisterExtension
static WireMockExtension USER_SESSION_SERVICE = WireMockExtension.newInstance()
.options(WireMockConfiguration.wireMockConfig().port(8082))
.build();
@Autowired
private UserSessionClient userSessionClient;
@Test
public void testTimeLimiterWorks() throws Exception {
String responseBody = "{ \"sessionId\": \"828bc3cb-52f0-482b-8247-d3db5c87c941\", \"valid\": true}";
String uuidString = "828bc3cb-52f0-482b-8247-d3db5c87c941";
UUID uuid = UUID.fromString(uuidString);
USER_SESSION_SERVICE.stubFor(get(urlPathEqualTo("/user-sessions/validate"))
.withQueryParam("sessionId", equalTo(uuidString))
.willReturn(aResponse().withBody(responseBody).withHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE).withFixedDelay(7000)));
NoFallbackAvailableException noFallbackAvailableException = catchThrowableOfType(() -> userSessionClient.validateSession(uuid), NoFallbackAvailableException.class);
TimeoutException timeoutException = (TimeoutException) noFallbackAvailableException.getCause();
assertThat(timeoutException).isNotNull();
@SpringBootTest({"server.port:0", "eureka.client.enabled:false"})
public class CircuitBreakerTest {
    @TestConfiguration
    public static class TestConfig {
        @Bean
        public ServiceInstanceListSupplier serviceInstanceListSupplier() {
            return new TestServiceInstanceListSupplier("user-session-service", 8082);
        }
    }

    @RegisterExtension
    static WireMockExtension USER_SESSION_SERVICE = WireMockExtension.newInstance()
            .options(WireMockConfiguration.wireMockConfig().port(8082))
            .build();

    @Autowired
    private UserSessionClient userSessionClient;

    @Test
    public void testTimeLimiterWorks() throws Exception {
        String responseBody = "{ \"sessionId\": \"828bc3cb-52f0-482b-8247-d3db5c87c941\", \"valid\": true}";

        String uuidString = "828bc3cb-52f0-482b-8247-d3db5c87c941";
        UUID uuid = UUID.fromString(uuidString);

        USER_SESSION_SERVICE.stubFor(get(urlPathEqualTo("/user-sessions/validate"))
                .withQueryParam("sessionId", equalTo(uuidString))
                .willReturn(aResponse().withBody(responseBody).withHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE).withFixedDelay(7000)));

        NoFallbackAvailableException noFallbackAvailableException = catchThrowableOfType(() -> userSessionClient.validateSession(uuid), NoFallbackAvailableException.class);
        TimeoutException timeoutException = (TimeoutException) noFallbackAvailableException.getCause();
        assertThat(timeoutException).isNotNull();

    }

}

The Feign client is called and the

TimeoutException
TimeoutException

is unwrapped and verified. Awesome. At least we know the call failed with a timeout.

This is almost complete but we can do better. If the configuration is set to 5 seconds for timeout, then why don’t we validate how much time the execution took, approximately.

I say approximately since there’s a lot more to a Feign client invocation than just sending the HTTP request and waiting for the response so there’s some additional overhead time, especially for the first Feign call (there is some lazy initialization to it).

Let’s wrap the call around with a

StopWatch
StopWatch

and let’s validate the time the execution took.

@SpringBootTest({"server.port:0", "eureka.client.enabled:false"})
public class CircuitBreakerTest {
@TestConfiguration
public static class TestConfig {
@Bean
public ServiceInstanceListSupplier serviceInstanceListSupplier() {
return new TestServiceInstanceListSupplier("user-session-service", 8082);
@RegisterExtension
static WireMockExtension USER_SESSION_SERVICE = WireMockExtension.newInstance()
.options(WireMockConfiguration.wireMockConfig().port(8082))
.build();
@Autowired
private UserSessionClient userSessionClient;
@Test
public void testTimeLimiterWorks() throws Exception {
String responseBody = "{ \"sessionId\": \"828bc3cb-52f0-482b-8247-d3db5c87c941\", \"valid\": true}";
String uuidString = "828bc3cb-52f0-482b-8247-d3db5c87c941";
UUID uuid = UUID.fromString(uuidString);
USER_SESSION_SERVICE.stubFor(get(urlPathEqualTo("/user-sessions/validate"))
.withQueryParam("sessionId", equalTo(uuidString))
.willReturn(aResponse().withBody(responseBody).withHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE).withFixedDelay(7000)));
StopWatch stopWatch = new StopWatch();
stopWatch.start();
NoFallbackAvailableException noFallbackAvailableException = catchThrowableOfType(() -> userSessionClient.validateSession(uuid), NoFallbackAvailableException.class);
stopWatch.stop();
TimeoutException timeoutException = (TimeoutException) noFallbackAvailableException.getCause();
assertThat(timeoutException).isNotNull();
assertThat(stopWatch.getTotalTimeMillis()).isLessThan(5500);
@SpringBootTest({"server.port:0", "eureka.client.enabled:false"})
public class CircuitBreakerTest {
    @TestConfiguration
    public static class TestConfig {
        @Bean
        public ServiceInstanceListSupplier serviceInstanceListSupplier() {
            return new TestServiceInstanceListSupplier("user-session-service", 8082);
        }
    }

    @RegisterExtension
    static WireMockExtension USER_SESSION_SERVICE = WireMockExtension.newInstance()
            .options(WireMockConfiguration.wireMockConfig().port(8082))
            .build();

    @Autowired
    private UserSessionClient userSessionClient;

    @Test
    public void testTimeLimiterWorks() throws Exception {
        String responseBody = "{ \"sessionId\": \"828bc3cb-52f0-482b-8247-d3db5c87c941\", \"valid\": true}";

        String uuidString = "828bc3cb-52f0-482b-8247-d3db5c87c941";
        UUID uuid = UUID.fromString(uuidString);

        USER_SESSION_SERVICE.stubFor(get(urlPathEqualTo("/user-sessions/validate"))
                .withQueryParam("sessionId", equalTo(uuidString))
                .willReturn(aResponse().withBody(responseBody).withHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE).withFixedDelay(7000)));

        StopWatch stopWatch = new StopWatch();
        stopWatch.start();
        NoFallbackAvailableException noFallbackAvailableException = catchThrowableOfType(() -> userSessionClient.validateSession(uuid), NoFallbackAvailableException.class);
        stopWatch.stop();
        TimeoutException timeoutException = (TimeoutException) noFallbackAvailableException.getCause();
        assertThat(timeoutException).isNotNull();
        assertThat(stopWatch.getTotalTimeMillis()).isLessThan(5500);
    }

}

I intentionally added a 500 ms extra to the verification since I don’t want random test failures. On my computer, it takes around 5100-5150 ms for the execution and just to be on the safe side, I go with 5500 ms. You can be more strict though.

That’s it. Not complicated at all however extremely useful.

On my GitHub, you can find a similar test case which I created for my Mastering microservice communication with Spring Cloud Feign course. Check it out if you’re interested in building a wider knowledge on Feign.

If you liked the article, don’t forget to share it with your colleagues, friends and make sure to follow me on Facebook and Twitter.

email-icon-whatsapp-viber-icon-white-texture-white-board-clothing-transparent-png-232675.png

Join the circle!

Get access to exclusive content I'll only send to this group. Deals, upcoming courses, regular articles and much more. Sign up and get access.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK