Testing resillence4j retry with mockable fault injection
One thing that separates a well engineered project from a so-so one is attention payed to error handling and retry. This isn't always easy as it sounds, first you need to taxonomize Exceptions, then come up with appropriate retry and backoff strategies. To that end, I created some interfaces and classes to make a good showing of how to do this.
The link to the code is here but rthe blog we will walk it step by step.
Resilence4j (https://resilience4j.readme.io/docs/getting-started) helps with a great deal of this by making a small purpose build librrary with nice building blocks like retry, bulkhead, and circuit breaker.. If you read about hystrix from nextflix years back this library grew from that one.
You can not go in "half-baked": Some things are useless to retry such as a NullPointerException based on bad input. One of my favorite ways to design APIs is to introduce a clear exception hierarchy, things you can retry and things you can not.
Below I used Interfaces as 'marker' classes. This has some advantages. Interfaces are "polymorphic". You can easily slap them on to existing Exceptions, extend Exceptions, or use them with new Exceptions.
package io.teknek.dysfx.exception;
sealed interface Retryable permits Recoverable, NotRecoverable {
//boolean isRecoverable();
}
public non-sealed interface NotRecoverable extends Retryable {
/*
@Override
default boolean isRecoverable(){
return false;
}*/
}
public non-sealed interface Recoverable extends Retryable {
/*
@Override
default boolean isRecoverable(){
return true;
}*/
}
In the title I mentioned "Mockable fault injection". That sounds like really complicated stuff right? That was a hook, :) It is actualy fairly easy. When you have access to mockito you can chain method calls. For example Throw exceptions two times and give a result on the third:
@Test
void retryExample(){
Supplier<Integer> data = Mockito.mock(Supplier.class);
Mockito.when(data.get()).thenThrow(new MyRecoverableException())
.thenThrow(new MyRecoverableException()).thenReturn(4);
Now the fun part. How to make Resilence4j know which exceptions to retry and not to retry? We implement Recoverable, an interface with no methods!
static class MyRecoverableException extends RuntimeException implements Recoverable {
}
Res4j allows you to build a Retry "Factory" of sorts. Once we activate RetryOnException we can provide a lambda function that can taxonomize the acceptions and take action.
RetryConfig config = RetryConfig.custom()
.maxAttempts(3)
.retryOnException(e -> e instanceof Recoverable)
.build();
It lets put it in action
@Test
void retryExample(){
Supplier<Integer> data = Mockito.mock(Supplier.class);
Mockito.when(data.get()).thenThrow(new MyRecoverableException())
.thenThrow(new MyRecoverableException()).thenReturn(4);
Retry retry = Retry.of("this", config);
Supplier<Integer> z = retry.decorateSupplier(data);
assertEquals(4, z.get());
assertEquals(3, retry.getMetrics().getNumberOfTotalCalls());
}
Notice how we used the "decorateSupplier". That is the res4j magic. Also build in is retry.getMetrics to count the calls. An assert shows how many times the code retried the call..
Like all good QA folks you want to try both outcomes so another test for the non-retry path is nice to prove to yourself that you know what you are doing.
@Test
void willNotRetry(){
Supplier<Integer> data = () -> Integer.parseInt("fotyfivedolla");
Retry retry = Retry.of("that", config);
Supplier<Integer> z = retry.decorateSupplier(data);
assertThrows(NumberFormatException.class, z::get);
}
Comments
Post a Comment