Repository: Unleash/unleash-client-java
Pull request #221 (2023-11-07 12:51:17)

feat: Obey HTTP Status codes from upstream.
Previously we have treated any status code as equal. This PR changes that to allow 429 and 50x status codes to incrementally increase the interval between each time we call. And then gradually decrease once it starts succeeding.


### Discussion points

- a bit worried about the metrics here, because we drop the metric bucket on the floor if we don't get a 20x back from the server. We did that previously as well, so there's nothing new here. A failing call to sendMetrics would dump the metrics on the floor, since we new up a new bucket before sending the previous one.


ivarconr: > drop the metric bucket on the floor if we don't get a 20x back from the server.

I agree this is a concern but I think it needs to be solved in a separate PR. Other SDKs will preserve the metrics and send them on the next interval. 
chriswk: > LGTM! Just a small comment about externalizing the throttling behavior into an instance so we don't repeat the code, but other than that this looks neat!

Handled with adding the throttler class.

Patch:
diff --git a/.github/workflows/pull_requests.yml b/.github/workflows/pull_requests.yml
index 8dda5dc4..8713d09b 100644
--- a/.github/workflows/pull_requests.yml
+++ b/.github/workflows/pull_requests.yml
@@ -10,7 +10,7 @@ jobs:
         version: [8,11,17]
     steps:
       - name: Checkout
-        uses: actions/checkout@v3
+        uses: actions/checkout@v4
       - name: Setup Java
         uses: actions/setup-java@v3
         with:
diff --git a/src/main/java/io/getunleash/metric/DefaultHttpMetricsSender.java b/src/main/java/io/getunleash/metric/DefaultHttpMetricsSender.java
index 8f6ecfcb..a69b5c38 100644
--- a/src/main/java/io/getunleash/metric/DefaultHttpMetricsSender.java
+++ b/src/main/java/io/getunleash/metric/DefaultHttpMetricsSender.java
@@ -37,26 +37,32 @@ public class DefaultHttpMetricsSender implements MetricSender {
                         .create();
     }
 
