From f795e2235554fb0cb568981098f160f196e81a8d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:48:22 +0000 Subject: [PATCH 1/2] fix: make FDv1 fallback TTL-based and recover to FDv2 after the TTL Co-Authored-By: Bee Klimt --- .../sdk/android/DefaultFDv2Requestor.java | 8 +- .../sdk/android/FDv1FallbackTtl.java | 91 +++++++++++++ .../sdk/android/FDv2DataSource.java | 121 +++++++++++++++--- .../sdk/android/FDv2PollingBase.java | 40 +++--- .../sdk/android/FDv2Requestor.java | 35 ++++- .../android/FDv2StreamingSynchronizer.java | 38 ++++-- .../sdk/android/HeaderConstants.java | 1 + .../sdk/android/SourceManager.java | 41 +++++- .../android/subsystems/FDv2SourceResult.java | 56 +++++++- .../sdk/android/DefaultFDv2RequestorTest.java | 28 ++++ .../sdk/android/FDv1FallbackTtlTest.java | 80 ++++++++++++ .../sdk/android/FDv2DataSourceTest.java | 81 +++++++++++- .../sdk/android/FDv2PollingBaseTest.java | 40 ++++++ .../FDv2StreamingSynchronizerTest.java | 43 +++++++ 14 files changed, 638 insertions(+), 65 deletions(-) create mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1FallbackTtl.java create mode 100644 launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv1FallbackTtlTest.java 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 2ffa79e8c..eee554b20 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 @@ -188,10 +188,12 @@ private void handleResponse( try { int code = response.code(); boolean fdv1Fallback = isFdv1Fallback(response); + long fdv1FallbackTtlSeconds = FDv1FallbackTtl.parseHeader( + response.header(HeaderConstants.FDV1_FALLBACK_TTL_HEADER)); if (code == 304) { logger.debug("FDv2 polling: 304 Not Modified"); - future.set(FDv2PayloadResponse.notModified(fdv1Fallback)); + future.set(FDv2PayloadResponse.notModified(fdv1Fallback, fdv1FallbackTtlSeconds)); return; } @@ -203,7 +205,7 @@ private void handleResponse( } else { logger.warn("Polling request failed with HTTP {}", code); } - future.set(FDv2PayloadResponse.failure(code, fdv1Fallback)); + future.set(FDv2PayloadResponse.failure(code, fdv1Fallback, fdv1FallbackTtlSeconds)); return; } @@ -222,7 +224,7 @@ private void handleResponse( String bodyStr = body.string(); logger.debug("Polling response received"); List events = FDv2Event.parseEventsArray(bodyStr); - future.set(FDv2PayloadResponse.success(events, code, fdv1Fallback)); + future.set(FDv2PayloadResponse.success(events, code, fdv1Fallback, fdv1FallbackTtlSeconds)); } catch (Exception e) { future.setException(e); 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 000000000..98ab24e17 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1FallbackTtl.java @@ -0,0 +1,91 @@ +package com.launchdarkly.sdk.android; + +import androidx.annotation.Nullable; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import java.util.Random; + +/** + * Interprets the TTL of an FDv1 fallback directive: how long to stay off FDv2 before + * attempting to recover to it. + *

+ * Package-private for internal use by the FDv2 data sources. + */ +final class FDv1FallbackTtl { + /** Used whenever the service supplies no usable TTL. */ + static final long DEFAULT_TTL_SECONDS = 60 * 60; + + private static final String GOODBYE_TTL_FIELD = "protocolFallbackTTL"; + + private static final Random random = new Random(); + + private FDv1FallbackTtl() {} + + /** + * Returns the TTL to use for a service-supplied value, in seconds. A value in + * (0, 1 hour] is used as given, because the service has already jittered it. Any other + * value, including 0 for an absent or malformed TTL, uses the jittered default, so a + * fallback is never indefinite. + */ + static long resolveSeconds(long serviceTtlSeconds) { + if (serviceTtlSeconds > 0 && serviceTtlSeconds <= DEFAULT_TTL_SECONDS) { + return serviceTtlSeconds; + } + return defaultSeconds(); + } + + /** + * Returns the default TTL minus a value drawn uniformly from [0, TTL/2], so that SDKs which + * fell back within the same window do not all re-attempt FDv2 at once. + */ + static long defaultSeconds() { + long jitter; + synchronized (random) { + jitter = (long) (random.nextDouble() * (DEFAULT_TTL_SECONDS / 2 + 1)); + } + return DEFAULT_TTL_SECONDS - jitter; + } + + /** + * Parses the value of an {@code x-ld-fd-fallback-ttl} header as a whole number of seconds. + * Returns 0 if the header is absent or malformed. + */ + static long parseHeader(@Nullable String value) { + if (value == null || value.isEmpty()) { + return 0; + } + try { + return Long.parseLong(value); + } catch (NumberFormatException e) { + return 0; + } + } + + /** + * Reads the {@code protocolFallbackTTL} field of a goodbye event's data. Returns null if the + * field is absent, meaning the goodbye carries no fallback directive. Returns 0 if the field + * is present but not a whole number of seconds. + */ + @Nullable + static Long 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 0L; + } + double seconds = field.getAsDouble(); + if (seconds != Math.rint(seconds) || Double.isInfinite(seconds)) { + return 0L; + } + return (long) 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 9b8d40562..9f3629c51 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 = FDv1FallbackTtl.resolveSeconds(result.getFdv1FallbackTtlSeconds()); + 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( + FDv1FallbackTtl.resolveSeconds(result.getFdv1FallbackTtlSeconds())); + } 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 92c3c3342..cd784e4a8 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 @@ -103,6 +103,7 @@ static FDv2SourceResult doPoll( } boolean fdv1Fallback = response.isFdv1Fallback(); + long fdv1FallbackTtlSeconds = response.getFdv1FallbackTtlSeconds(); // 304 Not Modified: nothing changed if (response.getStatusCode() == 304) { @@ -112,7 +113,7 @@ static FDv2SourceResult doPoll( selector, Collections.emptyMap(), null, - true), fdv1Fallback); + true), fdv1Fallback, fdv1FallbackTtlSeconds); } if (!response.isSuccess()) { @@ -122,9 +123,9 @@ static FDv2SourceResult doPoll( LDFailure failure = new LDInvalidResponseCodeFailure( "Polling request failed", null, code, recoverable); if (oneShot || !recoverable) { - return FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback); + return FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback, fdv1FallbackTtlSeconds); } else { - return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback); + return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback, fdv1FallbackTtlSeconds); } } @@ -134,8 +135,8 @@ static FDv2SourceResult doPoll( LDFailure failure = new LDFailure("FDv2 polling response contained no events", LDFailure.FailureType.INVALID_RESPONSE_BODY); return oneShot - ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback) - : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback); + ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback, fdv1FallbackTtlSeconds) + : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback, fdv1FallbackTtlSeconds); } FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); @@ -148,8 +149,8 @@ static FDv2SourceResult doPoll( LDFailure failure = new LDFailure( "FDv2 protocol handler error", e, LDFailure.FailureType.INVALID_RESPONSE_BODY); return oneShot - ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback) - : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback); + ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback, fdv1FallbackTtlSeconds) + : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback, fdv1FallbackTtlSeconds); } switch (action.getAction()) { @@ -159,15 +160,15 @@ static FDv2SourceResult doPoll( try { ChangeSet> changeSet = FDv2ChangeSetTranslator.toChangeSet(raw, logger); - return FDv2SourceResult.changeSet(changeSet, fdv1Fallback); + return FDv2SourceResult.changeSet(changeSet, fdv1Fallback, fdv1FallbackTtlSeconds); } catch (SerializationException e) { LDUtil.logExceptionAtErrorLevel(logger, e, "Polling failed to translate changeset"); LDFailure failure = new LDFailure( "Failed to translate FDv2 polling changeset", e, LDFailure.FailureType.INVALID_RESPONSE_BODY); return oneShot - ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback) - : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback); + ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback, fdv1FallbackTtlSeconds) + : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback, fdv1FallbackTtlSeconds); } } case ERROR: { @@ -179,13 +180,18 @@ static FDv2SourceResult doPoll( "Polling error: " + error.getReason(), LDFailure.FailureType.UNKNOWN_ERROR); return oneShot - ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback) - : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback); + ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback, fdv1FallbackTtlSeconds) + : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback, fdv1FallbackTtlSeconds); } case GOODBYE: { String reason = ((FDv2ProtocolHandler.FDv2ActionGoodbye) action).getReason(); logger.info("Polling received GOODBYE with reason: '{}'", reason); - return FDv2SourceResult.status(FDv2SourceResult.Status.goodbye(reason), fdv1Fallback); + Long goodbyeTtlSeconds = FDv1FallbackTtl.fromGoodbye(event.getData()); + if (goodbyeTtlSeconds != null) { + return FDv2SourceResult.status( + FDv2SourceResult.Status.goodbye(reason), true, goodbyeTtlSeconds); + } + return FDv2SourceResult.status(FDv2SourceResult.Status.goodbye(reason), fdv1Fallback, fdv1FallbackTtlSeconds); } case INTERNAL_ERROR: { FDv2ProtocolHandler.FDv2ActionInternalError internalError = @@ -196,8 +202,8 @@ static FDv2SourceResult doPoll( "Polling internal error: " + internalError.getMessage(), LDFailure.FailureType.INVALID_RESPONSE_BODY); return oneShot - ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback) - : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback); + ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback, fdv1FallbackTtlSeconds) + : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback, fdv1FallbackTtlSeconds); } case NONE: break; @@ -210,7 +216,7 @@ static FDv2SourceResult doPoll( "FDv2 polling response events produced no changeset", LDFailure.FailureType.INVALID_RESPONSE_BODY); return oneShot - ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback) - : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback); + ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback, fdv1FallbackTtlSeconds) + : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback, fdv1FallbackTtlSeconds); } } 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 b4b351b27..d3e7df43b 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 @@ -28,21 +28,32 @@ final class FDv2PayloadResponse { private final boolean successful; private final int statusCode; private final boolean fdv1Fallback; + private final long fdv1FallbackTtlSeconds; private FDv2PayloadResponse( @Nullable List events, boolean successful, int statusCode, - boolean fdv1Fallback) { + boolean fdv1Fallback, + long fdv1FallbackTtlSeconds) { this.events = events; this.successful = successful; this.statusCode = statusCode; this.fdv1Fallback = fdv1Fallback; + this.fdv1FallbackTtlSeconds = fdv1FallbackTtlSeconds; } /** Creates a successful response with parsed events. */ static FDv2PayloadResponse success(@NonNull List events, int statusCode, boolean fdv1Fallback) { - return new FDv2PayloadResponse(events, true, statusCode, fdv1Fallback); + return success(events, statusCode, fdv1Fallback, 0); + } + + static FDv2PayloadResponse success( + @NonNull List events, + int statusCode, + boolean fdv1Fallback, + long fdv1FallbackTtlSeconds) { + return new FDv2PayloadResponse(events, true, statusCode, fdv1Fallback, fdv1FallbackTtlSeconds); } /** @@ -50,12 +61,20 @@ static FDv2PayloadResponse success(@NonNull List events, int statusCo * last request. */ static FDv2PayloadResponse notModified(boolean fdv1Fallback) { - return new FDv2PayloadResponse(null, true, 304, fdv1Fallback); + return notModified(fdv1Fallback, 0); + } + + static FDv2PayloadResponse notModified(boolean fdv1Fallback, long fdv1FallbackTtlSeconds) { + return new FDv2PayloadResponse(null, true, 304, fdv1Fallback, fdv1FallbackTtlSeconds); } /** Creates an unsuccessful response with the HTTP status code. */ static FDv2PayloadResponse failure(int statusCode, boolean fdv1Fallback) { - return new FDv2PayloadResponse(null, false, statusCode, fdv1Fallback); + return failure(statusCode, fdv1Fallback, 0); + } + + static FDv2PayloadResponse failure(int statusCode, boolean fdv1Fallback, long fdv1FallbackTtlSeconds) { + return new FDv2PayloadResponse(null, false, statusCode, fdv1Fallback, fdv1FallbackTtlSeconds); } /** The parsed FDv2 events; null for 304 or unsuccessful responses. */ @@ -81,6 +100,14 @@ public int getStatusCode() { public boolean isFdv1Fallback() { return fdv1Fallback; } + + /** + * The value of the {@code x-ld-fd-fallback-ttl} header in seconds, or 0 if the header + * was absent or malformed. + */ + public long getFdv1FallbackTtlSeconds() { + return fdv1FallbackTtlSeconds; + } } /** 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 ad6daf6dd..9c851a38c 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 @@ -268,6 +268,13 @@ private static boolean isFdv1Fallback(@Nullable ResponseHeaders headers) { return value != null && value.equalsIgnoreCase("true"); } + private static long fdv1FallbackTtlSeconds(@Nullable ResponseHeaders headers) { + if (headers == null) { + return 0; + } + return FDv1FallbackTtl.parseHeader(headers.value(HeaderConstants.FDV1_FALLBACK_TTL_HEADER)); + } + @VisibleForTesting void handleMessage(MessageEvent event) { String eventName = event.getEventName(); @@ -280,6 +287,7 @@ void handleMessage(MessageEvent event) { } boolean fdv1Fallback = isFdv1Fallback(event.getHeaders()); + long fdv1FallbackTtlSeconds = fdv1FallbackTtlSeconds(event.getHeaders()); FDv2Event fdv2Event; try { @@ -291,7 +299,7 @@ void handleMessage(MessageEvent event) { FDv2SourceResult.Status.interrupted( new LDFailure("Failed to parse SSE event", e, LDFailure.FailureType.INVALID_RESPONSE_BODY)), - fdv1Fallback)); + fdv1Fallback, fdv1FallbackTtlSeconds)); restartStream(true); return; } @@ -305,7 +313,7 @@ void handleMessage(MessageEvent event) { FDv2SourceResult.Status.interrupted( new LDFailure("Protocol handler error", e, LDFailure.FailureType.INVALID_RESPONSE_BODY)), - fdv1Fallback)); + fdv1Fallback, fdv1FallbackTtlSeconds)); restartStream(true); return; } @@ -318,13 +326,13 @@ void handleMessage(MessageEvent event) { FDv2ChangeSetTranslator.toChangeSet(raw, logger); recordStreamInit(false); streamStarted = 0; - resultQueue.put(FDv2SourceResult.changeSet(changeSet, fdv1Fallback)); + resultQueue.put(FDv2SourceResult.changeSet(changeSet, fdv1Fallback, fdv1FallbackTtlSeconds)); } catch (Exception e) { LDUtil.logExceptionAtErrorLevel(logger, e, "Failed to translate changeset"); FDv2SourceResult result = FDv2SourceResult.status(FDv2SourceResult.Status.interrupted( new LDFailure("Failed to translate changeset", e, LDFailure.FailureType.INVALID_RESPONSE_BODY)), - fdv1Fallback); + fdv1Fallback, fdv1FallbackTtlSeconds); resultQueue.put(result); restartStream(true); } @@ -342,7 +350,15 @@ void handleMessage(MessageEvent event) { case GOODBYE: { String reason = ((FDv2ProtocolHandler.FDv2ActionGoodbye) action).getReason(); logger.info("Stream received GOODBYE with reason: '{}'", reason); - resultQueue.put(FDv2SourceResult.status(FDv2SourceResult.Status.goodbye(reason), fdv1Fallback)); + Long goodbyeTtlSeconds = FDv1FallbackTtl.fromGoodbye(fdv2Event.getData()); + if (goodbyeTtlSeconds != 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), true, goodbyeTtlSeconds)); + break; + } + resultQueue.put(FDv2SourceResult.status(FDv2SourceResult.Status.goodbye(reason), fdv1Fallback, fdv1FallbackTtlSeconds)); restartStream(false); break; } @@ -355,7 +371,7 @@ void handleMessage(MessageEvent event) { FDv2SourceResult.Status.interrupted( new LDFailure("FDv2 protocol internal error: " + internalError.getMessage(), LDFailure.FailureType.INVALID_RESPONSE_BODY)), - fdv1Fallback)); + fdv1Fallback, fdv1FallbackTtlSeconds)); // Only restart for invalid-data errors (bad payload or JSON); for unknown events // or protocol sequence violations the stream may still be healthy. FDv2ProtocolHandler.FDv2ProtocolErrorType errorType = internalError.getErrorType(); @@ -397,10 +413,14 @@ private void handleError(FaultEvent event) { protocolHandler.reset(); boolean fdv1Fallback = isFdv1Fallback(event.getHeaders()); + long fdv1FallbackTtlSeconds = fdv1FallbackTtlSeconds(event.getHeaders()); if (t instanceof StreamHttpErrorException) { StreamHttpErrorException httpError = (StreamHttpErrorException) t; fdv1Fallback = fdv1Fallback || isFdv1Fallback(httpError.getHeaders()); + if (fdv1FallbackTtlSeconds == 0) { + fdv1FallbackTtlSeconds = fdv1FallbackTtlSeconds(httpError.getHeaders()); + } int code = httpError.getCode(); boolean recoverable = LDUtil.isHttpErrorRecoverable(code); LDFailure failure = new LDInvalidResponseCodeFailure( @@ -409,7 +429,7 @@ private void handleError(FaultEvent event) { if (!recoverable) { logger.error("Encountered non-retriable error: {}. Aborting connection to stream. Verify correct Mobile Key and Stream URI", code); shutdownFuture.set(FDv2SourceResult.status( - FDv2SourceResult.Status.terminalError(failure), fdv1Fallback)); + FDv2SourceResult.Status.terminalError(failure), fdv1Fallback, fdv1FallbackTtlSeconds)); EventSource es; synchronized (closeLock) { closed = true; @@ -428,7 +448,7 @@ private void handleError(FaultEvent event) { } else { logger.warn("Stream received HTTP error {}; will retry", code); streamStarted = System.currentTimeMillis(); - resultQueue.put(FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback)); + resultQueue.put(FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback, fdv1FallbackTtlSeconds)); } } else { LDUtil.logExceptionAtWarnLevel(logger, t, "Stream network error"); @@ -437,7 +457,7 @@ private void handleError(FaultEvent event) { FDv2SourceResult.Status.interrupted( new LDFailure("Stream network error", t, LDFailure.FailureType.NETWORK_FAILURE)), - fdv1Fallback)); + fdv1Fallback, fdv1FallbackTtlSeconds)); } } 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 35d24fc2d..147334fa8 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 bbbd8adb6..55d7337bb 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 0403c1734..31914f533 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 @@ -75,25 +75,62 @@ public String getReason() { @Nullable private final Status status; private final boolean fdv1Fallback; + private final long fdv1FallbackTtlSeconds; private FDv2SourceResult(@NonNull SourceResultType resultType, @Nullable ChangeSet> changeSet, @Nullable Status status, - boolean fdv1Fallback) { + boolean fdv1Fallback, + long fdv1FallbackTtlSeconds) { this.resultType = resultType; this.changeSet = changeSet; this.status = status; this.fdv1Fallback = fdv1Fallback; + this.fdv1FallbackTtlSeconds = fdv1FallbackTtlSeconds; } @NonNull public static FDv2SourceResult changeSet(@NonNull ChangeSet> changeSet, boolean fdv1Fallback) { - return new FDv2SourceResult(SourceResultType.CHANGE_SET, changeSet, null, fdv1Fallback); + return changeSet(changeSet, fdv1Fallback, 0); + } + + /** + * Creates a change set result. + * + * @param changeSet the change set + * @param fdv1Fallback true if the server directed the SDK to fall back to FDv1 + * @param fdv1FallbackTtlSeconds the fallback TTL supplied by the server, in seconds, or 0 if + * none was supplied + * @return the result + */ + @NonNull + public static FDv2SourceResult changeSet( + @NonNull ChangeSet> changeSet, + boolean fdv1Fallback, + long fdv1FallbackTtlSeconds) { + return new FDv2SourceResult(SourceResultType.CHANGE_SET, changeSet, null, fdv1Fallback, fdv1FallbackTtlSeconds); } @NonNull public static FDv2SourceResult status(@NonNull Status status, boolean fdv1Fallback) { - return new FDv2SourceResult(SourceResultType.STATUS, null, status, fdv1Fallback); + return status(status, fdv1Fallback, 0); + } + + /** + * Creates a status result. + * + * @param status the status + * @param fdv1Fallback true if the server directed the SDK to fall back to FDv1 + * @param fdv1FallbackTtlSeconds the fallback TTL supplied by the server, in seconds, or 0 if + * none was supplied + * @return the result + */ + @NonNull + public static FDv2SourceResult status( + @NonNull Status status, + boolean fdv1Fallback, + long fdv1FallbackTtlSeconds) { + return new FDv2SourceResult(SourceResultType.STATUS, null, status, fdv1Fallback, fdv1FallbackTtlSeconds); } @NonNull @@ -112,10 +149,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; } + + /** + * The fallback TTL supplied by the server, in seconds, via the {@code x-ld-fd-fallback-ttl} + * response header or a goodbye event's {@code protocolFallbackTTL}. This is the raw value; 0 + * means none was supplied. It is only meaningful when {@link #isFdv1Fallback()} is true. + */ + public long getFdv1FallbackTtlSeconds() { + return fdv1FallbackTtlSeconds; + } } 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 a724ea87e..44f6026c2 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,34 @@ 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.getFdv1FallbackTtlSeconds()); + } + } + } + + @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()); + assertEquals(0, response.getFdv1FallbackTtlSeconds()); + } + } + } + @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 000000000..9f8905c30 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv1FallbackTtlTest.java @@ -0,0 +1,80 @@ +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(long ttlSeconds) { + assertTrue("expected a default TTL but got " + ttlSeconds, + ttlSeconds >= MIN_DEFAULT_SECONDS && ttlSeconds <= MAX_DEFAULT_SECONDS); + } + + @Test + public void validServiceTtlIsUsedUnchanged() { + for (long ttl : new long[]{1, 60, 1800, 3599, 3600}) { + assertEquals(ttl, FDv1FallbackTtl.resolveSeconds(ttl)); + } + } + + @Test + public void invalidServiceTtlUsesDefault() { + for (long ttl : new long[]{0, -1, -3600, 3601, Long.MAX_VALUE, Long.MIN_VALUE}) { + assertIsDefault(FDv1FallbackTtl.resolveSeconds(ttl)); + } + } + + @Test + public void defaultIsJittered() { + Set seen = new HashSet<>(); + for (int i = 0; i < 100; i++) { + long ttl = FDv1FallbackTtl.defaultSeconds(); + assertIsDefault(ttl); + seen.add(ttl); + } + assertTrue(seen.size() > 1); + } + + @Test + public void parseHeader() { + assertEquals(120, FDv1FallbackTtl.parseHeader("120")); + assertEquals(-5, FDv1FallbackTtl.parseHeader("-5")); + assertEquals(0, FDv1FallbackTtl.parseHeader(null)); + assertEquals(0, FDv1FallbackTtl.parseHeader("")); + assertEquals(0, FDv1FallbackTtl.parseHeader("soon")); + assertEquals(0, FDv1FallbackTtl.parseHeader("12.5")); + assertEquals(0, FDv1FallbackTtl.parseHeader("99999999999999999999")); + } + + @Test + public void fromGoodbyeWithoutTtlIsNotADirective() { + assertNull(FDv1FallbackTtl.fromGoodbye(null)); + assertNull(FDv1FallbackTtl.fromGoodbye(JsonParser.parseString("{\"reason\":\"x\"}"))); + } + + @Test + public void fromGoodbyeWithTtl() { + assertEquals(Long.valueOf(90), + FDv1FallbackTtl.fromGoodbye(JsonParser.parseString("{\"protocolFallbackTTL\":90}"))); + assertEquals(Long.valueOf(-5), + FDv1FallbackTtl.fromGoodbye(JsonParser.parseString("{\"protocolFallbackTTL\":-5}"))); + } + + @Test + public void fromGoodbyeWithMalformedTtlIsADirectiveWithNoTtl() { + for (String ttl : new String[]{"\"90\"", "12.5", "null", "{}"}) { + assertEquals(Long.valueOf(0), + FDv1FallbackTtl.fromGoodbye(JsonParser.parseString("{\"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 33ecfc61a..a477dd268 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), true, 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), true, 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), true, 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 86852f97e..82aa4886c 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\"}}]}"; @@ -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, false)); + + FDv2SourceResult result = doPoll(requestor, false); + + assertEquals(SourceSignal.GOODBYE, result.getStatus().getState()); + assertTrue(result.isFdv1Fallback()); + assertEquals(90, result.getFdv1FallbackTtlSeconds()); + } + + @Test + public void goodbyeWithoutProtocolFallbackTtlIsNotFdv1Fallback() { + MockFDv2Requestor requestor = new MockFDv2Requestor(); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( + parseEvents(GOODBYE_JSON), 200, false)); + + 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, true, 120)); + + FDv2SourceResult result = doPoll(requestor, false); + + assertTrue(result.isFdv1Fallback()); + assertEquals(120, result.getFdv1FallbackTtlSeconds()); + } } 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 9aa3891d9..5dc57a84f 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.getFdv1FallbackTtlSeconds()); + + 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.getFdv1FallbackTtlSeconds()); + + 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\"}]}"); From 4fe768c34f316ab5fee06ad5ba193df577def3d7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:27:10 +0000 Subject: [PATCH 2/2] refactor: carry FDv1 fallback as a nullable FDv1FallbackTTL value Co-Authored-By: Bee Klimt --- .../sdk/android/DefaultFDv2Requestor.java | 12 +- .../sdk/android/FDv1FallbackTTL.java | 104 ++++++++++++++++++ .../sdk/android/FDv1FallbackTtl.java | 91 --------------- .../sdk/android/FDv2DataSource.java | 4 +- .../sdk/android/FDv2PollingBase.java | 43 ++++---- .../sdk/android/FDv2Requestor.java | 44 +++----- .../android/FDv2StreamingSynchronizer.java | 50 ++++----- .../android/subsystems/FDv2SourceResult.java | 57 +++++----- .../sdk/android/DefaultFDv2RequestorTest.java | 5 +- .../sdk/android/FDv1FallbackTTLTest.java | 73 ++++++++++++ .../sdk/android/FDv1FallbackTtlTest.java | 80 -------------- .../sdk/android/FDv2DataSourceTest.java | 6 +- .../sdk/android/FDv2PollingBaseTest.java | 58 +++++----- .../android/FDv2PollingInitializerTest.java | 14 +-- .../android/FDv2PollingSynchronizerTest.java | 30 ++--- .../FDv2StreamingSynchronizerTest.java | 4 +- 16 files changed, 330 insertions(+), 345 deletions(-) create mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1FallbackTTL.java delete mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1FallbackTtl.java create mode 100644 launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv1FallbackTTLTest.java delete mode 100644 launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv1FallbackTtlTest.java 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 eee554b20..f3a2db8dc 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,13 +187,13 @@ private void handleResponse( @NonNull LDAwaitFuture future) { try { int code = response.code(); - boolean fdv1Fallback = isFdv1Fallback(response); - long fdv1FallbackTtlSeconds = FDv1FallbackTtl.parseHeader( - response.header(HeaderConstants.FDV1_FALLBACK_TTL_HEADER)); + FDv1FallbackTTL fdv1Fallback = isFdv1Fallback(response) + ? FDv1FallbackTTL.fromHeader(response.header(HeaderConstants.FDV1_FALLBACK_TTL_HEADER)) + : null; if (code == 304) { logger.debug("FDv2 polling: 304 Not Modified"); - future.set(FDv2PayloadResponse.notModified(fdv1Fallback, fdv1FallbackTtlSeconds)); + future.set(FDv2PayloadResponse.notModified(fdv1Fallback)); return; } @@ -205,7 +205,7 @@ private void handleResponse( } else { logger.warn("Polling request failed with HTTP {}", code); } - future.set(FDv2PayloadResponse.failure(code, fdv1Fallback, fdv1FallbackTtlSeconds)); + future.set(FDv2PayloadResponse.failure(code, fdv1Fallback)); return; } @@ -224,7 +224,7 @@ private void handleResponse( String bodyStr = body.string(); logger.debug("Polling response received"); List events = FDv2Event.parseEventsArray(bodyStr); - future.set(FDv2PayloadResponse.success(events, code, fdv1Fallback, fdv1FallbackTtlSeconds)); + future.set(FDv2PayloadResponse.success(events, code, fdv1Fallback)); } catch (Exception e) { future.setException(e); 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 000000000..0e1f9482a --- /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/FDv1FallbackTtl.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1FallbackTtl.java deleted file mode 100644 index 98ab24e17..000000000 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1FallbackTtl.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.launchdarkly.sdk.android; - -import androidx.annotation.Nullable; - -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonPrimitive; - -import java.util.Random; - -/** - * Interprets the TTL of an FDv1 fallback directive: how long to stay off FDv2 before - * attempting to recover to it. - *

- * Package-private for internal use by the FDv2 data sources. - */ -final class FDv1FallbackTtl { - /** Used whenever the service supplies no usable TTL. */ - static final long DEFAULT_TTL_SECONDS = 60 * 60; - - private static final String GOODBYE_TTL_FIELD = "protocolFallbackTTL"; - - private static final Random random = new Random(); - - private FDv1FallbackTtl() {} - - /** - * Returns the TTL to use for a service-supplied value, in seconds. A value in - * (0, 1 hour] is used as given, because the service has already jittered it. Any other - * value, including 0 for an absent or malformed TTL, uses the jittered default, so a - * fallback is never indefinite. - */ - static long resolveSeconds(long serviceTtlSeconds) { - if (serviceTtlSeconds > 0 && serviceTtlSeconds <= DEFAULT_TTL_SECONDS) { - return serviceTtlSeconds; - } - return defaultSeconds(); - } - - /** - * Returns the default TTL minus a value drawn uniformly from [0, TTL/2], so that SDKs which - * fell back within the same window do not all re-attempt FDv2 at once. - */ - static long defaultSeconds() { - long jitter; - synchronized (random) { - jitter = (long) (random.nextDouble() * (DEFAULT_TTL_SECONDS / 2 + 1)); - } - return DEFAULT_TTL_SECONDS - jitter; - } - - /** - * Parses the value of an {@code x-ld-fd-fallback-ttl} header as a whole number of seconds. - * Returns 0 if the header is absent or malformed. - */ - static long parseHeader(@Nullable String value) { - if (value == null || value.isEmpty()) { - return 0; - } - try { - return Long.parseLong(value); - } catch (NumberFormatException e) { - return 0; - } - } - - /** - * Reads the {@code protocolFallbackTTL} field of a goodbye event's data. Returns null if the - * field is absent, meaning the goodbye carries no fallback directive. Returns 0 if the field - * is present but not a whole number of seconds. - */ - @Nullable - static Long 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 0L; - } - double seconds = field.getAsDouble(); - if (seconds != Math.rint(seconds) || Double.isInfinite(seconds)) { - return 0L; - } - return (long) 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 9f3629c51..fb047ab71 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 @@ -482,7 +482,7 @@ private void runGeneralInitializers( * FDv2 when the directive's TTL elapses. The fallback is never indefinite. */ private void fallBackToFdv1(@NonNull FDv2SourceResult result) { - long ttlSeconds = FDv1FallbackTtl.resolveSeconds(result.getFdv1FallbackTtlSeconds()); + long ttlSeconds = result.getFdv1Fallback().getSeconds(); logger.info(FDV1_FALLBACK_MESSAGE, ttlSeconds); scheduleFdv2Recovery(ttlSeconds); sourceManager.fdv1Fallback(); @@ -708,7 +708,7 @@ private void runSynchronizers( if (sourceManager.isCurrentSynchronizerFDv1Fallback()) { // Already on FDv1: a newer directive only replaces the TTL. scheduleFdv2Recovery( - FDv1FallbackTtl.resolveSeconds(result.getFdv1FallbackTtlSeconds())); + result.getFdv1Fallback().getSeconds()); } else { fallBackToFdv1(result); running = false; 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 cd784e4a8..5ab35a3dc 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,8 +102,7 @@ static FDv2SourceResult doPoll( : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(cause), false); } - boolean fdv1Fallback = response.isFdv1Fallback(); - long fdv1FallbackTtlSeconds = response.getFdv1FallbackTtlSeconds(); + FDv1FallbackTTL fdv1Fallback = response.getFdv1Fallback(); // 304 Not Modified: nothing changed if (response.getStatusCode() == 304) { @@ -113,7 +112,7 @@ static FDv2SourceResult doPoll( selector, Collections.emptyMap(), null, - true), fdv1Fallback, fdv1FallbackTtlSeconds); + true), fdv1Fallback); } if (!response.isSuccess()) { @@ -123,9 +122,9 @@ static FDv2SourceResult doPoll( LDFailure failure = new LDInvalidResponseCodeFailure( "Polling request failed", null, code, recoverable); if (oneShot || !recoverable) { - return FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback, fdv1FallbackTtlSeconds); + return FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback); } else { - return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback, fdv1FallbackTtlSeconds); + return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback); } } @@ -135,8 +134,8 @@ static FDv2SourceResult doPoll( LDFailure failure = new LDFailure("FDv2 polling response contained no events", LDFailure.FailureType.INVALID_RESPONSE_BODY); return oneShot - ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback, fdv1FallbackTtlSeconds) - : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback, fdv1FallbackTtlSeconds); + ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback) + : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback); } FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); @@ -149,8 +148,8 @@ static FDv2SourceResult doPoll( LDFailure failure = new LDFailure( "FDv2 protocol handler error", e, LDFailure.FailureType.INVALID_RESPONSE_BODY); return oneShot - ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback, fdv1FallbackTtlSeconds) - : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback, fdv1FallbackTtlSeconds); + ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback) + : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback); } switch (action.getAction()) { @@ -160,15 +159,15 @@ static FDv2SourceResult doPoll( try { ChangeSet> changeSet = FDv2ChangeSetTranslator.toChangeSet(raw, logger); - return FDv2SourceResult.changeSet(changeSet, fdv1Fallback, fdv1FallbackTtlSeconds); + return FDv2SourceResult.changeSet(changeSet, fdv1Fallback); } catch (SerializationException e) { LDUtil.logExceptionAtErrorLevel(logger, e, "Polling failed to translate changeset"); LDFailure failure = new LDFailure( "Failed to translate FDv2 polling changeset", e, LDFailure.FailureType.INVALID_RESPONSE_BODY); return oneShot - ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback, fdv1FallbackTtlSeconds) - : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback, fdv1FallbackTtlSeconds); + ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback) + : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback); } } case ERROR: { @@ -180,18 +179,18 @@ static FDv2SourceResult doPoll( "Polling error: " + error.getReason(), LDFailure.FailureType.UNKNOWN_ERROR); return oneShot - ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback, fdv1FallbackTtlSeconds) - : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback, fdv1FallbackTtlSeconds); + ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback) + : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback); } case GOODBYE: { String reason = ((FDv2ProtocolHandler.FDv2ActionGoodbye) action).getReason(); logger.info("Polling received GOODBYE with reason: '{}'", reason); - Long goodbyeTtlSeconds = FDv1FallbackTtl.fromGoodbye(event.getData()); - if (goodbyeTtlSeconds != null) { + FDv1FallbackTTL goodbyeFallback = FDv1FallbackTTL.fromGoodbye(event.getData()); + if (goodbyeFallback != null) { return FDv2SourceResult.status( - FDv2SourceResult.Status.goodbye(reason), true, goodbyeTtlSeconds); + FDv2SourceResult.Status.goodbye(reason), goodbyeFallback); } - return FDv2SourceResult.status(FDv2SourceResult.Status.goodbye(reason), fdv1Fallback, fdv1FallbackTtlSeconds); + return FDv2SourceResult.status(FDv2SourceResult.Status.goodbye(reason), fdv1Fallback); } case INTERNAL_ERROR: { FDv2ProtocolHandler.FDv2ActionInternalError internalError = @@ -202,8 +201,8 @@ static FDv2SourceResult doPoll( "Polling internal error: " + internalError.getMessage(), LDFailure.FailureType.INVALID_RESPONSE_BODY); return oneShot - ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback, fdv1FallbackTtlSeconds) - : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback, fdv1FallbackTtlSeconds); + ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback) + : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback); } case NONE: break; @@ -216,7 +215,7 @@ static FDv2SourceResult doPoll( "FDv2 polling response events produced no changeset", LDFailure.FailureType.INVALID_RESPONSE_BODY); return oneShot - ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback, fdv1FallbackTtlSeconds) - : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback, fdv1FallbackTtlSeconds); + ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback) + : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback); } } 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 d3e7df43b..d3e3b5f59 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,54 +27,39 @@ final class FDv2PayloadResponse { private final List events; private final boolean successful; private final int statusCode; - private final boolean fdv1Fallback; - private final long fdv1FallbackTtlSeconds; + @Nullable + private final FDv1FallbackTTL fdv1Fallback; private FDv2PayloadResponse( @Nullable List events, boolean successful, int statusCode, - boolean fdv1Fallback, - long fdv1FallbackTtlSeconds) { + @Nullable FDv1FallbackTTL fdv1Fallback) { this.events = events; this.successful = successful; this.statusCode = statusCode; this.fdv1Fallback = fdv1Fallback; - this.fdv1FallbackTtlSeconds = fdv1FallbackTtlSeconds; } /** Creates a successful response with parsed events. */ - static FDv2PayloadResponse success(@NonNull List events, int statusCode, boolean fdv1Fallback) { - return success(events, statusCode, fdv1Fallback, 0); - } - static FDv2PayloadResponse success( @NonNull List events, int statusCode, - boolean fdv1Fallback, - long fdv1FallbackTtlSeconds) { - return new FDv2PayloadResponse(events, true, statusCode, fdv1Fallback, fdv1FallbackTtlSeconds); + @Nullable FDv1FallbackTTL fdv1Fallback) { + return new FDv2PayloadResponse(events, true, statusCode, fdv1Fallback); } /** * Creates a successful 304 Not Modified response indicating no change since the * last request. */ - static FDv2PayloadResponse notModified(boolean fdv1Fallback) { - return notModified(fdv1Fallback, 0); - } - - static FDv2PayloadResponse notModified(boolean fdv1Fallback, long fdv1FallbackTtlSeconds) { - return new FDv2PayloadResponse(null, true, 304, fdv1Fallback, fdv1FallbackTtlSeconds); + 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) { - return failure(statusCode, fdv1Fallback, 0); - } - - static FDv2PayloadResponse failure(int statusCode, boolean fdv1Fallback, long fdv1FallbackTtlSeconds) { - return new FDv2PayloadResponse(null, false, statusCode, fdv1Fallback, fdv1FallbackTtlSeconds); + static FDv2PayloadResponse failure(int statusCode, @Nullable FDv1FallbackTTL fdv1Fallback) { + return new FDv2PayloadResponse(null, false, statusCode, fdv1Fallback); } /** The parsed FDv2 events; null for 304 or unsuccessful responses. */ @@ -98,15 +83,16 @@ public int getStatusCode() { * that the SDK should fall back to FDv1 data sources. */ public boolean isFdv1Fallback() { - return fdv1Fallback; + return fdv1Fallback != null; } /** - * The value of the {@code x-ld-fd-fallback-ttl} header in seconds, or 0 if the header - * was absent or malformed. + * 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. */ - public long getFdv1FallbackTtlSeconds() { - return fdv1FallbackTtlSeconds; + @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 9c851a38c..048ced9f1 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,19 +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"); - } - - private static long fdv1FallbackTtlSeconds(@Nullable ResponseHeaders headers) { - if (headers == null) { - return 0; + if (value == null || !value.equalsIgnoreCase("true")) { + return null; } - return FDv1FallbackTtl.parseHeader(headers.value(HeaderConstants.FDV1_FALLBACK_TTL_HEADER)); + return FDv1FallbackTTL.fromHeader(headers.value(HeaderConstants.FDV1_FALLBACK_TTL_HEADER)); } @VisibleForTesting @@ -286,8 +283,7 @@ void handleMessage(MessageEvent event) { return; } - boolean fdv1Fallback = isFdv1Fallback(event.getHeaders()); - long fdv1FallbackTtlSeconds = fdv1FallbackTtlSeconds(event.getHeaders()); + FDv1FallbackTTL fdv1Fallback = fdv1Fallback(event.getHeaders()); FDv2Event fdv2Event; try { @@ -299,7 +295,7 @@ void handleMessage(MessageEvent event) { FDv2SourceResult.Status.interrupted( new LDFailure("Failed to parse SSE event", e, LDFailure.FailureType.INVALID_RESPONSE_BODY)), - fdv1Fallback, fdv1FallbackTtlSeconds)); + fdv1Fallback)); restartStream(true); return; } @@ -313,7 +309,7 @@ void handleMessage(MessageEvent event) { FDv2SourceResult.Status.interrupted( new LDFailure("Protocol handler error", e, LDFailure.FailureType.INVALID_RESPONSE_BODY)), - fdv1Fallback, fdv1FallbackTtlSeconds)); + fdv1Fallback)); restartStream(true); return; } @@ -326,13 +322,13 @@ void handleMessage(MessageEvent event) { FDv2ChangeSetTranslator.toChangeSet(raw, logger); recordStreamInit(false); streamStarted = 0; - resultQueue.put(FDv2SourceResult.changeSet(changeSet, fdv1Fallback, fdv1FallbackTtlSeconds)); + resultQueue.put(FDv2SourceResult.changeSet(changeSet, fdv1Fallback)); } catch (Exception e) { LDUtil.logExceptionAtErrorLevel(logger, e, "Failed to translate changeset"); FDv2SourceResult result = FDv2SourceResult.status(FDv2SourceResult.Status.interrupted( new LDFailure("Failed to translate changeset", e, LDFailure.FailureType.INVALID_RESPONSE_BODY)), - fdv1Fallback, fdv1FallbackTtlSeconds); + fdv1Fallback); resultQueue.put(result); restartStream(true); } @@ -350,15 +346,15 @@ void handleMessage(MessageEvent event) { case GOODBYE: { String reason = ((FDv2ProtocolHandler.FDv2ActionGoodbye) action).getReason(); logger.info("Stream received GOODBYE with reason: '{}'", reason); - Long goodbyeTtlSeconds = FDv1FallbackTtl.fromGoodbye(fdv2Event.getData()); - if (goodbyeTtlSeconds != null) { + 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), true, goodbyeTtlSeconds)); + FDv2SourceResult.Status.goodbye(reason), goodbyeFallback)); break; } - resultQueue.put(FDv2SourceResult.status(FDv2SourceResult.Status.goodbye(reason), fdv1Fallback, fdv1FallbackTtlSeconds)); + resultQueue.put(FDv2SourceResult.status(FDv2SourceResult.Status.goodbye(reason), fdv1Fallback)); restartStream(false); break; } @@ -371,7 +367,7 @@ void handleMessage(MessageEvent event) { FDv2SourceResult.Status.interrupted( new LDFailure("FDv2 protocol internal error: " + internalError.getMessage(), LDFailure.FailureType.INVALID_RESPONSE_BODY)), - fdv1Fallback, fdv1FallbackTtlSeconds)); + fdv1Fallback)); // Only restart for invalid-data errors (bad payload or JSON); for unknown events // or protocol sequence violations the stream may still be healthy. FDv2ProtocolHandler.FDv2ProtocolErrorType errorType = internalError.getErrorType(); @@ -412,14 +408,12 @@ private void handleError(FaultEvent event) { recordStreamInit(true); protocolHandler.reset(); - boolean fdv1Fallback = isFdv1Fallback(event.getHeaders()); - long fdv1FallbackTtlSeconds = fdv1FallbackTtlSeconds(event.getHeaders()); + FDv1FallbackTTL fdv1Fallback = fdv1Fallback(event.getHeaders()); if (t instanceof StreamHttpErrorException) { StreamHttpErrorException httpError = (StreamHttpErrorException) t; - fdv1Fallback = fdv1Fallback || isFdv1Fallback(httpError.getHeaders()); - if (fdv1FallbackTtlSeconds == 0) { - fdv1FallbackTtlSeconds = fdv1FallbackTtlSeconds(httpError.getHeaders()); + if (fdv1Fallback == null) { + fdv1Fallback = fdv1Fallback(httpError.getHeaders()); } int code = httpError.getCode(); boolean recoverable = LDUtil.isHttpErrorRecoverable(code); @@ -429,7 +423,7 @@ private void handleError(FaultEvent event) { if (!recoverable) { logger.error("Encountered non-retriable error: {}. Aborting connection to stream. Verify correct Mobile Key and Stream URI", code); shutdownFuture.set(FDv2SourceResult.status( - FDv2SourceResult.Status.terminalError(failure), fdv1Fallback, fdv1FallbackTtlSeconds)); + FDv2SourceResult.Status.terminalError(failure), fdv1Fallback)); EventSource es; synchronized (closeLock) { closed = true; @@ -448,7 +442,7 @@ private void handleError(FaultEvent event) { } else { logger.warn("Stream received HTTP error {}; will retry", code); streamStarted = System.currentTimeMillis(); - resultQueue.put(FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback, fdv1FallbackTtlSeconds)); + resultQueue.put(FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback)); } } else { LDUtil.logExceptionAtWarnLevel(logger, t, "Stream network error"); @@ -457,7 +451,7 @@ private void handleError(FaultEvent event) { FDv2SourceResult.Status.interrupted( new LDFailure("Stream network error", t, LDFailure.FailureType.NETWORK_FAILURE)), - fdv1Fallback, fdv1FallbackTtlSeconds)); + fdv1Fallback)); } } 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 31914f533..660a28ac8 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,63 +75,61 @@ public String getReason() { private final ChangeSet> changeSet; @Nullable private final Status status; - private final boolean fdv1Fallback; - private final long fdv1FallbackTtlSeconds; + @Nullable + private final FDv1FallbackTTL fdv1Fallback; private FDv2SourceResult(@NonNull SourceResultType resultType, @Nullable ChangeSet> changeSet, @Nullable Status status, - boolean fdv1Fallback, - long fdv1FallbackTtlSeconds) { + @Nullable FDv1FallbackTTL fdv1Fallback) { this.resultType = resultType; this.changeSet = changeSet; this.status = status; this.fdv1Fallback = fdv1Fallback; - this.fdv1FallbackTtlSeconds = fdv1FallbackTtlSeconds; } + /** + * 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, 0); + return changeSet(changeSet, fdv1Fallback ? FDv1FallbackTTL.defaultTTL() : null); } /** * Creates a change set result. * - * @param changeSet the change set - * @param fdv1Fallback true if the server directed the SDK to fall back to FDv1 - * @param fdv1FallbackTtlSeconds the fallback TTL supplied by the server, in seconds, or 0 if - * none was supplied + * @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, - boolean fdv1Fallback, - long fdv1FallbackTtlSeconds) { - return new FDv2SourceResult(SourceResultType.CHANGE_SET, changeSet, null, fdv1Fallback, fdv1FallbackTtlSeconds); + @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, 0); + return status(status, fdv1Fallback ? FDv1FallbackTTL.defaultTTL() : null); } /** * Creates a status result. * - * @param status the status - * @param fdv1Fallback true if the server directed the SDK to fall back to FDv1 - * @param fdv1FallbackTtlSeconds the fallback TTL supplied by the server, in seconds, or 0 if - * none was supplied + * @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, - boolean fdv1Fallback, - long fdv1FallbackTtlSeconds) { - return new FDv2SourceResult(SourceResultType.STATUS, null, status, fdv1Fallback, fdv1FallbackTtlSeconds); + public static FDv2SourceResult status(@NonNull Status status, @Nullable FDv1FallbackTTL fdv1Fallback) { + return new FDv2SourceResult(SourceResultType.STATUS, null, status, fdv1Fallback); } @NonNull @@ -153,15 +152,15 @@ public Status getStatus() { * via the {@code x-ld-fd-fallback} response header or a goodbye event. */ public boolean isFdv1Fallback() { - return fdv1Fallback; + return fdv1Fallback != null; } /** - * The fallback TTL supplied by the server, in seconds, via the {@code x-ld-fd-fallback-ttl} - * response header or a goodbye event's {@code protocolFallbackTTL}. This is the raw value; 0 - * means none was supplied. It is only meaningful when {@link #isFdv1Fallback()} is true. + * The TTL of the server's FDv1 fallback directive, or null if {@link #isFdv1Fallback()} is + * false. */ - public long getFdv1FallbackTtlSeconds() { - return fdv1FallbackTtlSeconds; + @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 44f6026c2..b6146a718 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 @@ -453,7 +453,7 @@ public void fdv1FallbackTtlHeaderParsed() throws Exception { try (DefaultFDv2Requestor requestor = makeRequestor(server)) { FDv2Requestor.FDv2PayloadResponse response = requestor.poll(Selector.EMPTY).get(5, TimeUnit.SECONDS); assertTrue(response.isFdv1Fallback()); - assertEquals(120, response.getFdv1FallbackTtlSeconds()); + assertEquals(120, response.getFdv1Fallback().getSeconds()); } } } @@ -467,7 +467,8 @@ public void fdv1FallbackTtlHeaderMalformed() throws Exception { try (DefaultFDv2Requestor requestor = makeRequestor(server)) { FDv2Requestor.FDv2PayloadResponse response = requestor.poll(Selector.EMPTY).get(5, TimeUnit.SECONDS); assertTrue(response.isFdv1Fallback()); - assertEquals(0, response.getFdv1FallbackTtlSeconds()); + long seconds = response.getFdv1Fallback().getSeconds(); + assertTrue(seconds >= 1800 && seconds <= 3600); } } } 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 000000000..51706946a --- /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/FDv1FallbackTtlTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv1FallbackTtlTest.java deleted file mode 100644 index 9f8905c30..000000000 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv1FallbackTtlTest.java +++ /dev/null @@ -1,80 +0,0 @@ -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(long ttlSeconds) { - assertTrue("expected a default TTL but got " + ttlSeconds, - ttlSeconds >= MIN_DEFAULT_SECONDS && ttlSeconds <= MAX_DEFAULT_SECONDS); - } - - @Test - public void validServiceTtlIsUsedUnchanged() { - for (long ttl : new long[]{1, 60, 1800, 3599, 3600}) { - assertEquals(ttl, FDv1FallbackTtl.resolveSeconds(ttl)); - } - } - - @Test - public void invalidServiceTtlUsesDefault() { - for (long ttl : new long[]{0, -1, -3600, 3601, Long.MAX_VALUE, Long.MIN_VALUE}) { - assertIsDefault(FDv1FallbackTtl.resolveSeconds(ttl)); - } - } - - @Test - public void defaultIsJittered() { - Set seen = new HashSet<>(); - for (int i = 0; i < 100; i++) { - long ttl = FDv1FallbackTtl.defaultSeconds(); - assertIsDefault(ttl); - seen.add(ttl); - } - assertTrue(seen.size() > 1); - } - - @Test - public void parseHeader() { - assertEquals(120, FDv1FallbackTtl.parseHeader("120")); - assertEquals(-5, FDv1FallbackTtl.parseHeader("-5")); - assertEquals(0, FDv1FallbackTtl.parseHeader(null)); - assertEquals(0, FDv1FallbackTtl.parseHeader("")); - assertEquals(0, FDv1FallbackTtl.parseHeader("soon")); - assertEquals(0, FDv1FallbackTtl.parseHeader("12.5")); - assertEquals(0, FDv1FallbackTtl.parseHeader("99999999999999999999")); - } - - @Test - public void fromGoodbyeWithoutTtlIsNotADirective() { - assertNull(FDv1FallbackTtl.fromGoodbye(null)); - assertNull(FDv1FallbackTtl.fromGoodbye(JsonParser.parseString("{\"reason\":\"x\"}"))); - } - - @Test - public void fromGoodbyeWithTtl() { - assertEquals(Long.valueOf(90), - FDv1FallbackTtl.fromGoodbye(JsonParser.parseString("{\"protocolFallbackTTL\":90}"))); - assertEquals(Long.valueOf(-5), - FDv1FallbackTtl.fromGoodbye(JsonParser.parseString("{\"protocolFallbackTTL\":-5}"))); - } - - @Test - public void fromGoodbyeWithMalformedTtlIsADirectiveWithNoTtl() { - for (String ttl : new String[]{"\"90\"", "12.5", "null", "{}"}) { - assertEquals(Long.valueOf(0), - FDv1FallbackTtl.fromGoodbye(JsonParser.parseString("{\"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 a477dd268..eb0c6604f 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 @@ -1750,7 +1750,7 @@ public void fdv1FallbackWithoutFdv1SynchronizerRecoversToFdv2AfterTtl() throws E MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); MockQueuedSynchronizer firstFdv2Sync = new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(true), true, 1)); + FDv2SourceResult.changeSet(makeChangeSet(true), FDv1FallbackTTL.fromHeader("1"))); MockQueuedSynchronizer secondFdv2Sync = new MockQueuedSynchronizer( FDv2SourceResult.changeSet(makeChangeSet(true), false)); @@ -1780,7 +1780,7 @@ public void fdv1FallbackRecoversToFdv2AfterTtl() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); MockQueuedSynchronizer firstFdv2Sync = new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(true), true, 1)); + FDv2SourceResult.changeSet(makeChangeSet(true), FDv1FallbackTTL.fromHeader("1"))); MockQueuedSynchronizer secondFdv2Sync = new MockQueuedSynchronizer( FDv2SourceResult.changeSet(makeChangeSet(true), false)); @@ -1821,7 +1821,7 @@ public void fdv1FallbackDuringInitializationWithoutFdv1SynchronizerStartsFdv2Aft FDv2DataSource dataSource = buildDataSource(sink, Collections.>singletonList( - () -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), true, 1))), + () -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), FDv1FallbackTTL.fromHeader("1")))), Collections.>singletonList(() -> fdv2Sync)); AwaitableCallback startCallback = startDataSource(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 82aa4886c..f562a2339 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 @@ -94,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); @@ -107,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); @@ -122,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); @@ -134,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); @@ -146,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); @@ -159,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); @@ -200,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); @@ -213,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); @@ -229,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); @@ -242,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); @@ -257,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); @@ -273,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); @@ -286,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); @@ -301,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); @@ -313,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); @@ -335,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); @@ -354,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); @@ -373,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); @@ -388,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); @@ -403,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); @@ -417,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); @@ -429,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); @@ -440,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); @@ -452,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); @@ -465,20 +465,20 @@ public void fdv1FallbackPropagatedOnGoodbye() { public void goodbyeWithProtocolFallbackTtlIsFdv1Fallback() { MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(GOODBYE_WITH_FALLBACK_TTL_JSON), 200, false)); + 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.getFdv1FallbackTtlSeconds()); + assertEquals(90, result.getFdv1Fallback().getSeconds()); } @Test public void goodbyeWithoutProtocolFallbackTtlIsNotFdv1Fallback() { MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(GOODBYE_JSON), 200, false)); + parseEvents(GOODBYE_JSON), 200, null)); FDv2SourceResult result = doPoll(requestor, false); @@ -489,11 +489,11 @@ public void goodbyeWithoutProtocolFallbackTtlIsNotFdv1Fallback() { @Test public void fdv1FallbackTtlPropagatedFromHeader() { MockFDv2Requestor requestor = new MockFDv2Requestor(); - requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.failure(500, true, 120)); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.failure(500, FDv1FallbackTTL.fromHeader("120"))); FDv2SourceResult result = doPoll(requestor, false); assertTrue(result.isFdv1Fallback()); - assertEquals(120, result.getFdv1FallbackTtlSeconds()); + 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 acab29188..95f5f2b5c 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 ffa8cfd20..b6c4070b1 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 5dc57a84f..c9e40ccd1 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 @@ -1245,7 +1245,7 @@ public void fdv1FallbackTtlHeaderOnMessageEvent() throws Exception { FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); assertTrue(result.isFdv1Fallback()); - assertEquals(120, result.getFdv1FallbackTtlSeconds()); + assertEquals(120, result.getFdv1Fallback().getSeconds()); sync.close(); } @@ -1265,7 +1265,7 @@ public void goodbyeWithProtocolFallbackTtlIsFdv1Fallback() throws Exception { assertEquals(SourceSignal.GOODBYE, result.getStatus().getState()); assertTrue(result.isFdv1Fallback()); - assertEquals(90, result.getFdv1FallbackTtlSeconds()); + assertEquals(90, result.getFdv1Fallback().getSeconds()); sync.close(); }