diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DefaultFDv2Requestor.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DefaultFDv2Requestor.java index 2ffa79e8..f3a2db8d 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DefaultFDv2Requestor.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DefaultFDv2Requestor.java @@ -187,7 +187,9 @@ private void handleResponse( @NonNull LDAwaitFuture future) { try { int code = response.code(); - boolean fdv1Fallback = isFdv1Fallback(response); + FDv1FallbackTTL fdv1Fallback = isFdv1Fallback(response) + ? FDv1FallbackTTL.fromHeader(response.header(HeaderConstants.FDV1_FALLBACK_TTL_HEADER)) + : null; if (code == 304) { logger.debug("FDv2 polling: 304 Not Modified"); diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1FallbackTTL.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1FallbackTTL.java new file mode 100644 index 00000000..0e1f9482 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1FallbackTTL.java @@ -0,0 +1,104 @@ +package com.launchdarkly.sdk.android; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import java.util.Random; + +/** + * The TTL of a server-directed FDv1 fallback: how long the SDK stays off FDv2 before attempting + * to recover to it. A fallback is never indefinite. + */ +public final class FDv1FallbackTTL { + /** The TTL used whenever the service supplies no usable one, before jitter. */ + static final long DEFAULT_TTL_SECONDS = 60 * 60; + + private static final String GOODBYE_TTL_FIELD = "protocolFallbackTTL"; + + private static final Random random = new Random(); + + private final long seconds; + + private FDv1FallbackTTL(long seconds) { + this.seconds = seconds; + } + + /** + * A service-supplied TTL in (0, 1 hour] is used as given, because the service has already + * jittered it. Any other value uses {@link #defaultTTL()}. + */ + @NonNull + private static FDv1FallbackTTL fromServiceTTL(long serviceTtlSeconds) { + if (serviceTtlSeconds > 0 && serviceTtlSeconds <= DEFAULT_TTL_SECONDS) { + return new FDv1FallbackTTL(serviceTtlSeconds); + } + return defaultTTL(); + } + + /** + * Returns the default TTL of 1 hour minus a value drawn uniformly from [0, 30 minutes], so + * that SDKs which fell back within the same window do not all re-attempt FDv2 at once. + * + * @return the TTL + */ + @NonNull + public static FDv1FallbackTTL defaultTTL() { + long jitter; + synchronized (random) { + jitter = (long) (random.nextDouble() * (DEFAULT_TTL_SECONDS / 2 + 1)); + } + return new FDv1FallbackTTL(DEFAULT_TTL_SECONDS - jitter); + } + + /** + * Returns the TTL for the value of an {@code x-ld-fd-fallback-ttl} header, which is a whole + * number of seconds. An absent or malformed header gives the default TTL. + */ + @NonNull + static FDv1FallbackTTL fromHeader(@Nullable String value) { + if (value == null || value.isEmpty()) { + return defaultTTL(); + } + try { + return fromServiceTTL(Long.parseLong(value)); + } catch (NumberFormatException e) { + return defaultTTL(); + } + } + + /** + * Returns the TTL in the {@code protocolFallbackTTL} field of a goodbye event's data, or null + * if the field is absent, meaning the goodbye carries no fallback directive. A field that is + * not a whole number of seconds gives the default TTL. + */ + @Nullable + static FDv1FallbackTTL fromGoodbye(@Nullable JsonElement data) { + if (data == null || !data.isJsonObject()) { + return null; + } + JsonObject obj = data.getAsJsonObject(); + if (!obj.has(GOODBYE_TTL_FIELD)) { + return null; + } + JsonElement field = obj.get(GOODBYE_TTL_FIELD); + if (field == null || !field.isJsonPrimitive() || !((JsonPrimitive) field).isNumber()) { + return defaultTTL(); + } + double seconds = field.getAsDouble(); + if (seconds != Math.rint(seconds) || Double.isInfinite(seconds)) { + return defaultTTL(); + } + return fromServiceTTL((long) seconds); + } + + /** + * @return the TTL in seconds; always in (0, 1 hour] + */ + public long getSeconds() { + return seconds; + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java index 9b8d4056..fb047ab7 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java @@ -28,6 +28,8 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; /** @@ -49,7 +51,8 @@ public interface DataSourceFactory { private final LDContext evaluationContext; private final DataSourceUpdateSinkV2 dataSourceUpdateSink; private static final String FDV1_FALLBACK_MESSAGE = - "Falling back to an FDv1 fallback synchronizer."; + "Server directed fallback to FDv1; FDv2 will be retried in {} seconds."; + private static final Object FDV2_RECOVERY = new Object(); private static final String INITIALIZER_ERROR = "Initializer error: {}"; private static final String INITIALIZER_CANCELLED = "Initializer cancelled: {}"; private static final String INITIALIZER_INTERRUPTED = "Initializer interrupted: {}"; @@ -78,6 +81,11 @@ public interface DataSourceFactory { // This future is set by either the worker thread terminating or stop() being called. private final LDAwaitFuture shutdownCause = new LDAwaitFuture<>(); + + // Set while a server-directed FDv1 fallback is in effect; the future completes with + // FDV2_RECOVERY when its TTL elapses. Only accessed on the worker thread. + private LDAwaitFuture fdv2RecoveryFuture; + private ScheduledFuture fdv2RecoveryTask; /** * Convenience constructor using default fallback and recovery timeouts. * See {@link #FDv2DataSource(LDContext, List, List, DataSourceFactory, DataSourceUpdateSinkV2, @@ -201,7 +209,7 @@ public void start(@NonNull Callback resultCallback) { runGeneralInitializers(context, dataSourceUpdateSink); } - if (!sourceManager.hasAvailableSynchronizers()) { + if (!sourceManager.hasAvailableSynchronizers() && !sourceManager.isInFdv1Fallback()) { if (!startCompleted.get()) { // try to claim this is the cause of the shutdown, but it might have already been set by an intentional stop(). shutdownCause.set(new LDFailure("All initializers exhausted and there are no available synchronizers.", LDFailure.FailureType.UNKNOWN_ERROR)); @@ -216,6 +224,7 @@ public void start(@NonNull Callback resultCallback) { logger.warn("FDv2DataSource error: {}", t.toString()); shutdownCause.set(t); } finally { + cancelFdv2Recovery(); // Here we grab the cause of shutdown to report with the OFF status. This is done to ensure that // all status callbacks are handled by the worker thread. This future may have been set @@ -377,7 +386,7 @@ private void runGeneralInitializers( // FDv1 fallback takes priority over all other result processing. // The spec requires honoring the fallback signal from any response, // regardless of whether data was included or what the selector state is. - if (result.isFdv1Fallback() && sourceManager.hasFDv1Fallback()) { + if (result.isFdv1Fallback()) { if (result.getResultType() == SourceResultType.CHANGE_SET) { ChangeSet> changeSet = result.getChangeSet(); if (changeSet != null) { @@ -385,8 +394,7 @@ private void runGeneralInitializers( anyDataReceived = true; } } - logger.info(FDV1_FALLBACK_MESSAGE); - sourceManager.fdv1Fallback(); + fallBackToFdv1(result); if (anyDataReceived) { sink.setStatus(DataSourceState.VALID, null); tryCompleteStart(true, null); @@ -468,6 +476,57 @@ private void runGeneralInitializers( } } + /** + * Acts on a server-directed FDv1 fallback: stops using the FDv2 synchronizers (retaining them) + * in favor of the FDv1 fallback synchronizer, if one is configured, and schedules recovery to + * FDv2 when the directive's TTL elapses. The fallback is never indefinite. + */ + private void fallBackToFdv1(@NonNull FDv2SourceResult result) { + long ttlSeconds = result.getFdv1Fallback().getSeconds(); + logger.info(FDV1_FALLBACK_MESSAGE, ttlSeconds); + scheduleFdv2Recovery(ttlSeconds); + sourceManager.fdv1Fallback(); + } + + private void scheduleFdv2Recovery(long ttlSeconds) { + cancelFdv2Recovery(); + LDAwaitFuture future = new LDAwaitFuture<>(); + fdv2RecoveryFuture = future; + fdv2RecoveryTask = sharedExecutor.schedule(() -> future.set(FDV2_RECOVERY), ttlSeconds, TimeUnit.SECONDS); + } + + private void cancelFdv2Recovery() { + if (fdv2RecoveryTask != null) { + fdv2RecoveryTask.cancel(false); + } + fdv2RecoveryTask = null; + fdv2RecoveryFuture = null; + } + + private void recoverToFdv2() { + cancelFdv2Recovery(); + sourceManager.recoverFromFdv1Fallback(); + logger.info("FDv1 fallback TTL elapsed; retrying FDv2 synchronizers."); + } + + /** + * Waits out the TTL of the active FDv1 fallback when no synchronizer can run in the meantime + * (e.g. no FDv1 fallback synchronizer is configured), then recovers to FDv2. + * + * @return false if the data source was stopped while waiting + */ + private boolean awaitFdv2Recovery(@NonNull DataSourceUpdateSinkV2 sink) + throws ExecutionException, InterruptedException { + logger.info("No synchronizer is available during FDv1 fallback; waiting to retry FDv2."); + sink.setStatus(DataSourceState.INTERRUPTED, null); + Object res = LDFutures.anyOf(fdv2RecoveryFuture, shutdownCause).get(); + if (res != FDV2_RECOVERY) { + return false; + } + recoverToFdv2(); + return true; + } + private List getConditions(int synchronizerCount, boolean isPrime) { if (synchronizerCount <= 1) { return Collections.emptyList(); @@ -515,8 +574,22 @@ private void runSynchronizers( @NonNull DataSourceUpdateSinkV2 sink ) { try { - Synchronizer synchronizer = sourceManager.getNextAvailableSynchronizerAndSetActive(); - while (synchronizer != null) { + while (true) { + Synchronizer synchronizer = sourceManager.getNextAvailableSynchronizerAndSetActive(); + if (synchronizer == null) { + if (stopCalled.get() || fdv2RecoveryFuture == null || !sourceManager.isInFdv1Fallback()) { + break; + } + try { + if (!awaitFdv2Recovery(sink)) { + break; + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + continue; + } String synchronizerName = synchronizer.name(); logger.info("Synchronizer '{}' is starting.", synchronizerName); resetSynchronizerStatusDedupe(); @@ -530,7 +603,15 @@ private void runSynchronizers( Future nextFuture = synchronizer.next(); // Race the next synchronizer result against any active conditions // (fallback/recovery timers). Whichever resolves first wins. - Object res = LDFutures.anyOf(conditions.getFuture(), nextFuture).get(); + Future recoveryFuture = fdv2RecoveryFuture != null + ? fdv2RecoveryFuture : new LDAwaitFuture<>(); + Object res = LDFutures.anyOf(conditions.getFuture(), nextFuture, recoveryFuture).get(); + + if (res == FDV2_RECOVERY) { + recoverToFdv2(); + running = false; + break; + } if (res instanceof FDv2DataSourceConditions.ConditionType) { FDv2DataSourceConditions.ConditionType ct = (FDv2DataSourceConditions.ConditionType) res; @@ -619,16 +700,19 @@ private void runSynchronizers( // After processing the result, check whether the server signaled // that this environment should fall back to FDv1 (via the - // x-ld-fd-fallback response header). We check regardless of - // whether the synchronizer is still running — a terminal error - // response can still carry the fallback header, and the server's - // instruction to use FDv1 should take precedence. - if (result.isFdv1Fallback() - && sourceManager.hasFDv1Fallback() - && !sourceManager.isCurrentSynchronizerFDv1Fallback()) { - logger.info(FDV1_FALLBACK_MESSAGE); - sourceManager.fdv1Fallback(); - running = false; + // x-ld-fd-fallback response header or a goodbye event). We check + // regardless of whether the synchronizer is still running — a + // terminal error response can still carry the fallback header, and + // the server's instruction to use FDv1 should take precedence. + if (result.isFdv1Fallback()) { + if (sourceManager.isCurrentSynchronizerFDv1Fallback()) { + // Already on FDv1: a newer directive only replaces the TTL. + scheduleFdv2Recovery( + result.getFdv1Fallback().getSeconds()); + } else { + fallBackToFdv1(result); + running = false; + } } } } @@ -651,7 +735,6 @@ private void runSynchronizers( Thread.currentThread().interrupt(); return; } - synchronizer = sourceManager.getNextAvailableSynchronizerAndSetActive(); } if (!stopCalled.get()) { logger.warn("No more synchronizers available."); diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingBase.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingBase.java index 92c3c334..5ab35a3d 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingBase.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingBase.java @@ -102,7 +102,7 @@ static FDv2SourceResult doPoll( : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(cause), false); } - boolean fdv1Fallback = response.isFdv1Fallback(); + FDv1FallbackTTL fdv1Fallback = response.getFdv1Fallback(); // 304 Not Modified: nothing changed if (response.getStatusCode() == 304) { @@ -185,6 +185,11 @@ static FDv2SourceResult doPoll( case GOODBYE: { String reason = ((FDv2ProtocolHandler.FDv2ActionGoodbye) action).getReason(); logger.info("Polling received GOODBYE with reason: '{}'", reason); + FDv1FallbackTTL goodbyeFallback = FDv1FallbackTTL.fromGoodbye(event.getData()); + if (goodbyeFallback != null) { + return FDv2SourceResult.status( + FDv2SourceResult.Status.goodbye(reason), goodbyeFallback); + } return FDv2SourceResult.status(FDv2SourceResult.Status.goodbye(reason), fdv1Fallback); } case INTERNAL_ERROR: { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2Requestor.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2Requestor.java index b4b351b2..d3e3b5f5 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2Requestor.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2Requestor.java @@ -27,13 +27,14 @@ final class FDv2PayloadResponse { private final List events; private final boolean successful; private final int statusCode; - private final boolean fdv1Fallback; + @Nullable + private final FDv1FallbackTTL fdv1Fallback; private FDv2PayloadResponse( @Nullable List events, boolean successful, int statusCode, - boolean fdv1Fallback) { + @Nullable FDv1FallbackTTL fdv1Fallback) { this.events = events; this.successful = successful; this.statusCode = statusCode; @@ -41,7 +42,10 @@ private FDv2PayloadResponse( } /** Creates a successful response with parsed events. */ - static FDv2PayloadResponse success(@NonNull List events, int statusCode, boolean fdv1Fallback) { + static FDv2PayloadResponse success( + @NonNull List events, + int statusCode, + @Nullable FDv1FallbackTTL fdv1Fallback) { return new FDv2PayloadResponse(events, true, statusCode, fdv1Fallback); } @@ -49,12 +53,12 @@ static FDv2PayloadResponse success(@NonNull List events, int statusCo * Creates a successful 304 Not Modified response indicating no change since the * last request. */ - static FDv2PayloadResponse notModified(boolean fdv1Fallback) { + static FDv2PayloadResponse notModified(@Nullable FDv1FallbackTTL fdv1Fallback) { return new FDv2PayloadResponse(null, true, 304, fdv1Fallback); } /** Creates an unsuccessful response with the HTTP status code. */ - static FDv2PayloadResponse failure(int statusCode, boolean fdv1Fallback) { + static FDv2PayloadResponse failure(int statusCode, @Nullable FDv1FallbackTTL fdv1Fallback) { return new FDv2PayloadResponse(null, false, statusCode, fdv1Fallback); } @@ -79,6 +83,15 @@ public int getStatusCode() { * that the SDK should fall back to FDv1 data sources. */ public boolean isFdv1Fallback() { + return fdv1Fallback != null; + } + + /** + * The TTL of the server's FDv1 fallback directive, from the {@code x-ld-fd-fallback-ttl} + * header, or null if {@link #isFdv1Fallback()} is false. + */ + @Nullable + public FDv1FallbackTTL getFdv1Fallback() { return fdv1Fallback; } } 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 ad6daf6d..048ced9f 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 @@ -260,12 +260,16 @@ private void recordStreamInit(boolean failed) { } } - private static boolean isFdv1Fallback(@Nullable ResponseHeaders headers) { + @Nullable + private static FDv1FallbackTTL fdv1Fallback(@Nullable ResponseHeaders headers) { if (headers == null) { - return false; + return null; } String value = headers.value(HeaderConstants.FDV1_FALLBACK_HEADER); - return value != null && value.equalsIgnoreCase("true"); + if (value == null || !value.equalsIgnoreCase("true")) { + return null; + } + return FDv1FallbackTTL.fromHeader(headers.value(HeaderConstants.FDV1_FALLBACK_TTL_HEADER)); } @VisibleForTesting @@ -279,7 +283,7 @@ void handleMessage(MessageEvent event) { return; } - boolean fdv1Fallback = isFdv1Fallback(event.getHeaders()); + FDv1FallbackTTL fdv1Fallback = fdv1Fallback(event.getHeaders()); FDv2Event fdv2Event; try { @@ -342,6 +346,14 @@ void handleMessage(MessageEvent event) { case GOODBYE: { String reason = ((FDv2ProtocolHandler.FDv2ActionGoodbye) action).getReason(); logger.info("Stream received GOODBYE with reason: '{}'", reason); + FDv1FallbackTTL goodbyeFallback = FDv1FallbackTTL.fromGoodbye(fdv2Event.getData()); + if (goodbyeFallback != null) { + // A fallback directive: the data source will close this synchronizer, so + // there is no point in reconnecting. + resultQueue.put(FDv2SourceResult.status( + FDv2SourceResult.Status.goodbye(reason), goodbyeFallback)); + break; + } resultQueue.put(FDv2SourceResult.status(FDv2SourceResult.Status.goodbye(reason), fdv1Fallback)); restartStream(false); break; @@ -396,11 +408,13 @@ private void handleError(FaultEvent event) { recordStreamInit(true); protocolHandler.reset(); - boolean fdv1Fallback = isFdv1Fallback(event.getHeaders()); + FDv1FallbackTTL fdv1Fallback = fdv1Fallback(event.getHeaders()); if (t instanceof StreamHttpErrorException) { StreamHttpErrorException httpError = (StreamHttpErrorException) t; - fdv1Fallback = fdv1Fallback || isFdv1Fallback(httpError.getHeaders()); + if (fdv1Fallback == null) { + fdv1Fallback = fdv1Fallback(httpError.getHeaders()); + } int code = httpError.getCode(); boolean recoverable = LDUtil.isHttpErrorRecoverable(code); LDFailure failure = new LDInvalidResponseCodeFailure( diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HeaderConstants.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HeaderConstants.java index 35d24fc2..147334fa 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HeaderConstants.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HeaderConstants.java @@ -5,4 +5,5 @@ */ abstract class HeaderConstants { static final String FDV1_FALLBACK_HEADER = "x-ld-fd-fallback"; + static final String FDV1_FALLBACK_TTL_HEADER = "x-ld-fd-fallback-ttl"; } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/SourceManager.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/SourceManager.java index bbbd8adb..55d7337b 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/SourceManager.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/SourceManager.java @@ -7,6 +7,7 @@ import java.io.Closeable; import java.io.IOException; +import java.util.ArrayList; import java.util.List; /** @@ -31,6 +32,9 @@ final class SourceManager implements Closeable { private SynchronizerFactoryWithState currentSynchronizerFactory; + /** Synchronizers blocked by {@link #fdv1Fallback()}, to be unblocked on recovery. */ + private final List blockedByFdv1Fallback = new ArrayList<>(); + SourceManager( @NonNull List synchronizerFactories, @NonNull List> initializers @@ -69,14 +73,43 @@ void fdv1Fallback() { for (SynchronizerFactoryWithState s : synchronizerFactories) { if (s.isFDv1Fallback()) { s.unblock(); - } else { + } else if (s.getState() == SynchronizerFactoryWithState.State.Available) { s.block(); + blockedByFdv1Fallback.add(s); } } synchronizerIndex = -1; } } + /** + * Reverses {@link #fdv1Fallback()}: blocks the FDv1 fallback synchronizer and makes the FDv2 + * synchronizers that the fallback blocked available again. Synchronizers blocked for other + * reasons (e.g. TERMINAL_ERROR) stay blocked. The next synchronizer returned is the first + * available one. + */ + /** True if {@link #fdv1Fallback()} has blocked FDv2 synchronizers that recovery would restore. */ + boolean isInFdv1Fallback() { + synchronized (activeSourceLock) { + return !blockedByFdv1Fallback.isEmpty(); + } + } + + void recoverFromFdv1Fallback() { + synchronized (activeSourceLock) { + for (SynchronizerFactoryWithState s : synchronizerFactories) { + if (s.isFDv1Fallback()) { + s.block(); + } + } + for (SynchronizerFactoryWithState s : blockedByFdv1Fallback) { + s.unblock(); + } + blockedByFdv1Fallback.clear(); + synchronizerIndex = -1; + } + } + private SynchronizerFactoryWithState getNextAvailableSynchronizer() { SynchronizerFactoryWithState candidate = null; int visited = 0; @@ -111,8 +144,7 @@ Synchronizer getNextAvailableSynchronizerAndSetActive() { while (tried < limit) { SynchronizerFactoryWithState factoryWithState = getNextAvailableSynchronizer(); if (factoryWithState == null) { - currentSynchronizerFactory = null; - return null; + break; } tried++; Synchronizer synchronizer = factoryWithState.build(); @@ -126,7 +158,10 @@ Synchronizer getNextAvailableSynchronizerAndSetActive() { activeSource = synchronizer; return synchronizer; } + // Nothing can replace the active source, so stop it rather than leave it running. currentSynchronizerFactory = null; + safeClose(activeSource); + activeSource = null; return null; } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/FDv2SourceResult.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/FDv2SourceResult.java index 0403c173..660a28ac 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/FDv2SourceResult.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/FDv2SourceResult.java @@ -4,6 +4,7 @@ import androidx.annotation.Nullable; import com.launchdarkly.sdk.android.DataModel; +import com.launchdarkly.sdk.android.FDv1FallbackTTL; import com.launchdarkly.sdk.fdv2.ChangeSet; import com.launchdarkly.sdk.fdv2.SourceResultType; import com.launchdarkly.sdk.fdv2.SourceSignal; @@ -74,25 +75,60 @@ public String getReason() { private final ChangeSet> changeSet; @Nullable private final Status status; - private final boolean fdv1Fallback; + @Nullable + private final FDv1FallbackTTL fdv1Fallback; private FDv2SourceResult(@NonNull SourceResultType resultType, @Nullable ChangeSet> changeSet, @Nullable Status status, - boolean fdv1Fallback) { + @Nullable FDv1FallbackTTL fdv1Fallback) { this.resultType = resultType; this.changeSet = changeSet; this.status = status; this.fdv1Fallback = fdv1Fallback; } + /** + * Creates a change set result. If {@code fdv1Fallback} is true, the fallback uses + * {@link FDv1FallbackTTL#defaultTTL()}. + */ @NonNull public static FDv2SourceResult changeSet(@NonNull ChangeSet> changeSet, boolean fdv1Fallback) { + return changeSet(changeSet, fdv1Fallback ? FDv1FallbackTTL.defaultTTL() : null); + } + + /** + * Creates a change set result. + * + * @param changeSet the change set + * @param fdv1Fallback the TTL of the server's FDv1 fallback directive, or null if there is none + * @return the result + */ + @NonNull + public static FDv2SourceResult changeSet( + @NonNull ChangeSet> changeSet, + @Nullable FDv1FallbackTTL fdv1Fallback) { return new FDv2SourceResult(SourceResultType.CHANGE_SET, changeSet, null, fdv1Fallback); } + /** + * Creates a status result. If {@code fdv1Fallback} is true, the fallback uses + * {@link FDv1FallbackTTL#defaultTTL()}. + */ @NonNull public static FDv2SourceResult status(@NonNull Status status, boolean fdv1Fallback) { + return status(status, fdv1Fallback ? FDv1FallbackTTL.defaultTTL() : null); + } + + /** + * Creates a status result. + * + * @param status the status + * @param fdv1Fallback the TTL of the server's FDv1 fallback directive, or null if there is none + * @return the result + */ + @NonNull + public static FDv2SourceResult status(@NonNull Status status, @Nullable FDv1FallbackTTL fdv1Fallback) { return new FDv2SourceResult(SourceResultType.STATUS, null, status, fdv1Fallback); } @@ -112,10 +148,19 @@ public Status getStatus() { } /** - * True if the server indicated that the SDK should fall back to FDv1 data sources - * via the {@code x-ld-fd-fallback} response header. + * True if the server indicated that the SDK should fall back to FDv1 data sources, + * via the {@code x-ld-fd-fallback} response header or a goodbye event. */ public boolean isFdv1Fallback() { + return fdv1Fallback != null; + } + + /** + * The TTL of the server's FDv1 fallback directive, or null if {@link #isFdv1Fallback()} is + * false. + */ + @Nullable + public FDv1FallbackTTL getFdv1Fallback() { return fdv1Fallback; } } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DefaultFDv2RequestorTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DefaultFDv2RequestorTest.java index a724ea87..b6146a71 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DefaultFDv2RequestorTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DefaultFDv2RequestorTest.java @@ -444,6 +444,35 @@ public void fdv1FallbackHeaderOnSuccess() throws Exception { } } + @Test + public void fdv1FallbackTtlHeaderParsed() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.header("x-ld-fd-fallback", "true"), + Handlers.header("x-ld-fd-fallback-ttl", "120"), + Handlers.bodyJson(VALID_EVENTS_JSON)))) { + try (DefaultFDv2Requestor requestor = makeRequestor(server)) { + FDv2Requestor.FDv2PayloadResponse response = requestor.poll(Selector.EMPTY).get(5, TimeUnit.SECONDS); + assertTrue(response.isFdv1Fallback()); + assertEquals(120, response.getFdv1Fallback().getSeconds()); + } + } + } + + @Test + public void fdv1FallbackTtlHeaderMalformed() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.header("x-ld-fd-fallback", "true"), + Handlers.header("x-ld-fd-fallback-ttl", "soon"), + Handlers.bodyJson(VALID_EVENTS_JSON)))) { + try (DefaultFDv2Requestor requestor = makeRequestor(server)) { + FDv2Requestor.FDv2PayloadResponse response = requestor.poll(Selector.EMPTY).get(5, TimeUnit.SECONDS); + assertTrue(response.isFdv1Fallback()); + long seconds = response.getFdv1Fallback().getSeconds(); + assertTrue(seconds >= 1800 && seconds <= 3600); + } + } + } + @Test public void fdv1FallbackHeaderAbsent() throws Exception { try (HttpServer server = HttpServer.start(Handlers.bodyJson(VALID_EVENTS_JSON))) { diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv1FallbackTTLTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv1FallbackTTLTest.java new file mode 100644 index 00000000..51706946 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv1FallbackTTLTest.java @@ -0,0 +1,73 @@ +package com.launchdarkly.sdk.android; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import com.google.gson.JsonParser; + +import org.junit.Test; + +import java.util.HashSet; +import java.util.Set; + +public class FDv1FallbackTTLTest { + private static final long MIN_DEFAULT_SECONDS = 30 * 60; + private static final long MAX_DEFAULT_SECONDS = 60 * 60; + + private static void assertIsDefault(FDv1FallbackTTL ttl) { + long seconds = ttl.getSeconds(); + assertTrue("expected a default TTL but got " + seconds, + seconds >= MIN_DEFAULT_SECONDS && seconds <= MAX_DEFAULT_SECONDS); + } + + private static FDv1FallbackTTL fromGoodbye(String json) { + return FDv1FallbackTTL.fromGoodbye(JsonParser.parseString(json)); + } + + @Test + public void defaultIsJittered() { + Set seen = new HashSet<>(); + for (int i = 0; i < 100; i++) { + FDv1FallbackTTL ttl = FDv1FallbackTTL.defaultTTL(); + assertIsDefault(ttl); + seen.add(ttl.getSeconds()); + } + assertTrue(seen.size() > 1); + } + + @Test + public void validHeaderIsUsedUnchanged() { + for (String ttl : new String[]{"1", "60", "1800", "3599", "3600"}) { + assertEquals(Long.parseLong(ttl), FDv1FallbackTTL.fromHeader(ttl).getSeconds()); + } + } + + @Test + public void invalidHeaderUsesDefault() { + String[] invalid = {null, "", "soon", "12.5", "0", "-1", "-3600", "3601", "99999999999999999999"}; + for (String ttl : invalid) { + assertIsDefault(FDv1FallbackTTL.fromHeader(ttl)); + } + } + + @Test + public void goodbyeWithoutTtlIsNotADirective() { + assertNull(FDv1FallbackTTL.fromGoodbye(null)); + assertNull(fromGoodbye("{\"reason\":\"x\"}")); + } + + @Test + public void validGoodbyeTtlIsUsedUnchanged() { + for (long ttl : new long[]{1, 90, 3600}) { + assertEquals(ttl, fromGoodbye("{\"protocolFallbackTTL\":" + ttl + "}").getSeconds()); + } + } + + @Test + public void invalidGoodbyeTtlUsesDefault() { + for (String ttl : new String[]{"0", "-5", "3601", "\"90\"", "12.5", "null", "{}"}) { + assertIsDefault(fromGoodbye("{\"protocolFallbackTTL\":" + ttl + "}")); + } + } +} diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java index 33ecfc61..eb0c6604 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java @@ -33,7 +33,9 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Queue; import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; import java.util.concurrent.Executors; @@ -1744,20 +1746,89 @@ public void fdv1FallbackNotTriggeredWhenAlreadyOnFdv1() throws Exception { } @Test - public void fdv1FallbackNotTriggeredWhenNoFdv1SlotExists() throws Exception { + public void fdv1FallbackWithoutFdv1SynchronizerRecoversToFdv2AfterTtl() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); - MockQueuedSynchronizer fdv2Sync = new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(true), true)); + MockQueuedSynchronizer firstFdv2Sync = new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(true), FDv1FallbackTTL.fromHeader("1"))); + + MockQueuedSynchronizer secondFdv2Sync = new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(true), false)); + + Queue fdv2Syncs = new ConcurrentLinkedQueue<>(Arrays.asList(firstFdv2Sync, secondFdv2Sync)); FDv2DataSource dataSource = buildDataSource(sink, Collections.>emptyList(), + Collections.>singletonList(fdv2Syncs::poll)); + + AwaitableCallback startCallback = startDataSource(dataSource); + assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); + + // No synchronizer runs during the 1 second TTL; then a fresh FDv2 synchronizer is built. + assertEquals( + Arrays.asList(DataSourceState.VALID, DataSourceState.INTERRUPTED, DataSourceState.VALID), + sink.awaitStatuses(3, AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + assertEquals(2, sink.getApplyCount()); + assertTrue(firstFdv2Sync.closed); + assertTrue(fdv2Syncs.isEmpty()); + + stopDataSource(dataSource); + } + + @Test + public void fdv1FallbackRecoversToFdv2AfterTtl() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + + MockQueuedSynchronizer firstFdv2Sync = new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(true), FDv1FallbackTTL.fromHeader("1"))); + + MockQueuedSynchronizer secondFdv2Sync = new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(true), false)); + + MockQueuedSynchronizer fdv1Sync = new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(true), false)); + + Queue fdv2Syncs = new ConcurrentLinkedQueue<>(Arrays.asList(firstFdv2Sync, secondFdv2Sync)); + + FDv2DataSource dataSource = new FDv2DataSource( + CONTEXT, + Collections.>emptyList(), + Collections.>singletonList(fdv2Syncs::poll), + () -> fdv1Sync, + sink, + executor, + logging.logger); + + AwaitableCallback startCallback = startDataSource(dataSource); + assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); + + // First FDv2, then FDv1 during the 1 second TTL, then a fresh FDv2 synchronizer. + sink.awaitApplyCount(3, AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertEquals(3, sink.getApplyCount()); + assertTrue(firstFdv2Sync.closed); + assertTrue(fdv1Sync.closed); + assertTrue(fdv2Syncs.isEmpty()); + + stopDataSource(dataSource); + } + + @Test + public void fdv1FallbackDuringInitializationWithoutFdv1SynchronizerStartsFdv2AfterTtl() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + + MockQueuedSynchronizer fdv2Sync = new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(true), false)); + + FDv2DataSource dataSource = buildDataSource(sink, + Collections.>singletonList( + () -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), FDv1FallbackTTL.fromHeader("1")))), Collections.>singletonList(() -> fdv2Sync)); AwaitableCallback startCallback = startDataSource(dataSource); assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); - assertEquals(DataSourceState.VALID, sink.awaitStatus(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + sink.awaitApplyCount(2, AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertEquals(2, sink.getApplyCount()); stopDataSource(dataSource); } @@ -2074,7 +2145,7 @@ public void orchestrationLogging_fdv1Fallback_logsInfo() throws Exception { logging.logger); AwaitableCallback startCallback = startDataSource(dataSource); assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); - awaitLogContains(logging, "Falling back to an FDv1 fallback synchronizer."); + awaitLogContains(logging, "Server directed fallback to FDv1; FDv2 will be retried in"); stopDataSource(dataSource); } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingBaseTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingBaseTest.java index 86852f97..f562a233 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingBaseTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingBaseTest.java @@ -67,6 +67,10 @@ public class FDv2PollingBaseTest { private static final String GOODBYE_JSON = "{\"events\": [{\"event\": \"goodbye\", \"data\": {\"reason\": \"test-reason\", \"silent\": false}}]}"; + /** Goodbye event carrying an FDv1 fallback directive. */ + private static final String GOODBYE_WITH_FALLBACK_TTL_JSON = + "{\"events\": [{\"event\": \"goodbye\", \"data\": {\"reason\": \"test-reason\", \"protocolFallbackTTL\": 90}}]}"; + /** Server error event. */ private static final String ERROR_EVENT_JSON = "{\"events\": [{\"event\": \"error\", \"data\": {\"id\": \"err1\", \"reason\": \"test-server-error\"}}]}"; @@ -90,7 +94,7 @@ private static FDv2SourceResult doPoll(MockFDv2Requestor requestor, boolean oneS @Test public void http304_returnsNoneChangeSet() { MockFDv2Requestor requestor = new MockFDv2Requestor(); - requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.notModified(false)); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.notModified(null)); FDv2SourceResult result = doPoll(requestor, true); @@ -103,7 +107,7 @@ public void http304_returnsNoneChangeSet() { public void http304_preservesCurrentSelector() { Selector selector = Selector.make(5, "cached-state"); MockFDv2Requestor requestor = new MockFDv2Requestor(); - requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.notModified(false)); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.notModified(null)); FDv2SourceResult result = FDv2PollingBase.doPoll(requestor, LOGGER, selector, false); @@ -118,7 +122,7 @@ public void http304_preservesCurrentSelector() { @Test public void recoverableHttpError_oneShot_returnsTerminalError() { MockFDv2Requestor requestor = new MockFDv2Requestor(); - requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.failure(500, false)); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.failure(500, null)); FDv2SourceResult result = doPoll(requestor, true); @@ -130,7 +134,7 @@ public void recoverableHttpError_oneShot_returnsTerminalError() { @Test public void recoverableHttpError_notOneShot_returnsInterrupted() { MockFDv2Requestor requestor = new MockFDv2Requestor(); - requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.failure(500, false)); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.failure(500, null)); FDv2SourceResult result = doPoll(requestor, false); @@ -142,7 +146,7 @@ public void recoverableHttpError_notOneShot_returnsInterrupted() { @Test public void nonRecoverableHttpError_oneShot_returnsTerminalError() { MockFDv2Requestor requestor = new MockFDv2Requestor(); - requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.failure(401, false)); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.failure(401, null)); FDv2SourceResult result = doPoll(requestor, true); @@ -155,7 +159,7 @@ public void nonRecoverableHttpError_oneShot_returnsTerminalError() { public void nonRecoverableHttpError_notOneShot_alsoReturnsTerminalError() { // Non-recoverable (e.g. 401) always maps to TERMINAL_ERROR regardless of oneShot. MockFDv2Requestor requestor = new MockFDv2Requestor(); - requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.failure(401, false)); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.failure(401, null)); FDv2SourceResult result = doPoll(requestor, false); @@ -196,7 +200,7 @@ public void networkError_notOneShot_returnsInterrupted() { public void emptyEventsArray_oneShot_returnsTerminalError() { MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - Collections.emptyList(), 200, false)); + Collections.emptyList(), 200, null)); FDv2SourceResult result = doPoll(requestor, true); @@ -209,7 +213,7 @@ public void emptyEventsArray_oneShot_returnsTerminalError() { public void emptyEventsArray_notOneShot_returnsInterrupted() { MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - Collections.emptyList(), 200, false)); + Collections.emptyList(), 200, null)); FDv2SourceResult result = doPoll(requestor, false); @@ -225,7 +229,7 @@ public void eventsWithNoChangesetAfterLoop_oneShot_returnsTerminalError() { // xfer-full server-intent without payload-transferred: loop ends with no CHANGESET. MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(XFER_FULL_NO_PAYLOAD_TRANSFERRED_JSON), 200, false)); + parseEvents(XFER_FULL_NO_PAYLOAD_TRANSFERRED_JSON), 200, null)); FDv2SourceResult result = doPoll(requestor, true); @@ -238,7 +242,7 @@ public void eventsWithNoChangesetAfterLoop_oneShot_returnsTerminalError() { public void eventsWithNoChangesetAfterLoop_notOneShot_returnsInterrupted() { MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(XFER_FULL_NO_PAYLOAD_TRANSFERRED_JSON), 200, false)); + parseEvents(XFER_FULL_NO_PAYLOAD_TRANSFERRED_JSON), 200, null)); FDv2SourceResult result = doPoll(requestor, false); @@ -253,7 +257,7 @@ public void eventsWithNoChangesetAfterLoop_notOneShot_returnsInterrupted() { public void goodbyeEvent_returnsGoodbye() { MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(GOODBYE_JSON), 200, false)); + parseEvents(GOODBYE_JSON), 200, null)); FDv2SourceResult result = doPoll(requestor, true); @@ -269,7 +273,7 @@ public void goodbyeEvent_returnsGoodbye() { public void serverErrorEvent_oneShot_returnsTerminalError() { MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(ERROR_EVENT_JSON), 200, false)); + parseEvents(ERROR_EVENT_JSON), 200, null)); FDv2SourceResult result = doPoll(requestor, true); @@ -282,7 +286,7 @@ public void serverErrorEvent_oneShot_returnsTerminalError() { public void serverErrorEvent_notOneShot_returnsInterrupted() { MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(ERROR_EVENT_JSON), 200, false)); + parseEvents(ERROR_EVENT_JSON), 200, null)); FDv2SourceResult result = doPoll(requestor, false); @@ -297,7 +301,7 @@ public void serverErrorEvent_notOneShot_returnsInterrupted() { public void successfulXferFull_emptyPayload_returnsChangeSet() { MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(XFER_NONE_JSON), 200, false)); + parseEvents(XFER_NONE_JSON), 200, null)); FDv2SourceResult result = doPoll(requestor, true); @@ -309,7 +313,7 @@ public void successfulXferFull_emptyPayload_returnsChangeSet() { public void successfulXferFull_withData_returnsChangeSet() { MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(XFER_FULL_EMPTY_JSON), 200, false)); + parseEvents(XFER_FULL_EMPTY_JSON), 200, null)); FDv2SourceResult result = doPoll(requestor, true); @@ -331,7 +335,7 @@ public void internalError_malformedPayloadTransferred_oneShot_returnsTerminalErr MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(malformedPayloadTransferredJson), 200, false)); + parseEvents(malformedPayloadTransferredJson), 200, null)); FDv2SourceResult result = doPoll(requestor, true); @@ -350,7 +354,7 @@ public void internalError_malformedPayloadTransferred_notOneShot_returnsInterrup MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(malformedPayloadTransferredJson), 200, false)); + parseEvents(malformedPayloadTransferredJson), 200, null)); FDv2SourceResult result = doPoll(requestor, false); @@ -369,7 +373,7 @@ public void unrecognizedEventType_oneShot_returnsTerminalError() { MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(unknownEventJson), 200, false)); + parseEvents(unknownEventJson), 200, null)); FDv2SourceResult result = doPoll(requestor, true); @@ -384,7 +388,7 @@ public void unrecognizedEventType_notOneShot_returnsInterrupted() { MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(unknownEventJson), 200, false)); + parseEvents(unknownEventJson), 200, null)); FDv2SourceResult result = doPoll(requestor, false); @@ -399,7 +403,7 @@ public void unrecognizedEventType_notOneShot_returnsInterrupted() { public void selectorIsPassedToRequestor() { Selector selector = Selector.make(7, "my-state"); MockFDv2Requestor requestor = new MockFDv2Requestor(); - requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.notModified(false)); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.notModified(null)); FDv2PollingBase.doPoll(requestor, LOGGER, selector, true); @@ -413,7 +417,7 @@ public void selectorIsPassedToRequestor() { public void fdv1FallbackPropagatedOnSuccess() { MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(XFER_FULL_EMPTY_JSON), 200, true)); + parseEvents(XFER_FULL_EMPTY_JSON), 200, FDv1FallbackTTL.defaultTTL())); FDv2SourceResult result = doPoll(requestor, false); @@ -425,7 +429,7 @@ public void fdv1FallbackPropagatedOnSuccess() { public void fdv1FallbackFalseByDefault() { MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(XFER_FULL_EMPTY_JSON), 200, false)); + parseEvents(XFER_FULL_EMPTY_JSON), 200, null)); FDv2SourceResult result = doPoll(requestor, false); @@ -436,7 +440,7 @@ public void fdv1FallbackFalseByDefault() { @Test public void fdv1FallbackPropagatedOnHttpError() { MockFDv2Requestor requestor = new MockFDv2Requestor(); - requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.failure(500, true)); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.failure(500, FDv1FallbackTTL.defaultTTL())); FDv2SourceResult result = doPoll(requestor, false); @@ -448,7 +452,7 @@ public void fdv1FallbackPropagatedOnHttpError() { public void fdv1FallbackPropagatedOnGoodbye() { MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(GOODBYE_JSON), 200, true)); + parseEvents(GOODBYE_JSON), 200, FDv1FallbackTTL.defaultTTL())); FDv2SourceResult result = doPoll(requestor, false); @@ -456,4 +460,40 @@ public void fdv1FallbackPropagatedOnGoodbye() { assertEquals(SourceSignal.GOODBYE, result.getStatus().getState()); assertTrue(result.isFdv1Fallback()); } + + @Test + public void goodbyeWithProtocolFallbackTtlIsFdv1Fallback() { + MockFDv2Requestor requestor = new MockFDv2Requestor(); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( + parseEvents(GOODBYE_WITH_FALLBACK_TTL_JSON), 200, null)); + + FDv2SourceResult result = doPoll(requestor, false); + + assertEquals(SourceSignal.GOODBYE, result.getStatus().getState()); + assertTrue(result.isFdv1Fallback()); + assertEquals(90, result.getFdv1Fallback().getSeconds()); + } + + @Test + public void goodbyeWithoutProtocolFallbackTtlIsNotFdv1Fallback() { + MockFDv2Requestor requestor = new MockFDv2Requestor(); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( + parseEvents(GOODBYE_JSON), 200, null)); + + FDv2SourceResult result = doPoll(requestor, false); + + assertEquals(SourceSignal.GOODBYE, result.getStatus().getState()); + assertFalse(result.isFdv1Fallback()); + } + + @Test + public void fdv1FallbackTtlPropagatedFromHeader() { + MockFDv2Requestor requestor = new MockFDv2Requestor(); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.failure(500, FDv1FallbackTTL.fromHeader("120"))); + + FDv2SourceResult result = doPoll(requestor, false); + + assertTrue(result.isFdv1Fallback()); + assertEquals(120, result.getFdv1Fallback().getSeconds()); + } } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingInitializerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingInitializerTest.java index acab2918..95f5f2b5 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingInitializerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingInitializerTest.java @@ -70,7 +70,7 @@ public void successfulPoll_futureCompletesWithChangeSet() throws Exception { // Unblock the in-flight poll with a successful response. LDAwaitFuture pollFuture = requestor.awaitNextPoll(); - pollFuture.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200, false)); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200, null)); FDv2SourceResult result = future.get(1, TimeUnit.SECONDS); assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); @@ -87,7 +87,7 @@ public void recoverableHttpError_futureCompletesWithTerminalError() throws Excep Future future = initializer.run(); LDAwaitFuture pollFuture = requestor.awaitNextPoll(); - pollFuture.set(FDv2Requestor.FDv2PayloadResponse.failure(500, false)); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.failure(500, null)); FDv2SourceResult result = future.get(1, TimeUnit.SECONDS); assertEquals(SourceResultType.STATUS, result.getResultType()); @@ -103,7 +103,7 @@ public void nonRecoverableHttpError_futureCompletesWithTerminalError() throws Ex Future future = initializer.run(); LDAwaitFuture pollFuture = requestor.awaitNextPoll(); - pollFuture.set(FDv2Requestor.FDv2PayloadResponse.failure(401, false)); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.failure(401, null)); FDv2SourceResult result = future.get(1, TimeUnit.SECONDS); assertEquals(SourceResultType.STATUS, result.getResultType()); @@ -148,7 +148,7 @@ public void closeBeforePollCompletes_futureCompletesWithShutdown() throws Except assertEquals(SourceSignal.SHUTDOWN, result.getStatus().getState()); // Allow the background thread to unblock cleanly. - pollFuture.set(FDv2Requestor.FDv2PayloadResponse.notModified(false)); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.notModified(null)); } @Test @@ -159,7 +159,7 @@ public void closeAfterPollCompletes_doesNotThrow() throws Exception { Future future = initializer.run(); LDAwaitFuture pollFuture = requestor.awaitNextPoll(); - pollFuture.set(FDv2Requestor.FDv2PayloadResponse.notModified(false)); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.notModified(null)); // Wait for poll to complete, then close — should not throw. future.get(1, TimeUnit.SECONDS); @@ -216,7 +216,7 @@ public void onlyOnePollIsIssued() throws Exception { Future future = initializer.run(); LDAwaitFuture pollFuture = requestor.awaitNextPoll(); - pollFuture.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200, false)); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200, null)); future.get(1, TimeUnit.SECONDS); @@ -236,7 +236,7 @@ public void closeCallsRequestorClose() throws Exception { LDAwaitFuture pollFuture = requestor.awaitNextPoll(); initializer.close(); - pollFuture.set(FDv2Requestor.FDv2PayloadResponse.notModified(false)); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.notModified(null)); assertEquals(1, requestor.closeCount); } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingSynchronizerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingSynchronizerTest.java index ffa8cfd2..b6c4070b 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingSynchronizerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingSynchronizerTest.java @@ -76,7 +76,7 @@ public void pollResult_deliveredViaNext() throws Exception { Future nextFuture = synchronizer.next(); LDAwaitFuture pollFuture = requestor.awaitNextPoll(); - pollFuture.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200, false)); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200, null)); FDv2SourceResult result = nextFuture.get(1, TimeUnit.SECONDS); assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); @@ -94,14 +94,14 @@ public void consecutivePolls_deliveredInOrder() throws Exception { // First poll Future nextFuture1 = synchronizer.next(); LDAwaitFuture poll1 = requestor.awaitNextPoll(); - poll1.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200, false)); + poll1.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200, null)); FDv2SourceResult result1 = nextFuture1.get(1, TimeUnit.SECONDS); assertEquals(SourceResultType.CHANGE_SET, result1.getResultType()); // Second poll (scheduleWithFixedDelay fires after interval elapses) Future nextFuture2 = synchronizer.next(); LDAwaitFuture poll2 = requestor.awaitNextPoll(); - poll2.set(FDv2Requestor.FDv2PayloadResponse.notModified(false)); + poll2.set(FDv2Requestor.FDv2PayloadResponse.notModified(null)); FDv2SourceResult result2 = nextFuture2.get(1, TimeUnit.SECONDS); assertEquals(SourceResultType.CHANGE_SET, result2.getResultType()); } finally { @@ -141,7 +141,7 @@ public void closeWhileNextWaiting_nextReturnsShutdown() throws Exception { assertEquals(SourceSignal.SHUTDOWN, result.getStatus().getState()); // Unblock the background thread. - pollFuture.set(FDv2Requestor.FDv2PayloadResponse.notModified(false)); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.notModified(null)); } @Test @@ -152,7 +152,7 @@ public void closeCallsRequestorClose() throws Exception { Future nextFuture = synchronizer.next(); LDAwaitFuture pollFuture = requestor.awaitNextPoll(); synchronizer.close(); - pollFuture.set(FDv2Requestor.FDv2PayloadResponse.notModified(false)); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.notModified(null)); nextFuture.get(1, TimeUnit.SECONDS); assertEquals(1, requestor.closeCount); @@ -169,7 +169,7 @@ public void terminalError_stopsPollingAndDeliveredViaNext() throws Exception { Future nextFuture = synchronizer.next(); LDAwaitFuture pollFuture = requestor.awaitNextPoll(); - pollFuture.set(FDv2Requestor.FDv2PayloadResponse.failure(401, false)); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.failure(401, null)); FDv2SourceResult result = nextFuture.get(1, TimeUnit.SECONDS); assertEquals(SourceResultType.STATUS, result.getResultType()); @@ -188,7 +188,7 @@ public void terminalError_subsequentNextAlsoReturnsTerminalResult() throws Excep try { Future firstFuture = synchronizer.next(); LDAwaitFuture pollFuture = requestor.awaitNextPoll(); - pollFuture.set(FDv2Requestor.FDv2PayloadResponse.failure(401, false)); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.failure(401, null)); firstFuture.get(1, TimeUnit.SECONDS); // Second next() — shutdownFuture is already completed with TERMINAL_ERROR. @@ -215,14 +215,14 @@ public void interruptedResult_doesNotStopPolling() throws Exception { // First poll → INTERRUPTED (recoverable 500). Future nextFuture1 = synchronizer.next(); LDAwaitFuture poll1 = requestor.awaitNextPoll(); - poll1.set(FDv2Requestor.FDv2PayloadResponse.failure(500, false)); + poll1.set(FDv2Requestor.FDv2PayloadResponse.failure(500, null)); FDv2SourceResult result1 = nextFuture1.get(1, TimeUnit.SECONDS); assertEquals(SourceSignal.INTERRUPTED, result1.getStatus().getState()); // Second poll fires normally (task was not cancelled). Future nextFuture2 = synchronizer.next(); LDAwaitFuture poll2 = requestor.awaitNextPoll(); - poll2.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200, false)); + poll2.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200, null)); FDv2SourceResult result2 = nextFuture2.get(1, TimeUnit.SECONDS); assertEquals(SourceResultType.CHANGE_SET, result2.getResultType()); } finally { @@ -296,7 +296,7 @@ public void close() throws IOException {} Future nextFuture = synchronizer.next(); LDAwaitFuture poll2 = delegate.awaitNextPoll(); - poll2.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200, false)); + poll2.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200, null)); FDv2SourceResult result = nextFuture.get(2, TimeUnit.SECONDS); assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); @@ -324,14 +324,14 @@ public void internalError_malformedPayloadTransferred_continuesPolling() throws // First poll → INTERNAL_ERROR → INTERRUPTED (task must NOT be cancelled). Future nextFuture1 = synchronizer.next(); LDAwaitFuture poll1 = requestor.awaitNextPoll(); - poll1.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(malformedJson), 200, false)); + poll1.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(malformedJson), 200, null)); FDv2SourceResult result1 = nextFuture1.get(1, TimeUnit.SECONDS); assertEquals(SourceSignal.INTERRUPTED, result1.getStatus().getState()); // Second poll fires normally (task was not cancelled). Future nextFuture2 = synchronizer.next(); LDAwaitFuture poll2 = requestor.awaitNextPoll(); - poll2.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200, false)); + poll2.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200, null)); FDv2SourceResult result2 = nextFuture2.get(1, TimeUnit.SECONDS); assertEquals(SourceResultType.CHANGE_SET, result2.getResultType()); } finally { @@ -351,13 +351,13 @@ public void unrecognizedEventType_continuesPolling() throws Exception { try { Future nextFuture1 = synchronizer.next(); LDAwaitFuture poll1 = requestor.awaitNextPoll(); - poll1.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(unknownEventJson), 200, false)); + poll1.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(unknownEventJson), 200, null)); FDv2SourceResult result1 = nextFuture1.get(1, TimeUnit.SECONDS); assertEquals(SourceSignal.INTERRUPTED, result1.getStatus().getState()); Future nextFuture2 = synchronizer.next(); LDAwaitFuture poll2 = requestor.awaitNextPoll(); - poll2.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200, false)); + poll2.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200, null)); FDv2SourceResult result2 = nextFuture2.get(1, TimeUnit.SECONDS); assertEquals(SourceResultType.CHANGE_SET, result2.getResultType()); } finally { @@ -378,7 +378,7 @@ public void selectorIsPassedToRequestor() throws Exception { Future nextFuture = synchronizer.next(); LDAwaitFuture pollFuture = requestor.awaitNextPoll(); - pollFuture.set(FDv2Requestor.FDv2PayloadResponse.notModified(false)); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.notModified(null)); nextFuture.get(1, TimeUnit.SECONDS); assertEquals(selector, requestor.receivedSelectors.poll(1, TimeUnit.SECONDS)); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizerTest.java index 9aa3891d..c9e40ccd 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizerTest.java @@ -1228,6 +1228,49 @@ public void fdv1FallbackHeaderOnMessageEvent() throws Exception { } } + @Test + public void fdv1FallbackTtlHeaderOnMessageEvent() throws Exception { + String serverIntent = makeEvent("server-intent", "{\"payloads\":[{\"id\":\"payload-1\",\"target\":100,\"intentCode\":\"xfer-full\",\"reason\":\"payload-missing\"}]}"); + String payloadTransferred = makeEvent("payload-transferred", "{\"state\":\"(p:payload-1:100)\",\"version\":100}"); + + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.header("x-ld-fd-fallback", "true"), + Handlers.header("x-ld-fd-fallback-ttl", "120"), + Handlers.SSE.start(), + Handlers.SSE.event(serverIntent), + Handlers.SSE.event(payloadTransferred), + Handlers.SSE.leaveOpen()))) { + + FDv2StreamingSynchronizer sync = makeSynchronizer(server.getUri()); + FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); + + assertTrue(result.isFdv1Fallback()); + assertEquals(120, result.getFdv1Fallback().getSeconds()); + + sync.close(); + } + } + + @Test + public void goodbyeWithProtocolFallbackTtlIsFdv1Fallback() throws Exception { + String goodbyeEvent = makeEvent("goodbye", "{\"reason\":\"fdv1-fallback\",\"protocolFallbackTTL\":90}"); + + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.SSE.start(), + Handlers.SSE.event(goodbyeEvent), + Handlers.SSE.leaveOpen()))) { + + FDv2StreamingSynchronizer sync = makeSynchronizer(server.getUri()); + FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); + + assertEquals(SourceSignal.GOODBYE, result.getStatus().getState()); + assertTrue(result.isFdv1Fallback()); + assertEquals(90, result.getFdv1Fallback().getSeconds()); + + sync.close(); + } + } + @Test public void fdv1FallbackHeaderAbsentOnMessageEvent() throws Exception { String serverIntent = makeEvent("server-intent", "{\"payloads\":[{\"id\":\"payload-1\",\"target\":100,\"intentCode\":\"xfer-full\",\"reason\":\"payload-missing\"}]}");