-    public void registerClient(ClientRegistration registration) {
+    public int registerClient(ClientRegistration registration) {
         if (!unleashConfig.isDisableMetrics()) {
             try {
-                post(clientRegistrationURL, registration);
+                int statusCode = post(clientRegistrationURL, registration);
                 eventDispatcher.dispatch(registration);
+                return statusCode;
             } catch (UnleashException ex) {
                 eventDispatcher.dispatch(ex);
+                return -1;
             }
         }
+        return -1;
     }
 
-    public void sendMetrics(ClientMetrics metrics) {
+    public int sendMetrics(ClientMetrics metrics) {
         if (!unleashConfig.isDisableMetrics()) {
             try {
-                post(clientMetricsURL, metrics);
+                int statusCode = post(clientMetricsURL, metrics);
                 eventDispatcher.dispatch(metrics);
+                return statusCode;
             } catch (UnleashException ex) {
                 eventDispatcher.dispatch(ex);
+                return -1;
             }
         }
+        return -1;
     }
 
     private int post(URL url, Object o) throws UnleashException {
diff --git a/src/main/java/io/getunleash/metric/MetricSender.java b/src/main/java/io/getunleash/metric/MetricSender.java
index 221f2de8..90cf9a47 100644
--- a/src/main/java/io/getunleash/metric/MetricSender.java
+++ b/src/main/java/io/getunleash/metric/MetricSender.java
@@ -1,7 +1,7 @@
 package io.getunleash.metric;
 
 public interface MetricSender {
-    void registerClient(ClientRegistration registration);
+    int registerClient(ClientRegistration registration);
 
-    void sendMetrics(ClientMetrics metrics);
+    int sendMetrics(ClientMetrics metrics);
 }
diff --git a/src/main/java/io/getunleash/metric/OkHttpMetricsSender.java b/src/main/java/io/getunleash/metric/OkHttpMetricsSender.java
index b637ff2d..ba4bd339 100644
--- a/src/main/java/io/getunleash/metric/OkHttpMetricsSender.java
+++ b/src/main/java/io/getunleash/metric/OkHttpMetricsSender.java
@@ -60,19 +60,21 @@ public class OkHttpMetricsSender implements MetricSender {
     }
 
     @Override
-    public void registerClient(ClientRegistration registration) {
+    public int registerClient(ClientRegistration registration) {
         if (!config.isDisableMetrics()) {
             try {
-                post(clientRegistrationUrl, registration);
+                int statusCode = post(clientRegistrationUrl, registration);
                 eventDispatcher.dispatch(registration);
+                return statusCode;
             } catch (UnleashException ex) {
                 eventDispatcher.dispatch(ex);
             }
         }
+        return -1;
     }
 
     @Override
-    public void sendMetrics(ClientMetrics metrics) {
+    public int sendMetrics(ClientMetrics metrics) {
         if (!config.isDisableMetrics()) {
             try {
                 post(clientMetricsUrl, metrics);
@@ -81,6 +83,7 @@ public class OkHttpMetricsSender implements MetricSender {
                 eventDispatcher.dispatch(ex);
             }
         }
+        return -1;
     }
 
     private int post(HttpUrl url, Object o) {
diff --git a/src/main/java/io/getunleash/metric/UnleashMetricServiceImpl.java b/src/main/java/io/getunleash/metric/UnleashMetricServiceImpl.java
index 83d4ae23..fa200cd1 100644
--- a/src/main/java/io/getunleash/metric/UnleashMetricServiceImpl.java
+++ b/src/main/java/io/getunleash/metric/UnleashMetricServiceImpl.java
@@ -1,12 +1,16 @@
 package io.getunleash.metric;
 
+import io.getunleash.util.Throttler;
 import io.getunleash.util.UnleashConfig;
 import io.getunleash.util.UnleashScheduledExecutor;
 import java.time.LocalDateTime;
 import java.time.ZoneId;
 import java.util.Set;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class UnleashMetricServiceImpl implements UnleashMetricService {
+    private static final Logger LOGGER = LoggerFactory.getLogger(UnleashMetricServiceImpl.class);
     private final LocalDateTime started;
     private final UnleashConfig unleashConfig;
 
@@ -15,6 +19,8 @@ public class UnleashMetricServiceImpl implements UnleashMetricService {
     // mutable
     private volatile MetricsBucket currentMetricsBucket;
 
+    private final Throttler throttler;
+
     public UnleashMetricServiceImpl(
             UnleashConfig unleashConfig, UnleashScheduledExecutor executor) {
         this(unleashConfig, unleashConfig.getMetricSenderFactory().apply(unleashConfig), executor);
@@ -28,6 +34,11 @@ public class UnleashMetricServiceImpl implements UnleashMetricService {
         this.started = LocalDateTime.now(ZoneId.of("UTC"));
         this.unleashConfig = unleashConfig;
         this.metricSender = metricSender;
+        this.throttler =
+                new Throttler(
+                        (int) unleashConfig.getSendMetricsInterval(),
+                        300,
+                        unleashConfig.getUnleashURLs().getClientMetricsURL());
         long metricsInterval = unleashConfig.getSendMetricsInterval();
         executor.setInterval(sendMetrics(), metricsInterval, metricsInterval);
     }
@@ -51,11 +62,29 @@ public class UnleashMetricServiceImpl implements UnleashMetricService {
 
     private Runnable sendMetrics() {
         return () -> {
-            MetricsBucket metricsBucket = this.currentMetricsBucket;
-            this.currentMetricsBucket = new MetricsBucket();
-            metricsBucket.end();
-            ClientMetrics metrics = new ClientMetrics(unleashConfig, metricsBucket);
-            metricSender.sendMetrics(metrics);
+            if (throttler.performAction()) {
+                MetricsBucket metricsBucket = this.currentMetricsBucket;
+                this.currentMetricsBucket = new MetricsBucket();
+                metricsBucket.end();
+                ClientMetrics metrics = new ClientMetrics(unleashConfig, metricsBucket);
+                int statusCode = metricSender.sendMetrics(metrics);
+                if (statusCode >= 200 && statusCode < 400) {
+                    throttler.decrementFailureCountAndResetSkips();
+                }
+                if (statusCode >= 400) {
+                    throttler.handleHttpErrorCodes(statusCode);
+                }
+            } else {
+                throttler.skipped();
+            }
         };
     }
+
+    protected int getSkips() {
+        return this.throttler.getSkips();
+    }
+
+    protected int getFailures() {
+        return this.throttler.getFailures();
+    }
 }
diff --git a/src/main/java/io/getunleash/repository/FeatureRepository.java b/src/main/java/io/getunleash/repository/FeatureRepository.java
index e826b948..611db85b 100644
--- a/src/main/java/io/getunleash/repository/FeatureRepository.java
+++ b/src/main/java/io/getunleash/repository/FeatureRepository.java
@@ -6,21 +6,26 @@ import io.getunleash.UnleashException;
 import io.getunleash.event.EventDispatcher;
 import io.getunleash.event.UnleashReady;
 import io.getunleash.lang.Nullable;
+import io.getunleash.util.Throttler;
 import io.getunleash.util.UnleashConfig;
 import io.getunleash.util.UnleashScheduledExecutor;
 import java.util.Collections;
 import java.util.List;
 import java.util.function.Consumer;
 import java.util.stream.Collectors;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class FeatureRepository implements IFeatureRepository {
-
+    private static final Logger LOGGER = LoggerFactory.getLogger(FeatureRepository.class);
     private final UnleashConfig unleashConfig;
     private final BackupHandler<FeatureCollection> featureBackupHandler;
     private final FeatureBootstrapHandler featureBootstrapHandler;
     private final FeatureFetcher featureFetcher;
     private final EventDispatcher eventDispatcher;
 
+    private final Throttler throttler;
+
     private FeatureCollection featureCollection;
     private boolean ready;
 
@@ -36,7 +41,11 @@ public class FeatureRepository implements IFeatureRepository {
         this.featureFetcher = unleashConfig.getUnleashFeatureFetcherFactory().apply(unleashConfig);
         this.featureBootstrapHandler = new FeatureBootstrapHandler(unleashConfig);
         this.eventDispatcher = new EventDispatcher(unleashConfig);
-
+        this.throttler =
+                new Throttler(
+                        (int) unleashConfig.getFetchTogglesInterval(),
+                        300,
+                        unleashConfig.getUnleashURLs().getFetchTogglesURL());
         this.initCollections(unleashConfig.getScheduledExecutor());
     }
 
@@ -46,12 +55,16 @@ public class FeatureRepository implements IFeatureRepository {
             EventDispatcher eventDispatcher,
             FeatureFetcher featureFetcher,
             FeatureBootstrapHandler featureBootstrapHandler) {
-
         this.unleashConfig = unleashConfig;
         this.featureBackupHandler = featureBackupHandler;
         this.featureFetcher = featureFetcher;
         this.featureBootstrapHandler = featureBootstrapHandler;
         this.eventDispatcher = eventDispatcher;
+        this.throttler =
+                new Throttler(
+                        (int) unleashConfig.getFetchTogglesInterval(),
+                        300,
+                        unleashConfig.getUnleashURLs().getFetchTogglesURL());
         this.initCollections(unleashConfig.getScheduledExecutor());
     }
 
@@ -66,6 +79,11 @@ public class FeatureRepository implements IFeatureRepository {
         this.featureFetcher = featureFetcher;
         this.featureBootstrapHandler = featureBootstrapHandler;
         this.eventDispatcher = new EventDispatcher(unleashConfig);
+        this.throttler =
+                new Throttler(
+                        (int) unleashConfig.getFetchTogglesInterval(),
+                        300,
+                        unleashConfig.getUnleashURLs().getFetchTogglesURL());
         this.initCollections(executor);
     }
 
@@ -90,33 +108,44 @@ 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) {
         return () -> {
-            try {
-                ClientFeaturesResponse response = featureFetcher.fetchFeatures();
-                eventDispatcher.dispatch(response);
-                if (response.getStatus() == ClientFeaturesResponse.Status.CHANGED) {
-                    SegmentCollection segmentCollection = response.getSegmentCollection();
-                    featureCollection =
-                            new FeatureCollection(
-                                    response.getToggleCollection(),
-                                    segmentCollection != null
-                                            ? segmentCollection
-                                            : new SegmentCollection(Collections.emptyList()));
-
-                    featureBackupHandler.write(featureCollection);
-                }
-
-                if (!ready) {
-                    eventDispatcher.dispatch(new UnleashReady());
-                    ready = true;
-                }
-            } catch (UnleashException e) {
-                if (handler != null) {
-                    handler.accept(e);
-                } else {
-                    throw e;
+            if (throttler.performAction()) {
+                try {
+                    ClientFeaturesResponse response = featureFetcher.fetchFeatures();
+                    eventDispatcher.dispatch(response);
+                    if (response.getStatus() == ClientFeaturesResponse.Status.CHANGED) {
+                        SegmentCollection segmentCollection = response.getSegmentCollection();
+                        featureCollection =
+                                new FeatureCollection(
+                                        response.getToggleCollection(),
+                                        segmentCollection != null
+                                                ? segmentCollection
+                                                : new SegmentCollection(Collections.emptyList()));
+
+                        featureBackupHandler.write(featureCollection);
+                    } else if (response.getStatus() == ClientFeaturesResponse.Status.UNAVAILABLE) {
+                        throttler.handleHttpErrorCodes(response.getHttpStatusCode());
+                        return;
+                    }
+                    throttler.decrementFailureCountAndResetSkips();
+                    if (!ready) {
+                        eventDispatcher.dispatch(new UnleashReady());
+                        ready = true;
+                    }
+                } catch (UnleashException e) {
+                    if (handler != null) {
+                        handler.accept(e);
+                    } else {
+                        throw e;
+                    }
                 }
+            } else {
+                throttler.skipped(); // We didn't do anything this iteration, just reduce the count
             }
         };
     }
@@ -137,4 +166,12 @@ public class FeatureRepository implements IFeatureRepository {
     public Segment getSegment(Integer id) {
         return featureCollection.getSegmentCollection().getSegment(id);
     }
+
+    public Integer getFailures() {
+        return this.throttler.getFailures();
+    }
+
+    public Integer getSkips() {
+        return this.throttler.getSkips();
+    }
 }
diff --git a/src/main/java/io/getunleash/repository/FeatureToggleResponse.java b/src/main/java/io/getunleash/repository/FeatureToggleResponse.java
index 73bbc9b8..b1c25565 100644
--- a/src/main/java/io/getunleash/repository/FeatureToggleResponse.java
+++ b/src/main/java/io/getunleash/repository/FeatureToggleResponse.java
@@ -13,7 +13,7 @@ public class FeatureToggleResponse implements UnleashEvent {
     public enum Status {
         NOT_CHANGED,
         CHANGED,
-        UNAVAILABLE
+        UNAVAILABLE,
     }
 
     private final Status status;
diff --git a/src/main/java/io/getunleash/util/ConstraintMerger.java b/src/main/java/io/getunleash/util/ConstraintMerger.java
index 28db759e..7440decb 100644
--- a/src/main/java/io/getunleash/util/ConstraintMerger.java
+++ b/src/main/java/io/getunleash/util/ConstraintMerger.java
@@ -17,7 +17,8 @@ public class ConstraintMerger {
                         Optional.ofNullable(strategy.getConstraints())
                                 .orElseGet(Collections::emptyList),
                         Optional.ofNullable(strategy.getSegments())
-                                .orElseGet(Collections::emptyList).stream()
+                                .orElseGet(Collections::emptyList)
+                                .stream()
                                 .map(repository::getSegment)
                                 .map(s -> s == null ? DENY_SEGMENT : s)
                                 .map(Segment::getConstraints)
diff --git a/src/main/java/io/getunleash/util/Throttler.java b/src/main/java/io/getunleash/util/Throttler.java
new file mode 100644
index 00000000..6427f1c1
--- /dev/null
+++ b/src/main/java/io/getunleash/util/Throttler.java
@@ -0,0 +1,103 @@
+package io.getunleash.util;
+
+import static java.lang.Integer.max;
+
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class Throttler {
+    private static final Logger LOGGER = LoggerFactory.getLogger(Throttler.class);
+    private final int maxSkips;
+
+    private final int intervalLength;
+    private final AtomicInteger skips = new AtomicInteger(0);
+    private final AtomicInteger failures = new AtomicInteger(0);
+
+    private final URL target;
+
+    public Throttler(int intervalLengthSeconds, int longestAcceptableIntervalSeconds, URL target) {
+        this.maxSkips = max(longestAcceptableIntervalSeconds / max(intervalLengthSeconds, 1), 1);
+        this.target = target;
+        this.intervalLength = intervalLengthSeconds;
+    }
+
+    /**
+     * We've had one successful call, so if we had 10 failures in a row, this will reduce the skips
+     * down to 9, so that we gradually start polling more often, instead of doing max load
+     * immediately after a sequence of errors.
+     */
+    public void decrementFailureCountAndResetSkips() {
+        skips.set(Math.max(failures.decrementAndGet(), 0));
+    }
+
+    /**
+     * We've gotten the message to back off (usually a 429 or a 50x). If we have successive
+     * failures, failure count here will be incremented higher and higher which will handle
+     * increasing our backoff, since we set the skip count to the failure count after every reset
+     */
+    public void increaseSkipCount() {
+        skips.set(Math.min(failures.incrementAndGet(), maxSkips));
+    }
+
+    /**
+     * We've received an error code that we don't expect to change, which means we've already logged
+     * an ERROR. To avoid hammering the server that just told us we did something wrong and to avoid
+     * flooding the logs, we'll increase our skip count to maximum
+     */
+    public void maximizeSkips() {
+        skips.set(maxSkips);
+        failures.incrementAndGet();
+    }
+
+    public boolean performAction() {
+        return skips.get() <= 0;
+    }
+
+    public void skipped() {
+        skips.decrementAndGet();
+    }
+
+    public void handleHttpErrorCodes(int responseCode) {
+        if (responseCode == HttpURLConnection.HTTP_UNAUTHORIZED || responseCode == HttpURLConnection.HTTP_FORBIDDEN) {
+            maximizeSkips();
+            LOGGER.error(
+                "Client was not authorized to talk to the Unleash API at {}. Backing off to {} times our poll interval (of {} seconds) to avoid overloading server",
+                this.target,
+                maxSkips,
+                this.intervalLength);
+        }
+        if (responseCode == HttpURLConnection.HTTP_NOT_FOUND) {
+            maximizeSkips();
+            LOGGER.error(
+                    "Server said that the endpoint at {} does not exist. Backing off to {} times our poll interval (of {} seconds) to avoid overloading server",
+                    this.target,
+                    maxSkips,
+                    this.intervalLength);
+        } else if (responseCode == 429) {
+            increaseSkipCount();
+            LOGGER.info(
+                    "RATE LIMITED for the {}. time. Further backing off. Current backoff at {} times our interval (of {} seconds)",
+                    failures.get(),
+                    skips.get(),
+                    this.intervalLength);
+        } else if (responseCode >= HttpURLConnection.HTTP_INTERNAL_ERROR) {
+            increaseSkipCount();
+            LOGGER.info(
+                    "Server failed with a {} status code. Backing off. Current backoff at {} times our poll interval (of {} seconds)",
+                    responseCode,
+                    skips.get(),
+                    this.intervalLength);
+        }
+    }
+
+    public int getSkips() {
+        return this.skips.get();
+    }
+
+    public int getFailures() {
+        return this.failures.get();
+    }
+}


Test patch:
diff --git a/src/test/java/io/getunleash/DefaultUnleashTest.java b/src/test/java/io/getunleash/DefaultUnleashTest.java
index 2f620735..44f32927 100644
--- a/src/test/java/io/getunleash/DefaultUnleashTest.java
+++ b/src/test/java/io/getunleash/DefaultUnleashTest.java
@@ -243,7 +243,6 @@ class DefaultUnleashTest {
         when(fetcher.fetchFeatures())
                 .thenThrow(UnleashException.class)
                 .thenReturn(new ClientFeaturesResponse(expectedStatus, expectedResponse));
-
         UnleashConfig config =
                 UnleashConfig.builder()
                         .unleashAPI("http://wrong:4242")
diff --git a/src/test/java/io/getunleash/event/SubscriberTest.java b/src/test/java/io/getunleash/event/SubscriberTest.java
index de76ead6..b190ac36 100644
--- a/src/test/java/io/getunleash/event/SubscriberTest.java
+++ b/src/test/java/io/getunleash/event/SubscriberTest.java
@@ -1,7 +1,8 @@
 package io.getunleash.event;
 
+import static com.github.tomakehurst.wiremock.client.WireMock.*;
 import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
-import static io.getunleash.repository.FeatureToggleResponse.Status.UNAVAILABLE;
+import static io.getunleash.repository.FeatureToggleResponse.Status.CHANGED;
 import static org.assertj.core.api.Assertions.assertThat;
 
 import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
@@ -44,19 +45,29 @@ public class SubscriberTest {
     }
 
     @Test
-    void subscriberAreNotified() {
+    void subscribersAreNotified() {
+        serverMock.stubFor(post("/client/register").willReturn(ok()));
+        serverMock.stubFor(post("/client/metrics").willReturn(ok()));
+        serverMock.stubFor(
+                get("/client/features")
+                        .willReturn(
+                                ok().withHeader("Content-Type", "application/json")
+                                        .withBody("{\"features\": [], \"version\": 2 }")));
         Unleash unleash = new DefaultUnleash(unleashConfig);
 
         unleash.isEnabled("myFeature");
         unleash.isEnabled("myFeature");
         unleash.isEnabled("myFeature");
 
-        assertThat(testSubscriber.togglesFetchedCounter).isEqualTo(2); // one forced, one scheduled
-        assertThat(testSubscriber.status).isEqualTo(UNAVAILABLE);
+        assertThat(testSubscriber.togglesFetchedCounter)
+                .isEqualTo(
+                        2); // Server successfully returns, we call synchronous fetch and schedule
+        // once, so 2 calls.
+        assertThat(testSubscriber.status).isEqualTo(CHANGED);
         assertThat(testSubscriber.toggleEvaluatedCounter).isEqualTo(3);
         assertThat(testSubscriber.toggleName).isEqualTo("myFeature");
         assertThat(testSubscriber.toggleEnabled).isFalse();
-        assertThat(testSubscriber.errors).hasSize(2);
+        assertThat(testSubscriber.errors).isEmpty();
 
         //        assertThat(testSubscriber.events).filteredOn(e -> e instanceof
         // ToggleBootstrapHandler.ToggleBootstrapRead).hasSize(1);
diff --git a/src/test/java/io/getunleash/metric/UnleashMetricServiceImplTest.java b/src/test/java/io/getunleash/metric/UnleashMetricServiceImplTest.java
index 07939f45..1640ce03 100644
--- a/src/test/java/io/getunleash/metric/UnleashMetricServiceImplTest.java
+++ b/src/test/java/io/getunleash/metric/UnleashMetricServiceImplTest.java
@@ -196,4 +196,220 @@ public class UnleashMetricServiceImplTest {
         assertThat(bucket.getToggles().get("someToggle").getYes()).isEqualTo(0l);
         assertThat(bucket.getToggles().get("someToggle").getNo()).isEqualTo(0l);
     }
+
+    @Test
+    public void should_backoff_when_told_to_by_429_code() {
+        UnleashConfig config =
+                UnleashConfig.builder()
+                        .appName("test")
+                        .sendMetricsInterval(10)
+                        .unleashAPI("http://unleash.com")
+                        .build();
+
+        UnleashScheduledExecutor executor = mock(UnleashScheduledExecutor.class);
+        DefaultHttpMetricsSender sender = mock(DefaultHttpMetricsSender.class);
+
+        UnleashMetricServiceImpl unleashMetricService =
+                new UnleashMetricServiceImpl(config, sender, executor);
+        unleashMetricService.countVariant("someToggle", "v1");
+        unleashMetricService.countVariant("someToggle", "v1");
+        unleashMetricService.countVariant("someToggle", "v1");
+        unleashMetricService.countVariant("someToggle", "v2");
+        unleashMetricService.countVariant("someToggle", "disabled");
+
+        // Call the sendMetricsCallback
+        ArgumentCaptor<Runnable> sendMetricsCallback = ArgumentCaptor.forClass(Runnable.class);
+        verify(executor).setInterval(sendMetricsCallback.capture(), anyLong(), anyLong());
+
+        when(sender.sendMetrics(any(ClientMetrics.class)))
+                .thenReturn(429)
+                .thenReturn(429)
+                .thenReturn(429)
+                .thenReturn(200)
+                .thenReturn(200)
+                .thenReturn(200)
+                .thenReturn(200);
+
+        sendMetricsCallback.getValue().run();
+        assertThat(unleashMetricService.getSkips()).isEqualTo(1);
+        assertThat(unleashMetricService.getFailures()).isEqualTo(1);
+        sendMetricsCallback.getValue().run();
+        assertThat(unleashMetricService.getSkips()).isEqualTo(0);
+        assertThat(unleashMetricService.getFailures()).isEqualTo(1);
+        sendMetricsCallback.getValue().run();
+        assertThat(unleashMetricService.getSkips()).isEqualTo(2);
+        assertThat(unleashMetricService.getFailures()).isEqualTo(2);
+        sendMetricsCallback.getValue().run(); // NO-OP because interval > 0
+        sendMetricsCallback.getValue().run(); // NO-OP because interval > 0
+        assertThat(unleashMetricService.getSkips()).isEqualTo(0);
+        assertThat(unleashMetricService.getFailures()).isEqualTo(2);
+        sendMetricsCallback.getValue().run();
+        assertThat(unleashMetricService.getSkips()).isEqualTo(3);
+        assertThat(unleashMetricService.getFailures()).isEqualTo(3);
+        sendMetricsCallback.getValue().run();
+        sendMetricsCallback.getValue().run();
+        sendMetricsCallback.getValue().run();
+        assertThat(unleashMetricService.getSkips()).isEqualTo(0);
+        assertThat(unleashMetricService.getFailures()).isEqualTo(3);
+        sendMetricsCallback.getValue().run();
+        assertThat(unleashMetricService.getSkips()).isEqualTo(2);
+        assertThat(unleashMetricService.getFailures()).isEqualTo(2);
+        sendMetricsCallback.getValue().run();
+        sendMetricsCallback.getValue().run();
+        assertThat(unleashMetricService.getSkips()).isEqualTo(0);
+        assertThat(unleashMetricService.getFailures()).isEqualTo(2);
+        sendMetricsCallback.getValue().run();
+        assertThat(unleashMetricService.getSkips()).isEqualTo(1);
+        assertThat(unleashMetricService.getFailures()).isEqualTo(1);
+        sendMetricsCallback.getValue().run();
+        assertThat(unleashMetricService.getSkips()).isEqualTo(0);
+        assertThat(unleashMetricService.getFailures()).isEqualTo(1);
+        sendMetricsCallback.getValue().run();
+        assertThat(unleashMetricService.getSkips()).isEqualTo(0);
+        assertThat(unleashMetricService.getFailures()).isEqualTo(0);
+    }
+
+    @Test
+    public void server_errors_should_also_incrementally_backoff() {
+        UnleashConfig config =
+                UnleashConfig.builder()
+                        .appName("test")
+                        .sendMetricsInterval(10)
+                        .unleashAPI("http://unleash.com")
+                        .build();
+
+        UnleashScheduledExecutor executor = mock(UnleashScheduledExecutor.class);
+        DefaultHttpMetricsSender sender = mock(DefaultHttpMetricsSender.class);
+
+        UnleashMetricServiceImpl unleashMetricService =
+                new UnleashMetricServiceImpl(config, sender, executor);
+        unleashMetricService.countVariant("someToggle", "v1");
+        unleashMetricService.countVariant("someToggle", "v1");
+        unleashMetricService.countVariant("someToggle", "v1");
+        unleashMetricService.countVariant("someToggle", "v2");
+        unleashMetricService.countVariant("someToggle", "disabled");
+
+        // Call the sendMetricsCallback
+        ArgumentCaptor<Runnable> sendMetricsCallback = ArgumentCaptor.forClass(Runnable.class);
+        verify(executor).setInterval(sendMetricsCallback.capture(), anyLong(), anyLong());
+        when(sender.sendMetrics(any(ClientMetrics.class)))
+                .thenReturn(500)
+                .thenReturn(502)
+                .thenReturn(503)
+                .thenReturn(304)
+                .thenReturn(304)
+                .thenReturn(304)
+                .thenReturn(304);
+        sendMetricsCallback.getValue().run();
+        assertThat(unleashMetricService.getSkips()).isEqualTo(1);
+        assertThat(unleashMetricService.getFailures()).isEqualTo(1);
+        sendMetricsCallback.getValue().run();
+        assertThat(unleashMetricService.getSkips()).isEqualTo(0);
+        assertThat(unleashMetricService.getFailures()).isEqualTo(1);
+        sendMetricsCallback.getValue().run();
+        assertThat(unleashMetricService.getSkips()).isEqualTo(2);
+        assertThat(unleashMetricService.getFailures()).isEqualTo(2);
+        sendMetricsCallback.getValue().run(); // NO-OP because interval > 0
+        sendMetricsCallback.getValue().run(); // NO-OP because interval > 0
+        assertThat(unleashMetricService.getSkips()).isEqualTo(0);
+        assertThat(unleashMetricService.getFailures()).isEqualTo(2);
+        sendMetricsCallback.getValue().run();
+        assertThat(unleashMetricService.getSkips()).isEqualTo(3);
+        assertThat(unleashMetricService.getFailures()).isEqualTo(3);
+        sendMetricsCallback.getValue().run();
+        sendMetricsCallback.getValue().run();
+        sendMetricsCallback.getValue().run();
+        assertThat(unleashMetricService.getSkips()).isEqualTo(0);
+        assertThat(unleashMetricService.getFailures()).isEqualTo(3);
+        sendMetricsCallback.getValue().run();
+        assertThat(unleashMetricService.getSkips()).isEqualTo(2);
+        assertThat(unleashMetricService.getFailures()).isEqualTo(2);
+        sendMetricsCallback.getValue().run();
+        sendMetricsCallback.getValue().run();
+        assertThat(unleashMetricService.getSkips()).isEqualTo(0);
+        assertThat(unleashMetricService.getFailures()).isEqualTo(2);
+        sendMetricsCallback.getValue().run();
+        assertThat(unleashMetricService.getSkips()).isEqualTo(1);
+        assertThat(unleashMetricService.getFailures()).isEqualTo(1);
+        sendMetricsCallback.getValue().run();
+        assertThat(unleashMetricService.getSkips()).isEqualTo(0);
+        assertThat(unleashMetricService.getFailures()).isEqualTo(1);
+        sendMetricsCallback.getValue().run();
+        assertThat(unleashMetricService.getSkips()).isEqualTo(0);
+        assertThat(unleashMetricService.getFailures()).isEqualTo(0);
+    }
+
+    @Test
+    public void failure_to_authenticate_immediately_increases_interval_to_max() {
+        UnleashConfig config =
+                UnleashConfig.builder()
+                        .appName("test")
+                        .sendMetricsInterval(10)
+                        .unleashAPI("http://unleash.com")
+                        .build();
+
+        UnleashScheduledExecutor executor = mock(UnleashScheduledExecutor.class);
+        DefaultHttpMetricsSender sender = mock(DefaultHttpMetricsSender.class);
+
+        UnleashMetricServiceImpl unleashMetricService =
+                new UnleashMetricServiceImpl(config, sender, executor);
+        unleashMetricService.countVariant("someToggle", "v1");
+        unleashMetricService.countVariant("someToggle", "v1");
+        unleashMetricService.countVariant("someToggle", "v1");
+        unleashMetricService.countVariant("someToggle", "v2");
+        unleashMetricService.countVariant("someToggle", "disabled");
+
+        // Call the sendMetricsCallback
+        ArgumentCaptor<Runnable> sendMetricsCallback = ArgumentCaptor.forClass(Runnable.class);
+        verify(executor).setInterval(sendMetricsCallback.capture(), anyLong(), anyLong());
+        when(sender.sendMetrics(any(ClientMetrics.class))).thenReturn(403).thenReturn(200);
+        sendMetricsCallback.getValue().run();
+        assertThat(unleashMetricService.getSkips()).isEqualTo(30);
+        assertThat(unleashMetricService.getFailures()).isEqualTo(1);
+        for (int i = 0; i < 30; i++) {
+            sendMetricsCallback.getValue().run();
+        }
+        assertThat(unleashMetricService.getFailures()).isEqualTo(1);
+        assertThat(unleashMetricService.getSkips()).isEqualTo(0);
+        sendMetricsCallback.getValue().run();
+        assertThat(unleashMetricService.getFailures()).isEqualTo(0);
+        assertThat(unleashMetricService.getSkips()).isEqualTo(0);
+    }
+
+    @Test
+    public void url_not_found_immediately_increases_interval_to_max() {
+        UnleashConfig config =
+                UnleashConfig.builder()
+                        .appName("test")
+                        .sendMetricsInterval(10)
+                        .unleashAPI("http://unleash.com")
+                        .build();
+
+        UnleashScheduledExecutor executor = mock(UnleashScheduledExecutor.class);
+        DefaultHttpMetricsSender sender = mock(DefaultHttpMetricsSender.class);
+
+        UnleashMetricServiceImpl unleashMetricService =
+                new UnleashMetricServiceImpl(config, sender, executor);
+        unleashMetricService.countVariant("someToggle", "v1");
+        unleashMetricService.countVariant("someToggle", "v1");
+        unleashMetricService.countVariant("someToggle", "v1");
+        unleashMetricService.countVariant("someToggle", "v2");
+        unleashMetricService.countVariant("someToggle", "disabled");
+
+        // Call the sendMetricsCallback
+        ArgumentCaptor<Runnable> sendMetricsCallback = ArgumentCaptor.forClass(Runnable.class);
+        verify(executor).setInterval(sendMetricsCallback.capture(), anyLong(), anyLong());
+        when(sender.sendMetrics(any(ClientMetrics.class))).thenReturn(404).thenReturn(200);
+        sendMetricsCallback.getValue().run();
+        assertThat(unleashMetricService.getSkips()).isEqualTo(30);
+        assertThat(unleashMetricService.getFailures()).isEqualTo(1);
+        for (int i = 0; i < 30; i++) {
+            sendMetricsCallback.getValue().run();
+        }
+        assertThat(unleashMetricService.getFailures()).isEqualTo(1);
+        assertThat(unleashMetricService.getSkips()).isEqualTo(0);
+        sendMetricsCallback.getValue().run();
+        assertThat(unleashMetricService.getFailures()).isEqualTo(0);
+        assertThat(unleashMetricService.getSkips()).isEqualTo(0);
+    }
 }
diff --git a/src/test/java/io/getunleash/repository/FeatureRepositoryTest.java b/src/test/java/io/getunleash/repository/FeatureRepositoryTest.java
index 818cfb86..ae361513 100644
--- a/src/test/java/io/getunleash/repository/FeatureRepositoryTest.java
+++ b/src/test/java/io/getunleash/repository/FeatureRepositoryTest.java
@@ -23,7 +23,6 @@ import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentCaptor;
 
 public class FeatureRepositoryTest {
-
     FeatureBackupHandlerFile backupHandler;
 
     FeatureBootstrapHandler bootstrapHandler;
@@ -293,6 +292,336 @@ public class FeatureRepositoryTest {
         verify(toggleBootstrapProvider, times(0)).read();
     }
 
+    @Test
+    public void should_increase_to_max_interval_when_denied()
+            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)))));
+        when(fetcher.fetchFeatures())
+                .thenReturn(
+                        new ClientFeaturesResponse(FeatureToggleResponse.Status.UNAVAILABLE, 403))
+                .thenReturn(
+                        new ClientFeaturesResponse(FeatureToggleResponse.Status.NOT_CHANGED, 304));
+
+        FeatureRepository featureRepository =
+                new FeatureRepository(
+                        config,
+                        backupHandler,
+                        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);
+        assertThat(featureRepository.getSkips()).isEqualTo(0);
+        runnableArgumentCaptor.getValue().run();
+        assertThat(featureRepository.getFailures()).isEqualTo(0);
+        assertThat(featureRepository.getSkips()).isEqualTo(0);
+    }
+
+    @Test
+    public void should_increase_to_max_interval_when_not_found()
+            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)))));
+        when(fetcher.fetchFeatures())
+                .thenReturn(
+                        new ClientFeaturesResponse(FeatureToggleResponse.Status.UNAVAILABLE, 404))
+                .thenReturn(
+                        new ClientFeaturesResponse(FeatureToggleResponse.Status.NOT_CHANGED, 304));
+
+        FeatureRepository featureRepository =
+                new FeatureRepository(
+                        config,
+                        backupHandler,
+                        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);
+        assertThat(featureRepository.getSkips()).isEqualTo(0);
+        runnableArgumentCaptor.getValue().run();
+        assertThat(featureRepository.getFailures()).isEqualTo(0);
+        assertThat(featureRepository.getSkips()).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();
+        assertThat(featureRepository.getSkips()).isEqualTo(1);
+        assertThat(featureRepository.getFailures()).isEqualTo(1);
+        runnableArgumentCaptor.getValue().run();
+        assertThat(featureRepository.getSkips()).isEqualTo(0);
+        assertThat(featureRepository.getFailures()).isEqualTo(1);
+        runnableArgumentCaptor.getValue().run();
+        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
+        assertThat(featureRepository.getSkips()).isEqualTo(0);
+        assertThat(featureRepository.getFailures()).isEqualTo(2);
+        runnableArgumentCaptor.getValue().run();
+        assertThat(featureRepository.getSkips()).isEqualTo(3);
+        assertThat(featureRepository.getFailures()).isEqualTo(3);
+        runnableArgumentCaptor.getValue().run();
+        runnableArgumentCaptor.getValue().run();
+        runnableArgumentCaptor.getValue().run();
+        assertThat(featureRepository.getSkips()).isEqualTo(0);
+        assertThat(featureRepository.getFailures()).isEqualTo(3);
+        runnableArgumentCaptor.getValue().run();
+        assertThat(featureRepository.getSkips()).isEqualTo(2);
+        assertThat(featureRepository.getFailures()).isEqualTo(2);
+        runnableArgumentCaptor.getValue().run();
+        runnableArgumentCaptor.getValue().run();
+        assertThat(featureRepository.getSkips()).isEqualTo(0);
+        assertThat(featureRepository.getFailures()).isEqualTo(2);
+        runnableArgumentCaptor.getValue().run();
+        assertThat(featureRepository.getSkips()).isEqualTo(1);
+        assertThat(featureRepository.getFailures()).isEqualTo(1);
+        runnableArgumentCaptor.getValue().run();
+        assertThat(featureRepository.getSkips()).isEqualTo(0);
+        assertThat(featureRepository.getFailures()).isEqualTo(1);
+        runnableArgumentCaptor.getValue().run();
+        assertThat(featureRepository.getSkips()).isEqualTo(0);
+        assertThat(featureRepository.getFailures()).isEqualTo(0);
+    }
+
+    @Test
+    public void server_errors_should_incrementally_increase_interval()
+            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, 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();
+        assertThat(featureRepository.getSkips()).isEqualTo(1);
+        assertThat(featureRepository.getFailures()).isEqualTo(1);
+        runnableArgumentCaptor.getValue().run();
+        assertThat(featureRepository.getSkips()).isEqualTo(0);
+        assertThat(featureRepository.getFailures()).isEqualTo(1);
+        runnableArgumentCaptor.getValue().run();
+        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
+        assertThat(featureRepository.getSkips()).isEqualTo(0);
+        assertThat(featureRepository.getFailures()).isEqualTo(2);
+        runnableArgumentCaptor.getValue().run();
+        assertThat(featureRepository.getSkips()).isEqualTo(3);
+        assertThat(featureRepository.getFailures()).isEqualTo(3);
+        runnableArgumentCaptor.getValue().run();
+        runnableArgumentCaptor.getValue().run();
+        runnableArgumentCaptor.getValue().run();
+        assertThat(featureRepository.getSkips()).isEqualTo(0);
+        assertThat(featureRepository.getFailures()).isEqualTo(3);
+        runnableArgumentCaptor.getValue().run();
+        assertThat(featureRepository.getSkips()).isEqualTo(2);
+        assertThat(featureRepository.getFailures()).isEqualTo(2);
+        runnableArgumentCaptor.getValue().run();
+        runnableArgumentCaptor.getValue().run();
+        assertThat(featureRepository.getSkips()).isEqualTo(0);
+        assertThat(featureRepository.getFailures()).isEqualTo(2);
+        runnableArgumentCaptor.getValue().run();
+        assertThat(featureRepository.getSkips()).isEqualTo(1);
+        assertThat(featureRepository.getFailures()).isEqualTo(1);
+        runnableArgumentCaptor.getValue().run();
+        assertThat(featureRepository.getSkips()).isEqualTo(0);
+        assertThat(featureRepository.getFailures()).isEqualTo(1);
+        runnableArgumentCaptor.getValue().run();
+        assertThat(featureRepository.getSkips()).isEqualTo(0);
+        assertThat(featureRepository.getFailures()).isEqualTo(0);
+    }
+
     private String fileToString(File f) throws IOException {
         return new String(Files.readAllBytes(f.toPath()), StandardCharsets.UTF_8);
     }
diff --git a/src/test/java/io/getunleash/util/IpAddressMatcherTest.java b/src/test/java/io/getunleash/util/IpAddressMatcherTest.java
index f9d48f37..8b2bf5cb 100644
--- a/src/test/java/io/getunleash/util/IpAddressMatcherTest.java
+++ b/src/test/java/io/getunleash/util/IpAddressMatcherTest.java
@@ -19,7 +19,9 @@ import static org.assertj.core.api.Assertions.assertThat;
 
 import org.junit.jupiter.api.Test;
 
-/** @author Luke Taylor */
+/**
+ * @author Luke Taylor
+ */
 class IpAddressMatcherTest {
     private final IpAddressMatcher v6matcher = new IpAddressMatcher("fe80::21f:5bff:fe33:bd68");
     private final IpAddressMatcher v4matcher = new IpAddressMatcher("192.168.1.104");
diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml
index a787811f..5b57a78a 100644
--- a/src/test/resources/logback-test.xml
+++ b/src/test/resources/logback-test.xml
@@ -11,5 +11,6 @@
     <root level="error">
         <appender-ref ref="STDOUT" />
     </root>
+    <logger name="io.getunleash" level="error" />
 </configuration>