diff --git a/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java b/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java index b6618d9e..50acf5e5 100644 --- a/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java +++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java @@ -43,7 +43,9 @@ public class TestService extends NanoHTTPD { "evaluation-hooks", "track-hooks", "client-per-context-summaries", - "client-event-source-http-errors" + "client-event-source-http-errors", + "retry-conformance-fdv1-streaming", + "retry-conformance-fdv1-polling" }; private static final String MIME_JSON = "application/json"; static final Gson gson = new GsonBuilder() diff --git a/launchdarkly-android-client-sdk/build.gradle b/launchdarkly-android-client-sdk/build.gradle index 3b7566cb..05e9fc44 100644 --- a/launchdarkly-android-client-sdk/build.gradle +++ b/launchdarkly-android-client-sdk/build.gradle @@ -73,7 +73,7 @@ ext {} ext.versions = [ "androidAnnotation": "1.2.0", "androidAppcompat": "1.1.0", - "eventsource": "4.2.0", + "eventsource": "5.0.0", "gson": "2.13.2", "jacksonCore": "2.10.5", "jacksonDatabind": "2.10.5.1", diff --git a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/AndroidTaskExecutorTest.java b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/AndroidTaskExecutorTest.java index 5b857468..e8b42197 100644 --- a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/AndroidTaskExecutorTest.java +++ b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/AndroidTaskExecutorTest.java @@ -2,10 +2,8 @@ import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotSame; -import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; @@ -24,11 +22,9 @@ import org.junit.Test; import org.junit.runner.RunWith; -import java.util.ArrayList; import java.util.concurrent.BlockingQueue; import java.util.concurrent.CountDownLatch; import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -122,46 +118,4 @@ public void scheduleTaskCatchesAndLogsExceptions() throws InterruptedException { assertThat(message2.getText(), containsString(this.getClass().getName())); // stacktrace } - @Test - public void repeatingTasks() throws InterruptedException { - BlockingQueue executedA = new LinkedBlockingQueue<>(), - executedB = new LinkedBlockingQueue<>(), - executedC = new LinkedBlockingQueue<>(); - ScheduledFuture taskA = taskExecutor.startRepeatingTask( - () -> { - executedA.add(System.currentTimeMillis()); - }, - 10, - 10 - ); - ScheduledFuture taskB = taskExecutor.startRepeatingTask( - () -> { - executedB.add(System.currentTimeMillis()); - }, - 10, - 20 - ); - ScheduledFuture taskC = taskExecutor.startRepeatingTask( - () -> { - executedC.add(System.currentTimeMillis()); - }, - 10000, - 20 - ); - try { - Thread.sleep(100); - - assertEquals("C task should not have executed yet", 0, executedC.size()); - } finally { - taskA.cancel(false); - taskB.cancel(false); - taskC.cancel(false); - } - executedA.drainTo(new ArrayList<>()); - // After stopping the task, let's tolerate it firing one more time, but no more than that. - if (executedA.poll(50, TimeUnit.MILLISECONDS) != null) { - assertNull("A task should have stopped executing", - executedA.poll(50, TimeUnit.MILLISECONDS)); - } - } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/AndroidTaskExecutor.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/AndroidTaskExecutor.java index b19c204d..50197a23 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/AndroidTaskExecutor.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/AndroidTaskExecutor.java @@ -42,12 +42,6 @@ public ScheduledFuture scheduleTask(Runnable action, long delayMillis) { return executor.schedule(wrapActionWithErrorHandling(action), delayMillis, TimeUnit.MILLISECONDS); } - @Override - public ScheduledFuture startRepeatingTask(Runnable action, long initialDelayMillis, long intervalMillis) { - return executor.scheduleAtFixedRate(wrapActionWithErrorHandling(action), - initialDelayMillis, intervalMillis, TimeUnit.MILLISECONDS); - } - @Override public void close() { executor.shutdownNow(); diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java index 8c9cc045..34b47a2f 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java @@ -292,7 +292,6 @@ public DataSource build(ClientContext clientContext) { pollInterval, maxNumPolls, clientContextImpl.getFetcher(), - clientContextImpl.getPlatformState(), clientContextImpl.getTaskExecutor(), clientContext.getBaseLogger() ); diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionInformation.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionInformation.java index a8a62fd8..15af7cc6 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionInformation.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionInformation.java @@ -38,9 +38,9 @@ enum ConnectionMode { OFFLINE(false), /** - * The SDK has been explicitly set offline, either in the initial configuration, by - * {@link LDClient#setOffline()}, or as a result of failed authentication to LaunchDarkly. The SDK will stay - * offline unless {@link LDClient#setOnline()} is called. + * The SDK has been explicitly set offline, either in the initial configuration or by + * {@link LDClient#setOffline()}. The SDK will stay offline unless + * {@link LDClient#setOnline()} is called. */ SET_OFFLINE(false), diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizer.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizer.java index ef8e0d5f..ad6daf6d 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizer.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizer.java @@ -195,8 +195,8 @@ private void startStream() { } EventSource es = new EventSource.Builder(connectStrategy) - .retryDelay(initialReconnectDelayMillis, TimeUnit.MILLISECONDS) .retryDelayStrategy(RetryDelayStrategy.defaultStrategy() + .initialDelay(initialReconnectDelayMillis, TimeUnit.MILLISECONDS) .maxDelay(MAX_RECONNECT_TIME_MS, TimeUnit.MILLISECONDS)) .errorStrategy(ErrorStrategy.alwaysContinue()) .build(); diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDUtil.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDUtil.java index ae8ed1f5..4a77849b 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDUtil.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDUtil.java @@ -230,6 +230,15 @@ static boolean isHttpErrorRecoverable(int statusCode) { return true; } + /** + * Tests whether an HTTP error status represents a condition that is unlikely to resolve on its own. + * @param statusCode the HTTP status + * @return true if the status is unlikely to change, such as a rejected credential; false otherwise + */ + static boolean isHttpErrorUnexpected(int statusCode) { + return !isHttpErrorRecoverable(statusCode); + } + static void logExceptionAtErrorLevel(LDLogger logger, Throwable ex, String msgFormat, Object... msgArgs) { logException(logger, ex, true, msgFormat, msgArgs); } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/PollingDataSource.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/PollingDataSource.java index a68697d2..68e07663 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/PollingDataSource.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/PollingDataSource.java @@ -24,10 +24,11 @@ final class PollingDataSource implements DataSource { final long pollIntervalMillis; // visible for testing long numberOfPollsRemaining; // visible for testing private final FeatureFetcher fetcher; - private final PlatformState platformState; private final TaskExecutor taskExecutor; private final LDLogger logger; - final AtomicReference> currentPollTask = new AtomicReference<>(); // visible for testing + private final PollingRetryState retryState; + private volatile boolean stopped; + private final AtomicReference> currentPollTask = new AtomicReference<>(); /** * @param context that this data source will fetch data for @@ -38,7 +39,6 @@ final class PollingDataSource implements DataSource { * @param pollIntervalMillis interval in millis between each polling request * @param maxNumberOfPolls the maximum number of polling attempts, use Long.MAX for effectively unlimited. * @param fetcher that will be used for each fetch - * @param platformState used for making decisions based on platform state * @param taskExecutor that will be used to schedule the polling tasks * @param logger for logging */ @@ -49,7 +49,6 @@ final class PollingDataSource implements DataSource { long pollIntervalMillis, long maxNumberOfPolls, FeatureFetcher fetcher, - PlatformState platformState, TaskExecutor taskExecutor, LDLogger logger ) { @@ -59,9 +58,9 @@ final class PollingDataSource implements DataSource { this.pollIntervalMillis = pollIntervalMillis; this.numberOfPollsRemaining = maxNumberOfPolls; this.fetcher = fetcher; - this.platformState = platformState; this.taskExecutor = taskExecutor; this.logger = logger; + this.retryState = new PollingRetryState(pollIntervalMillis); } @Override @@ -73,16 +72,14 @@ public void start(final Callback resultCallback) { return; } - Runnable pollRunnable = () -> poll(resultCallback); logger.debug("Scheduling polling task with interval of {}ms, starting after {}ms, with number of polls {}", pollIntervalMillis, initialDelayMillis, numberOfPollsRemaining); - ScheduledFuture task = taskExecutor.startRepeatingTask(pollRunnable, - initialDelayMillis, pollIntervalMillis); - currentPollTask.set(task); + schedulePoll(resultCallback, initialDelayMillis); } @Override public void stop(Callback completionCallback) { + stopped = true; ScheduledFuture task = currentPollTask.getAndSet(null); if (task != null) { task.cancel(true); @@ -90,18 +87,46 @@ public void stop(Callback completionCallback) { completionCallback.onSuccess(null); } - private void poll(Callback resultCallback) { - // poll if there are polls remaining - if (numberOfPollsRemaining > 0) { - numberOfPollsRemaining--; - ConnectivityManager.fetchAndSetData(fetcher, context, dataSourceUpdateSink, - resultCallback, logger); - } else { - // terminate if we have no polls remaining - ScheduledFuture task = currentPollTask.getAndSet(null); - if (task != null) { - task.cancel(true); + private void schedulePoll(Callback resultCallback, long delayMillis) { + if (stopped) { + return; + } + currentPollTask.set(taskExecutor.scheduleTask(() -> { + if (stopped) { + return; } + poll(resultCallback); + }, delayMillis)); + } + + private void poll(Callback resultCallback) { + if (numberOfPollsRemaining <= 0) { + return; } + numberOfPollsRemaining--; + + // Each poll schedules the next one, so the wait can grow after a failure. + ConnectivityManager.fetchAndSetData(fetcher, context, dataSourceUpdateSink, + new Callback() { + @Override + public void onSuccess(Boolean result) { + try { + retryState.recordSuccess(); + resultCallback.onSuccess(result); + } finally { + schedulePoll(resultCallback, retryState.nextDelayMillis()); + } + } + + @Override + public void onError(Throwable e) { + try { + retryState.recordFailure(e); + resultCallback.onError(e); + } finally { + schedulePoll(resultCallback, retryState.nextDelayMillis()); + } + } + }, logger); } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/PollingRetryState.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/PollingRetryState.java new file mode 100644 index 00000000..7796e05f --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/PollingRetryState.java @@ -0,0 +1,79 @@ +package com.launchdarkly.sdk.android; + +import androidx.annotation.Nullable; + +import com.launchdarkly.eventsource.RetryDelayStrategy; + +import java.util.concurrent.TimeUnit; + +/** + * Computes the wait before a polling data source polls again. + *

+ * A poll interval is already a wait, so an ordinary failure does not extend it. Only a failure + * that is unlikely to correct itself, such as a rejected credential, moves polling into a + * regime that waits longer, and it stays there until enough consecutive polls succeed. + *

+ * This class is not thread safe. + */ +final class PollingRetryState { + private static final int RESET_THRESHOLD_SUCCESSES = 2; + private static final long EXTENDED_INITIAL_DELAY_MS = 300_000; + private static final long EXTENDED_MAX_DELAY_MS = 3_600_000; + + private final long pollIntervalMillis; + + private RetryDelayStrategy extendedDelay; + private boolean inExtendedRegime; + private int consecutiveSuccesses; + + PollingRetryState(long pollIntervalMillis) { + this.pollIntervalMillis = pollIntervalMillis; + this.extendedDelay = freshExtendedDelay(); + } + + // Neither bound drops below the poll interval, which would make a failing source poll more + // often than a healthy one. + private RetryDelayStrategy freshExtendedDelay() { + return RetryDelayStrategy.defaultStrategy() + .initialDelay(Math.max(EXTENDED_INITIAL_DELAY_MS, pollIntervalMillis), + TimeUnit.MILLISECONDS) + .maxDelay(Math.max(EXTENDED_MAX_DELAY_MS, pollIntervalMillis), + TimeUnit.MILLISECONDS); + } + + void recordFailure(@Nullable Throwable error) { + consecutiveSuccesses = 0; + if (!inExtendedRegime) { + if (isUnexpected(error)) { + inExtendedRegime = true; + extendedDelay = freshExtendedDelay(); + } + return; + } + extendedDelay = extendedDelay.getNext(); + } + + // True for an HTTP status the service is unlikely to stop returning, such as a 401. + private static boolean isUnexpected(@Nullable Throwable error) { + return error instanceof LDInvalidResponseCodeFailure + && LDUtil.isHttpErrorUnexpected( + ((LDInvalidResponseCodeFailure) error).getResponseCode()); + } + + void recordSuccess() { + consecutiveSuccesses++; + if (consecutiveSuccesses >= RESET_THRESHOLD_SUCCESSES) { + inExtendedRegime = false; + extendedDelay = freshExtendedDelay(); + } + } + + long nextDelayMillis() { + // A success shows the service works, so polling returns to its interval at once even + // though the regime has not reset yet. + if (!inExtendedRegime || consecutiveSuccesses > 0) { + return pollIntervalMillis; + } + return Math.max(pollIntervalMillis, extendedDelay.getDelayMillis()); + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StreamingDataSource.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StreamingDataSource.java index 366254b2..3bf6ab92 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StreamingDataSource.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StreamingDataSource.java @@ -48,26 +48,30 @@ final class StreamingDataSource implements DataSource { private static final String PATCH = "patch"; private static final String DELETE = "delete"; - private static final long MAX_RECONNECT_TIME_MS = 300_000; // 5 minutes - private static final long READ_TIMEOUT_MS = 300_000; // 5 minutes is the standard read timeout used for all LaunchDarkly stream connections, based on // an expectation that the server will send heartbeats at a shorter interval than that - private BackgroundEventSource es; + private static final long MAX_RECONNECT_TIME_MS = 30_000; + private static final long EXTENDED_INITIAL_RECONNECT_DELAY_MS = 300_000; + private static final long EXTENDED_MAX_RECONNECT_TIME_MS = 3_600_000; + + // volatile because the connection error handler reads it on the stream thread to switch + // retry regimes, while start() and stopSync() write it from other threads. + private volatile BackgroundEventSource es; private final LDContext context; private final HttpProperties httpProperties; private final boolean evaluationReasons; final int initialReconnectDelayMillis; // visible for testing + private final RetryDelayStrategy normalRetryDelay; + private final RetryDelayStrategy extendedRetryDelay; private final boolean useReport; private final URI streamUri; private final DataSourceUpdateSink dataSourceUpdateSink; private final FeatureFetcher fetcher; private final boolean streamEvenInBackground; + // volatile because stopSync() writes it from the thread stop() spawns, and start() reads it. private volatile boolean running = false; - // volatile because it is written from the EventSource background thread (onError) - // and read from start(), which may be invoked on a different thread. - private volatile boolean connection401Error = false; private final DiagnosticStore diagnosticStore; private long eventSourceStarted; private final LDLogger logger; @@ -79,6 +83,22 @@ final class StreamingDataSource implements DataSource { @NonNull FeatureFetcher fetcher, int initialReconnectDelayMillis, boolean streamEvenInBackground + ) { + this(clientContext, context, dataSourceUpdateSink, fetcher, initialReconnectDelayMillis, + streamEvenInBackground, EXTENDED_INITIAL_RECONNECT_DELAY_MS, + EXTENDED_MAX_RECONNECT_TIME_MS); + } + + @VisibleForTesting + StreamingDataSource( + @NonNull ClientContext clientContext, + @NonNull LDContext context, + @NonNull DataSourceUpdateSink dataSourceUpdateSink, + @NonNull FeatureFetcher fetcher, + int initialReconnectDelayMillis, + boolean streamEvenInBackground, + long extendedInitialReconnectDelayMillis, + long extendedMaxReconnectTimeMillis ) { this.context = context; this.dataSourceUpdateSink = dataSourceUpdateSink; @@ -88,13 +108,19 @@ final class StreamingDataSource implements DataSource { this.evaluationReasons = clientContext.isEvaluationReasons(); this.useReport = clientContext.getHttp().isUseReport(); this.initialReconnectDelayMillis = initialReconnectDelayMillis; + this.normalRetryDelay = RetryDelayStrategy.defaultStrategy() + .initialDelay(initialReconnectDelayMillis, TimeUnit.MILLISECONDS) + .maxDelay(MAX_RECONNECT_TIME_MS, TimeUnit.MILLISECONDS); + this.extendedRetryDelay = RetryDelayStrategy.defaultStrategy() + .initialDelay(extendedInitialReconnectDelayMillis, TimeUnit.MILLISECONDS) + .maxDelay(extendedMaxReconnectTimeMillis, TimeUnit.MILLISECONDS); this.streamEvenInBackground = streamEvenInBackground; this.diagnosticStore = ClientContextImpl.get(clientContext).getDiagnosticStore(); this.logger = clientContext.getBaseLogger(); } public void start(@NonNull Callback resultCallback) { - if (!running && !connection401Error) { + if (!running) { logger.debug("Starting."); BackgroundEventHandler handler = new BackgroundEventHandler() { @@ -133,24 +159,8 @@ public void onError(Throwable t) { diagnosticStore.recordStreamInit(eventSourceStarted, (int) (System.currentTimeMillis() - eventSourceStarted), true); } int code = ((StreamHttpErrorException) t).getCode(); - if (!LDUtil.isHttpErrorRecoverable(code)) { - logger.error("Encountered non-retriable error: {}. Aborting connection to stream. Verify correct Mobile Key and Stream URI", code); - running = false; - // Set the connection401Error guard before notifying the callback. A consumer - // may react to the error by synchronously calling start() again, and the - // guard must already be set so that retry is a no-op. - if (code == 401) { - connection401Error = true; - } - resultCallback.onError(new LDInvalidResponseCodeFailure("Unexpected Response Code From Stream Connection", t, code, false)); - if (code == 401) { - dataSourceUpdateSink.shutDown(); - } - stop(null); - } else { - eventSourceStarted = System.currentTimeMillis(); - resultCallback.onError(new LDInvalidResponseCodeFailure("Unexpected Response Code From Stream Connection", t, code, true)); - } + eventSourceStarted = System.currentTimeMillis(); + resultCallback.onError(new LDInvalidResponseCodeFailure("Unexpected Response Code From Stream Connection", t, code, true)); } else { resultCallback.onError(new LDFailure("Network error in stream connection", t, LDFailure.FailureType.NETWORK_FAILURE)); } @@ -173,20 +183,22 @@ public void onError(Throwable t) { } EventSource.Builder esBuilder = new EventSource.Builder(connectStrategy) - .retryDelay(initialReconnectDelayMillis, TimeUnit.MILLISECONDS) - .retryDelayStrategy(RetryDelayStrategy.defaultStrategy() - .maxDelay(MAX_RECONNECT_TIME_MS, TimeUnit.MILLISECONDS)); + .retryDelayStrategy(normalRetryDelay) + .retryDelayStrategy(extendedRetryDelay); eventSourceStarted = System.currentTimeMillis(); es = new BackgroundEventSource.Builder(handler, esBuilder) - // The stream thread asks this handler, before it reconnects, whether an - // error ends the stream. The onError callback above runs on a different - // thread, so a stop() from there can arrive after a fast reconnect has - // already opened a new connection. A decision made here cannot lose that race. + // The stream thread calls this handler after a failure and before it + // computes the reconnect delay, so a regime switched here applies to the + // reconnect that follows this failure. .connectionErrorHandler(t -> { - if (t instanceof StreamHttpErrorException && - !LDUtil.isHttpErrorRecoverable(((StreamHttpErrorException) t).getCode())) { - return ConnectionErrorHandler.Action.SHUTDOWN; + BackgroundEventSource current = es; + if (current != null && t instanceof StreamHttpErrorException && + LDUtil.isHttpErrorUnexpected(((StreamHttpErrorException) t).getCode())) { + current.getEventSource() + .activateRetryDelayStrategy(extendedRetryDelay); + logger.error("Encountered unexpected error: {}. Verify correct Mobile Key and Stream URI. Engaging extended backoff", + ((StreamHttpErrorException) t).getCode()); } return ConnectionErrorHandler.Action.PROCEED; }) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/TaskExecutor.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/TaskExecutor.java index eeb82181..741f8abe 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/TaskExecutor.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/TaskExecutor.java @@ -27,14 +27,4 @@ interface TaskExecutor extends Closeable { * @return a ScheduledFuture that can be used to cancel the task */ ScheduledFuture scheduleTask(Runnable action, long delayMillis); - - /** - * Schedules an action to be run repeatedly at intervals. It will not be done on the main thread. - * - * @param action the action to execute at each interval - * @param initialDelayMillis milliseconds to wait before the first execution - * @param intervalMillis milliseconds between executions - * @return a ScheduledFuture that can be used to cancel the task - */ - ScheduledFuture startRepeatingTask(Runnable action, long initialDelayMillis, long intervalMillis); } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/DataSourceUpdateSink.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/DataSourceUpdateSink.java index c1d8be9a..9d1353db 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/DataSourceUpdateSink.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/DataSourceUpdateSink.java @@ -44,12 +44,15 @@ public interface DataSourceUpdateSink { void setStatus(@NonNull ConnectionInformation.ConnectionMode connectionMode, @Nullable Throwable failure); /** - * Informs the SDK that the data source is being permanently shut down due to an unrecoverable - * problem reported by LaunchDarkly, such as the mobile key being invalid. + * Informs the SDK that the data source is permanently shutting down and will send no more + * data. *

* This implies that the SDK should also stop other components that communicate with * LaunchDarkly, such as the event processor. It also changes the connection mode to * {@link com.launchdarkly.sdk.android.ConnectionInformation.ConnectionMode#SHUTDOWN}. + *

+ * The data sources in this SDK do not call this method. They retry after a failure instead, + * and they wait longer after a failure that is unlikely to correct itself. */ void shutDown(); } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/DataSourceUpdateSinkV2.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/DataSourceUpdateSinkV2.java index 81d97b00..435ab0ab 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/DataSourceUpdateSinkV2.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/DataSourceUpdateSinkV2.java @@ -20,13 +20,16 @@ public interface DataSourceUpdateSinkV2 extends TransactionalDataSourceUpdateSin void setStatus(@NonNull DataSourceState state, @Nullable Throwable failure); /** - * Informs the SDK that the data source is being permanently shut down due to an - * unrecoverable problem reported by LaunchDarkly, such as the mobile key being invalid. + * Informs the SDK that the data source is permanently shutting down and will send no + * more data. *

* This implies that the SDK should also stop other components that communicate * with LaunchDarkly, such as the event processor. It also changes the connection * mode to * {@link com.launchdarkly.sdk.android.ConnectionInformation.ConnectionMode#SHUTDOWN}. + *

+ * The data sources in this SDK do not call this method. They retry after a failure + * instead, and they wait longer after a failure that is unlikely to correct itself. */ void shutDown(); } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDUtilTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDUtilTest.java index 01b1d411..0d60cfb4 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDUtilTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDUtilTest.java @@ -27,4 +27,20 @@ public void testSanitizeSpaces() { Assert.assertEquals("--hello--", LDUtil.sanitizeSpaces(" hello ")); Assert.assertEquals("world", LDUtil.sanitizeSpaces("world")); } + + @Test + public void testIsHttpErrorUnexpected() { + // Statuses that are unlikely to resolve on their own. + Assert.assertTrue(LDUtil.isHttpErrorUnexpected(401)); + Assert.assertTrue(LDUtil.isHttpErrorUnexpected(403)); + Assert.assertTrue(LDUtil.isHttpErrorUnexpected(404)); + Assert.assertTrue(LDUtil.isHttpErrorUnexpected(405)); + + // Statuses that the service is likely to stop returning. + Assert.assertFalse(LDUtil.isHttpErrorUnexpected(400)); + Assert.assertFalse(LDUtil.isHttpErrorUnexpected(408)); + Assert.assertFalse(LDUtil.isHttpErrorUnexpected(429)); + Assert.assertFalse(LDUtil.isHttpErrorUnexpected(500)); + Assert.assertFalse(LDUtil.isHttpErrorUnexpected(503)); + } } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/PollingDataSourceTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/PollingDataSourceTest.java index d16fdbc0..4fd7e733 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/PollingDataSourceTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/PollingDataSourceTest.java @@ -3,9 +3,7 @@ import static com.launchdarkly.sdk.android.AssertHelpers.requireNoMoreValues; import static com.launchdarkly.sdk.android.AssertHelpers.requireValue; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertTrue; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; @@ -260,7 +258,6 @@ public void terminatesAfterMaxNumberOfPolls() throws Exception { 50, 2, // maximum number of requests is 2 clientContext.getFetcher(), - clientContext.getPlatformState(), clientContext.getTaskExecutor(), clientContext.getBaseLogger() ); @@ -271,8 +268,6 @@ public void terminatesAfterMaxNumberOfPolls() throws Exception { try { ds.start(LDUtil.noOpCallback()); - ScheduledFuture pollTask = ds.currentPollTask.get(); - assertFalse(pollTask.isCancelled()); LDContext context1 = requireValue(fetcher.receivedContexts, 500, TimeUnit.MILLISECONDS); @@ -280,7 +275,133 @@ public void terminatesAfterMaxNumberOfPolls() throws Exception { // if a third request is sent, this will fail here requireNoMoreValues(fetcher.receivedContexts, 200, TimeUnit.MILLISECONDS); - assertTrue(pollTask.isCancelled()); + } finally { + ds.stop(LDUtil.noOpCallback()); + } + } + + private PollingDataSource makeDataSource(FeatureFetcher fetcherToUse, TaskExecutor executor, + long pollIntervalMillis) { + return new PollingDataSource( + CONTEXT, + dataSourceUpdateSink, + 0, + pollIntervalMillis, + Long.MAX_VALUE, + fetcherToUse, + executor, + logging.logger + ); + } + + @Test + public void resultArrivingAfterTheExecutorShutsDownDoesNotScheduleAnotherPoll() throws Exception { + BlockingQueue> heldFetches = new LinkedBlockingQueue<>(); + FeatureFetcher holdingFetcher = new FeatureFetcher() { + @Override + public void fetch(LDContext context, Callback callback) { + heldFetches.add(callback); + } + + @Override + public void close() {} + }; + + PollingDataSource ds = makeDataSource(holdingFetcher, taskExecutor, 50); + + // Start a poll, then shut everything down while its request is still outstanding. + ds.start(LDUtil.noOpCallback()); + Callback heldFetch = requireValue(heldFetches, 500, TimeUnit.MILLISECONDS); + ds.stop(LDUtil.noOpCallback()); + taskExecutor.close(); + + // Scheduling onto a closed executor throws, so the failed request must not reschedule. + heldFetch.onError(new LDFailure("network error", LDFailure.FailureType.NETWORK_FAILURE)); + } + + @Test + public void pollArmedBeforeStopDoesNotFetchWhenItFires() throws Exception { + // Schedules tasks for real, but returns no handle, so stop has nothing to cancel. + TaskExecutor executorWithoutCancellation = new TaskExecutor() { + @Override + public void executeOnMainThread(Runnable action) { + taskExecutor.executeOnMainThread(action); + } + + @Override + public ScheduledFuture scheduleTask(Runnable action, long delayMillis) { + taskExecutor.scheduleTask(action, delayMillis); + return null; + } + + @Override + public void close() {} + }; + + PollingDataSource ds = new PollingDataSource( + CONTEXT, dataSourceUpdateSink, 100, 50, Long.MAX_VALUE, + fetcher, executorWithoutCancellation, logging.logger); + fetcher.setupSuccessResponse("{}"); + + // Stop the data source while its first poll waits to run. + ds.start(LDUtil.noOpCallback()); + ds.stop(LDUtil.noOpCallback()); + + // A stopped data source sends no request, even from a poll it could not cancel. + requireNoMoreValues(fetcher.receivedContexts, 500, TimeUnit.MILLISECONDS); + } + + @Test + public void pollingContinuesWhenResultCallbackThrowsOnSuccess() throws Exception { + PollingDataSource ds = makeDataSource(fetcher, taskExecutor, 50); + + fetcher.setupSuccessResponse("{}"); + fetcher.setupSuccessResponse("{}"); + + try { + // Throw out of the success callback of every poll. + ds.start(new Callback() { + @Override + public void onSuccess(Boolean result) { + throw new RuntimeException("consumer failure"); + } + + @Override + public void onError(Throwable e) { + } + }); + requireValue(fetcher.receivedContexts, 500, TimeUnit.MILLISECONDS); + + // A throwing consumer does not end the poll chain. + requireValue(fetcher.receivedContexts, 500, TimeUnit.MILLISECONDS); + } finally { + ds.stop(LDUtil.noOpCallback()); + } + } + + @Test + public void pollingContinuesWhenResultCallbackThrowsOnError() throws Exception { + PollingDataSource ds = makeDataSource(fetcher, taskExecutor, 50); + + fetcher.setupErrorResponse(new LDFailure("network error", LDFailure.FailureType.NETWORK_FAILURE)); + fetcher.setupSuccessResponse("{}"); + + try { + // Throw out of the error callback, which the first poll triggers by failing. + ds.start(new Callback() { + @Override + public void onSuccess(Boolean result) { + } + + @Override + public void onError(Throwable e) { + throw new RuntimeException("consumer failure"); + } + }); + requireValue(fetcher.receivedContexts, 500, TimeUnit.MILLISECONDS); + + // A failed poll whose consumer also throws still leads to the next poll. + requireValue(fetcher.receivedContexts, 500, TimeUnit.MILLISECONDS); } finally { ds.stop(LDUtil.noOpCallback()); } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/PollingRetryStateTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/PollingRetryStateTest.java new file mode 100644 index 00000000..449a4910 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/PollingRetryStateTest.java @@ -0,0 +1,99 @@ +package com.launchdarkly.sdk.android; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class PollingRetryStateTest { + private static final long POLL_INTERVAL_MS = 1_000; + private static final long EXTENDED_INITIAL_DELAY_MS = 300_000; + + @Test + public void ordinaryFailuresKeepPollInterval() { + Throwable[] ordinaryFailures = { + new LDFailure("network error", LDFailure.FailureType.NETWORK_FAILURE), + new LDInvalidResponseCodeFailure("bad request", 400, true), + new LDInvalidResponseCodeFailure("request timeout", 408, true), + new LDInvalidResponseCodeFailure("too many requests", 429, true), + new LDInvalidResponseCodeFailure("server error", 503, true), + }; + + for (Throwable failure : ordinaryFailures) { + PollingRetryState state = new PollingRetryState(POLL_INTERVAL_MS); + + state.recordFailure(failure); + + // A poll interval is already a wait, so an ordinary failure does not extend it. + assertEquals(failure.toString(), POLL_INTERVAL_MS, state.nextDelayMillis()); + } + } + + @Test + public void unexpectedStatusesExtendTheWait() { + int[] unexpectedStatuses = {401, 403, 404, 405}; + + for (int status : unexpectedStatuses) { + PollingRetryState state = new PollingRetryState(POLL_INTERVAL_MS); + + state.recordFailure(new LDInvalidResponseCodeFailure("status " + status, status, true)); + + // The wait becomes minutes long. Jitter can halve a delay. + assertTrue("status " + status, + state.nextDelayMillis() >= EXTENDED_INITIAL_DELAY_MS / 2); + } + } + + @Test + public void extendedWaitNeverDropsBelowPollInterval() { + // This poll interval is longer than the extended delay. + PollingRetryState state = new PollingRetryState(3_600_000); + + state.recordFailure(new LDInvalidResponseCodeFailure("unauthorized", 401, true)); + + // A failing source never polls more often than a healthy one. + assertEquals(3_600_000, state.nextDelayMillis()); + } + + @Test + public void successReturnsToPollIntervalImmediately() { + PollingRetryState state = new PollingRetryState(POLL_INTERVAL_MS); + state.recordFailure(new LDInvalidResponseCodeFailure("unauthorized", 401, true)); + + // The wait is extended before the success. + assertTrue(state.nextDelayMillis() >= EXTENDED_INITIAL_DELAY_MS / 2); + + // One poll succeeds. + state.recordSuccess(); + + // A success shows that the service works, so polling resumes at its interval. + assertEquals(POLL_INTERVAL_MS, state.nextDelayMillis()); + } + + @Test + public void oneSuccessDoesNotEndTheExtendedRegime() { + PollingRetryState state = new PollingRetryState(POLL_INTERVAL_MS); + state.recordFailure(new LDInvalidResponseCodeFailure("unauthorized", 401, true)); + + // One poll succeeds, then the next one fails for an ordinary reason. + state.recordSuccess(); + state.recordFailure(new LDFailure("network error", LDFailure.FailureType.NETWORK_FAILURE)); + + // One success is not enough to leave the extended regime. + assertTrue(state.nextDelayMillis() >= EXTENDED_INITIAL_DELAY_MS / 2); + } + + @Test + public void twoSuccessesEndTheExtendedRegime() { + PollingRetryState state = new PollingRetryState(POLL_INTERVAL_MS); + state.recordFailure(new LDInvalidResponseCodeFailure("unauthorized", 401, true)); + + // Two polls succeed, then the next one fails for an ordinary reason. + state.recordSuccess(); + state.recordSuccess(); + state.recordFailure(new LDFailure("network error", LDFailure.FailureType.NETWORK_FAILURE)); + + // Enough successes end the extended regime, so the wait is the poll interval again. + assertEquals(POLL_INTERVAL_MS, state.nextDelayMillis()); + } +} diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StreamingDataSourceTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StreamingDataSourceTest.java index aa1bbef8..c0be925d 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StreamingDataSourceTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StreamingDataSourceTest.java @@ -148,6 +148,17 @@ private StreamingDataSource makeStreamingDataSource( MockComponents.MockDataSourceUpdateSink sink, boolean evaluationReasons, boolean useReport, int initialReconnectDelayMillis) { + ClientContext clientContext = makeDataSourceClientContext( + streamBaseUri, sink, evaluationReasons, useReport); + return (StreamingDataSource) Components.streamingDataSource() + .initialReconnectDelayMillis(initialReconnectDelayMillis) + .build(clientContext); + } + + private ClientContext makeDataSourceClientContext( + URI streamBaseUri, + MockComponents.MockDataSourceUpdateSink sink, + boolean evaluationReasons, boolean useReport) { LDConfig.Builder configBuilder = new LDConfig.Builder(AutoEnvAttributes.Disabled) .serviceEndpoints(Components.serviceEndpoints().streaming(streamBaseUri)) .evaluationReasons(evaluationReasons); @@ -158,11 +169,7 @@ private StreamingDataSource makeStreamingDataSource( configBuilder.build(), MOBILE_KEY, "", perEnvironmentData, makeFeatureFetcher(), CONTEXT, logging.logger, platformState, environmentReporter, taskExecutor); - ClientContext clientContext = ClientContextImpl.forDataSource( - baseClientContext, sink, CONTEXT, false, false); - return (StreamingDataSource) Components.streamingDataSource() - .initialReconnectDelayMillis(initialReconnectDelayMillis) - .build(clientContext); + return ClientContextImpl.forDataSource(baseClientContext, sink, CONTEXT, false, false); } private static String makeSseEvent(String type, String data) { @@ -635,48 +642,6 @@ public void startSendsRequestWithReportAndReasons() throws Exception { // --- start(): error handling verified via HttpServer --- - @Test - public void startWithHttp401ShutsDownSink() throws Exception { - try (HttpServer server = HttpServer.start(Handlers.status(401))) { - StreamingDataSource sds = makeStreamingDataSource( - server.getUri(), false, false); - TrackingCallback callback = new TrackingCallback(); - startDataSource(sds, callback); - - Throwable error = callback.awaitError(); - assertNotNull(error); - assertTrue(error instanceof LDInvalidResponseCodeFailure); - LDInvalidResponseCodeFailure failure = (LDInvalidResponseCodeFailure) error; - assertEquals(401, failure.getResponseCode()); - assertFalse(failure.isRetryable()); - // shutDown() runs on the EventSource background thread, so poll briefly - // to allow that thread to complete before asserting. - long deadline = System.currentTimeMillis() + 1000; - while (!dataSourceUpdateSink.shutDownCalled && System.currentTimeMillis() < deadline) { - Thread.sleep(10); - } - assertTrue(dataSourceUpdateSink.shutDownCalled); - } - } - - @Test - public void startWithHttp403ReportsNonRetriableWithoutShutDown() throws Exception { - try (HttpServer server = HttpServer.start(Handlers.status(403))) { - StreamingDataSource sds = makeStreamingDataSource( - server.getUri(), false, false); - TrackingCallback callback = new TrackingCallback(); - startDataSource(sds, callback); - - Throwable error = callback.awaitError(); - assertNotNull(error); - assertTrue(error instanceof LDInvalidResponseCodeFailure); - LDInvalidResponseCodeFailure failure = (LDInvalidResponseCodeFailure) error; - assertEquals(403, failure.getResponseCode()); - assertFalse(failure.isRetryable()); - assertFalse(dataSourceUpdateSink.shutDownCalled); - } - } - @Test public void startWithHttp500ReportsRetriableError() throws Exception { try (HttpServer server = HttpServer.start(Handlers.status(500))) { @@ -771,7 +736,7 @@ public void startWithNetworkErrorReportsNetworkFailure() throws Exception { } @Test - public void startWithHttp401PreventsSubsequentStart() throws Exception { + public void startWhileAlreadyRunningIsANoOp() throws Exception { try (HttpServer server = HttpServer.start(Handlers.status(401))) { StreamingDataSource sds = makeStreamingDataSource( server.getUri(), false, false); @@ -780,7 +745,7 @@ public void startWithHttp401PreventsSubsequentStart() throws Exception { assertNotNull(callback1.awaitError()); - // Second start should be a no-op due to connection401Error flag + // The data source is still running and retrying, so it ignores the second start. TrackingCallback callback2 = new TrackingCallback(); startDataSource(sds, callback2); @@ -790,71 +755,76 @@ public void startWithHttp401PreventsSubsequentStart() throws Exception { } } - // --- start(): no reconnect after an unrecoverable HTTP error --- - @Test - public void unrecoverableErrorOnInitialConnectDoesNotReconnect() throws Exception { - String putEvent = makeSseEvent("put", VALID_PUT_JSON); + public void startWithHttp401KeepsTheSinkRunning() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.status(401))) { + StreamingDataSource sds = makeStreamingDataSource( + server.getUri(), false, false); + TrackingCallback callback = new TrackingCallback(); - // A second request would get a working stream. The SDK must not make it. - try (HttpServer server = HttpServer.start(Handlers.sequential( - Handlers.status(401), - Handlers.all( - Handlers.SSE.start(), - Handlers.SSE.event(putEvent), - Handlers.SSE.leaveOpen())))) { + startDataSource(sds, callback); + Throwable error = callback.awaitError(); + + // A rejected credential is retryable now, because the stream keeps reconnecting. + assertNotNull(error); + assertTrue(error instanceof LDInvalidResponseCodeFailure); + LDInvalidResponseCodeFailure failure = (LDInvalidResponseCodeFailure) error; + assertEquals(401, failure.getResponseCode()); + assertTrue(failure.isRetryable()); + assertFalse(dataSourceUpdateSink.shutDownCalled); + } + } + @Test + public void recoverableErrorReconnectsWithoutExtendedBackoff() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.status(503))) { StreamingDataSource sds = makeStreamingDataSource( server.getUri(), dataSourceUpdateSink, false, false, 1); TrackingCallback callback = new TrackingCallback(); - sds.start(callback); - Throwable error = callback.awaitError(); - assertNotNull(error); - assertFalse(((LDInvalidResponseCodeFailure) error).isRetryable()); + startDataSource(sds, callback); + // The normal regime starts at the configured 1ms delay, so the retry arrives at once. + server.getRecorder().requireRequest(); server.getRecorder().requireRequest(); - server.getRecorder().requireNoRequests(500, TimeUnit.MILLISECONDS); - assertNull("no stream data expected after the error", - callback.successes.poll(100, TimeUnit.MILLISECONDS)); } } @Test - public void unrecoverableErrorOnReconnectDoesNotReconnectAgain() throws Exception { - String putEvent = makeSseEvent("put", VALID_PUT_JSON); - - try (HttpServer server = HttpServer.start(Handlers.sequential( - // The first connection delivers data, then the server ends the stream. - Handlers.all( - Handlers.SSE.start(), - Handlers.SSE.event(putEvent)), - // The reconnect gets an unrecoverable status. - Handlers.status(403), - // A third request would get a working stream. The SDK must not make it. - Handlers.all( - Handlers.SSE.start(), - Handlers.SSE.event(putEvent), - Handlers.SSE.leaveOpen())))) { - + public void unexpectedErrorReconnectsWithExtendedBackoff() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.status(401))) { StreamingDataSource sds = makeStreamingDataSource( server.getUri(), dataSourceUpdateSink, false, false, 1); TrackingCallback callback = new TrackingCallback(); - sds.start(callback); - assertNotNull(callback.awaitSuccess()); + startDataSource(sds, callback); + server.getRecorder().requireRequest(); - // The dropped stream reports a network failure first; the 403 follows. - Throwable error = callback.awaitError(); - while (error != null && !(error instanceof LDInvalidResponseCodeFailure)) { - error = callback.awaitError(); - } - assertNotNull(error); - assertEquals(403, ((LDInvalidResponseCodeFailure) error).getResponseCode()); + // The extended regime waits minutes, so no retry arrives in this window. + server.getRecorder().requireNoRequests(500, TimeUnit.MILLISECONDS); + } + } + + @Test + public void unexpectedErrorRetriesAfterTheExtendedDelay() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.status(401))) { + ClientContext clientContext = makeDataSourceClientContext( + server.getUri(), dataSourceUpdateSink, false, false); + long extendedInitialDelayMillis = 400; + long extendedMaxDelayMillis = 800; + StreamingDataSource sds = new StreamingDataSource( + clientContext, CONTEXT, dataSourceUpdateSink, makeFeatureFetcher(), + 1, false, extendedInitialDelayMillis, extendedMaxDelayMillis); + TrackingCallback callback = new TrackingCallback(); + startDataSource(sds, callback); server.getRecorder().requireRequest(); + + // Jitter can halve the extended delay, so a retry cannot arrive this soon. + server.getRecorder().requireNoRequests(100, TimeUnit.MILLISECONDS); + + // The stream does retry once the extended delay elapses. server.getRecorder().requireRequest(); - server.getRecorder().requireNoRequests(500, TimeUnit.MILLISECONDS); } } diff --git a/shared-test-code/src/main/java/com/launchdarkly/sdk/android/ManualTaskExecutor.java b/shared-test-code/src/main/java/com/launchdarkly/sdk/android/ManualTaskExecutor.java index 0f48f4a2..88b67bff 100644 --- a/shared-test-code/src/main/java/com/launchdarkly/sdk/android/ManualTaskExecutor.java +++ b/shared-test-code/src/main/java/com/launchdarkly/sdk/android/ManualTaskExecutor.java @@ -51,13 +51,6 @@ public ScheduledFuture scheduleTask(Runnable action, long delayMillis) { return future; } - @Override - public ScheduledFuture startRepeatingTask(Runnable action, long initialDelayMillis, long intervalMillis) { - ManualScheduledFuture future = new ManualScheduledFuture(action); - pending.add(future); - return future; - } - @Override public void close() { pending.clear(); diff --git a/shared-test-code/src/main/java/com/launchdarkly/sdk/android/SimpleTestTaskExecutor.java b/shared-test-code/src/main/java/com/launchdarkly/sdk/android/SimpleTestTaskExecutor.java index f53c0dcb..47f09845 100644 --- a/shared-test-code/src/main/java/com/launchdarkly/sdk/android/SimpleTestTaskExecutor.java +++ b/shared-test-code/src/main/java/com/launchdarkly/sdk/android/SimpleTestTaskExecutor.java @@ -29,12 +29,6 @@ public ScheduledFuture scheduleTask(Runnable action, long delayMillis) { return executor.schedule(action, delayMillis, TimeUnit.MILLISECONDS); } - @Override - public ScheduledFuture startRepeatingTask(Runnable action, long initialDelayMillis, long intervalMillis) { - return executor.scheduleAtFixedRate(action, - initialDelayMillis, intervalMillis, TimeUnit.MILLISECONDS); - } - @Override public void close() { executor.shutdownNow();