Repository: Unleash/unleash-client-java
Pull request #225 (2023-11-21 10:59:26)

chore: allow to fail if unavailable
## About the changes
On synchronous initialization, we were not failing in case of an invalid response code but still marking the client as ready. With this change, we're making sure we have a valid features collection before marking the client as ready and we're not going to throttle in case of asynchronous initialization.

chriswk: I see our tests that test that we increment failure count when we fail to connect is failing.

Patch:
diff --git a/src/main/java/io/getunleash/repository/FeatureRepository.java b/src/main/java/io/getunleash/repository/FeatureRepository.java
index 611db85b..36e54c33 100644
--- a/src/main/java/io/getunleash/repository/FeatureRepository.java
+++ b/src/main/java/io/getunleash/repository/FeatureRepository.java
@@ -95,7 +95,9 @@ public class FeatureRepository implements IFeatureRepository {
         }
 
         if (unleashConfig.isSynchronousFetchOnInitialisation()) {
-            updateFeatures(null).run();
+            updateFeatures(e -> {
+                throw e;
+            }).run();
         }
 
         if (!unleashConfig.isDisablePolling()) {
@@ -108,11 +110,7 @@ public class FeatureRepository implements IFeatureRepository {
         }
     }
 
-    private Integer calculateMaxSkips(int fetchTogglesInterval) {
-        return Integer.max(20, 300 / Integer.max(fetchTogglesInterval, 1));
-    }
-
-    private Runnable updateFeatures(@Nullable final Consumer<UnleashException> handler) {
+    private Runnable updateFeatures(final Consumer<UnleashException> handler) {
         return () -> {
             if (throttler.performAction()) {
                 try {
@@ -129,7 +127,12 @@ public class FeatureRepository implements IFeatureRepository {
 
                         featureBackupHandler.write(featureCollection);
                     } else if (response.getStatus() == ClientFeaturesResponse.Status.UNAVAILABLE) {
-                        throttler.handleHttpErrorCodes(response.getHttpStatusCode());
+                        if (!ready && unleashConfig.isSynchronousFetchOnInitialisation()) {
+                            throw new UnleashException(String.format("Could not initialize Unleash, got response code %d", response.getHttpStatusCode()), null);
+                        }
+                        if (ready) {
+                            throttler.handleHttpErrorCodes(response.getHttpStatusCode());
+                        }
                         return;
                     }
                     throttler.decrementFailureCountAndResetSkips();
@@ -138,11 +141,7 @@ public class FeatureRepository implements IFeatureRepository {
                         ready = true;
                     }
                 } catch (UnleashException e) {
-                    if (handler != null) {
-                        handler.accept(e);
-                    } else {
-                        throw e;
-                    }
+                    handler.accept(e);
                 }
             } else {
                 throttler.skipped(); // We didn't do anything this iteration, just reduce the count


Test patch:
diff --git a/src/test/java/io/getunleash/DefaultUnleashTest.java b/src/test/java/io/getunleash/DefaultUnleashTest.java
index 44f32927..d60e1ce1 100644
--- a/src/test/java/io/getunleash/DefaultUnleashTest.java
+++ b/src/test/java/io/getunleash/DefaultUnleashTest.java
@@ -1,5 +1,7 @@
 package io.getunleash;
 
+import static com.github.tomakehurst.wiremock.client.WireMock.*;
+import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
 import static java.util.Arrays.asList;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -8,15 +10,23 @@ import static org.mockito.Mockito.*;
 import ch.qos.logback.classic.Logger;
 import ch.qos.logback.classic.spi.ILoggingEvent;
 import ch.qos.logback.core.read.ListAppender;
+import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
 import io.getunleash.event.EventDispatcher;
+import io.getunleash.event.UnleashReady;
+import io.getunleash.event.UnleashSubscriber;
+import io.getunleash.integration.TestDefinition;
 import io.getunleash.metric.UnleashMetricService;
 import io.getunleash.repository.*;
 import io.getunleash.strategy.DefaultStrategy;
 import io.getunleash.strategy.Strategy;
 import io.getunleash.util.UnleashConfig;
+
+import java.net.URI;
+import java.net.URISyntaxException;
 import java.util.*;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
 import org.slf4j.LoggerFactory;
 
 class DefaultUnleashTest {
@@ -26,6 +36,13 @@ class DefaultUnleashTest {
     private EventDispatcher eventDispatcher;
     private UnleashMetricService metricService;
 
+    @RegisterExtension
+    static WireMockExtension serverMock =
+        WireMockExtension.newInstance()
+            .configureStaticDsl(true)
+            .options(wireMockConfig().dynamicPort().dynamicHttpsPort())
+            .build();
+
     @BeforeEach
     public void setup() {
         UnleashConfig unleashConfig =
@@ -222,6 +239,7 @@ class DefaultUnleashTest {
 
     @Test
     public void synchronous_fetch_on_initialisation_fails_on_initialization() {
+        IsReadyTestSubscriber readySubscriber = new IsReadyTestSubscriber();
         UnleashConfig config =
                 UnleashConfig.builder()
                         .unleashAPI("http://wrong:4242")
@@ -229,9 +247,58 @@ class DefaultUnleashTest {
                         .apiKey("default:development:1234567890123456")
                         .instanceId("multiple_connection_exception")
                         .synchronousFetchOnInitialisation(true)
+                        .subscriber(readySubscriber)
                         .build();
 
         assertThatThrownBy(() -> new DefaultUnleash(config)).isInstanceOf(UnleashException.class);
+        assertThat(readySubscriber.ready).isFalse();
+    }
+
+    @Test
+    public void synchronous_fetch_on_initialisation_fails_on_non_200_response() throws URISyntaxException {
+        mockUnleashAPI(401);
+        IsReadyTestSubscriber readySubscriber = new IsReadyTestSubscriber();
+        UnleashConfig config =
+            UnleashConfig.builder()
+                .unleashAPI(new URI("http://localhost:" + serverMock.getPort() + "/api/"))
+                .appName("wrong_upstream")
+                .apiKey("default:development:1234567890123456")
+                .instanceId("non-200")
+                .synchronousFetchOnInitialisation(true)
+                .subscriber(readySubscriber)
+                .build();
+
+        assertThatThrownBy(() -> new DefaultUnleash(config)).isInstanceOf(UnleashException.class);
+        assertThat(readySubscriber.ready).isFalse();
+    }
+
+    @Test
+    public void synchronous_fetch_on_initialisation_switches_to_ready_on_200() throws URISyntaxException {
+        mockUnleashAPI(200);
+        IsReadyTestSubscriber readySubscriber = new IsReadyTestSubscriber();
+        UnleashConfig config =
+            UnleashConfig.builder()
+                .unleashAPI(new URI("http://localhost:" + serverMock.getPort() + "/api/"))
+                .appName("wrong_upstream")
+                .apiKey("default:development:1234567890123456")
+                .instanceId("with-success-response")
+                .synchronousFetchOnInitialisation(true)
+                .subscriber(readySubscriber)
+                .build();
+        new DefaultUnleash(config);
+        assertThat(readySubscriber.ready).isTrue();
+    }
+
+    private void mockUnleashAPI(int featuresStatusCode) {
+        stubFor(
+            get(urlEqualTo("/api/client/features"))
+                .withHeader("Accept", equalTo("application/json"))
+                .willReturn(
+                    aResponse()
+                        .withStatus(featuresStatusCode)
+                        .withHeader("Content-Type", "application/json")
+                        .withBody("{\"features\": []}")));
+        stubFor(post(urlEqualTo("/api/client/register")).willReturn(aResponse().withStatus(200)));
     }
 
     @Test
@@ -271,4 +338,11 @@ class DefaultUnleashTest {
         assertThat(id)
                 .isEqualTo("f83eb743f4c8dc41294aafb96f454763e5a90b96db8b7040ddc505d636bdb243");
     }
+
+    private static class IsReadyTestSubscriber implements UnleashSubscriber {
+        public boolean ready = false;
+        public void onReady(UnleashReady unleashReady) {
+            this.ready = true;
+        }
+    }
 }
diff --git a/src/test/java/io/getunleash/repository/FeatureRepositoryTest.java b/src/test/java/io/getunleash/repository/FeatureRepositoryTest.java
index ae361513..c0641ef3 100644
--- a/src/test/java/io/getunleash/repository/FeatureRepositoryTest.java
+++ b/src/test/java/io/getunleash/repository/FeatureRepositoryTest.java
@@ -1,14 +1,17 @@
 package io.getunleash.repository;
 
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.junit.jupiter.api.Assertions.*;
-import static org.mockito.Mockito.*;
-
 import io.getunleash.*;
 import io.getunleash.event.EventDispatcher;
 import io.getunleash.lang.Nullable;
 import io.getunleash.util.UnleashConfig;
 import io.getunleash.util.UnleashScheduledExecutor;
+import org.jetbrains.annotations.NotNull;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.ArgumentCaptor;
+
 import java.io.File;
 import java.io.IOException;
 import java.net.URISyntaxException;
@@ -18,9 +21,11 @@ import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.mockito.ArgumentCaptor;
+
+import static io.getunleash.repository.FeatureToggleResponse.Status.*;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
 
 public class FeatureRepositoryTest {
     FeatureBackupHandlerFile backupHandler;
@@ -99,26 +104,8 @@ public class FeatureRepositoryTest {
                         .synchronousFetchOnInitialisation(false)
                         .build();
 
-        FeatureCollection featureCollection =
-                populatedFeatureCollection(
-                        null,
-                        new FeatureToggle(
-                                "toggleFetcherCalled",
-                                false,
-                                Arrays.asList(new ActivationStrategy("custom", null))));
+        when(backupHandler.read()).thenReturn(simpleFeatureCollection(false));
 
-        when(backupHandler.read()).thenReturn(featureCollection);
-
-        featureCollection =
-                populatedFeatureCollection(
-                        null,
-                        new FeatureToggle(
-                                "toggleFetcherCalled",
-                                true,
-                                Arrays.asList(new ActivationStrategy("custom", null))));
-        ClientFeaturesResponse response =
-                new ClientFeaturesResponse(
-                        ClientFeaturesResponse.Status.CHANGED, featureCollection);
 
         FeatureRepository featureRepository =
                 new FeatureRepository(config, backupHandler, executor, fetcher, bootstrapHandler);
@@ -126,6 +113,7 @@ public class FeatureRepositoryTest {
         verify(executor).setInterval(runnableArgumentCaptor.capture(), anyLong(), anyLong());
         verify(fetcher, times(0)).fetchFeatures();
 
+        ClientFeaturesResponse response = new ClientFeaturesResponse(CHANGED, simpleFeatureCollection(true));
         when(fetcher.fetchFeatures()).thenReturn(response);
         runnableArgumentCaptor.getValue().run();
 
@@ -174,9 +162,7 @@ public class FeatureRepositoryTest {
         when(backupHandler.read()).thenReturn(new FeatureCollection());
 
         FeatureCollection featureCollection = populatedFeatureCollection(null);
-        ClientFeaturesResponse response =
-                new ClientFeaturesResponse(
-                        ClientFeaturesResponse.Status.CHANGED, featureCollection);
+        ClientFeaturesResponse response = new ClientFeaturesResponse(CHANGED, featureCollection);
         when(fetcher.fetchFeatures()).thenReturn(response);
 
         new FeatureRepository(
@@ -200,7 +186,7 @@ public class FeatureRepositoryTest {
         FeatureCollection featureCollection = populatedFeatureCollection(null);
         ClientFeaturesResponse response =
                 new ClientFeaturesResponse(
-                        ClientFeaturesResponse.Status.CHANGED, featureCollection);
+                        CHANGED, featureCollection);
 
         when(fetcher.fetchFeatures()).thenReturn(response);
 
@@ -266,37 +252,18 @@ public class FeatureRepositoryTest {
 
         when(backupHandler.read())
                 .thenReturn(
-                        populatedFeatureCollection(
-                                Arrays.asList(
-                                        new Segment(
-                                                1,
-                                                "some-name",
-                                                Arrays.asList(
-                                                        new Constraint(
-                                                                "some-context",
-                                                                Operator.IN,
-                                                                "some-value")))),
-                                new FeatureToggle(
-                                        "toggleFeatureName1",
-                                        true,
-                                        Collections.singletonList(
-                                                new ActivationStrategy("custom", null))),
-                                new FeatureToggle(
-                                        "toggleFeatureName2",
-                                        true,
-                                        Collections.singletonList(
-                                                new ActivationStrategy("custom", null)))));
+                    getFeatureCollection());
 
         new FeatureRepository(
                 config, backupHandler, new EventDispatcher(config), fetcher, bootstrapHandler);
         verify(toggleBootstrapProvider, times(0)).read();
     }
 
-    @Test
-    public void should_increase_to_max_interval_when_denied()
+    @ParameterizedTest
+    @ValueSource(ints = {403, 404})
+    public void should_increase_to_max_interval_when_code(int code)
             throws URISyntaxException, IOException {
-        UnleashScheduledExecutor executor = mock(UnleashScheduledExecutor.class);
-        ArgumentCaptor<Runnable> runnableArgumentCaptor = ArgumentCaptor.forClass(Runnable.class);
+        TestRunner runner = new TestRunner();
         File file =
                 new File(getClass().getClassLoader().getResource("unleash-repo-v2.json").toURI());
         ToggleBootstrapProvider toggleBootstrapProvider = mock(ToggleBootstrapProvider.class);
@@ -305,36 +272,10 @@ public class FeatureRepositoryTest {
                 UnleashConfig.builder()
                         .synchronousFetchOnInitialisation(false)
                         .appName("test-sync-update")
-                        .scheduledExecutor(executor)
+                        .scheduledExecutor(runner.executor)
                         .unleashAPI("http://localhost:8080")
                         .build();
-        when(backupHandler.read())
-                .thenReturn(
-                        populatedFeatureCollection(
-                                Arrays.asList(
-                                        new Segment(
-                                                1,
-                                                "some-name",
-                                                Arrays.asList(
-                                                        new Constraint(
-                                                                "some-context",
-                                                                Operator.IN,
-                                                                "some-value")))),
-                                new FeatureToggle(
-                                        "toggleFeatureName1",
-                                        true,
-                                        Collections.singletonList(
-                                                new ActivationStrategy("custom", null))),
-                                new FeatureToggle(
-                                        "toggleFeatureName2",
-                                        true,
-                                        Collections.singletonList(
-                                                new ActivationStrategy("custom", null)))));
-        when(fetcher.fetchFeatures())
-                .thenReturn(
-                        new ClientFeaturesResponse(FeatureToggleResponse.Status.UNAVAILABLE, 403))
-                .thenReturn(
-                        new ClientFeaturesResponse(FeatureToggleResponse.Status.NOT_CHANGED, 304));
+        when(backupHandler.read()).thenReturn(getFeatureCollection());
 
         FeatureRepository featureRepository =
                 new FeatureRepository(
@@ -343,25 +284,26 @@ public class FeatureRepositoryTest {
                         new EventDispatcher(config),
                         fetcher,
                         bootstrapHandler);
-        verify(executor).setInterval(runnableArgumentCaptor.capture(), anyLong(), anyLong());
-        runnableArgumentCaptor.getValue().run();
+
+        runner.assertThatFetchesAndReceives(CHANGED, 200); // set it ready
+
+        runner.assertThatFetchesAndReceives(UNAVAILABLE, code);
         assertThat(featureRepository.getFailures()).isEqualTo(1);
         assertThat(featureRepository.getSkips()).isEqualTo(30);
         for (int i = 0; i < 30; i++) {
-            runnableArgumentCaptor.getValue().run();
+            runner.assertThatSkipsNextRun();
         }
         assertThat(featureRepository.getFailures()).isEqualTo(1);
         assertThat(featureRepository.getSkips()).isEqualTo(0);
-        runnableArgumentCaptor.getValue().run();
+        runner.assertThatFetchesAndReceives(NOT_CHANGED, 304);
         assertThat(featureRepository.getFailures()).isEqualTo(0);
         assertThat(featureRepository.getSkips()).isEqualTo(0);
     }
 
     @Test
-    public void should_increase_to_max_interval_when_not_found()
+    public void should_incrementally_increase_interval_as_we_receive_too_many_requests()
             throws URISyntaxException, IOException {
-        UnleashScheduledExecutor executor = mock(UnleashScheduledExecutor.class);
-        ArgumentCaptor<Runnable> runnableArgumentCaptor = ArgumentCaptor.forClass(Runnable.class);
+        TestRunner runner = new TestRunner();
         File file =
                 new File(getClass().getClassLoader().getResource("unleash-repo-v2.json").toURI());
         ToggleBootstrapProvider toggleBootstrapProvider = mock(ToggleBootstrapProvider.class);
@@ -370,37 +312,10 @@ public class FeatureRepositoryTest {
                 UnleashConfig.builder()
                         .synchronousFetchOnInitialisation(false)
                         .appName("test-sync-update")
-                        .scheduledExecutor(executor)
+                        .scheduledExecutor(runner.executor)
                         .unleashAPI("http://localhost:8080")
                         .build();
-        when(backupHandler.read())
-                .thenReturn(
-                        populatedFeatureCollection(
-                                Arrays.asList(
-                                        new Segment(
-                                                1,
-                                                "some-name",
-                                                Arrays.asList(
-                                                        new Constraint(
-                                                                "some-context",
-                                                                Operator.IN,
-                                                                "some-value")))),
-                                new FeatureToggle(
-                                        "toggleFeatureName1",
-                                        true,
-                                        Collections.singletonList(
-                                                new ActivationStrategy("custom", null))),
-                                new FeatureToggle(
-                                        "toggleFeatureName2",
-                                        true,
-                                        Collections.singletonList(
-                                                new ActivationStrategy("custom", null)))));
-        when(fetcher.fetchFeatures())
-                .thenReturn(
-                        new ClientFeaturesResponse(FeatureToggleResponse.Status.UNAVAILABLE, 404))
-                .thenReturn(
-                        new ClientFeaturesResponse(FeatureToggleResponse.Status.NOT_CHANGED, 304));
-
+        when(backupHandler.read()).thenReturn(getFeatureCollection());
         FeatureRepository featureRepository =
                 new FeatureRepository(
                         config,
@@ -408,116 +323,67 @@ public class FeatureRepositoryTest {
                         new EventDispatcher(config),
                         fetcher,
                         bootstrapHandler);
-        verify(executor).setInterval(runnableArgumentCaptor.capture(), anyLong(), anyLong());
-        runnableArgumentCaptor.getValue().run();
-        assertThat(featureRepository.getFailures()).isEqualTo(1);
-        assertThat(featureRepository.getSkips()).isEqualTo(30);
-        for (int i = 0; i < 30; i++) {
-            runnableArgumentCaptor.getValue().run();
-        }
-        assertThat(featureRepository.getFailures()).isEqualTo(1);
+
+        runner.assertThatFetchesAndReceives(UNAVAILABLE, 429);
+        // client is not ready don't count errors or skips
         assertThat(featureRepository.getSkips()).isEqualTo(0);
-        runnableArgumentCaptor.getValue().run();
         assertThat(featureRepository.getFailures()).isEqualTo(0);
+
+        runner.assertThatFetchesAndReceives(UNAVAILABLE, 429);
+        // client is not ready don't count errors or skips
         assertThat(featureRepository.getSkips()).isEqualTo(0);
-    }
+        assertThat(featureRepository.getFailures()).isEqualTo(0);
 
-    @Test
-    public void should_incrementally_increase_interval_as_we_receive_too_many_requests()
-            throws URISyntaxException, IOException {
-        UnleashScheduledExecutor executor = mock(UnleashScheduledExecutor.class);
-        ArgumentCaptor<Runnable> runnableArgumentCaptor = ArgumentCaptor.forClass(Runnable.class);
-        File file =
-                new File(getClass().getClassLoader().getResource("unleash-repo-v2.json").toURI());
-        ToggleBootstrapProvider toggleBootstrapProvider = mock(ToggleBootstrapProvider.class);
-        when(toggleBootstrapProvider.read()).thenReturn(fileToString(file));
-        UnleashConfig config =
-                UnleashConfig.builder()
-                        .synchronousFetchOnInitialisation(false)
-                        .appName("test-sync-update")
-                        .scheduledExecutor(executor)
-                        .unleashAPI("http://localhost:8080")
-                        .build();
-        when(backupHandler.read())
-                .thenReturn(
-                        populatedFeatureCollection(
-                                Arrays.asList(
-                                        new Segment(
-                                                1,
-                                                "some-name",
-                                                Arrays.asList(
-                                                        new Constraint(
-                                                                "some-context",
-                                                                Operator.IN,
-                                                                "some-value")))),
-                                new FeatureToggle(
-                                        "toggleFeatureName1",
-                                        true,
-                                        Collections.singletonList(
-                                                new ActivationStrategy("custom", null))),
-                                new FeatureToggle(
-                                        "toggleFeatureName2",
-                                        true,
-                                        Collections.singletonList(
-                                                new ActivationStrategy("custom", null)))));
-        FeatureRepository featureRepository =
-                new FeatureRepository(
-                        config,
-                        backupHandler,
-                        new EventDispatcher(config),
-                        fetcher,
-                        bootstrapHandler);
-        verify(executor).setInterval(runnableArgumentCaptor.capture(), anyLong(), anyLong());
-        when(fetcher.fetchFeatures())
-                .thenReturn(
-                        new ClientFeaturesResponse(FeatureToggleResponse.Status.UNAVAILABLE, 429))
-                .thenReturn(
-                        new ClientFeaturesResponse(FeatureToggleResponse.Status.UNAVAILABLE, 429))
-                .thenReturn(
-                        new ClientFeaturesResponse(FeatureToggleResponse.Status.UNAVAILABLE, 429))
-                .thenReturn(
-                        new ClientFeaturesResponse(FeatureToggleResponse.Status.NOT_CHANGED, 304))
-                .thenReturn(
-                        new ClientFeaturesResponse(FeatureToggleResponse.Status.NOT_CHANGED, 304))
-                .thenReturn(
-                        new ClientFeaturesResponse(FeatureToggleResponse.Status.NOT_CHANGED, 304))
-                .thenReturn(
-                        new ClientFeaturesResponse(FeatureToggleResponse.Status.NOT_CHANGED, 304));
-        runnableArgumentCaptor.getValue().run();
+        // this changes the client to ready
+        runner.assertThatFetchesAndReceives(CHANGED, 200);
+        assertThat(featureRepository.getSkips()).isEqualTo(0);
+
+        runner.assertThatFetchesAndReceives(UNAVAILABLE, 429);
         assertThat(featureRepository.getSkips()).isEqualTo(1);
         assertThat(featureRepository.getFailures()).isEqualTo(1);
-        runnableArgumentCaptor.getValue().run();
+
+        runner.assertThatSkipsNextRun();
         assertThat(featureRepository.getSkips()).isEqualTo(0);
         assertThat(featureRepository.getFailures()).isEqualTo(1);
-        runnableArgumentCaptor.getValue().run();
+
+        runner.assertThatFetchesAndReceives(UNAVAILABLE, 429);
         assertThat(featureRepository.getSkips()).isEqualTo(2);
         assertThat(featureRepository.getFailures()).isEqualTo(2);
-        runnableArgumentCaptor.getValue().run(); // NO-OP because interval > 0
-        runnableArgumentCaptor.getValue().run(); // NO-OP because interval > 0
+
+        runner.assertThatSkipsNextRun();
+        assertThat(featureRepository.getSkips()).isEqualTo(1);
+        runner.assertThatSkipsNextRun();
         assertThat(featureRepository.getSkips()).isEqualTo(0);
         assertThat(featureRepository.getFailures()).isEqualTo(2);
-        runnableArgumentCaptor.getValue().run();
+
+        runner.assertThatFetchesAndReceives(UNAVAILABLE, 429);
         assertThat(featureRepository.getSkips()).isEqualTo(3);
         assertThat(featureRepository.getFailures()).isEqualTo(3);
-        runnableArgumentCaptor.getValue().run();
-        runnableArgumentCaptor.getValue().run();
-        runnableArgumentCaptor.getValue().run();
+
+        runner.assertThatSkipsNextRun();
+        runner.assertThatSkipsNextRun();
+        runner.assertThatSkipsNextRun();
         assertThat(featureRepository.getSkips()).isEqualTo(0);
         assertThat(featureRepository.getFailures()).isEqualTo(3);
-        runnableArgumentCaptor.getValue().run();
+
+        runner.assertThatFetchesAndReceives(NOT_CHANGED, 304);
         assertThat(featureRepository.getSkips()).isEqualTo(2);
         assertThat(featureRepository.getFailures()).isEqualTo(2);
-        runnableArgumentCaptor.getValue().run();
-        runnableArgumentCaptor.getValue().run();
+
+        runner.assertThatSkipsNextRun();
+        runner.assertThatSkipsNextRun();
         assertThat(featureRepository.getSkips()).isEqualTo(0);
         assertThat(featureRepository.getFailures()).isEqualTo(2);
-        runnableArgumentCaptor.getValue().run();
+
+        runner.assertThatFetchesAndReceives(NOT_CHANGED, 304);
         assertThat(featureRepository.getSkips()).isEqualTo(1);
         assertThat(featureRepository.getFailures()).isEqualTo(1);
-        runnableArgumentCaptor.getValue().run();
+
+        runner.assertThatSkipsNextRun();
         assertThat(featureRepository.getSkips()).isEqualTo(0);
         assertThat(featureRepository.getFailures()).isEqualTo(1);
-        runnableArgumentCaptor.getValue().run();
+
+        runner.assertThatFetchesAndReceives(NOT_CHANGED, 304);
         assertThat(featureRepository.getSkips()).isEqualTo(0);
         assertThat(featureRepository.getFailures()).isEqualTo(0);
     }
@@ -525,8 +391,7 @@ public class FeatureRepositoryTest {
     @Test
     public void server_errors_should_incrementally_increase_interval()
             throws URISyntaxException, IOException {
-        UnleashScheduledExecutor executor = mock(UnleashScheduledExecutor.class);
-        ArgumentCaptor<Runnable> runnableArgumentCaptor = ArgumentCaptor.forClass(Runnable.class);
+        TestRunner runner = new TestRunner();
         File file =
                 new File(getClass().getClassLoader().getResource("unleash-repo-v2.json").toURI());
         ToggleBootstrapProvider toggleBootstrapProvider = mock(ToggleBootstrapProvider.class);
@@ -535,31 +400,10 @@ public class FeatureRepositoryTest {
                 UnleashConfig.builder()
                         .synchronousFetchOnInitialisation(false)
                         .appName("test-sync-update")
-                        .scheduledExecutor(executor)
+                        .scheduledExecutor(runner.executor)
                         .unleashAPI("http://localhost:8080")
                         .build();
-        when(backupHandler.read())
-                .thenReturn(
-                        populatedFeatureCollection(
-                                Arrays.asList(
-                                        new Segment(
-                                                1,
-                                                "some-name",
-                                                Arrays.asList(
-                                                        new Constraint(
-                                                                "some-context",
-                                                                Operator.IN,
-                                                                "some-value")))),
-                                new FeatureToggle(
-                                        "toggleFeatureName1",
-                                        true,
-                                        Collections.singletonList(
-                                                new ActivationStrategy("custom", null))),
-                                new FeatureToggle(
-                                        "toggleFeatureName2",
-                                        true,
-                                        Collections.singletonList(
-                                                new ActivationStrategy("custom", null)))));
+        when(backupHandler.read()).thenReturn(getFeatureCollection());
         FeatureRepository featureRepository =
                 new FeatureRepository(
                         config,
@@ -567,57 +411,44 @@ public class FeatureRepositoryTest {
                         new EventDispatcher(config),
                         fetcher,
                         bootstrapHandler);
-        verify(executor).setInterval(runnableArgumentCaptor.capture(), anyLong(), anyLong());
-        when(fetcher.fetchFeatures())
-                .thenReturn(
-                        new ClientFeaturesResponse(FeatureToggleResponse.Status.UNAVAILABLE, 500))
-                .thenReturn(
-                        new ClientFeaturesResponse(FeatureToggleResponse.Status.UNAVAILABLE, 502))
-                .thenReturn(
-                        new ClientFeaturesResponse(FeatureToggleResponse.Status.UNAVAILABLE, 503))
-                .thenReturn(
-                        new ClientFeaturesResponse(FeatureToggleResponse.Status.NOT_CHANGED, 304))
-                .thenReturn(
-                        new ClientFeaturesResponse(FeatureToggleResponse.Status.NOT_CHANGED, 304))
-                .thenReturn(
-                        new ClientFeaturesResponse(FeatureToggleResponse.Status.NOT_CHANGED, 304))
-                .thenReturn(
-                        new ClientFeaturesResponse(FeatureToggleResponse.Status.NOT_CHANGED, 304));
-        runnableArgumentCaptor.getValue().run();
+
+        runner.assertThatFetchesAndReceives(CHANGED, 200); // set it ready
+
+        runner.assertThatFetchesAndReceives(UNAVAILABLE, 500);
         assertThat(featureRepository.getSkips()).isEqualTo(1);
         assertThat(featureRepository.getFailures()).isEqualTo(1);
-        runnableArgumentCaptor.getValue().run();
+        runner.assertThatSkipsNextRun();
         assertThat(featureRepository.getSkips()).isEqualTo(0);
         assertThat(featureRepository.getFailures()).isEqualTo(1);
-        runnableArgumentCaptor.getValue().run();
+        runner.assertThatFetchesAndReceives(UNAVAILABLE, 502);
         assertThat(featureRepository.getSkips()).isEqualTo(2);
         assertThat(featureRepository.getFailures()).isEqualTo(2);
-        runnableArgumentCaptor.getValue().run(); // NO-OP because interval > 0
-        runnableArgumentCaptor.getValue().run(); // NO-OP because interval > 0
+        runner.assertThatSkipsNextRun();
+        runner.assertThatSkipsNextRun();
         assertThat(featureRepository.getSkips()).isEqualTo(0);
         assertThat(featureRepository.getFailures()).isEqualTo(2);
-        runnableArgumentCaptor.getValue().run();
+        runner.assertThatFetchesAndReceives(UNAVAILABLE, 503);
         assertThat(featureRepository.getSkips()).isEqualTo(3);
         assertThat(featureRepository.getFailures()).isEqualTo(3);
-        runnableArgumentCaptor.getValue().run();
-        runnableArgumentCaptor.getValue().run();
-        runnableArgumentCaptor.getValue().run();
+        runner.assertThatSkipsNextRun();
+        runner.assertThatSkipsNextRun();
+        runner.assertThatSkipsNextRun();
         assertThat(featureRepository.getSkips()).isEqualTo(0);
         assertThat(featureRepository.getFailures()).isEqualTo(3);
-        runnableArgumentCaptor.getValue().run();
+        runner.assertThatFetchesAndReceives(NOT_CHANGED, 304);
         assertThat(featureRepository.getSkips()).isEqualTo(2);
         assertThat(featureRepository.getFailures()).isEqualTo(2);
-        runnableArgumentCaptor.getValue().run();
-        runnableArgumentCaptor.getValue().run();
+        runner.assertThatSkipsNextRun();
+        runner.assertThatSkipsNextRun();
         assertThat(featureRepository.getSkips()).isEqualTo(0);
         assertThat(featureRepository.getFailures()).isEqualTo(2);
-        runnableArgumentCaptor.getValue().run();
+        runner.assertThatFetchesAndReceives(NOT_CHANGED, 304);
         assertThat(featureRepository.getSkips()).isEqualTo(1);
         assertThat(featureRepository.getFailures()).isEqualTo(1);
-        runnableArgumentCaptor.getValue().run();
+        runner.assertThatSkipsNextRun();
         assertThat(featureRepository.getSkips()).isEqualTo(0);
         assertThat(featureRepository.getFailures()).isEqualTo(1);
-        runnableArgumentCaptor.getValue().run();
+        runner.assertThatFetchesAndReceives(NOT_CHANGED, 304);
         assertThat(featureRepository.getSkips()).isEqualTo(0);
         assertThat(featureRepository.getFailures()).isEqualTo(0);
     }
@@ -625,4 +456,73 @@ public class FeatureRepositoryTest {
     private String fileToString(File f) throws IOException {
         return new String(Files.readAllBytes(f.toPath()), StandardCharsets.UTF_8);
     }
+
+    @NotNull
+    private FeatureCollection simpleFeatureCollection(boolean enabled) {
+        return populatedFeatureCollection(
+            null,
+            new FeatureToggle(
+                "toggleFetcherCalled",
+                enabled,
+                Arrays.asList(new ActivationStrategy("custom", null))));
+    }
+
+    @NotNull
+    private FeatureCollection getFeatureCollection() {
+        return populatedFeatureCollection(
+            Arrays.asList(
+                new Segment(
+                    1,
+                    "some-name",
+                    Arrays.asList(
+                        new Constraint(
+                            "some-context",
+                            Operator.IN,
+                            "some-value")))),
+            new FeatureToggle(
+                "toggleFeatureName1",
+                true,
+                Collections.singletonList(
+                    new ActivationStrategy("custom", null))),
+            new FeatureToggle(
+                "toggleFeatureName2",
+                true,
+                Collections.singletonList(
+                    new ActivationStrategy("custom", null))));
+    }
+
+    private class TestRunner {
+
+        private final UnleashScheduledExecutor executor;
+        private final ArgumentCaptor<Runnable> runnableArgumentCaptor;
+        private int count = 0;
+
+        private boolean initialized = false;
+
+        public TestRunner() {
+            this.executor = mock(UnleashScheduledExecutor.class);
+            this.runnableArgumentCaptor = ArgumentCaptor.forClass(Runnable.class);
+        }
+
+        private void ensureInitialized() {
+            if (!initialized) {
+                verify(executor).setInterval(runnableArgumentCaptor.capture(), anyLong(), anyLong());
+                initialized = true;
+            }
+        }
+
+        public void assertThatFetchesAndReceives(FeatureToggleResponse.Status status, int statusCode) {
+            ensureInitialized();
+            when(fetcher.fetchFeatures())
+                .thenReturn(new ClientFeaturesResponse(status, statusCode));
+            runnableArgumentCaptor.getValue().run();
+            verify(fetcher, times(++count)).fetchFeatures();
+        }
+
+        public void assertThatSkipsNextRun() {
+            ensureInitialized();
+            runnableArgumentCaptor.getValue().run();
+            verify(fetcher, times(count)).fetchFeatures();
+        }
+    }
 }