From b38910f5560e72460aae8934415a8b9ca14c3444 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 31 Mar 2026 09:38:14 -0700 Subject: [PATCH 01/23] [SDK-1829] chore: add fdv1Fallback field to FDv2 result types Add an fdv1Fallback boolean to FDv2SourceResult and FDv2PayloadResponse so that FDv2 data sources can signal when the server's x-ld-fd-fallback header indicates a fallback to FDv1. Existing factory methods retain their signatures and default to false; new overloads accept the flag explicitly. Made-with: Cursor --- .../sdk/android/FDv2Requestor.java | 29 ++++++++++++++++--- .../sdk/android/HeaderConstants.java | 8 +++++ .../android/subsystems/FDv2SourceResult.java | 29 +++++++++++++++++-- 3 files changed, 59 insertions(+), 7 deletions(-) create mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HeaderConstants.java 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 302a5db5..f42b22f3 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,19 +27,27 @@ final class FDv2PayloadResponse { private final List events; private final boolean successful; private final int statusCode; + private final boolean fdv1Fallback; private FDv2PayloadResponse( @Nullable List events, boolean successful, - int statusCode) { + int statusCode, + boolean fdv1Fallback) { this.events = events; this.successful = successful; this.statusCode = statusCode; + this.fdv1Fallback = fdv1Fallback; } /** Creates a successful response with parsed events. */ static FDv2PayloadResponse success(@NonNull List events, int statusCode) { - return new FDv2PayloadResponse(events, true, statusCode); + return new FDv2PayloadResponse(events, true, statusCode, false); + } + + /** Creates a successful response with parsed events and an FDv1 fallback indicator. */ + static FDv2PayloadResponse success(@NonNull List events, int statusCode, boolean fdv1Fallback) { + return new FDv2PayloadResponse(events, true, statusCode, fdv1Fallback); } /** @@ -47,12 +55,17 @@ static FDv2PayloadResponse success(@NonNull List events, int statusCo * last request. */ static FDv2PayloadResponse notModified() { - return new FDv2PayloadResponse(null, true, 304); + return new FDv2PayloadResponse(null, true, 304, false); } /** Creates an unsuccessful response with the HTTP status code. */ static FDv2PayloadResponse failure(int statusCode) { - return new FDv2PayloadResponse(null, false, statusCode); + return new FDv2PayloadResponse(null, false, statusCode, false); + } + + /** Creates an unsuccessful response with the HTTP status code and an FDv1 fallback indicator. */ + static FDv2PayloadResponse failure(int statusCode, boolean fdv1Fallback) { + return new FDv2PayloadResponse(null, false, statusCode, fdv1Fallback); } /** The parsed FDv2 events; null for 304 or unsuccessful responses. */ @@ -70,6 +83,14 @@ public boolean isSuccess() { public int getStatusCode() { return statusCode; } + + /** + * True if the server sent the {@code x-ld-fd-fallback: true} header, indicating + * that the SDK should fall back to FDv1 data sources. + */ + public boolean isFdv1Fallback() { + return fdv1Fallback; + } } /** 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 new file mode 100644 index 00000000..35d24fc2 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HeaderConstants.java @@ -0,0 +1,8 @@ +package com.launchdarkly.sdk.android; + +/** + * HTTP header constants used by FDv2 data sources. + */ +abstract class HeaderConstants { + static final String FDV1_FALLBACK_HEADER = "x-ld-fd-fallback"; +} 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 17225222..e1e5477c 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 @@ -74,21 +74,36 @@ public String getReason() { private final ChangeSet> changeSet; @Nullable private final Status status; + private final boolean fdv1Fallback; - private FDv2SourceResult(@NonNull SourceResultType resultType, @Nullable ChangeSet> changeSet, @Nullable Status status) { + private FDv2SourceResult(@NonNull SourceResultType resultType, + @Nullable ChangeSet> changeSet, + @Nullable Status status, + boolean fdv1Fallback) { this.resultType = resultType; this.changeSet = changeSet; this.status = status; + this.fdv1Fallback = fdv1Fallback; } @NonNull public static FDv2SourceResult changeSet(@NonNull ChangeSet> changeSet) { - return new FDv2SourceResult(SourceResultType.CHANGE_SET, changeSet, null); + return new FDv2SourceResult(SourceResultType.CHANGE_SET, changeSet, null, false); + } + + @NonNull + public static FDv2SourceResult changeSet(@NonNull ChangeSet> changeSet, boolean fdv1Fallback) { + return new FDv2SourceResult(SourceResultType.CHANGE_SET, changeSet, null, fdv1Fallback); } @NonNull public static FDv2SourceResult status(@NonNull Status status) { - return new FDv2SourceResult(SourceResultType.STATUS, null, status); + return new FDv2SourceResult(SourceResultType.STATUS, null, status, false); + } + + @NonNull + public static FDv2SourceResult status(@NonNull Status status, boolean fdv1Fallback) { + return new FDv2SourceResult(SourceResultType.STATUS, null, status, fdv1Fallback); } @NonNull @@ -105,4 +120,12 @@ public ChangeSet> getChangeSet() { public Status getStatus() { return status; } + + /** + * True if the server indicated that the SDK should fall back to FDv1 data sources + * via the {@code x-ld-fd-fallback} response header. + */ + public boolean isFdv1Fallback() { + return fdv1Fallback; + } } From e2bbb3dd9f75c5ba804e1da8a797da72f6746aed Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 31 Mar 2026 09:38:31 -0700 Subject: [PATCH 02/23] [SDK-1829] chore: add fdv1FallbackSynchronizers to mode definitions Extend ModeDefinition and ResolvedModeDefinition with a separate list of FDv1 fallback synchronizer factories. These are kept distinct from the regular synchronizer list so that the orchestration layer can manage their blocked/unblocked state independently. The existing two-argument constructor delegates to the new three-argument form with an empty fallback list, preserving backward compatibility. Made-with: Cursor --- .../launchdarkly/sdk/android/ModeDefinition.java | 15 +++++++++++++++ .../sdk/android/ResolvedModeDefinition.java | 10 +++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java index 81d69b8f..13b54cda 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java @@ -27,13 +27,23 @@ final class ModeDefinition { private final List> initializers; private final List> synchronizers; + private final List> fdv1FallbackSynchronizers; ModeDefinition( @NonNull List> initializers, @NonNull List> synchronizers + ) { + this(initializers, synchronizers, Collections.>emptyList()); + } + + ModeDefinition( + @NonNull List> initializers, + @NonNull List> synchronizers, + @NonNull List> fdv1FallbackSynchronizers ) { this.initializers = Collections.unmodifiableList(initializers); this.synchronizers = Collections.unmodifiableList(synchronizers); + this.fdv1FallbackSynchronizers = Collections.unmodifiableList(fdv1FallbackSynchronizers); } @NonNull @@ -45,4 +55,9 @@ List> getInitializers() { List> getSynchronizers() { return synchronizers; } + + @NonNull + List> getFdv1FallbackSynchronizers() { + return fdv1FallbackSynchronizers; + } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java index e404a5ac..314f903f 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java @@ -24,13 +24,16 @@ final class ResolvedModeDefinition { private final List> initializerFactories; private final List> synchronizerFactories; + private final List> fdv1FallbackSynchronizerFactories; ResolvedModeDefinition( @NonNull List> initializerFactories, - @NonNull List> synchronizerFactories + @NonNull List> synchronizerFactories, + @NonNull List> fdv1FallbackSynchronizerFactories ) { this.initializerFactories = Collections.unmodifiableList(initializerFactories); this.synchronizerFactories = Collections.unmodifiableList(synchronizerFactories); + this.fdv1FallbackSynchronizerFactories = Collections.unmodifiableList(fdv1FallbackSynchronizerFactories); } @NonNull @@ -42,4 +45,9 @@ List> getInitializerFactories() { List> getSynchronizerFactories() { return synchronizerFactories; } + + @NonNull + List> getFdv1FallbackSynchronizerFactories() { + return fdv1FallbackSynchronizerFactories; + } } From 425906c1592d84259cba1b2c70fd7e0010b8cba4 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 31 Mar 2026 09:38:48 -0700 Subject: [PATCH 03/23] [SDK-1829] chore: detect x-ld-fd-fallback header in FDv2 transport layer Read the x-ld-fd-fallback response header in the polling requestor and streaming synchronizer, and propagate the fallback signal through every FDv2SourceResult produced by these components. The header is checked case-insensitively and carried on both success and error paths so the orchestration layer can act on it regardless of how the response was classified. Made-with: Cursor --- .../sdk/android/DefaultFDv2Requestor.java | 10 +- .../sdk/android/FDv2PollingBase.java | 34 +++--- .../android/FDv2StreamingSynchronizer.java | 40 +++++-- .../sdk/android/DefaultFDv2RequestorTest.java | 63 ++++++++++ .../sdk/android/FDv2PollingBaseTest.java | 52 +++++++++ .../FDv2StreamingSynchronizerTest.java | 110 ++++++++++++++++++ 6 files changed, 281 insertions(+), 28 deletions(-) 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 fc306bb4..652b9d43 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 @@ -177,11 +177,17 @@ public void onResponse(@NonNull Call call, @NonNull Response response) { return future; } + private static boolean isFdv1Fallback(@NonNull Response response) { + String value = response.header(HeaderConstants.FDV1_FALLBACK_HEADER); + return value != null && value.equalsIgnoreCase("true"); + } + private void handleResponse( @NonNull Response response, @NonNull LDAwaitFuture future) { try { int code = response.code(); + boolean fdv1Fallback = isFdv1Fallback(response); if (code == 304) { logger.debug("FDv2 polling: 304 Not Modified"); @@ -197,7 +203,7 @@ private void handleResponse( } else { logger.warn("Polling request failed with HTTP {}", code); } - future.set(FDv2PayloadResponse.failure(code)); + future.set(FDv2PayloadResponse.failure(code, fdv1Fallback)); return; } @@ -216,7 +222,7 @@ private void handleResponse( String bodyStr = body.string(); logger.debug("Polling response received"); List events = FDv2Event.parseEventsArray(bodyStr); - future.set(FDv2PayloadResponse.success(events, code)); + 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/FDv2PollingBase.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingBase.java index 1f8781e4..7a77070c 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,6 +102,8 @@ static FDv2SourceResult doPoll( : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(cause)); } + boolean fdv1Fallback = response.isFdv1Fallback(); + // 304 Not Modified: nothing changed if (response.getStatusCode() == 304) { logger.debug("Polling got 304 Not Modified"); @@ -120,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)); + return FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback); } else { - return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure)); + return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback); } } @@ -132,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)) - : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure)); + ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback) + : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback); } FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); @@ -146,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)) - : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure)); + ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback) + : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback); } switch (action.getAction()) { @@ -157,15 +159,15 @@ static FDv2SourceResult doPoll( try { ChangeSet> changeSet = FDv2ChangeSetTranslator.toChangeSet(raw, logger); - return FDv2SourceResult.changeSet(changeSet); + 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)) - : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure)); + ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback) + : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback); } } case ERROR: { @@ -177,13 +179,13 @@ static FDv2SourceResult doPoll( "Polling error: " + error.getReason(), LDFailure.FailureType.UNKNOWN_ERROR); return oneShot - ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure)) - : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure)); + ? 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); - return FDv2SourceResult.status(FDv2SourceResult.Status.goodbye(reason)); + return FDv2SourceResult.status(FDv2SourceResult.Status.goodbye(reason), fdv1Fallback); } case INTERNAL_ERROR: { FDv2ProtocolHandler.FDv2ActionInternalError internalError = @@ -194,8 +196,8 @@ static FDv2SourceResult doPoll( "Polling internal error: " + internalError.getMessage(), LDFailure.FailureType.INVALID_RESPONSE_BODY); return oneShot - ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure)) - : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure)); + ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback) + : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback); } case NONE: break; @@ -208,7 +210,7 @@ static FDv2SourceResult doPoll( "FDv2 polling response events produced no changeset", LDFailure.FailureType.INVALID_RESPONSE_BODY); return oneShot - ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure)) - : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure)); + ? 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/FDv2StreamingSynchronizer.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizer.java index 71d20339..55b8f23d 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 @@ -13,6 +13,7 @@ import com.launchdarkly.eventsource.RetryDelayStrategy; import com.launchdarkly.eventsource.StreamClosedByCallerException; import com.launchdarkly.eventsource.StreamEvent; +import com.launchdarkly.eventsource.ResponseHeaders; import com.launchdarkly.eventsource.StreamException; import com.launchdarkly.eventsource.StreamHttpErrorException; import com.launchdarkly.logging.LDLogger; @@ -258,6 +259,14 @@ private void recordStreamInit(boolean failed) { } } + private static boolean isFdv1Fallback(@Nullable ResponseHeaders headers) { + if (headers == null) { + return false; + } + String value = headers.value(HeaderConstants.FDV1_FALLBACK_HEADER); + return value != null && value.equalsIgnoreCase("true"); + } + @VisibleForTesting void handleMessage(MessageEvent event) { String eventName = event.getEventName(); @@ -269,6 +278,8 @@ void handleMessage(MessageEvent event) { return; } + boolean fdv1Fallback = isFdv1Fallback(event.getHeaders()); + FDv2Event fdv2Event; try { fdv2Event = new FDv2Event(eventName, @@ -278,7 +289,8 @@ void handleMessage(MessageEvent event) { resultQueue.put(FDv2SourceResult.status( FDv2SourceResult.Status.interrupted( new LDFailure("Failed to parse SSE event", e, - LDFailure.FailureType.INVALID_RESPONSE_BODY)))); + LDFailure.FailureType.INVALID_RESPONSE_BODY)), + fdv1Fallback)); restartStream(true); return; } @@ -291,7 +303,8 @@ void handleMessage(MessageEvent event) { resultQueue.put(FDv2SourceResult.status( FDv2SourceResult.Status.interrupted( new LDFailure("Protocol handler error", e, - LDFailure.FailureType.INVALID_RESPONSE_BODY)))); + LDFailure.FailureType.INVALID_RESPONSE_BODY)), + fdv1Fallback)); restartStream(true); return; } @@ -304,12 +317,13 @@ void handleMessage(MessageEvent event) { FDv2ChangeSetTranslator.toChangeSet(raw, logger); recordStreamInit(false); streamStarted = 0; - resultQueue.put(FDv2SourceResult.changeSet(changeSet)); + 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))); + LDFailure.FailureType.INVALID_RESPONSE_BODY)), + fdv1Fallback); resultQueue.put(result); restartStream(true); } @@ -327,7 +341,7 @@ 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))); + resultQueue.put(FDv2SourceResult.status(FDv2SourceResult.Status.goodbye(reason), fdv1Fallback)); restartStream(false); break; } @@ -339,7 +353,8 @@ void handleMessage(MessageEvent event) { resultQueue.put(FDv2SourceResult.status( FDv2SourceResult.Status.interrupted( new LDFailure("FDv2 protocol internal error: " + internalError.getMessage(), - LDFailure.FailureType.INVALID_RESPONSE_BODY)))); + LDFailure.FailureType.INVALID_RESPONSE_BODY)), + 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(); @@ -380,8 +395,12 @@ private void handleError(FaultEvent event) { recordStreamInit(true); protocolHandler.reset(); + boolean fdv1Fallback = isFdv1Fallback(event.getHeaders()); + if (t instanceof StreamHttpErrorException) { - int code = ((StreamHttpErrorException) t).getCode(); + StreamHttpErrorException httpError = (StreamHttpErrorException) t; + fdv1Fallback = fdv1Fallback || isFdv1Fallback(httpError.getHeaders()); + int code = httpError.getCode(); boolean recoverable = LDUtil.isHttpErrorRecoverable(code); LDFailure failure = new LDInvalidResponseCodeFailure( "Unexpected response code from stream", t, code, recoverable); @@ -389,7 +408,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))); + FDv2SourceResult.Status.terminalError(failure), fdv1Fallback)); EventSource es; synchronized (closeLock) { closed = true; @@ -408,7 +427,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))); + resultQueue.put(FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback)); } } else { LDUtil.logExceptionAtWarnLevel(logger, t, "Stream network error"); @@ -416,7 +435,8 @@ private void handleError(FaultEvent event) { resultQueue.put(FDv2SourceResult.status( FDv2SourceResult.Status.interrupted( new LDFailure("Stream network error", t, - LDFailure.FailureType.NETWORK_FAILURE)))); + LDFailure.FailureType.NETWORK_FAILURE)), + 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 59a83788..a724ea87 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 @@ -429,6 +429,69 @@ public void payloadFilterNotAddedWhenEmpty() throws Exception { } } + // ---- x-ld-fd-fallback header detection ---- + + @Test + public void fdv1FallbackHeaderOnSuccess() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.header("x-ld-fd-fallback", "true"), + Handlers.bodyJson(VALID_EVENTS_JSON)))) { + try (DefaultFDv2Requestor requestor = makeRequestor(server)) { + FDv2Requestor.FDv2PayloadResponse response = requestor.poll(Selector.EMPTY).get(5, TimeUnit.SECONDS); + assertTrue(response.isSuccess()); + assertTrue(response.isFdv1Fallback()); + } + } + } + + @Test + public void fdv1FallbackHeaderAbsent() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.bodyJson(VALID_EVENTS_JSON))) { + try (DefaultFDv2Requestor requestor = makeRequestor(server)) { + FDv2Requestor.FDv2PayloadResponse response = requestor.poll(Selector.EMPTY).get(5, TimeUnit.SECONDS); + assertTrue(response.isSuccess()); + assertFalse(response.isFdv1Fallback()); + } + } + } + + @Test + public void fdv1FallbackHeaderCaseInsensitive() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.header("x-ld-fd-fallback", "True"), + Handlers.bodyJson(VALID_EVENTS_JSON)))) { + try (DefaultFDv2Requestor requestor = makeRequestor(server)) { + FDv2Requestor.FDv2PayloadResponse response = requestor.poll(Selector.EMPTY).get(5, TimeUnit.SECONDS); + assertTrue(response.isFdv1Fallback()); + } + } + } + + @Test + public void fdv1FallbackHeaderOnFailure() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.header("x-ld-fd-fallback", "true"), + Handlers.status(500)))) { + try (DefaultFDv2Requestor requestor = makeRequestor(server)) { + FDv2Requestor.FDv2PayloadResponse response = requestor.poll(Selector.EMPTY).get(5, TimeUnit.SECONDS); + assertFalse(response.isSuccess()); + assertTrue(response.isFdv1Fallback()); + } + } + } + + @Test + public void fdv1FallbackHeaderFalseValue() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.header("x-ld-fd-fallback", "false"), + Handlers.bodyJson(VALID_EVENTS_JSON)))) { + try (DefaultFDv2Requestor requestor = makeRequestor(server)) { + FDv2Requestor.FDv2PayloadResponse response = requestor.poll(Selector.EMPTY).get(5, TimeUnit.SECONDS); + assertFalse(response.isFdv1Fallback()); + } + } + } + @Test public void evaluationReasonsAddedToRequest() throws Exception { try (HttpServer server = HttpServer.start(Handlers.bodyJson(EMPTY_EVENTS_JSON))) { 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 eb24c43f..367dab76 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 @@ -18,7 +18,9 @@ import java.util.List; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; /** * Unit tests for {@link FDv2PollingBase#doPoll(FDv2Requestor, LDLogger, Selector, boolean)}. @@ -404,4 +406,54 @@ public void selectorIsPassedToRequestor() { assertEquals(1, requestor.receivedSelectors.size()); assertEquals(selector, requestor.receivedSelectors.poll()); } + + // ---- fdv1Fallback propagation ---- + + @Test + public void fdv1FallbackPropagatedOnSuccess() { + MockFDv2Requestor requestor = new MockFDv2Requestor(); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( + parseEvents(XFER_FULL_EMPTY_JSON), 200, true)); + + FDv2SourceResult result = doPoll(requestor, false); + + assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); + assertTrue(result.isFdv1Fallback()); + } + + @Test + public void fdv1FallbackFalseByDefault() { + MockFDv2Requestor requestor = new MockFDv2Requestor(); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( + parseEvents(XFER_FULL_EMPTY_JSON), 200)); + + FDv2SourceResult result = doPoll(requestor, false); + + assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); + assertFalse(result.isFdv1Fallback()); + } + + @Test + public void fdv1FallbackPropagatedOnHttpError() { + MockFDv2Requestor requestor = new MockFDv2Requestor(); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.failure(500, true)); + + FDv2SourceResult result = doPoll(requestor, false); + + assertEquals(SourceResultType.STATUS, result.getResultType()); + assertTrue(result.isFdv1Fallback()); + } + + @Test + public void fdv1FallbackPropagatedOnGoodbye() { + MockFDv2Requestor requestor = new MockFDv2Requestor(); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( + parseEvents(GOODBYE_JSON), 200, true)); + + FDv2SourceResult result = doPoll(requestor, false); + + assertEquals(SourceResultType.STATUS, result.getResultType()); + assertEquals(SourceSignal.GOODBYE, result.getStatus().getState()); + assertTrue(result.isFdv1Fallback()); + } } 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 ab123fbd..003b2382 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 @@ -1197,6 +1197,116 @@ public void nonRecoverableHttpErrorRecordsDiagnostic() throws Exception { } } + // ---- x-ld-fd-fallback header detection ---- + + @Test + public void fdv1FallbackHeaderOnMessageEvent() 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.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); + + assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); + assertTrue(result.isFdv1Fallback()); + + 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\"}]}"); + String payloadTransferred = makeEvent("payload-transferred", "{\"state\":\"(p:payload-1:100)\",\"version\":100}"); + + try (HttpServer server = HttpServer.start(Handlers.all( + 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); + + assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); + assertFalse(result.isFdv1Fallback()); + + sync.close(); + } + } + + @Test + public void fdv1FallbackHeaderOnHttpError() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.header("x-ld-fd-fallback", "true"), + Handlers.status(401)))) { + + FDv2StreamingSynchronizer sync = makeSynchronizer(server.getUri()); + FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); + + assertEquals(SourceResultType.STATUS, result.getResultType()); + assertEquals(SourceSignal.TERMINAL_ERROR, result.getStatus().getState()); + assertTrue(result.isFdv1Fallback()); + + sync.close(); + } + } + + @Test + public void fdv1FallbackHeaderOnRecoverableHttpError() 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.sequential( + Handlers.all( + Handlers.header("x-ld-fd-fallback", "true"), + Handlers.status(503)), + Handlers.all( + 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); + + assertEquals(SourceResultType.STATUS, result.getResultType()); + assertEquals(SourceSignal.INTERRUPTED, result.getStatus().getState()); + assertTrue(result.isFdv1Fallback()); + + sync.close(); + } + } + + @Test + public void fdv1FallbackHeaderCaseInsensitive() 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.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); + + assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); + assertTrue(result.isFdv1Fallback()); + + sync.close(); + } + } + private static String makeEvent(String type, String data) { return "event: " + type + "\ndata: " + data; } From 6ba18744a0ec77040db0e1fc70899dfc4bef0879 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 31 Mar 2026 09:39:05 -0700 Subject: [PATCH 04/23] [SDK-1829] chore: add FDv1PollingSynchronizer for fallback polling Implement a standalone FDv1 polling synchronizer that fetches flag data from the legacy /sdk/evalx endpoints and produces FDv2SourceResult values. This allows the FDv2 orchestration layer to fall back to FDv1 polling without depending on the existing PollingDataSource / HttpFeatureFlagFetcher stack, which is tightly coupled to the FDv1 DataSourceUpdateSink. Uses LDUtil.isHttpErrorRecoverable() for FDv2-style error classification (INTERRUPTED vs TERMINAL_ERROR). Made-with: Cursor --- .../sdk/android/FDv1PollingSynchronizer.java | 261 +++++++++++++ .../android/FDv1PollingSynchronizerTest.java | 347 ++++++++++++++++++ 2 files changed, 608 insertions(+) create mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizer.java create mode 100644 launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizerTest.java diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizer.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizer.java new file mode 100644 index 00000000..fb41810f --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizer.java @@ -0,0 +1,261 @@ +package com.launchdarkly.sdk.android; + +import androidx.annotation.NonNull; + +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.android.DataModel.Flag; +import com.launchdarkly.sdk.android.subsystems.FDv2SourceResult; +import com.launchdarkly.sdk.android.subsystems.Synchronizer; +import com.launchdarkly.sdk.fdv2.ChangeSet; +import com.launchdarkly.sdk.fdv2.ChangeSetType; +import com.launchdarkly.sdk.fdv2.Selector; +import com.launchdarkly.sdk.internal.http.HttpHelpers; +import com.launchdarkly.sdk.internal.http.HttpProperties; +import com.launchdarkly.sdk.json.JsonSerialization; +import com.launchdarkly.sdk.json.SerializationException; + +import java.io.IOException; +import java.net.URI; +import java.util.Map; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.ConnectionPool; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; + +import static com.launchdarkly.sdk.android.LDConfig.JSON; + +/** + * FDv1 polling synchronizer used as a fallback when the server signals that FDv2 endpoints + * are unavailable via the {@code x-ld-fd-fallback} response header. + *

+ * Polls the FDv1 mobile evaluation endpoint and converts the response into + * {@link FDv2SourceResult} objects so it can be used as a drop-in synchronizer within the + * FDv2 data source pipeline. + */ +final class FDv1PollingSynchronizer implements Synchronizer { + + private final URI pollingUri; + private final boolean useReport; + private final boolean evaluationReasons; + private final okhttp3.Headers headers; + private final RequestBody reportBody; + private final OkHttpClient httpClient; + private final LDLogger logger; + + private final LDAsyncQueue resultQueue = new LDAsyncQueue<>(); + private final LDAwaitFuture shutdownFuture = new LDAwaitFuture<>(); + + private volatile ScheduledFuture scheduledTask; + private final Object taskLock = new Object(); + + /** + * @param evaluationContext the context to evaluate flags for + * @param pollingBaseUri base URI for the FDv1 polling endpoint + * @param httpProperties SDK HTTP configuration + * @param useReport true to use HTTP REPORT with context in body + * @param evaluationReasons true to request evaluation reasons + * @param executor scheduler for recurring poll tasks + * @param initialDelayMillis delay before the first poll in milliseconds + * @param pollIntervalMillis delay between the end of one poll and the start of the next + * @param logger logger + */ + FDv1PollingSynchronizer( + @NonNull LDContext evaluationContext, + @NonNull URI pollingBaseUri, + @NonNull HttpProperties httpProperties, + boolean useReport, + boolean evaluationReasons, + @NonNull ScheduledExecutorService executor, + long initialDelayMillis, + long pollIntervalMillis, + @NonNull LDLogger logger) { + this.useReport = useReport; + this.evaluationReasons = evaluationReasons; + this.logger = logger; + this.headers = httpProperties.toHeadersBuilder().build(); + + URI basePath = HttpHelpers.concatenateUriPath(pollingBaseUri, + useReport + ? StandardEndpoints.POLLING_REQUEST_REPORT_BASE_PATH + : StandardEndpoints.POLLING_REQUEST_GET_BASE_PATH); + this.pollingUri = useReport + ? basePath + : HttpHelpers.concatenateUriPath(basePath, LDUtil.urlSafeBase64(evaluationContext)); + this.reportBody = useReport + ? RequestBody.create(JsonSerialization.serialize(evaluationContext), JSON) + : null; + + this.httpClient = httpProperties.toHttpClientBuilder() + .connectionPool(new ConnectionPool(0, 1, TimeUnit.MILLISECONDS)) + .retryOnConnectionFailure(true) + .build(); + + synchronized (taskLock) { + scheduledTask = executor.scheduleWithFixedDelay( + this::pollAndEnqueue, + initialDelayMillis, + pollIntervalMillis, + TimeUnit.MILLISECONDS); + } + } + + private void pollAndEnqueue() { + try { + FDv2SourceResult result = doPoll(); + + if (result.getResultType() == com.launchdarkly.sdk.fdv2.SourceResultType.STATUS) { + FDv2SourceResult.Status status = result.getStatus(); + if (status != null && status.getState() == com.launchdarkly.sdk.fdv2.SourceSignal.TERMINAL_ERROR) { + synchronized (taskLock) { + if (scheduledTask != null) { + scheduledTask.cancel(false); + scheduledTask = null; + } + } + closeHttpClient(); + shutdownFuture.set(result); + return; + } + } + + resultQueue.put(result); + } catch (RuntimeException e) { + LDUtil.logExceptionAtErrorLevel(logger, e, "Unexpected exception in FDv1 polling synchronizer task"); + resultQueue.put(FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(e))); + } + } + + private FDv2SourceResult doPoll() { + LDAwaitFuture pollFuture = new LDAwaitFuture<>(); + + try { + URI requestUri = pollingUri; + if (evaluationReasons) { + requestUri = HttpHelpers.addQueryParam(requestUri, "withReasons", "true"); + } + + logger.debug("FDv1 fallback polling request to: {}", requestUri); + + Request.Builder reqBuilder = new Request.Builder() + .url(requestUri.toURL()) + .headers(headers); + + if (useReport) { + reqBuilder.method("REPORT", reportBody); + } else { + reqBuilder.get(); + } + + httpClient.newCall(reqBuilder.build()).enqueue(new Callback() { + @Override + public void onFailure(@NonNull Call call, @NonNull IOException e) { + pollFuture.setException(e); + } + + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + try { + handleResponse(response, pollFuture); + } finally { + response.close(); + } + } + }); + + return pollFuture.get(); + } catch (InterruptedException e) { + return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(e)); + } catch (Exception e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + if (cause instanceof IOException) { + LDUtil.logExceptionAtErrorLevel(logger, cause, "FDv1 fallback polling failed with network error"); + } else { + LDUtil.logExceptionAtErrorLevel(logger, cause, "FDv1 fallback polling failed"); + } + return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(cause)); + } + } + + private void handleResponse(@NonNull Response response, @NonNull LDAwaitFuture future) { + try { + int code = response.code(); + + if (!response.isSuccessful()) { + if (code == 400) { + logger.error("Received 400 response when fetching flag values. Please check recommended R8 and/or ProGuard settings"); + } + boolean recoverable = LDUtil.isHttpErrorRecoverable(code); + logger.warn("FDv1 fallback polling failed with HTTP {}", code); + LDFailure failure = new LDInvalidResponseCodeFailure( + "FDv1 fallback polling request failed", null, code, recoverable); + if (!recoverable) { + future.set(FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure))); + } else { + future.set(FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure))); + } + return; + } + + ResponseBody body = response.body(); + if (body == null) { + future.setException(new IOException("FDv1 fallback polling response had no body")); + return; + } + + String bodyStr = body.string(); + logger.debug("FDv1 fallback polling response received"); + + EnvironmentData envData = EnvironmentData.fromJson(bodyStr); + Map flags = envData.getAll(); + + ChangeSet> changeSet = new ChangeSet<>( + ChangeSetType.Full, + Selector.EMPTY, + flags, + null, + true); + + future.set(FDv2SourceResult.changeSet(changeSet, false)); + + } catch (SerializationException e) { + LDUtil.logExceptionAtErrorLevel(logger, e, "FDv1 fallback polling failed to parse response"); + LDFailure failure = new LDFailure( + "FDv1 fallback: invalid JSON response", e, LDFailure.FailureType.INVALID_RESPONSE_BODY); + future.set(FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure))); + } catch (Exception e) { + future.setException(e); + } + } + + @Override + @NonNull + public Future next() { + return LDFutures.anyOf(shutdownFuture, resultQueue.take()); + } + + @Override + public void close() { + synchronized (taskLock) { + if (scheduledTask != null) { + scheduledTask.cancel(false); + scheduledTask = null; + } + } + shutdownFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown())); + closeHttpClient(); + } + + private void closeHttpClient() { + HttpProperties.shutdownHttpClient(httpClient); + } +} diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizerTest.java new file mode 100644 index 00000000..50156c7c --- /dev/null +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizerTest.java @@ -0,0 +1,347 @@ +package com.launchdarkly.sdk.android; + +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.android.subsystems.FDv2SourceResult; +import com.launchdarkly.sdk.fdv2.ChangeSetType; +import com.launchdarkly.sdk.fdv2.SourceResultType; +import com.launchdarkly.sdk.fdv2.SourceSignal; +import com.launchdarkly.sdk.internal.http.HttpProperties; +import com.launchdarkly.testhelpers.httptest.Handler; +import com.launchdarkly.testhelpers.httptest.Handlers; +import com.launchdarkly.testhelpers.httptest.HttpServer; +import com.launchdarkly.testhelpers.httptest.RequestInfo; + +import org.junit.After; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.Timeout; + +import java.net.URI; +import java.util.HashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Unit tests for {@link FDv1PollingSynchronizer}. + */ +public class FDv1PollingSynchronizerTest { + + @Rule + public Timeout globalTimeout = Timeout.seconds(10); + + private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + + private static final LDContext CONTEXT = LDContext.create("test-context-key"); + private static final LDLogger LOGGER = LDLogger.none(); + + private static final String VALID_FDV1_JSON = + "{\"flag1\":{\"key\":\"flag1\",\"version\":1,\"value\":true}," + + "\"flag2\":{\"key\":\"flag2\",\"version\":2,\"value\":false}}"; + + private static final String SINGLE_FLAG_JSON = + "{\"flag1\":{\"key\":\"flag1\",\"version\":1,\"value\":true}}"; + + @After + public void tearDown() { + executor.shutdownNow(); + } + + private static HttpProperties httpProperties() { + return new HttpProperties( + 10_000, + new HashMap<>(), + null, null, null, null, + 10_000, + null, null); + } + + private FDv1PollingSynchronizer makeSynchronizer(HttpServer server) { + return makeSynchronizer(server, 0, 60_000, false, false, LOGGER); + } + + private FDv1PollingSynchronizer makeSynchronizer(HttpServer server, long initialDelay, long pollInterval) { + return makeSynchronizer(server, initialDelay, pollInterval, false, false, LOGGER); + } + + private FDv1PollingSynchronizer makeSynchronizer( + HttpServer server, long initialDelay, long pollInterval, + boolean useReport, boolean evaluationReasons, LDLogger logger) { + return new FDv1PollingSynchronizer( + CONTEXT, + server.getUri(), + httpProperties(), + useReport, + evaluationReasons, + executor, + initialDelay, + pollInterval, + logger); + } + + @Test + public void successfulPollReturnsChangeSet() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.bodyJson(VALID_FDV1_JSON))) { + FDv1PollingSynchronizer sync = makeSynchronizer(server); + try { + Future future = sync.next(); + FDv2SourceResult result = future.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); + assertNotNull(result.getChangeSet()); + assertEquals(ChangeSetType.Full, result.getChangeSet().getType()); + assertNotNull(result.getChangeSet().getData()); + assertEquals(2, result.getChangeSet().getData().size()); + assertTrue(result.getChangeSet().getData().containsKey("flag1")); + assertTrue(result.getChangeSet().getData().containsKey("flag2")); + assertFalse(result.isFdv1Fallback()); + } finally { + sync.close(); + } + } + } + + @Test + public void nonRecoverableHttpErrorReturnsTerminalError() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.status(401))) { + FDv1PollingSynchronizer sync = makeSynchronizer(server); + try { + FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); + + assertEquals(SourceResultType.STATUS, result.getResultType()); + assertEquals(SourceSignal.TERMINAL_ERROR, result.getStatus().getState()); + } finally { + sync.close(); + } + } + } + + @Test + public void recoverableHttpErrorReturnsInterrupted() throws Exception { + Handler sequence = Handlers.sequential( + Handlers.status(500), + Handlers.bodyJson(SINGLE_FLAG_JSON)); + + try (HttpServer server = HttpServer.start(sequence)) { + FDv1PollingSynchronizer sync = makeSynchronizer(server, 0, 100); + try { + FDv2SourceResult result1 = sync.next().get(5, TimeUnit.SECONDS); + assertEquals(SourceResultType.STATUS, result1.getResultType()); + assertEquals(SourceSignal.INTERRUPTED, result1.getStatus().getState()); + + FDv2SourceResult result2 = sync.next().get(5, TimeUnit.SECONDS); + assertEquals(SourceResultType.CHANGE_SET, result2.getResultType()); + assertNotNull(result2.getChangeSet()); + assertEquals(1, result2.getChangeSet().getData().size()); + } finally { + sync.close(); + } + } + } + + @Test + public void closeReturnsShutdown() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.bodyJson(VALID_FDV1_JSON))) { + FDv1PollingSynchronizer sync = makeSynchronizer(server, 60_000, 60_000); + sync.close(); + + FDv2SourceResult result = sync.next().get(1, TimeUnit.SECONDS); + assertEquals(SourceResultType.STATUS, result.getResultType()); + assertEquals(SourceSignal.SHUTDOWN, result.getStatus().getState()); + } + } + + @Test + public void invalidJsonReturnsInterrupted() throws Exception { + Handler sequence = Handlers.sequential( + Handlers.bodyJson("not valid json"), + Handlers.bodyJson(SINGLE_FLAG_JSON)); + + try (HttpServer server = HttpServer.start(sequence)) { + FDv1PollingSynchronizer sync = makeSynchronizer(server, 0, 100); + try { + FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); + assertEquals(SourceResultType.STATUS, result.getResultType()); + assertEquals(SourceSignal.INTERRUPTED, result.getStatus().getState()); + } finally { + sync.close(); + } + } + } + + @Test + public void emptyResponseReturnsChangeSetWithNoFlags() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.bodyJson("{}"))) { + FDv1PollingSynchronizer sync = makeSynchronizer(server); + try { + FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); + + assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); + assertNotNull(result.getChangeSet()); + assertEquals(ChangeSetType.Full, result.getChangeSet().getType()); + assertTrue(result.getChangeSet().getData().isEmpty()); + } finally { + sync.close(); + } + } + } + + // ---- Request path and method verification ---- + + @Test + public void getRequestUsesCorrectPath() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.bodyJson(SINGLE_FLAG_JSON))) { + FDv1PollingSynchronizer sync = makeSynchronizer(server); + try { + sync.next().get(5, TimeUnit.SECONDS); + + RequestInfo req = server.getRecorder().requireRequest(); + assertEquals("GET", req.getMethod()); + String expectedBase64 = LDUtil.urlSafeBase64(CONTEXT); + String expectedPath = StandardEndpoints.POLLING_REQUEST_GET_BASE_PATH + "/" + expectedBase64; + assertTrue("path should be " + expectedPath + " but was " + req.getPath(), + req.getPath().equals(expectedPath)); + } finally { + sync.close(); + } + } + } + + @Test + public void reportRequestUsesCorrectPathAndMethod() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.bodyJson(SINGLE_FLAG_JSON))) { + FDv1PollingSynchronizer sync = makeSynchronizer(server, 0, 60_000, true, false, LOGGER); + try { + sync.next().get(5, TimeUnit.SECONDS); + + RequestInfo req = server.getRecorder().requireRequest(); + assertEquals("REPORT", req.getMethod()); + assertTrue("path should start with " + StandardEndpoints.POLLING_REQUEST_REPORT_BASE_PATH, + req.getPath().startsWith(StandardEndpoints.POLLING_REQUEST_REPORT_BASE_PATH)); + assertFalse("REPORT path should not contain context segment", + req.getPath().startsWith(StandardEndpoints.POLLING_REQUEST_GET_BASE_PATH)); + assertNotNull("body should contain serialized context", req.getBody()); + assertTrue("body should contain context key", + req.getBody().contains("test-context-key")); + } finally { + sync.close(); + } + } + } + + @Test + public void reportRequestReturnsValidChangeSet() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.bodyJson(VALID_FDV1_JSON))) { + FDv1PollingSynchronizer sync = makeSynchronizer(server, 0, 60_000, true, false, LOGGER); + try { + FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); + + assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); + assertNotNull(result.getChangeSet()); + assertEquals(2, result.getChangeSet().getData().size()); + assertFalse(result.isFdv1Fallback()); + } finally { + sync.close(); + } + } + } + + // ---- withReasons query parameter ---- + + @Test + public void evaluationReasonsAppendsQueryParam() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.bodyJson(SINGLE_FLAG_JSON))) { + FDv1PollingSynchronizer sync = makeSynchronizer(server, 0, 60_000, false, true, LOGGER); + try { + sync.next().get(5, TimeUnit.SECONDS); + + RequestInfo req = server.getRecorder().requireRequest(); + assertTrue("query should contain withReasons=true", + req.getQuery() != null && req.getQuery().contains("withReasons=true")); + } finally { + sync.close(); + } + } + } + + // ---- Network error handling ---- + + @Test + public void networkErrorReturnsInterrupted() throws Exception { + FDv1PollingSynchronizer sync = new FDv1PollingSynchronizer( + CONTEXT, + URI.create("http://localhost:1"), + httpProperties(), + false, + false, + executor, + 0, + 60_000, + LOGGER); + try { + FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); + + assertEquals(SourceResultType.STATUS, result.getResultType()); + assertEquals(SourceSignal.INTERRUPTED, result.getStatus().getState()); + } finally { + sync.close(); + } + } + + // ---- Terminal error stops polling ---- + + @Test + public void terminalErrorStopsPolling() throws Exception { + Handler sequence = Handlers.sequential( + Handlers.status(401), + Handlers.bodyJson(SINGLE_FLAG_JSON)); + + try (HttpServer server = HttpServer.start(sequence)) { + FDv1PollingSynchronizer sync = makeSynchronizer(server, 0, 100); + try { + FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); + assertEquals(SourceResultType.STATUS, result.getResultType()); + assertEquals(SourceSignal.TERMINAL_ERROR, result.getStatus().getState()); + + Thread.sleep(500); + + assertEquals("should have made exactly one request before stopping", + 1, server.getRecorder().count()); + } finally { + sync.close(); + } + } + } + + // ---- Repeated polling ---- + + @Test + public void pollsRepeatAtConfiguredInterval() throws Exception { + Handler sequence = Handlers.sequential( + Handlers.bodyJson(SINGLE_FLAG_JSON), + Handlers.bodyJson(VALID_FDV1_JSON)); + + try (HttpServer server = HttpServer.start(sequence)) { + FDv1PollingSynchronizer sync = makeSynchronizer(server, 0, 200); + try { + FDv2SourceResult result1 = sync.next().get(5, TimeUnit.SECONDS); + assertEquals(SourceResultType.CHANGE_SET, result1.getResultType()); + assertEquals(1, result1.getChangeSet().getData().size()); + + FDv2SourceResult result2 = sync.next().get(5, TimeUnit.SECONDS); + assertEquals(SourceResultType.CHANGE_SET, result2.getResultType()); + assertEquals(2, result2.getChangeSet().getData().size()); + } finally { + sync.close(); + } + } + } +} From c259c4a5163686bfefd6fe11c0a12e2dc1ec89b1 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 31 Mar 2026 09:39:26 -0700 Subject: [PATCH 05/23] [SDK-1829] chore: wire FDv1 fallback into FDv2 data source orchestration Connect the fallback signal to the orchestration layer end-to-end. The FDv2DataSource constructor now accepts an optional list of FDv1 fallback synchronizer factories, wrapping them as blocked entries appended after the regular synchronizers. When a synchronizer result carries fdv1Fallback=true, the run loop triggers SourceManager's fdv1Fallback() method, which blocks all FDv2 synchronizers, unblocks the FDv1 slot, and resets the scan index under the active source lock. The builder configures an FDv1PollingSynchronizer in the default mode table for STREAMING, POLLING, and BACKGROUND modes (not OFFLINE or ONE_SHOT, which have no synchronizers to fall back from). Made-with: Cursor --- .../sdk/android/FDv2DataSource.java | 58 +++++++++---- .../sdk/android/FDv2DataSourceBuilder.java | 36 +++++++- .../sdk/android/SourceManager.java | 19 ++-- .../android/FDv2DataSourceBuilderTest.java | 62 +++++++++++++ .../sdk/android/FDv2DataSourceTest.java | 87 +++++++++++++++++++ 5 files changed, 236 insertions(+), 26 deletions(-) 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 5a5ff5b3..1de6dae2 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 @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.android; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import com.launchdarkly.logging.LDLogger; import com.launchdarkly.sdk.LDContext; @@ -58,39 +59,46 @@ public interface DataSourceFactory { /** * Convenience constructor using default fallback and recovery timeouts. - * See {@link #FDv2DataSource(LDContext, List, List, DataSourceUpdateSinkV2, + * See {@link #FDv2DataSource(LDContext, List, List, List, DataSourceUpdateSinkV2, * ScheduledExecutorService, LDLogger, long, long)} for parameter documentation. */ FDv2DataSource( @NonNull LDContext evaluationContext, @NonNull List> initializers, @NonNull List> synchronizers, + @Nullable List> fdv1FallbackSynchronizers, @NonNull DataSourceUpdateSinkV2 dataSourceUpdateSink, @NonNull ScheduledExecutorService sharedExecutor, @NonNull LDLogger logger ) { - this(evaluationContext, initializers, synchronizers, dataSourceUpdateSink, sharedExecutor, logger, + this(evaluationContext, initializers, synchronizers, fdv1FallbackSynchronizers, + dataSourceUpdateSink, sharedExecutor, logger, FDv2DataSourceConditions.DEFAULT_FALLBACK_TIMEOUT_SECONDS, FDv2DataSourceConditions.DEFAULT_RECOVERY_TIMEOUT_SECONDS); } /** - * @param evaluationContext the context to evaluate flags for - * @param initializers factories for one-shot initializers, tried in order - * @param synchronizers factories for recurring synchronizers, tried in order - * @param dataSourceUpdateSink sink to apply changesets and status updates to - * @param sharedExecutor executor used for internal background tasks; must have at least - * 2 threads available for this data source to run properly. - * @param logger logger - * @param fallbackTimeoutSeconds seconds of INTERRUPTED state before falling back to the - * next synchronizer - * @param recoveryTimeoutSeconds seconds before attempting to recover to the primary - * synchronizer + * @param evaluationContext the context to evaluate flags for + * @param initializers factories for one-shot initializers, tried in order + * @param synchronizers factories for recurring synchronizers, tried in order + * @param fdv1FallbackSynchronizers factories for FDv1 fallback synchronizers, or null if none; + * these are appended after the regular synchronizers in a + * blocked state and only activated when the server sends the + * {@code x-ld-fd-fallback} header + * @param dataSourceUpdateSink sink to apply changesets and status updates to + * @param sharedExecutor executor used for internal background tasks; must have at least + * 2 threads available for this data source to run properly. + * @param logger logger + * @param fallbackTimeoutSeconds seconds of INTERRUPTED state before falling back to the + * next synchronizer + * @param recoveryTimeoutSeconds seconds before attempting to recover to the primary + * synchronizer */ FDv2DataSource( @NonNull LDContext evaluationContext, @NonNull List> initializers, @NonNull List> synchronizers, + @Nullable List> fdv1FallbackSynchronizers, @NonNull DataSourceUpdateSinkV2 dataSourceUpdateSink, @NonNull ScheduledExecutorService sharedExecutor, @NonNull LDLogger logger, @@ -100,11 +108,20 @@ public interface DataSourceFactory { this.evaluationContext = evaluationContext; this.dataSourceUpdateSink = dataSourceUpdateSink; this.logger = logger; - List synchronizerFactoriesWithState = new ArrayList<>(); + + List allSynchronizers = new ArrayList<>(); for (DataSourceFactory factory : synchronizers) { - synchronizerFactoriesWithState.add(new SynchronizerFactoryWithState(factory)); + allSynchronizers.add(new SynchronizerFactoryWithState(factory)); + } + if (fdv1FallbackSynchronizers != null) { + for (DataSourceFactory factory : fdv1FallbackSynchronizers) { + SynchronizerFactoryWithState fdv1 = new SynchronizerFactoryWithState(factory, true); + fdv1.block(); + allSynchronizers.add(fdv1); + } } - this.sourceManager = new SourceManager(synchronizerFactoriesWithState, new ArrayList<>(initializers)); + + this.sourceManager = new SourceManager(allSynchronizers, new ArrayList<>(initializers)); this.fallbackTimeoutSeconds = fallbackTimeoutSeconds; this.recoveryTimeoutSeconds = recoveryTimeoutSeconds; this.sharedExecutor = sharedExecutor; @@ -384,6 +401,15 @@ private void runSynchronizers( } break; } + + if (running + && result.isFdv1Fallback() + && sourceManager.hasFDv1Fallback() + && !sourceManager.isCurrentSynchronizerFDv1Fallback()) { + logger.info("Server signaled FDv1 fallback; switching to FDv1 polling synchronizer."); + sourceManager.fdv1Fallback(); + running = false; + } } } } catch (ExecutionException e) { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java index 448c94ee..65c24ffe 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java @@ -90,16 +90,35 @@ private Map makeDefaultModeTable() { 0, LDConfig.DEFAULT_BACKGROUND_POLL_INTERVAL_MILLIS, ctx.getBaseLogger()); }; + ComponentConfigurer fdv1FallbackPollingSynchronizer = ctx -> { + DataSourceSetup s = new DataSourceSetup(ctx); + URI pollingBase = StandardEndpoints.selectBaseUri( + ctx.getServiceEndpoints().getPollingBaseUri(), + StandardEndpoints.DEFAULT_POLLING_BASE_URI, + "polling", ctx.getBaseLogger()); + return new FDv1PollingSynchronizer( + ctx.getEvaluationContext(), pollingBase, s.httpProps, + ctx.getHttp().isUseReport(), ctx.isEvaluationReasons(), + sharedExecutor, 0, + PollingDataSourceBuilder.DEFAULT_POLL_INTERVAL_MILLIS, + ctx.getBaseLogger()); + }; + + List> fdv1FallbackList = + Collections.singletonList(fdv1FallbackPollingSynchronizer); + Map table = new LinkedHashMap<>(); table.put(ConnectionMode.STREAMING, new ModeDefinition( // TODO: cacheInitializer — add once implemented Arrays.asList(/* cacheInitializer, */ pollingInitializer), - Arrays.asList(streamingSynchronizer, pollingSynchronizer) + Arrays.asList(streamingSynchronizer, pollingSynchronizer), + fdv1FallbackList )); table.put(ConnectionMode.POLLING, new ModeDefinition( // TODO: Arrays.asList(cacheInitializer) — add once implemented Collections.>emptyList(), - Collections.singletonList(pollingSynchronizer) + Collections.singletonList(pollingSynchronizer), + fdv1FallbackList )); table.put(ConnectionMode.OFFLINE, new ModeDefinition( // TODO: Arrays.asList(cacheInitializer) — add once implemented @@ -114,7 +133,8 @@ private Map makeDefaultModeTable() { table.put(ConnectionMode.BACKGROUND, new ModeDefinition( // TODO: Arrays.asList(cacheInitializer) — add once implemented Collections.>emptyList(), - Collections.singletonList(backgroundPollingSynchronizer) + Collections.singletonList(backgroundPollingSynchronizer), + fdv1FallbackList )); return table; } @@ -189,10 +209,14 @@ public DataSource build(ClientContext clientContext) { // Reset includeInitializers to default after each build to prevent stale state. includeInitializers = true; + List> fdv1Factories = + resolved.getFdv1FallbackSynchronizerFactories(); + return new FDv2DataSource( clientContext.getEvaluationContext(), initFactories, resolved.getSynchronizerFactories(), + fdv1Factories.isEmpty() ? null : fdv1Factories, (DataSourceUpdateSinkV2) baseSink, sharedExecutor, clientContext.getBaseLogger() @@ -218,7 +242,11 @@ private static ResolvedModeDefinition resolve( for (ComponentConfigurer configurer : def.getSynchronizers()) { syncFactories.add(() -> configurer.build(clientContext)); } - return new ResolvedModeDefinition(initFactories, syncFactories); + List> fdv1FallbackFactories = new ArrayList<>(); + for (ComponentConfigurer configurer : def.getFdv1FallbackSynchronizers()) { + fdv1FallbackFactories.add(() -> configurer.build(clientContext)); + } + return new ResolvedModeDefinition(initFactories, syncFactories, fdv1FallbackFactories); } /** 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 4d945eef..bbbd8adb 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 @@ -59,14 +59,21 @@ boolean hasFDv1Fallback() { return false; } - /** Block all non-FDv1 synchronizers and unblock the FDv1 fallback. Android: no-op for now. */ + /** + * Block all non-FDv1 synchronizers, unblock the FDv1 fallback, and reset the + * synchronizer index so the next {@link #getNextAvailableSynchronizerAndSetActive()} + * picks the now-unblocked FDv1 slot. + */ void fdv1Fallback() { - for (SynchronizerFactoryWithState s : synchronizerFactories) { - if (s.isFDv1Fallback()) { - s.unblock(); - } else { - s.block(); + synchronized (activeSourceLock) { + for (SynchronizerFactoryWithState s : synchronizerFactories) { + if (s.isFDv1Fallback()) { + s.unblock(); + } else { + s.block(); + } } + synchronizerIndex = -1; } } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java index 69c0859f..1f332186 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java @@ -224,6 +224,68 @@ private static ScheduledExecutorService getSharedExecutor(FDv2DataSourceBuilder } } + // ---- Default mode table FDv1 fallback slot configuration ---- + + @Test + public void defaultModeTable_streamingHasFdv1Fallback() { + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); + builder.build(makeClientContext()); + + ModeDefinition streaming = builder.getModeDefinition(ConnectionMode.STREAMING); + assertNotNull(streaming); + assertEquals(1, streaming.getInitializers().size()); + assertEquals(2, streaming.getSynchronizers().size()); + assertEquals(1, streaming.getFdv1FallbackSynchronizers().size()); + } + + @Test + public void defaultModeTable_pollingHasFdv1Fallback() { + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); + builder.build(makeClientContext()); + + ModeDefinition polling = builder.getModeDefinition(ConnectionMode.POLLING); + assertNotNull(polling); + assertEquals(0, polling.getInitializers().size()); + assertEquals(1, polling.getSynchronizers().size()); + assertEquals(1, polling.getFdv1FallbackSynchronizers().size()); + } + + @Test + public void defaultModeTable_backgroundHasFdv1Fallback() { + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); + builder.build(makeClientContext()); + + ModeDefinition background = builder.getModeDefinition(ConnectionMode.BACKGROUND); + assertNotNull(background); + assertEquals(0, background.getInitializers().size()); + assertEquals(1, background.getSynchronizers().size()); + assertEquals(1, background.getFdv1FallbackSynchronizers().size()); + } + + @Test + public void defaultModeTable_offlineHasNoFdv1Fallback() { + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); + builder.build(makeClientContext()); + + ModeDefinition offline = builder.getModeDefinition(ConnectionMode.OFFLINE); + assertNotNull(offline); + assertEquals(0, offline.getInitializers().size()); + assertEquals(0, offline.getSynchronizers().size()); + assertEquals(0, offline.getFdv1FallbackSynchronizers().size()); + } + + @Test + public void defaultModeTable_oneShotHasNoFdv1Fallback() { + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); + builder.build(makeClientContext()); + + ModeDefinition oneShot = builder.getModeDefinition(ConnectionMode.ONE_SHOT); + assertNotNull(oneShot); + assertEquals(1, oneShot.getInitializers().size()); + assertEquals(0, oneShot.getSynchronizers().size()); + assertEquals(0, oneShot.getFdv1FallbackSynchronizers().size()); + } + @Test public void setActiveMode_notInTable_throws() { Map customTable = new LinkedHashMap<>(); 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 cd730bf9..24c43c05 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 @@ -81,6 +81,7 @@ private FDv2DataSource buildDataSource( CONTEXT, initializers, synchronizers, + null, sink, executor, logging.logger); @@ -96,6 +97,7 @@ private FDv2DataSource buildDataSource( CONTEXT, initializers, synchronizers, + null, sink, executor, logging.logger, @@ -1421,6 +1423,91 @@ public void stopReportsOffStatus() throws Exception { assertEquals(DataSourceState.OFF, offStatus); } + // ---- FDv1 fallback ---- + + @Test + public void fdv1FallbackSwitchesToFdv1Synchronizer() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + + MockQueuedSynchronizer fdv2Sync = new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(true), true)); + + MockQueuedSynchronizer fdv1Sync = new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(true), false)); + + FDv2DataSource dataSource = new FDv2DataSource( + CONTEXT, + Collections.>emptyList(), + Collections.>singletonList(() -> fdv2Sync), + Collections.>singletonList(() -> fdv1Sync), + sink, + executor, + logging.logger); + + AwaitableCallback startCallback = startDataSource(dataSource); + assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); + + assertEquals(DataSourceState.VALID, sink.awaitStatus(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + + DataSourceState secondValid = sink.awaitStatus(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertEquals(DataSourceState.VALID, secondValid); + + stopDataSource(dataSource); + } + + @Test + public void fdv1FallbackNotTriggeredWhenAlreadyOnFdv1() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + + MockQueuedSynchronizer fdv2Sync = new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(true), true)); + + AtomicInteger fdv1BuildCount = new AtomicInteger(0); + MockQueuedSynchronizer fdv1Sync = new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(true), false)); + + FDv2DataSource dataSource = new FDv2DataSource( + CONTEXT, + Collections.>emptyList(), + Collections.>singletonList(() -> fdv2Sync), + Collections.>singletonList(() -> { + fdv1BuildCount.incrementAndGet(); + return fdv1Sync; + }), + sink, + executor, + logging.logger); + + AwaitableCallback startCallback = startDataSource(dataSource); + assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); + + assertEquals(DataSourceState.VALID, sink.awaitStatus(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + assertEquals(DataSourceState.VALID, sink.awaitStatus(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + + assertEquals(1, fdv1BuildCount.get()); + + stopDataSource(dataSource); + } + + @Test + public void fdv1FallbackNotTriggeredWhenNoFdv1SlotExists() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + + MockQueuedSynchronizer fdv2Sync = new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(true), true)); + + FDv2DataSource dataSource = buildDataSource(sink, + Collections.>emptyList(), + Collections.>singletonList(() -> fdv2Sync)); + + AwaitableCallback startCallback = startDataSource(dataSource); + assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); + + assertEquals(DataSourceState.VALID, sink.awaitStatus(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + + stopDataSource(dataSource); + } + @Test public void needsRefresh_sameContext_returnsFalse() { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); From a6860d24926be5afecc6fafd5931df34bb83d752 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 31 Mar 2026 15:56:48 -0700 Subject: [PATCH 06/23] [SDK-1829] refactor: remove unnecessary method overloads from FDv2 types Remove single-argument overloads from FDv2SourceResult, FDv2PayloadResponse, and ModeDefinition, keeping only the versions that include the fdv1Fallback parameter. Update all call sites across production and test code. Made-with: Cursor --- .../sdk/android/FDv1PollingSynchronizer.java | 14 +- .../sdk/android/FDv2DataSourceBuilder.java | 2 + .../sdk/android/FDv2PollingBase.java | 8 +- .../sdk/android/FDv2PollingInitializer.java | 4 +- .../sdk/android/FDv2PollingSynchronizer.java | 4 +- .../sdk/android/FDv2Requestor.java | 10 -- .../android/FDv2StreamingSynchronizer.java | 5 +- .../sdk/android/ModeDefinition.java | 7 - .../android/subsystems/FDv2SourceResult.java | 10 -- .../sdk/android/ConnectivityManagerTest.java | 16 +- .../android/FDv2DataSourceBuilderTest.java | 30 ++-- .../android/FDv2DataSourceConditionsTest.java | 8 +- .../sdk/android/FDv2DataSourceTest.java | 152 +++++++++--------- .../sdk/android/FDv2PollingBaseTest.java | 36 ++--- .../android/FDv2PollingInitializerTest.java | 8 +- .../android/FDv2PollingSynchronizerTest.java | 22 +-- 16 files changed, 164 insertions(+), 172 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizer.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizer.java index fb41810f..5bc29330 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizer.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizer.java @@ -131,7 +131,7 @@ private void pollAndEnqueue() { resultQueue.put(result); } catch (RuntimeException e) { LDUtil.logExceptionAtErrorLevel(logger, e, "Unexpected exception in FDv1 polling synchronizer task"); - resultQueue.put(FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(e))); + resultQueue.put(FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(e), false)); } } @@ -174,7 +174,7 @@ public void onResponse(@NonNull Call call, @NonNull Response response) { return pollFuture.get(); } catch (InterruptedException e) { - return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(e)); + return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(e), false); } catch (Exception e) { Throwable cause = e.getCause() != null ? e.getCause() : e; if (cause instanceof IOException) { @@ -182,7 +182,7 @@ public void onResponse(@NonNull Call call, @NonNull Response response) { } else { LDUtil.logExceptionAtErrorLevel(logger, cause, "FDv1 fallback polling failed"); } - return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(cause)); + return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(cause), false); } } @@ -199,9 +199,9 @@ private void handleResponse(@NonNull Response response, @NonNull LDAwaitFuture makeDefaultModeTable() { table.put(ConnectionMode.OFFLINE, new ModeDefinition( // TODO: Arrays.asList(cacheInitializer) — add once implemented Collections.>emptyList(), + Collections.>emptyList(), Collections.>emptyList() )); table.put(ConnectionMode.ONE_SHOT, new ModeDefinition( // TODO: cacheInitializer and streamingInitializer — add once implemented Arrays.asList(/* cacheInitializer, */ pollingInitializer /*, streamingInitializer, */), + Collections.>emptyList(), Collections.>emptyList() )); table.put(ConnectionMode.BACKGROUND, new ModeDefinition( 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 7a77070c..92c3c334 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 @@ -89,7 +89,7 @@ static FDv2SourceResult doPoll( Future future = requestor.poll(selector); response = future.get(); } catch (InterruptedException e) { - return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(e)); + return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(e), false); } catch (ExecutionException e) { Throwable cause = e.getCause() != null ? e.getCause() : e; if (cause instanceof IOException) { @@ -98,8 +98,8 @@ static FDv2SourceResult doPoll( LDUtil.logExceptionAtErrorLevel(logger, cause, "Polling failed"); } return oneShot - ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(cause)) - : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(cause)); + ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(cause), false) + : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(cause), false); } boolean fdv1Fallback = response.isFdv1Fallback(); @@ -112,7 +112,7 @@ static FDv2SourceResult doPoll( selector, Collections.emptyMap(), null, - true)); + true), fdv1Fallback); } if (!response.isSuccess()) { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingInitializer.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingInitializer.java index 550af863..c71267d7 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingInitializer.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingInitializer.java @@ -54,7 +54,7 @@ public Future run() { pollFuture.set(result); } catch (RuntimeException e) { LDUtil.logExceptionAtErrorLevel(logger, e, "Unexpected exception in polling initializer"); - pollFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(e))); + pollFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(e), false)); } }); @@ -63,7 +63,7 @@ public Future run() { @Override public void close() { - shutdownFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown())); + shutdownFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown(), false)); closeRequestor(); } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingSynchronizer.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingSynchronizer.java index c35f20e0..4e0b9781 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingSynchronizer.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingSynchronizer.java @@ -93,7 +93,7 @@ private void pollAndEnqueue() { // cancels the task on any unchecked exception, ending all future polls with no // error signal. Log and enqueue an INTERRUPTED result so the consumer is notified LDUtil.logExceptionAtErrorLevel(logger, e, "Unexpected exception in polling synchronizer task"); - resultQueue.put(FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(e))); + resultQueue.put(FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(e), false)); } } @@ -111,7 +111,7 @@ public void close() { scheduledTask = null; } } - shutdownFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown())); + shutdownFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown(), false)); closeRequestor(); } } 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 f42b22f3..a6b59298 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 @@ -41,11 +41,6 @@ private FDv2PayloadResponse( } /** Creates a successful response with parsed events. */ - static FDv2PayloadResponse success(@NonNull List events, int statusCode) { - return new FDv2PayloadResponse(events, true, statusCode, false); - } - - /** Creates a successful response with parsed events and an FDv1 fallback indicator. */ static FDv2PayloadResponse success(@NonNull List events, int statusCode, boolean fdv1Fallback) { return new FDv2PayloadResponse(events, true, statusCode, fdv1Fallback); } @@ -59,11 +54,6 @@ static FDv2PayloadResponse notModified() { } /** Creates an unsuccessful response with the HTTP status code. */ - static FDv2PayloadResponse failure(int statusCode) { - return new FDv2PayloadResponse(null, false, statusCode, false); - } - - /** Creates an unsuccessful response with the HTTP status code and an FDv1 fallback indicator. */ static FDv2PayloadResponse failure(int statusCode, boolean fdv1Fallback) { return new FDv2PayloadResponse(null, false, statusCode, 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 55b8f23d..692c79ee 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 @@ -159,7 +159,7 @@ public void close() { } catch (IOException ignored) { } } - shutdownFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown())); + shutdownFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown(), false)); } private void startStream() { @@ -234,7 +234,8 @@ private void startStream() { resultQueue.put(FDv2SourceResult.status( FDv2SourceResult.Status.interrupted( new LDFailure("Stream thread ended unexpectedly", e, - LDFailure.FailureType.UNKNOWN_ERROR)))); + LDFailure.FailureType.UNKNOWN_ERROR)), + false)); } finally { es.close(); } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java index 13b54cda..18959ee1 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java @@ -29,13 +29,6 @@ final class ModeDefinition { private final List> synchronizers; private final List> fdv1FallbackSynchronizers; - ModeDefinition( - @NonNull List> initializers, - @NonNull List> synchronizers - ) { - this(initializers, synchronizers, Collections.>emptyList()); - } - ModeDefinition( @NonNull List> initializers, @NonNull List> synchronizers, 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 e1e5477c..0403c173 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 @@ -86,21 +86,11 @@ private FDv2SourceResult(@NonNull SourceResultType resultType, this.fdv1Fallback = fdv1Fallback; } - @NonNull - public static FDv2SourceResult changeSet(@NonNull ChangeSet> changeSet) { - return new FDv2SourceResult(SourceResultType.CHANGE_SET, changeSet, null, false); - } - @NonNull public static FDv2SourceResult changeSet(@NonNull ChangeSet> changeSet, boolean fdv1Fallback) { return new FDv2SourceResult(SourceResultType.CHANGE_SET, changeSet, null, fdv1Fallback); } - @NonNull - public static FDv2SourceResult status(@NonNull Status status) { - return new FDv2SourceResult(SourceResultType.STATUS, null, status, false); - } - @NonNull public static FDv2SourceResult status(@NonNull Status status, boolean fdv1Fallback) { return new FDv2SourceResult(SourceResultType.STATUS, null, status, fdv1Fallback); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java index 062de9e1..58bccace 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java @@ -854,14 +854,17 @@ private FDv2DataSourceBuilder makeFDv2DataSourceFactory() { Map table = new LinkedHashMap<>(); table.put(com.launchdarkly.sdk.android.ConnectionMode.STREAMING, new ModeDefinition( Collections.>emptyList(), - Collections.>singletonList(ctx -> null) + Collections.>singletonList(ctx -> null), + Collections.>emptyList() )); table.put(com.launchdarkly.sdk.android.ConnectionMode.BACKGROUND, new ModeDefinition( Collections.>emptyList(), - Collections.>singletonList(ctx -> null) + Collections.>singletonList(ctx -> null), + Collections.>emptyList() )); table.put(com.launchdarkly.sdk.android.ConnectionMode.OFFLINE, new ModeDefinition( Collections.>emptyList(), + Collections.>emptyList(), Collections.>emptyList() )); return new FDv2DataSourceBuilder(table, com.launchdarkly.sdk.android.ConnectionMode.STREAMING) { @@ -1009,7 +1012,8 @@ public void fdv2_sameModeDoesNotRebuild() throws Exception { public void fdv2_equivalentConfigDoesNotRebuild() throws Exception { ModeDefinition sharedDef = new ModeDefinition( Collections.>emptyList(), - Collections.>singletonList(ctx -> null) + Collections.>singletonList(ctx -> null), + Collections.>emptyList() ); Map table = new LinkedHashMap<>(); table.put(com.launchdarkly.sdk.android.ConnectionMode.STREAMING, sharedDef); @@ -1073,11 +1077,13 @@ public void fdv2_modeSwitchDoesNotIncludeInitializers() throws Exception { Map table = new LinkedHashMap<>(); table.put(com.launchdarkly.sdk.android.ConnectionMode.STREAMING, new ModeDefinition( Collections.>singletonList(ctx -> null), - Collections.>singletonList(ctx -> null) + Collections.>singletonList(ctx -> null), + Collections.>emptyList() )); table.put(com.launchdarkly.sdk.android.ConnectionMode.BACKGROUND, new ModeDefinition( Collections.>singletonList(ctx -> null), - Collections.>singletonList(ctx -> null) + Collections.>singletonList(ctx -> null), + Collections.>emptyList() )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(table, com.launchdarkly.sdk.android.ConnectionMode.STREAMING) { diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java index 1f332186..21076ed0 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java @@ -67,7 +67,8 @@ public void customModeTable_buildsCorrectly() { Map customTable = new LinkedHashMap<>(); customTable.put(ConnectionMode.POLLING, new ModeDefinition( Collections.>emptyList(), - Collections.>singletonList(ctx -> null) + Collections.>singletonList(ctx -> null), + Collections.>emptyList() )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.POLLING); @@ -80,7 +81,8 @@ public void startingMode_notInTable_throws() { Map customTable = new LinkedHashMap<>(); customTable.put(ConnectionMode.POLLING, new ModeDefinition( Collections.>emptyList(), - Collections.>singletonList(ctx -> null) + Collections.>singletonList(ctx -> null), + Collections.>emptyList() )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); @@ -97,11 +99,13 @@ public void setActiveMode_buildUsesSpecifiedMode() { Map customTable = new LinkedHashMap<>(); customTable.put(ConnectionMode.STREAMING, new ModeDefinition( Collections.>singletonList(ctx -> null), - Collections.>singletonList(ctx -> null) + Collections.>singletonList(ctx -> null), + Collections.>emptyList() )); customTable.put(ConnectionMode.POLLING, new ModeDefinition( Collections.>emptyList(), - Collections.>singletonList(ctx -> null) + Collections.>singletonList(ctx -> null), + Collections.>emptyList() )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); @@ -115,7 +119,8 @@ public void setActiveMode_withoutInitializers_buildsWithEmptyInitializers() { Map customTable = new LinkedHashMap<>(); customTable.put(ConnectionMode.STREAMING, new ModeDefinition( Collections.>singletonList(ctx -> null), - Collections.>singletonList(ctx -> null) + Collections.>singletonList(ctx -> null), + Collections.>emptyList() )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); @@ -129,7 +134,8 @@ public void defaultBehavior_usesStartingMode() { Map customTable = new LinkedHashMap<>(); customTable.put(ConnectionMode.STREAMING, new ModeDefinition( Collections.>singletonList(ctx -> null), - Collections.>singletonList(ctx -> null) + Collections.>singletonList(ctx -> null), + Collections.>emptyList() )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); @@ -141,11 +147,13 @@ public void defaultBehavior_usesStartingMode() { public void getModeDefinition_returnsCorrectDefinition() { ModeDefinition streamingDef = new ModeDefinition( Collections.>singletonList(ctx -> null), - Collections.>singletonList(ctx -> null) + Collections.>singletonList(ctx -> null), + Collections.>emptyList() ); ModeDefinition pollingDef = new ModeDefinition( Collections.>emptyList(), - Collections.>singletonList(ctx -> null) + Collections.>singletonList(ctx -> null), + Collections.>emptyList() ); Map customTable = new LinkedHashMap<>(); @@ -162,7 +170,8 @@ public void getModeDefinition_returnsCorrectDefinition() { public void getModeDefinition_sameObjectUsedForEquivalenceCheck() { ModeDefinition sharedDef = new ModeDefinition( Collections.>emptyList(), - Collections.>singletonList(ctx -> null) + Collections.>singletonList(ctx -> null), + Collections.>emptyList() ); Map customTable = new LinkedHashMap<>(); @@ -291,7 +300,8 @@ public void setActiveMode_notInTable_throws() { Map customTable = new LinkedHashMap<>(); customTable.put(ConnectionMode.STREAMING, new ModeDefinition( Collections.>emptyList(), - Collections.>singletonList(ctx -> null) + Collections.>singletonList(ctx -> null), + Collections.>emptyList() )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceConditionsTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceConditionsTest.java index 77126e47..7acade43 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceConditionsTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceConditionsTest.java @@ -46,16 +46,16 @@ public void tearDown() { // ---- helpers ---- private static FDv2SourceResult interrupted() { - return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(new RuntimeException("interrupted"))); + return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(new RuntimeException("interrupted")), false); } private static FDv2SourceResult terminalError() { - return FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(new RuntimeException("terminal"))); + return FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(new RuntimeException("terminal")), false); } private static FDv2SourceResult changeSet() { return FDv2SourceResult.changeSet( - new ChangeSet<>(ChangeSetType.None, Selector.EMPTY, new HashMap<>(), null, true)); + new ChangeSet<>(ChangeSetType.None, Selector.EMPTY, new HashMap<>(), null, true), false); } // ==== FallbackCondition ==== @@ -145,7 +145,7 @@ public void fallback_informWithNonInterruptedStatus_doesNotStartTimer() throws I FallbackCondition condition = new FallbackCondition(executor, 0); LDAwaitFuture future = condition.getFuture(); - condition.inform(FDv2SourceResult.status(FDv2SourceResult.Status.goodbye("bye"))); + condition.inform(FDv2SourceResult.status(FDv2SourceResult.Status.goodbye("bye"), false)); try { future.get(100, TimeUnit.MILLISECONDS); 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 24c43c05..95e99b16 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 @@ -136,11 +136,11 @@ private static ChangeSet> makeFullChangeSet(Map run() { @Override public void close() { - future.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown())); + future.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown(), false)); } } @@ -200,7 +200,7 @@ private static class MockSynchronizer implements Synchronizer { public LDAwaitFuture next() { if (closed) { LDAwaitFuture f = new LDAwaitFuture<>(); - f.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown())); + f.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown(), false)); return f; } if (!resultReturned) { @@ -265,7 +265,7 @@ public void close() { if (pendingFuture != null) { LDAwaitFuture f = pendingFuture; pendingFuture = null; - f.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown())); + f.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown(), false)); } } } @@ -279,7 +279,7 @@ public void firstInitializerProvidesData_startSucceedsAndSinkReceivesApply() thr items.put(flag.getKey(), flag); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeFullChangeSet(items)))), + Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeFullChangeSet(items), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -301,7 +301,7 @@ public void firstInitializerFailsSecondInitializerSucceeds() throws Exception { () -> new MockInitializer(new RuntimeException("first fails")), () -> { secondCalled.set(true); - return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true))); + return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)); }), Collections.emptyList()); @@ -320,10 +320,10 @@ public void firstInitializerSucceedsWithSelectorSecondInitializerNotInvoked() th FDv2DataSource dataSource = buildDataSource(sink, Arrays.asList( - () -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true))), + () -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)), () -> { secondCalled.set(true); - return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true))); + return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)); }), Collections.emptyList()); @@ -346,7 +346,7 @@ public void allInitializersFailSwitchesToSynchronizers() throws Exception { () -> new MockInitializer(new RuntimeException("second fails"))), Collections.singletonList(() -> { syncCalled.set(true); - return new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false))); + return new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false), false)); })); AwaitableCallback startCallback = startDataSource(dataSource); @@ -363,7 +363,7 @@ public void allInitializersFailWithNoSynchronizers_startReportsNotInitialized() FDv2DataSource dataSource = buildDataSource(sink, Collections.singletonList( - () -> new MockInitializer(FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(new RuntimeException("fail"))))), + () -> new MockInitializer(FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(new RuntimeException("fail")), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -379,8 +379,8 @@ public void secondInitializerSucceeds_afterFirstReturnsTerminalError() throws Ex FDv2DataSource dataSource = buildDataSource(sink, Arrays.asList( - () -> new MockInitializer(FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(new RuntimeException("first fails")))), - () -> new MockInitializer(FDv2SourceResult.changeSet(makeFullChangeSet(items)))), + () -> new MockInitializer(FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(new RuntimeException("first fails")), false)), + () -> new MockInitializer(FDv2SourceResult.changeSet(makeFullChangeSet(items), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -396,7 +396,7 @@ public void oneInitializerNoSynchronizerIsWellBehaved() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true)))), + Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -416,8 +416,8 @@ public void noInitializers_synchronizerProvidesData_startSucceedsAndSinkReceives FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeFullChangeSet(items)), - FDv2SourceResult.status(FDv2SourceResult.Status.shutdown())))); + FDv2SourceResult.changeSet(makeFullChangeSet(items), false), + FDv2SourceResult.status(FDv2SourceResult.Status.shutdown(), false)))); AwaitableCallback startCallback = startDataSource(dataSource); assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); @@ -447,9 +447,9 @@ public void oneInitializerOneSynchronizerIsWellBehaved() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false)))), + Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), false))), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))))); + FDv2SourceResult.changeSet(makeChangeSet(false), false)))); AwaitableCallback startCallback = startDataSource(dataSource); assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); @@ -467,7 +467,7 @@ public void emptyInitializerListSkipsToSynchronizers() throws Exception { Collections.emptyList(), Collections.singletonList(() -> { syncCalled.set(true); - return new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false))); + return new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false), false)); })); AwaitableCallback startCallback = startDataSource(dataSource); @@ -480,10 +480,10 @@ public void emptyInitializerListSkipsToSynchronizers() throws Exception { public void fallbackAndRecoveryTasksWellBehaved() throws Exception { // First sync: changeset then INTERRUPTED; second sync: changeset; recovery brings back first MockQueuedSynchronizer firstSync = new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)), + FDv2SourceResult.changeSet(makeChangeSet(false), false), interrupted()); MockQueuedSynchronizer secondSync = new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))); + FDv2SourceResult.changeSet(makeChangeSet(false), false)); AtomicInteger firstCallCount = new AtomicInteger(0); AtomicInteger secondCallCount = new AtomicInteger(0); @@ -521,7 +521,7 @@ public void canDisposeWhenSynchronizersFallingBack() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)), + FDv2SourceResult.changeSet(makeChangeSet(false), false), interrupted())), 1, 2); @@ -540,10 +540,10 @@ public void terminalErrorBlocksSynchronizer() throws Exception { Collections.emptyList(), Arrays.asList( () -> { callOrder.offer(1); return new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)), + FDv2SourceResult.changeSet(makeChangeSet(false), false), terminalError()); }, () -> { callOrder.offer(2); return new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))); })); + FDv2SourceResult.changeSet(makeChangeSet(false), false)); })); AwaitableCallback startCallback = startDataSource(dataSource); assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); @@ -594,7 +594,7 @@ public void blockedSynchronizerSkippedInRotation() throws Exception { Arrays.asList( () -> { firstCallCount.incrementAndGet(); return new MockQueuedSynchronizer(terminalError()); }, () -> { secondCallCount.incrementAndGet(); return new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))); })); + FDv2SourceResult.changeSet(makeChangeSet(false), false)); })); AwaitableCallback startCallback = startDataSource(dataSource); assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); @@ -625,10 +625,10 @@ public void recoveryResetsToFirstAvailableSynchronizer() throws Exception { AtomicInteger secondCallCount = new AtomicInteger(0); MockQueuedSynchronizer firstSync = new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)), + FDv2SourceResult.changeSet(makeChangeSet(false), false), interrupted()); MockQueuedSynchronizer secondSync = new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))); + FDv2SourceResult.changeSet(makeChangeSet(false), false)); FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), @@ -657,10 +657,10 @@ public void fallbackMovesToNextSynchronizer() throws Exception { Collections.emptyList(), Arrays.asList( () -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)), + FDv2SourceResult.changeSet(makeChangeSet(false), false), interrupted()), () -> { secondCalledQueue.offer(true); return new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))); }), + FDv2SourceResult.changeSet(makeChangeSet(false), false)); }), 1, 300); AwaitableCallback startCallback = startDataSource(dataSource); @@ -683,7 +683,7 @@ public void stop_closesSynchronizerAndCallsShutDown() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))))); + FDv2SourceResult.changeSet(makeChangeSet(false), false)))); AwaitableCallback startCallback = startDataSource(dataSource); assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); @@ -705,7 +705,7 @@ public void stopAfterInitializersCompletesImmediately() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true)))), + Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -721,7 +721,7 @@ public void closeWhileSynchronizerRunningShutdownsSource() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), - Collections.singletonList(() -> new MockQueuedSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false))) { + Collections.singletonList(() -> new MockQueuedSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false), false)) { @Override public void close() { syncClosed.set(true); @@ -742,7 +742,7 @@ public void multipleStopCallsAreIdempotent() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true)))), + Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -781,7 +781,7 @@ public void dataSourceClosedDuringSynchronizationReportsOffWithoutError() throws FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))))); + FDv2SourceResult.changeSet(makeChangeSet(false), false)))); AwaitableCallback startCallback = startDataSource(dataSource); assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); @@ -803,7 +803,7 @@ public void stopInterruptsConditionWaiting() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)), + FDv2SourceResult.changeSet(makeChangeSet(false), false), interrupted())), 120, 300); @@ -825,7 +825,7 @@ public void startedFlagPreventsMultipleRuns() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.singletonList(() -> { runCount.incrementAndGet(); - return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true))); + return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)); }), Collections.emptyList()); @@ -850,7 +850,7 @@ public void startBeforeRunCompletesAllComplete() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))))); + FDv2SourceResult.changeSet(makeChangeSet(false), false)))); AwaitableCallback cb1 = new AwaitableCallback<>(); AwaitableCallback cb2 = new AwaitableCallback<>(); @@ -869,7 +869,7 @@ public void multipleStartCallsEventuallyComplete() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))))); + FDv2SourceResult.changeSet(makeChangeSet(false), false)))); List> callbacks = new ArrayList<>(); for (int i = 0; i < 5; i++) { @@ -891,7 +891,7 @@ public void concurrentStopAndStartHandledSafely() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))))); + FDv2SourceResult.changeSet(makeChangeSet(false), false)))); AwaitableCallback startCallback = startDataSource(dataSource); stopDataSource(dataSource); // stop immediately after starting @@ -904,7 +904,7 @@ public void dataSourceUpdatesApplyThreadSafe() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); List results = new ArrayList<>(); for (int i = 0; i < 10; i++) { - results.add(FDv2SourceResult.changeSet(makeChangeSet(false))); + results.add(FDv2SourceResult.changeSet(makeChangeSet(false), false)); } MockQueuedSynchronizer sync = new MockQueuedSynchronizer(); for (FDv2SourceResult r : results) { @@ -935,7 +935,7 @@ public void initializerThrowsExecutionException_secondInitializerSucceeds() thro FDv2DataSource dataSource = buildDataSource(sink, Arrays.asList( () -> { firstCalled.set(true); return new MockInitializer(new RuntimeException("execution exception")); }, - () -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true)))), + () -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -953,7 +953,7 @@ public void initializerThrowsInterruptedException_secondInitializerSucceeds() th FDv2DataSource dataSource = buildDataSource(sink, Arrays.asList( () -> { firstCalled.set(true); return new MockInitializer(new InterruptedException("interrupted")); }, - () -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true)))), + () -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -971,7 +971,7 @@ public void synchronizerThrowsExecutionException_nextSynchronizerSucceeds() thro Collections.emptyList(), Arrays.asList( () -> new MockSynchronizer(new RuntimeException("execution exception")), - () -> new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false))))); + () -> new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false), false)))); AwaitableCallback startCallback = startDataSource(dataSource); assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); @@ -989,7 +989,7 @@ public void synchronizerThrowsInterruptedException_nextSynchronizerSucceeds() th Collections.emptyList(), Arrays.asList( () -> { firstCalled.set(true); return new MockSynchronizer(new InterruptedException("interrupted")); }, - () -> new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false))))); + () -> new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false), false)))); AwaitableCallback startCallback = startDataSource(dataSource); assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); @@ -1012,7 +1012,7 @@ public void activeSourceClosedWhenSwitchingSynchronizers() throws Exception { Collections.emptyList(), Arrays.asList( () -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)), + FDv2SourceResult.changeSet(makeChangeSet(false), false), terminalError()) { @Override public void close() { @@ -1020,7 +1020,7 @@ public void close() { super.close(); } }, - () -> new MockQueuedSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false))))); + () -> new MockQueuedSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false), false)))); AwaitableCallback startCallback = startDataSource(dataSource); assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); @@ -1038,7 +1038,7 @@ public void activeSourceClosedOnShutdown() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))) { + FDv2SourceResult.changeSet(makeChangeSet(false), false)) { @Override public void close() { syncClosed.set(true); @@ -1088,7 +1088,7 @@ public void conditionsClosedAfterSynchronizerLoop() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)), + FDv2SourceResult.changeSet(makeChangeSet(false), false), terminalError())), 1, 2); @@ -1105,9 +1105,9 @@ public void conditionsInformedOfAllResults() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)), + FDv2SourceResult.changeSet(makeChangeSet(false), false), interrupted(), - FDv2SourceResult.changeSet(makeChangeSet(false)))), + FDv2SourceResult.changeSet(makeChangeSet(false), false))), 10, 20); AwaitableCallback startCallback = startDataSource(dataSource); @@ -1126,7 +1126,7 @@ public void conditionsClosedOnException() throws Exception { Collections.emptyList(), Arrays.asList( () -> new MockSynchronizer(new RuntimeException("error")), - () -> new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false)))), + () -> new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false), false))), 1, 2); AwaitableCallback startCallback = startDataSource(dataSource); @@ -1143,7 +1143,7 @@ public void primeSynchronizerHasNoRecoveryCondition() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); MockQueuedSynchronizer firstSync = new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))); + FDv2SourceResult.changeSet(makeChangeSet(false), false)); FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), @@ -1171,8 +1171,8 @@ public void singleSynchronizerHasNoConditions() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)), - FDv2SourceResult.changeSet(makeChangeSet(false)))), + FDv2SourceResult.changeSet(makeChangeSet(false), false), + FDv2SourceResult.changeSet(makeChangeSet(false), false))), 1, 2); AwaitableCallback startCallback = startDataSource(dataSource); @@ -1190,8 +1190,8 @@ public void conditionFutureNeverCompletesWhenNoConditions() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)), - FDv2SourceResult.changeSet(makeChangeSet(false)))), + FDv2SourceResult.changeSet(makeChangeSet(false), false), + FDv2SourceResult.changeSet(makeChangeSet(false), false))), 1, 2); AwaitableCallback startCallback = startDataSource(dataSource); @@ -1213,10 +1213,10 @@ public void selectorNonEmptyCompletesInitialization() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Arrays.asList( - () -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true))), + () -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)), () -> { secondCalledQueue.offer(true); - return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false))); + return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), false)); }), Collections.emptyList()); @@ -1233,7 +1233,7 @@ public void initializerChangeSetWithoutSelectorCompletesIfLastInitializer() thro MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false)))), + Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -1249,7 +1249,7 @@ public void synchronizerChangeSetAlwaysCompletesStartFuture() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), - Collections.singletonList(() -> new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false))))); + Collections.singletonList(() -> new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false), false)))); AwaitableCallback startCallback = startDataSource(dataSource); assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); @@ -1263,9 +1263,9 @@ public void multipleChangeSetsAppliedInOrder() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)), - FDv2SourceResult.changeSet(makeChangeSet(false)), - FDv2SourceResult.changeSet(makeChangeSet(false))))); + FDv2SourceResult.changeSet(makeChangeSet(false), false), + FDv2SourceResult.changeSet(makeChangeSet(false), false), + FDv2SourceResult.changeSet(makeChangeSet(false), false)))); AwaitableCallback startCallback = startDataSource(dataSource); assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); @@ -1282,9 +1282,9 @@ public void goodbyeStatusHandledGracefully() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)), - FDv2SourceResult.status(FDv2SourceResult.Status.goodbye("server-requested")), - FDv2SourceResult.changeSet(makeChangeSet(false))))); + FDv2SourceResult.changeSet(makeChangeSet(false), false), + FDv2SourceResult.status(FDv2SourceResult.Status.goodbye("server-requested"), false), + FDv2SourceResult.changeSet(makeChangeSet(false), false)))); AwaitableCallback startCallback = startDataSource(dataSource); assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); @@ -1303,8 +1303,8 @@ public void shutdownStatusFallsBackToNextSynchronizer() throws Exception { Collections.emptyList(), Arrays.asList( () -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)), - FDv2SourceResult.status(FDv2SourceResult.Status.shutdown())), + FDv2SourceResult.changeSet(makeChangeSet(false), false), + FDv2SourceResult.status(FDv2SourceResult.Status.shutdown(), false)), () -> { secondSyncCalled.set(true); return new MockQueuedSynchronizer(); })); AwaitableCallback startCallback = startDataSource(dataSource); @@ -1324,7 +1324,7 @@ public void statusTransitionsToValidAfterInitialization() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false)))), + Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -1345,7 +1345,7 @@ public void statusIncludesErrorInfoOnFailure() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(terminalErr))))); + FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(terminalErr), false)))); AwaitableCallback startCallback = startDataSource(dataSource); assertFalse(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); @@ -1367,9 +1367,9 @@ public void statusRemainsValidDuringSynchronizerOperation() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)), - FDv2SourceResult.changeSet(makeChangeSet(false)), - FDv2SourceResult.changeSet(makeChangeSet(false))))); + FDv2SourceResult.changeSet(makeChangeSet(false), false), + FDv2SourceResult.changeSet(makeChangeSet(false), false), + FDv2SourceResult.changeSet(makeChangeSet(false), false)))); AwaitableCallback startCallback = startDataSource(dataSource); assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); @@ -1388,8 +1388,8 @@ public void statusTransitionsFromValidToOffWhenAllSynchronizersFail() throws Exc FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)), - FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(err))))); + FDv2SourceResult.changeSet(makeChangeSet(false), false), + FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(err), false)))); AwaitableCallback startCallback = startDataSource(dataSource); assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); // changeset arrives first, so start succeeds @@ -1409,7 +1409,7 @@ public void stopReportsOffStatus() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))))); + FDv2SourceResult.changeSet(makeChangeSet(false), false)))); AwaitableCallback startCallback = startDataSource(dataSource); assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); 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 367dab76..1863fa5b 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 @@ -118,7 +118,7 @@ public void http304_preservesCurrentSelector() { @Test public void recoverableHttpError_oneShot_returnsTerminalError() { MockFDv2Requestor requestor = new MockFDv2Requestor(); - requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.failure(500)); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.failure(500, false)); FDv2SourceResult result = doPoll(requestor, true); @@ -130,7 +130,7 @@ public void recoverableHttpError_oneShot_returnsTerminalError() { @Test public void recoverableHttpError_notOneShot_returnsInterrupted() { MockFDv2Requestor requestor = new MockFDv2Requestor(); - requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.failure(500)); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.failure(500, false)); FDv2SourceResult result = doPoll(requestor, false); @@ -142,7 +142,7 @@ public void recoverableHttpError_notOneShot_returnsInterrupted() { @Test public void nonRecoverableHttpError_oneShot_returnsTerminalError() { MockFDv2Requestor requestor = new MockFDv2Requestor(); - requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.failure(401)); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.failure(401, false)); FDv2SourceResult result = doPoll(requestor, true); @@ -155,7 +155,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)); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.failure(401, false)); FDv2SourceResult result = doPoll(requestor, false); @@ -196,7 +196,7 @@ public void networkError_notOneShot_returnsInterrupted() { public void emptyEventsArray_oneShot_returnsTerminalError() { MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - Collections.emptyList(), 200)); + Collections.emptyList(), 200, false)); FDv2SourceResult result = doPoll(requestor, true); @@ -209,7 +209,7 @@ public void emptyEventsArray_oneShot_returnsTerminalError() { public void emptyEventsArray_notOneShot_returnsInterrupted() { MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - Collections.emptyList(), 200)); + Collections.emptyList(), 200, false)); FDv2SourceResult result = doPoll(requestor, false); @@ -225,7 +225,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)); + parseEvents(XFER_FULL_NO_PAYLOAD_TRANSFERRED_JSON), 200, false)); FDv2SourceResult result = doPoll(requestor, true); @@ -238,7 +238,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)); + parseEvents(XFER_FULL_NO_PAYLOAD_TRANSFERRED_JSON), 200, false)); FDv2SourceResult result = doPoll(requestor, false); @@ -253,7 +253,7 @@ public void eventsWithNoChangesetAfterLoop_notOneShot_returnsInterrupted() { public void goodbyeEvent_returnsGoodbye() { MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(GOODBYE_JSON), 200)); + parseEvents(GOODBYE_JSON), 200, false)); FDv2SourceResult result = doPoll(requestor, true); @@ -269,7 +269,7 @@ public void goodbyeEvent_returnsGoodbye() { public void serverErrorEvent_oneShot_returnsTerminalError() { MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(ERROR_EVENT_JSON), 200)); + parseEvents(ERROR_EVENT_JSON), 200, false)); FDv2SourceResult result = doPoll(requestor, true); @@ -282,7 +282,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)); + parseEvents(ERROR_EVENT_JSON), 200, false)); FDv2SourceResult result = doPoll(requestor, false); @@ -297,7 +297,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)); + parseEvents(XFER_NONE_JSON), 200, false)); FDv2SourceResult result = doPoll(requestor, true); @@ -309,7 +309,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)); + parseEvents(XFER_FULL_EMPTY_JSON), 200, false)); FDv2SourceResult result = doPoll(requestor, true); @@ -331,7 +331,7 @@ public void internalError_malformedPayloadTransferred_oneShot_returnsTerminalErr MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(malformedPayloadTransferredJson), 200)); + parseEvents(malformedPayloadTransferredJson), 200, false)); FDv2SourceResult result = doPoll(requestor, true); @@ -350,7 +350,7 @@ public void internalError_malformedPayloadTransferred_notOneShot_returnsInterrup MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(malformedPayloadTransferredJson), 200)); + parseEvents(malformedPayloadTransferredJson), 200, false)); FDv2SourceResult result = doPoll(requestor, false); @@ -369,7 +369,7 @@ public void unrecognizedEventType_oneShot_returnsTerminalError() { MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(unknownEventJson), 200)); + parseEvents(unknownEventJson), 200, false)); FDv2SourceResult result = doPoll(requestor, true); @@ -384,7 +384,7 @@ public void unrecognizedEventType_notOneShot_returnsInterrupted() { MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(unknownEventJson), 200)); + parseEvents(unknownEventJson), 200, false)); FDv2SourceResult result = doPoll(requestor, false); @@ -425,7 +425,7 @@ public void fdv1FallbackPropagatedOnSuccess() { public void fdv1FallbackFalseByDefault() { MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(XFER_FULL_EMPTY_JSON), 200)); + parseEvents(XFER_FULL_EMPTY_JSON), 200, false)); FDv2SourceResult result = doPoll(requestor, false); 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 3f258a02..3cc72e23 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)); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200, false)); 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)); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.failure(500, false)); 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)); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.failure(401, false)); FDv2SourceResult result = future.get(1, TimeUnit.SECONDS); assertEquals(SourceResultType.STATUS, result.getResultType()); @@ -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)); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200, false)); future.get(1, TimeUnit.SECONDS); 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 ed982f11..6335b5a8 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)); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200, false)); FDv2SourceResult result = nextFuture.get(1, TimeUnit.SECONDS); assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); @@ -94,7 +94,7 @@ 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)); + poll1.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200, false)); FDv2SourceResult result1 = nextFuture1.get(1, TimeUnit.SECONDS); assertEquals(SourceResultType.CHANGE_SET, result1.getResultType()); @@ -169,7 +169,7 @@ public void terminalError_stopsPollingAndDeliveredViaNext() throws Exception { Future nextFuture = synchronizer.next(); LDAwaitFuture pollFuture = requestor.awaitNextPoll(); - pollFuture.set(FDv2Requestor.FDv2PayloadResponse.failure(401)); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.failure(401, false)); 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)); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.failure(401, false)); 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)); + poll1.set(FDv2Requestor.FDv2PayloadResponse.failure(500, false)); 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)); + poll2.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200, false)); 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)); + poll2.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200, false)); 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)); + poll1.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(malformedJson), 200, false)); 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)); + poll2.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200, false)); 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)); + poll1.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(unknownEventJson), 200, false)); 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)); + poll2.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200, false)); FDv2SourceResult result2 = nextFuture2.get(1, TimeUnit.SECONDS); assertEquals(SourceResultType.CHANGE_SET, result2.getResultType()); } finally { From 86d7645ee452af33780df5b53ebfc40d2ea80211 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 31 Mar 2026 16:19:23 -0700 Subject: [PATCH 07/23] [SDK-1829] refactor: use single nullable FDv1 fallback synchronizer instead of list Aligns with java-core and js-core implementations, which both model FDv1 fallback as a single optional synchronizer rather than a list. Made-with: Cursor --- .../sdk/android/FDv2DataSource.java | 24 +++++++-------- .../sdk/android/FDv2DataSourceBuilder.java | 27 +++++++---------- .../sdk/android/ModeDefinition.java | 13 ++++---- .../sdk/android/ResolvedModeDefinition.java | 13 ++++---- .../sdk/android/ConnectivityManagerTest.java | 12 ++++---- .../android/FDv2DataSourceBuilderTest.java | 30 +++++++++---------- .../sdk/android/FDv2DataSourceTest.java | 6 ++-- 7 files changed, 59 insertions(+), 66 deletions(-) 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 1de6dae2..c9172cef 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 @@ -59,19 +59,19 @@ public interface DataSourceFactory { /** * Convenience constructor using default fallback and recovery timeouts. - * See {@link #FDv2DataSource(LDContext, List, List, List, DataSourceUpdateSinkV2, + * See {@link #FDv2DataSource(LDContext, List, List, DataSourceFactory, DataSourceUpdateSinkV2, * ScheduledExecutorService, LDLogger, long, long)} for parameter documentation. */ FDv2DataSource( @NonNull LDContext evaluationContext, @NonNull List> initializers, @NonNull List> synchronizers, - @Nullable List> fdv1FallbackSynchronizers, + @Nullable DataSourceFactory fdv1FallbackSynchronizer, @NonNull DataSourceUpdateSinkV2 dataSourceUpdateSink, @NonNull ScheduledExecutorService sharedExecutor, @NonNull LDLogger logger ) { - this(evaluationContext, initializers, synchronizers, fdv1FallbackSynchronizers, + this(evaluationContext, initializers, synchronizers, fdv1FallbackSynchronizer, dataSourceUpdateSink, sharedExecutor, logger, FDv2DataSourceConditions.DEFAULT_FALLBACK_TIMEOUT_SECONDS, FDv2DataSourceConditions.DEFAULT_RECOVERY_TIMEOUT_SECONDS); @@ -81,9 +81,9 @@ public interface DataSourceFactory { * @param evaluationContext the context to evaluate flags for * @param initializers factories for one-shot initializers, tried in order * @param synchronizers factories for recurring synchronizers, tried in order - * @param fdv1FallbackSynchronizers factories for FDv1 fallback synchronizers, or null if none; - * these are appended after the regular synchronizers in a - * blocked state and only activated when the server sends the + * @param fdv1FallbackSynchronizer factory for the FDv1 fallback synchronizer, or null if none; + * appended after the regular synchronizers in a blocked state + * and only activated when the server sends the * {@code x-ld-fd-fallback} header * @param dataSourceUpdateSink sink to apply changesets and status updates to * @param sharedExecutor executor used for internal background tasks; must have at least @@ -98,7 +98,7 @@ public interface DataSourceFactory { @NonNull LDContext evaluationContext, @NonNull List> initializers, @NonNull List> synchronizers, - @Nullable List> fdv1FallbackSynchronizers, + @Nullable DataSourceFactory fdv1FallbackSynchronizer, @NonNull DataSourceUpdateSinkV2 dataSourceUpdateSink, @NonNull ScheduledExecutorService sharedExecutor, @NonNull LDLogger logger, @@ -113,12 +113,10 @@ public interface DataSourceFactory { for (DataSourceFactory factory : synchronizers) { allSynchronizers.add(new SynchronizerFactoryWithState(factory)); } - if (fdv1FallbackSynchronizers != null) { - for (DataSourceFactory factory : fdv1FallbackSynchronizers) { - SynchronizerFactoryWithState fdv1 = new SynchronizerFactoryWithState(factory, true); - fdv1.block(); - allSynchronizers.add(fdv1); - } + if (fdv1FallbackSynchronizer != null) { + SynchronizerFactoryWithState fdv1 = new SynchronizerFactoryWithState(fdv1FallbackSynchronizer, true); + fdv1.block(); + allSynchronizers.add(fdv1); } this.sourceManager = new SourceManager(allSynchronizers, new ArrayList<>(initializers)); diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java index 95333b7c..0353bd6d 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java @@ -104,39 +104,36 @@ private Map makeDefaultModeTable() { ctx.getBaseLogger()); }; - List> fdv1FallbackList = - Collections.singletonList(fdv1FallbackPollingSynchronizer); - Map table = new LinkedHashMap<>(); table.put(ConnectionMode.STREAMING, new ModeDefinition( // TODO: cacheInitializer — add once implemented Arrays.asList(/* cacheInitializer, */ pollingInitializer), Arrays.asList(streamingSynchronizer, pollingSynchronizer), - fdv1FallbackList + fdv1FallbackPollingSynchronizer )); table.put(ConnectionMode.POLLING, new ModeDefinition( // TODO: Arrays.asList(cacheInitializer) — add once implemented Collections.>emptyList(), Collections.singletonList(pollingSynchronizer), - fdv1FallbackList + fdv1FallbackPollingSynchronizer )); table.put(ConnectionMode.OFFLINE, new ModeDefinition( // TODO: Arrays.asList(cacheInitializer) — add once implemented Collections.>emptyList(), Collections.>emptyList(), - Collections.>emptyList() + null )); table.put(ConnectionMode.ONE_SHOT, new ModeDefinition( // TODO: cacheInitializer and streamingInitializer — add once implemented Arrays.asList(/* cacheInitializer, */ pollingInitializer /*, streamingInitializer, */), Collections.>emptyList(), - Collections.>emptyList() + null )); table.put(ConnectionMode.BACKGROUND, new ModeDefinition( // TODO: Arrays.asList(cacheInitializer) — add once implemented Collections.>emptyList(), Collections.singletonList(backgroundPollingSynchronizer), - fdv1FallbackList + fdv1FallbackPollingSynchronizer )); return table; } @@ -211,14 +208,11 @@ public DataSource build(ClientContext clientContext) { // Reset includeInitializers to default after each build to prevent stale state. includeInitializers = true; - List> fdv1Factories = - resolved.getFdv1FallbackSynchronizerFactories(); - return new FDv2DataSource( clientContext.getEvaluationContext(), initFactories, resolved.getSynchronizerFactories(), - fdv1Factories.isEmpty() ? null : fdv1Factories, + resolved.getFdv1FallbackSynchronizerFactory(), (DataSourceUpdateSinkV2) baseSink, sharedExecutor, clientContext.getBaseLogger() @@ -244,11 +238,10 @@ private static ResolvedModeDefinition resolve( for (ComponentConfigurer configurer : def.getSynchronizers()) { syncFactories.add(() -> configurer.build(clientContext)); } - List> fdv1FallbackFactories = new ArrayList<>(); - for (ComponentConfigurer configurer : def.getFdv1FallbackSynchronizers()) { - fdv1FallbackFactories.add(() -> configurer.build(clientContext)); - } - return new ResolvedModeDefinition(initFactories, syncFactories, fdv1FallbackFactories); + ComponentConfigurer fdv1Configurer = def.getFdv1FallbackSynchronizer(); + FDv2DataSource.DataSourceFactory fdv1Factory = + fdv1Configurer != null ? () -> fdv1Configurer.build(clientContext) : null; + return new ResolvedModeDefinition(initFactories, syncFactories, fdv1Factory); } /** diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java index 18959ee1..4d7628ff 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.android; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import com.launchdarkly.sdk.android.subsystems.ComponentConfigurer; import com.launchdarkly.sdk.android.subsystems.Initializer; @@ -27,16 +28,16 @@ final class ModeDefinition { private final List> initializers; private final List> synchronizers; - private final List> fdv1FallbackSynchronizers; + private final ComponentConfigurer fdv1FallbackSynchronizer; ModeDefinition( @NonNull List> initializers, @NonNull List> synchronizers, - @NonNull List> fdv1FallbackSynchronizers + @Nullable ComponentConfigurer fdv1FallbackSynchronizer ) { this.initializers = Collections.unmodifiableList(initializers); this.synchronizers = Collections.unmodifiableList(synchronizers); - this.fdv1FallbackSynchronizers = Collections.unmodifiableList(fdv1FallbackSynchronizers); + this.fdv1FallbackSynchronizer = fdv1FallbackSynchronizer; } @NonNull @@ -49,8 +50,8 @@ List> getSynchronizers() { return synchronizers; } - @NonNull - List> getFdv1FallbackSynchronizers() { - return fdv1FallbackSynchronizers; + @Nullable + ComponentConfigurer getFdv1FallbackSynchronizer() { + return fdv1FallbackSynchronizer; } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java index 314f903f..c8220fd5 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.android; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import com.launchdarkly.sdk.android.subsystems.Initializer; import com.launchdarkly.sdk.android.subsystems.Synchronizer; @@ -24,16 +25,16 @@ final class ResolvedModeDefinition { private final List> initializerFactories; private final List> synchronizerFactories; - private final List> fdv1FallbackSynchronizerFactories; + private final FDv2DataSource.DataSourceFactory fdv1FallbackSynchronizerFactory; ResolvedModeDefinition( @NonNull List> initializerFactories, @NonNull List> synchronizerFactories, - @NonNull List> fdv1FallbackSynchronizerFactories + @Nullable FDv2DataSource.DataSourceFactory fdv1FallbackSynchronizerFactory ) { this.initializerFactories = Collections.unmodifiableList(initializerFactories); this.synchronizerFactories = Collections.unmodifiableList(synchronizerFactories); - this.fdv1FallbackSynchronizerFactories = Collections.unmodifiableList(fdv1FallbackSynchronizerFactories); + this.fdv1FallbackSynchronizerFactory = fdv1FallbackSynchronizerFactory; } @NonNull @@ -46,8 +47,8 @@ List> getSynchronizerFactories() return synchronizerFactories; } - @NonNull - List> getFdv1FallbackSynchronizerFactories() { - return fdv1FallbackSynchronizerFactories; + @Nullable + FDv2DataSource.DataSourceFactory getFdv1FallbackSynchronizerFactory() { + return fdv1FallbackSynchronizerFactory; } } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java index 58bccace..07871781 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java @@ -855,17 +855,17 @@ private FDv2DataSourceBuilder makeFDv2DataSourceFactory() { table.put(com.launchdarkly.sdk.android.ConnectionMode.STREAMING, new ModeDefinition( Collections.>emptyList(), Collections.>singletonList(ctx -> null), - Collections.>emptyList() + null )); table.put(com.launchdarkly.sdk.android.ConnectionMode.BACKGROUND, new ModeDefinition( Collections.>emptyList(), Collections.>singletonList(ctx -> null), - Collections.>emptyList() + null )); table.put(com.launchdarkly.sdk.android.ConnectionMode.OFFLINE, new ModeDefinition( Collections.>emptyList(), Collections.>emptyList(), - Collections.>emptyList() + null )); return new FDv2DataSourceBuilder(table, com.launchdarkly.sdk.android.ConnectionMode.STREAMING) { @Override @@ -1013,7 +1013,7 @@ public void fdv2_equivalentConfigDoesNotRebuild() throws Exception { ModeDefinition sharedDef = new ModeDefinition( Collections.>emptyList(), Collections.>singletonList(ctx -> null), - Collections.>emptyList() + null ); Map table = new LinkedHashMap<>(); table.put(com.launchdarkly.sdk.android.ConnectionMode.STREAMING, sharedDef); @@ -1078,12 +1078,12 @@ public void fdv2_modeSwitchDoesNotIncludeInitializers() throws Exception { table.put(com.launchdarkly.sdk.android.ConnectionMode.STREAMING, new ModeDefinition( Collections.>singletonList(ctx -> null), Collections.>singletonList(ctx -> null), - Collections.>emptyList() + null )); table.put(com.launchdarkly.sdk.android.ConnectionMode.BACKGROUND, new ModeDefinition( Collections.>singletonList(ctx -> null), Collections.>singletonList(ctx -> null), - Collections.>emptyList() + null )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(table, com.launchdarkly.sdk.android.ConnectionMode.STREAMING) { diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java index 21076ed0..7da9723e 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java @@ -68,7 +68,7 @@ public void customModeTable_buildsCorrectly() { customTable.put(ConnectionMode.POLLING, new ModeDefinition( Collections.>emptyList(), Collections.>singletonList(ctx -> null), - Collections.>emptyList() + null )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.POLLING); @@ -82,7 +82,7 @@ public void startingMode_notInTable_throws() { customTable.put(ConnectionMode.POLLING, new ModeDefinition( Collections.>emptyList(), Collections.>singletonList(ctx -> null), - Collections.>emptyList() + null )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); @@ -100,12 +100,12 @@ public void setActiveMode_buildUsesSpecifiedMode() { customTable.put(ConnectionMode.STREAMING, new ModeDefinition( Collections.>singletonList(ctx -> null), Collections.>singletonList(ctx -> null), - Collections.>emptyList() + null )); customTable.put(ConnectionMode.POLLING, new ModeDefinition( Collections.>emptyList(), Collections.>singletonList(ctx -> null), - Collections.>emptyList() + null )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); @@ -120,7 +120,7 @@ public void setActiveMode_withoutInitializers_buildsWithEmptyInitializers() { customTable.put(ConnectionMode.STREAMING, new ModeDefinition( Collections.>singletonList(ctx -> null), Collections.>singletonList(ctx -> null), - Collections.>emptyList() + null )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); @@ -135,7 +135,7 @@ public void defaultBehavior_usesStartingMode() { customTable.put(ConnectionMode.STREAMING, new ModeDefinition( Collections.>singletonList(ctx -> null), Collections.>singletonList(ctx -> null), - Collections.>emptyList() + null )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); @@ -148,12 +148,12 @@ public void getModeDefinition_returnsCorrectDefinition() { ModeDefinition streamingDef = new ModeDefinition( Collections.>singletonList(ctx -> null), Collections.>singletonList(ctx -> null), - Collections.>emptyList() + null ); ModeDefinition pollingDef = new ModeDefinition( Collections.>emptyList(), Collections.>singletonList(ctx -> null), - Collections.>emptyList() + null ); Map customTable = new LinkedHashMap<>(); @@ -171,7 +171,7 @@ public void getModeDefinition_sameObjectUsedForEquivalenceCheck() { ModeDefinition sharedDef = new ModeDefinition( Collections.>emptyList(), Collections.>singletonList(ctx -> null), - Collections.>emptyList() + null ); Map customTable = new LinkedHashMap<>(); @@ -244,7 +244,7 @@ public void defaultModeTable_streamingHasFdv1Fallback() { assertNotNull(streaming); assertEquals(1, streaming.getInitializers().size()); assertEquals(2, streaming.getSynchronizers().size()); - assertEquals(1, streaming.getFdv1FallbackSynchronizers().size()); + assertNotNull(streaming.getFdv1FallbackSynchronizer()); } @Test @@ -256,7 +256,7 @@ public void defaultModeTable_pollingHasFdv1Fallback() { assertNotNull(polling); assertEquals(0, polling.getInitializers().size()); assertEquals(1, polling.getSynchronizers().size()); - assertEquals(1, polling.getFdv1FallbackSynchronizers().size()); + assertNotNull(polling.getFdv1FallbackSynchronizer()); } @Test @@ -268,7 +268,7 @@ public void defaultModeTable_backgroundHasFdv1Fallback() { assertNotNull(background); assertEquals(0, background.getInitializers().size()); assertEquals(1, background.getSynchronizers().size()); - assertEquals(1, background.getFdv1FallbackSynchronizers().size()); + assertNotNull(background.getFdv1FallbackSynchronizer()); } @Test @@ -280,7 +280,7 @@ public void defaultModeTable_offlineHasNoFdv1Fallback() { assertNotNull(offline); assertEquals(0, offline.getInitializers().size()); assertEquals(0, offline.getSynchronizers().size()); - assertEquals(0, offline.getFdv1FallbackSynchronizers().size()); + assertNull(offline.getFdv1FallbackSynchronizer()); } @Test @@ -292,7 +292,7 @@ public void defaultModeTable_oneShotHasNoFdv1Fallback() { assertNotNull(oneShot); assertEquals(1, oneShot.getInitializers().size()); assertEquals(0, oneShot.getSynchronizers().size()); - assertEquals(0, oneShot.getFdv1FallbackSynchronizers().size()); + assertNull(oneShot.getFdv1FallbackSynchronizer()); } @Test @@ -301,7 +301,7 @@ public void setActiveMode_notInTable_throws() { customTable.put(ConnectionMode.STREAMING, new ModeDefinition( Collections.>emptyList(), Collections.>singletonList(ctx -> null), - Collections.>emptyList() + null )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); 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 95e99b16..93cde493 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 @@ -1439,7 +1439,7 @@ public void fdv1FallbackSwitchesToFdv1Synchronizer() throws Exception { CONTEXT, Collections.>emptyList(), Collections.>singletonList(() -> fdv2Sync), - Collections.>singletonList(() -> fdv1Sync), + () -> fdv1Sync, sink, executor, logging.logger); @@ -1470,10 +1470,10 @@ public void fdv1FallbackNotTriggeredWhenAlreadyOnFdv1() throws Exception { CONTEXT, Collections.>emptyList(), Collections.>singletonList(() -> fdv2Sync), - Collections.>singletonList(() -> { + () -> { fdv1BuildCount.incrementAndGet(); return fdv1Sync; - }), + }, sink, executor, logging.logger); From dddc21f5aeb8c030911be1b692a7a119b7c67058 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Wed, 1 Apr 2026 09:42:26 -0700 Subject: [PATCH 08/23] [SDK-1829] feat: detect FDv1 fallback signal during initialization Check for x-ld-fd-fallback during the initializer phase, not just during synchronization. When an initializer response carries the fallback signal, skip remaining initializers and transition directly to the FDv1 synchronizer. Aligns with js-core's implementation. Made-with: Cursor --- .../sdk/android/FDv2DataSource.java | 17 ++++++++++ .../sdk/android/FDv2DataSourceTest.java | 33 +++++++++++++++++++ 2 files changed, 50 insertions(+) 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 c9172cef..2c0b47a1 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 @@ -282,6 +282,18 @@ private void runInitializers( } break; } + + if (result.isFdv1Fallback() + && sourceManager.hasFDv1Fallback()) { + logger.info("Server signaled FDv1 fallback during initialization; " + + "skipping remaining initializers."); + sourceManager.fdv1Fallback(); + if (anyDataReceived) { + sink.setStatus(DataSourceState.VALID, null); + tryCompleteStart(true, null); + } + return; + } } catch (ExecutionException e) { logger.warn("Initializer error: {}", e.getCause() != null ? e.getCause().toString() : e.toString()); sink.setStatus(DataSourceState.INTERRUPTED, e.getCause() != null ? e.getCause() : e); @@ -400,6 +412,11 @@ private void runSynchronizers( break; } + // 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). Only act on the signal if the + // synchronizer is still running (not shut down or terminally errored), + // a fallback slot exists, and we aren't already on FDv1. if (running && result.isFdv1Fallback() && sourceManager.hasFDv1Fallback() 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 93cde493..2eafa805 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 @@ -1425,6 +1425,39 @@ public void stopReportsOffStatus() throws Exception { // ---- FDv1 fallback ---- + @Test + public void fdv1FallbackDuringInitializationSkipsRemainingInitializers() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + AtomicBoolean secondInitCalled = new AtomicBoolean(false); + + MockQueuedSynchronizer fdv1Sync = new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(true), false)); + + FDv2DataSource dataSource = new FDv2DataSource( + CONTEXT, + Arrays.>asList( + () -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), true)), + () -> { + secondInitCalled.set(true); + return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)); + }), + Collections.>singletonList( + () -> new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false), false))), + () -> fdv1Sync, + sink, + executor, + logging.logger); + + AwaitableCallback startCallback = startDataSource(dataSource); + assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); + + assertFalse(secondInitCalled.get()); + + sink.awaitApplyCount(2, AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + stopDataSource(dataSource); + } + @Test public void fdv1FallbackSwitchesToFdv1Synchronizer() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); From 9af892008aa5e7e80fbf86f5780ed8e5671dcbe3 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Wed, 1 Apr 2026 10:05:44 -0700 Subject: [PATCH 09/23] [SDK-1829] fix: forward FDv1 fallback header on 304 Not Modified responses The notModified() factory previously hardcoded fdv1Fallback to false, silently discarding the x-ld-fd-fallback header on 304 responses. Now the computed value is forwarded, aligning with the CSFDV2 spec and js-core behavior. Made-with: Cursor --- .../launchdarkly/sdk/android/DefaultFDv2Requestor.java | 2 +- .../java/com/launchdarkly/sdk/android/FDv2Requestor.java | 4 ++-- .../com/launchdarkly/sdk/android/FDv2PollingBaseTest.java | 6 +++--- .../sdk/android/FDv2PollingInitializerTest.java | 6 +++--- .../sdk/android/FDv2PollingSynchronizerTest.java | 8 ++++---- 5 files changed, 13 insertions(+), 13 deletions(-) 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 652b9d43..2ffa79e8 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 @@ -191,7 +191,7 @@ private void handleResponse( if (code == 304) { logger.debug("FDv2 polling: 304 Not Modified"); - future.set(FDv2PayloadResponse.notModified()); + future.set(FDv2PayloadResponse.notModified(fdv1Fallback)); return; } 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 a6b59298..b4b351b2 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 @@ -49,8 +49,8 @@ static FDv2PayloadResponse success(@NonNull List events, int statusCo * Creates a successful 304 Not Modified response indicating no change since the * last request. */ - static FDv2PayloadResponse notModified() { - return new FDv2PayloadResponse(null, true, 304, false); + static FDv2PayloadResponse notModified(boolean fdv1Fallback) { + return new FDv2PayloadResponse(null, true, 304, fdv1Fallback); } /** Creates an unsuccessful response with the HTTP status code. */ 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 1863fa5b..86852f97 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 @@ -90,7 +90,7 @@ private static FDv2SourceResult doPoll(MockFDv2Requestor requestor, boolean oneS @Test public void http304_returnsNoneChangeSet() { MockFDv2Requestor requestor = new MockFDv2Requestor(); - requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.notModified()); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.notModified(false)); FDv2SourceResult result = doPoll(requestor, true); @@ -103,7 +103,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()); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.notModified(false)); FDv2SourceResult result = FDv2PollingBase.doPoll(requestor, LOGGER, selector, false); @@ -399,7 +399,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()); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.notModified(false)); FDv2PollingBase.doPoll(requestor, LOGGER, selector, true); 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 3cc72e23..acab2918 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 @@ -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()); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.notModified(false)); } @Test @@ -159,7 +159,7 @@ public void closeAfterPollCompletes_doesNotThrow() throws Exception { Future future = initializer.run(); LDAwaitFuture pollFuture = requestor.awaitNextPoll(); - pollFuture.set(FDv2Requestor.FDv2PayloadResponse.notModified()); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.notModified(false)); // Wait for poll to complete, then close — should not throw. 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()); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.notModified(false)); 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 6335b5a8..ffa8cfd2 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 @@ -101,7 +101,7 @@ public void consecutivePolls_deliveredInOrder() throws Exception { // Second poll (scheduleWithFixedDelay fires after interval elapses) Future nextFuture2 = synchronizer.next(); LDAwaitFuture poll2 = requestor.awaitNextPoll(); - poll2.set(FDv2Requestor.FDv2PayloadResponse.notModified()); + poll2.set(FDv2Requestor.FDv2PayloadResponse.notModified(false)); 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()); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.notModified(false)); } @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()); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.notModified(false)); nextFuture.get(1, TimeUnit.SECONDS); assertEquals(1, requestor.closeCount); @@ -378,7 +378,7 @@ public void selectorIsPassedToRequestor() throws Exception { Future nextFuture = synchronizer.next(); LDAwaitFuture pollFuture = requestor.awaitNextPoll(); - pollFuture.set(FDv2Requestor.FDv2PayloadResponse.notModified()); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.notModified(false)); nextFuture.get(1, TimeUnit.SECONDS); assertEquals(selector, requestor.receivedSelectors.poll(1, TimeUnit.SECONDS)); From 3fd905cdf9cf81817ab41e4ac259623f1587d16c Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Wed, 1 Apr 2026 11:41:36 -0700 Subject: [PATCH 10/23] [SDK-1829] refactor: inject FeatureFetcher into FDv1PollingSynchronizer Mirror how PollingDataSource delegates HTTP concerns to a shared FeatureFetcher rather than managing its own OkHttp client and URI construction. The synchronizer now calls fetcher.fetch() for the raw JSON and handles only the FDv2 adaptation (ChangeSet conversion, error classification, scheduling). Made-with: Cursor --- .../sdk/android/FDv1PollingSynchronizer.java | 186 +++------ .../sdk/android/FDv2DataSourceBuilder.java | 9 +- .../android/FDv1PollingSynchronizerTest.java | 389 +++++++----------- 3 files changed, 215 insertions(+), 369 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizer.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizer.java index 5bc29330..3e7bea85 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizer.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizer.java @@ -5,51 +5,34 @@ import com.launchdarkly.logging.LDLogger; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.android.DataModel.Flag; +import com.launchdarkly.sdk.android.subsystems.Callback; import com.launchdarkly.sdk.android.subsystems.FDv2SourceResult; import com.launchdarkly.sdk.android.subsystems.Synchronizer; import com.launchdarkly.sdk.fdv2.ChangeSet; import com.launchdarkly.sdk.fdv2.ChangeSetType; import com.launchdarkly.sdk.fdv2.Selector; -import com.launchdarkly.sdk.internal.http.HttpHelpers; -import com.launchdarkly.sdk.internal.http.HttpProperties; -import com.launchdarkly.sdk.json.JsonSerialization; import com.launchdarkly.sdk.json.SerializationException; import java.io.IOException; -import java.net.URI; import java.util.Map; import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; -import okhttp3.Call; -import okhttp3.Callback; -import okhttp3.ConnectionPool; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.Response; -import okhttp3.ResponseBody; - -import static com.launchdarkly.sdk.android.LDConfig.JSON; - /** * FDv1 polling synchronizer used as a fallback when the server signals that FDv2 endpoints * are unavailable via the {@code x-ld-fd-fallback} response header. *

- * Polls the FDv1 mobile evaluation endpoint and converts the response into - * {@link FDv2SourceResult} objects so it can be used as a drop-in synchronizer within the - * FDv2 data source pipeline. + * Delegates the actual HTTP fetch to a {@link FeatureFetcher} (the same transport used by the + * production FDv1 polling data source) and converts the response into {@link FDv2SourceResult} + * objects so it can be used as a drop-in synchronizer within the FDv2 data source pipeline. */ final class FDv1PollingSynchronizer implements Synchronizer { - private final URI pollingUri; - private final boolean useReport; - private final boolean evaluationReasons; - private final okhttp3.Headers headers; - private final RequestBody reportBody; - private final OkHttpClient httpClient; + private final LDContext evaluationContext; + private final FeatureFetcher fetcher; + private final boolean ownsFetcher; private final LDLogger logger; private final LDAsyncQueue resultQueue = new LDAsyncQueue<>(); @@ -60,10 +43,9 @@ final class FDv1PollingSynchronizer implements Synchronizer { /** * @param evaluationContext the context to evaluate flags for - * @param pollingBaseUri base URI for the FDv1 polling endpoint - * @param httpProperties SDK HTTP configuration - * @param useReport true to use HTTP REPORT with context in body - * @param evaluationReasons true to request evaluation reasons + * @param fetcher the HTTP transport for FDv1 polling requests + * @param ownsFetcher true if this synchronizer should close the fetcher on shutdown; + * false if the fetcher is shared and managed externally * @param executor scheduler for recurring poll tasks * @param initialDelayMillis delay before the first poll in milliseconds * @param pollIntervalMillis delay between the end of one poll and the start of the next @@ -71,34 +53,16 @@ final class FDv1PollingSynchronizer implements Synchronizer { */ FDv1PollingSynchronizer( @NonNull LDContext evaluationContext, - @NonNull URI pollingBaseUri, - @NonNull HttpProperties httpProperties, - boolean useReport, - boolean evaluationReasons, + @NonNull FeatureFetcher fetcher, + boolean ownsFetcher, @NonNull ScheduledExecutorService executor, long initialDelayMillis, long pollIntervalMillis, @NonNull LDLogger logger) { - this.useReport = useReport; - this.evaluationReasons = evaluationReasons; + this.evaluationContext = evaluationContext; + this.fetcher = fetcher; + this.ownsFetcher = ownsFetcher; this.logger = logger; - this.headers = httpProperties.toHeadersBuilder().build(); - - URI basePath = HttpHelpers.concatenateUriPath(pollingBaseUri, - useReport - ? StandardEndpoints.POLLING_REQUEST_REPORT_BASE_PATH - : StandardEndpoints.POLLING_REQUEST_GET_BASE_PATH); - this.pollingUri = useReport - ? basePath - : HttpHelpers.concatenateUriPath(basePath, LDUtil.urlSafeBase64(evaluationContext)); - this.reportBody = useReport - ? RequestBody.create(JsonSerialization.serialize(evaluationContext), JSON) - : null; - - this.httpClient = httpProperties.toHttpClientBuilder() - .connectionPool(new ConnectionPool(0, 1, TimeUnit.MILLISECONDS)) - .retryOnConnectionFailure(true) - .build(); synchronized (taskLock) { scheduledTask = executor.scheduleWithFixedDelay( @@ -122,7 +86,7 @@ private void pollAndEnqueue() { scheduledTask = null; } } - closeHttpClient(); + closeFetcher(); shutdownFuture.set(result); return; } @@ -136,104 +100,69 @@ private void pollAndEnqueue() { } private FDv2SourceResult doPoll() { - LDAwaitFuture pollFuture = new LDAwaitFuture<>(); + LDAwaitFuture jsonFuture = new LDAwaitFuture<>(); - try { - URI requestUri = pollingUri; - if (evaluationReasons) { - requestUri = HttpHelpers.addQueryParam(requestUri, "withReasons", "true"); + fetcher.fetch(evaluationContext, new Callback() { + @Override + public void onSuccess(String result) { + jsonFuture.set(result); } - logger.debug("FDv1 fallback polling request to: {}", requestUri); + @Override + public void onError(Throwable e) { + jsonFuture.setException(e); + } + }); - Request.Builder reqBuilder = new Request.Builder() - .url(requestUri.toURL()) - .headers(headers); + try { + String json = jsonFuture.get(); + logger.debug("FDv1 fallback polling response received"); - if (useReport) { - reqBuilder.method("REPORT", reportBody); - } else { - reqBuilder.get(); - } + EnvironmentData envData = EnvironmentData.fromJson(json); + Map flags = envData.getAll(); - httpClient.newCall(reqBuilder.build()).enqueue(new Callback() { - @Override - public void onFailure(@NonNull Call call, @NonNull IOException e) { - pollFuture.setException(e); - } + ChangeSet> changeSet = new ChangeSet<>( + ChangeSetType.Full, + Selector.EMPTY, + flags, + null, + true); - @Override - public void onResponse(@NonNull Call call, @NonNull Response response) { - try { - handleResponse(response, pollFuture); - } finally { - response.close(); - } - } - }); + return FDv2SourceResult.changeSet(changeSet, false); - return pollFuture.get(); } catch (InterruptedException e) { return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(e), false); - } catch (Exception e) { + } catch (java.util.concurrent.ExecutionException e) { Throwable cause = e.getCause() != null ? e.getCause() : e; - if (cause instanceof IOException) { - LDUtil.logExceptionAtErrorLevel(logger, cause, "FDv1 fallback polling failed with network error"); - } else { - LDUtil.logExceptionAtErrorLevel(logger, cause, "FDv1 fallback polling failed"); - } - return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(cause), false); - } - } - private void handleResponse(@NonNull Response response, @NonNull LDAwaitFuture future) { - try { - int code = response.code(); - - if (!response.isSuccessful()) { + if (cause instanceof LDInvalidResponseCodeFailure) { + int code = ((LDInvalidResponseCodeFailure) cause).getResponseCode(); if (code == 400) { - logger.error("Received 400 response when fetching flag values. Please check recommended R8 and/or ProGuard settings"); + logger.error("Received 400 response when fetching flag values. " + + "Please check recommended R8 and/or ProGuard settings"); } boolean recoverable = LDUtil.isHttpErrorRecoverable(code); logger.warn("FDv1 fallback polling failed with HTTP {}", code); LDFailure failure = new LDInvalidResponseCodeFailure( - "FDv1 fallback polling request failed", null, code, recoverable); + "FDv1 fallback polling request failed", cause, code, recoverable); if (!recoverable) { - future.set(FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), false)); + return FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), false); } else { - future.set(FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), false)); + return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), false); } - return; } - ResponseBody body = response.body(); - if (body == null) { - future.setException(new IOException("FDv1 fallback polling response had no body")); - return; + if (cause instanceof IOException) { + LDUtil.logExceptionAtErrorLevel(logger, cause, "FDv1 fallback polling failed with network error"); + } else { + LDUtil.logExceptionAtErrorLevel(logger, cause, "FDv1 fallback polling failed"); } - - String bodyStr = body.string(); - logger.debug("FDv1 fallback polling response received"); - - EnvironmentData envData = EnvironmentData.fromJson(bodyStr); - Map flags = envData.getAll(); - - ChangeSet> changeSet = new ChangeSet<>( - ChangeSetType.Full, - Selector.EMPTY, - flags, - null, - true); - - future.set(FDv2SourceResult.changeSet(changeSet, false)); - + return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(cause), false); } catch (SerializationException e) { LDUtil.logExceptionAtErrorLevel(logger, e, "FDv1 fallback polling failed to parse response"); LDFailure failure = new LDFailure( "FDv1 fallback: invalid JSON response", e, LDFailure.FailureType.INVALID_RESPONSE_BODY); - future.set(FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), false)); - } catch (Exception e) { - future.setException(e); + return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), false); } } @@ -252,10 +181,15 @@ public void close() { } } shutdownFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown(), false)); - closeHttpClient(); + closeFetcher(); } - private void closeHttpClient() { - HttpProperties.shutdownHttpClient(httpClient); + private void closeFetcher() { + if (ownsFetcher) { + try { + fetcher.close(); + } catch (IOException ignored) { + } + } } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java index 0353bd6d..47c0f8bc 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java @@ -91,14 +91,9 @@ private Map makeDefaultModeTable() { }; ComponentConfigurer fdv1FallbackPollingSynchronizer = ctx -> { - DataSourceSetup s = new DataSourceSetup(ctx); - URI pollingBase = StandardEndpoints.selectBaseUri( - ctx.getServiceEndpoints().getPollingBaseUri(), - StandardEndpoints.DEFAULT_POLLING_BASE_URI, - "polling", ctx.getBaseLogger()); + FeatureFetcher fetcher = new HttpFeatureFlagFetcher(ClientContextImpl.get(ctx)); return new FDv1PollingSynchronizer( - ctx.getEvaluationContext(), pollingBase, s.httpProps, - ctx.getHttp().isUseReport(), ctx.isEvaluationReasons(), + ctx.getEvaluationContext(), fetcher, true, sharedExecutor, 0, PollingDataSourceBuilder.DEFAULT_POLL_INTERVAL_MILLIS, ctx.getBaseLogger()); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizerTest.java index 50156c7c..eb484dcd 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizerTest.java @@ -2,25 +2,22 @@ import com.launchdarkly.logging.LDLogger; import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.android.subsystems.Callback; import com.launchdarkly.sdk.android.subsystems.FDv2SourceResult; import com.launchdarkly.sdk.fdv2.ChangeSetType; import com.launchdarkly.sdk.fdv2.SourceResultType; import com.launchdarkly.sdk.fdv2.SourceSignal; -import com.launchdarkly.sdk.internal.http.HttpProperties; -import com.launchdarkly.testhelpers.httptest.Handler; -import com.launchdarkly.testhelpers.httptest.Handlers; -import com.launchdarkly.testhelpers.httptest.HttpServer; -import com.launchdarkly.testhelpers.httptest.RequestInfo; import org.junit.After; import org.junit.Rule; import org.junit.Test; import org.junit.rules.Timeout; -import java.net.URI; -import java.util.HashMap; +import java.io.IOException; +import java.util.concurrent.BlockingQueue; import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -54,238 +51,165 @@ public void tearDown() { executor.shutdownNow(); } - private static HttpProperties httpProperties() { - return new HttpProperties( - 10_000, - new HashMap<>(), - null, null, null, null, - 10_000, - null, null); - } + /** + * Test double that queues responses for the synchronizer to consume. + * Mirrors the {@code MockFetcher} pattern used in {@link PollingDataSourceTest}. + */ + private static class MockFetcher implements FeatureFetcher { + final BlockingQueue responses = new LinkedBlockingQueue<>(); + + void queueSuccess(String json) { + responses.add(json); + } - private FDv1PollingSynchronizer makeSynchronizer(HttpServer server) { - return makeSynchronizer(server, 0, 60_000, false, false, LOGGER); + void queueError(Throwable error) { + responses.add(error); + } + + @Override + public void fetch(LDContext context, Callback callback) { + Object response = responses.poll(); + if (response == null) { + callback.onError(new RuntimeException("MockFetcher: no responses queued")); + return; + } + if (response instanceof Throwable) { + callback.onError((Throwable) response); + } else { + callback.onSuccess((String) response); + } + } + + @Override + public void close() {} } - private FDv1PollingSynchronizer makeSynchronizer(HttpServer server, long initialDelay, long pollInterval) { - return makeSynchronizer(server, initialDelay, pollInterval, false, false, LOGGER); + private FDv1PollingSynchronizer makeSynchronizer(MockFetcher fetcher) { + return makeSynchronizer(fetcher, 0, 60_000); } - private FDv1PollingSynchronizer makeSynchronizer( - HttpServer server, long initialDelay, long pollInterval, - boolean useReport, boolean evaluationReasons, LDLogger logger) { + private FDv1PollingSynchronizer makeSynchronizer(MockFetcher fetcher, long initialDelay, long pollInterval) { return new FDv1PollingSynchronizer( - CONTEXT, - server.getUri(), - httpProperties(), - useReport, - evaluationReasons, - executor, - initialDelay, - pollInterval, - logger); + CONTEXT, fetcher, false, executor, + initialDelay, pollInterval, LOGGER); } @Test public void successfulPollReturnsChangeSet() throws Exception { - try (HttpServer server = HttpServer.start(Handlers.bodyJson(VALID_FDV1_JSON))) { - FDv1PollingSynchronizer sync = makeSynchronizer(server); - try { - Future future = sync.next(); - FDv2SourceResult result = future.get(5, TimeUnit.SECONDS); - - assertNotNull(result); - assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); - assertNotNull(result.getChangeSet()); - assertEquals(ChangeSetType.Full, result.getChangeSet().getType()); - assertNotNull(result.getChangeSet().getData()); - assertEquals(2, result.getChangeSet().getData().size()); - assertTrue(result.getChangeSet().getData().containsKey("flag1")); - assertTrue(result.getChangeSet().getData().containsKey("flag2")); - assertFalse(result.isFdv1Fallback()); - } finally { - sync.close(); - } - } - } + MockFetcher fetcher = new MockFetcher(); + fetcher.queueSuccess(VALID_FDV1_JSON); - @Test - public void nonRecoverableHttpErrorReturnsTerminalError() throws Exception { - try (HttpServer server = HttpServer.start(Handlers.status(401))) { - FDv1PollingSynchronizer sync = makeSynchronizer(server); - try { - FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); - - assertEquals(SourceResultType.STATUS, result.getResultType()); - assertEquals(SourceSignal.TERMINAL_ERROR, result.getStatus().getState()); - } finally { - sync.close(); - } + FDv1PollingSynchronizer sync = makeSynchronizer(fetcher); + try { + Future future = sync.next(); + FDv2SourceResult result = future.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); + assertNotNull(result.getChangeSet()); + assertEquals(ChangeSetType.Full, result.getChangeSet().getType()); + assertNotNull(result.getChangeSet().getData()); + assertEquals(2, result.getChangeSet().getData().size()); + assertTrue(result.getChangeSet().getData().containsKey("flag1")); + assertTrue(result.getChangeSet().getData().containsKey("flag2")); + assertFalse(result.isFdv1Fallback()); + } finally { + sync.close(); } } @Test - public void recoverableHttpErrorReturnsInterrupted() throws Exception { - Handler sequence = Handlers.sequential( - Handlers.status(500), - Handlers.bodyJson(SINGLE_FLAG_JSON)); - - try (HttpServer server = HttpServer.start(sequence)) { - FDv1PollingSynchronizer sync = makeSynchronizer(server, 0, 100); - try { - FDv2SourceResult result1 = sync.next().get(5, TimeUnit.SECONDS); - assertEquals(SourceResultType.STATUS, result1.getResultType()); - assertEquals(SourceSignal.INTERRUPTED, result1.getStatus().getState()); - - FDv2SourceResult result2 = sync.next().get(5, TimeUnit.SECONDS); - assertEquals(SourceResultType.CHANGE_SET, result2.getResultType()); - assertNotNull(result2.getChangeSet()); - assertEquals(1, result2.getChangeSet().getData().size()); - } finally { - sync.close(); - } - } - } + public void nonRecoverableHttpErrorReturnsTerminalError() throws Exception { + MockFetcher fetcher = new MockFetcher(); + fetcher.queueError(new LDInvalidResponseCodeFailure( + "Unexpected response", 401, true)); - @Test - public void closeReturnsShutdown() throws Exception { - try (HttpServer server = HttpServer.start(Handlers.bodyJson(VALID_FDV1_JSON))) { - FDv1PollingSynchronizer sync = makeSynchronizer(server, 60_000, 60_000); - sync.close(); + FDv1PollingSynchronizer sync = makeSynchronizer(fetcher); + try { + FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); - FDv2SourceResult result = sync.next().get(1, TimeUnit.SECONDS); assertEquals(SourceResultType.STATUS, result.getResultType()); - assertEquals(SourceSignal.SHUTDOWN, result.getStatus().getState()); + assertEquals(SourceSignal.TERMINAL_ERROR, result.getStatus().getState()); + } finally { + sync.close(); } } @Test - public void invalidJsonReturnsInterrupted() throws Exception { - Handler sequence = Handlers.sequential( - Handlers.bodyJson("not valid json"), - Handlers.bodyJson(SINGLE_FLAG_JSON)); - - try (HttpServer server = HttpServer.start(sequence)) { - FDv1PollingSynchronizer sync = makeSynchronizer(server, 0, 100); - try { - FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); - assertEquals(SourceResultType.STATUS, result.getResultType()); - assertEquals(SourceSignal.INTERRUPTED, result.getStatus().getState()); - } finally { - sync.close(); - } + public void recoverableHttpErrorReturnsInterrupted() throws Exception { + MockFetcher fetcher = new MockFetcher(); + fetcher.queueError(new LDInvalidResponseCodeFailure( + "Unexpected response", 500, true)); + fetcher.queueSuccess(SINGLE_FLAG_JSON); + + FDv1PollingSynchronizer sync = makeSynchronizer(fetcher, 0, 100); + try { + FDv2SourceResult result1 = sync.next().get(5, TimeUnit.SECONDS); + assertEquals(SourceResultType.STATUS, result1.getResultType()); + assertEquals(SourceSignal.INTERRUPTED, result1.getStatus().getState()); + + FDv2SourceResult result2 = sync.next().get(5, TimeUnit.SECONDS); + assertEquals(SourceResultType.CHANGE_SET, result2.getResultType()); + assertNotNull(result2.getChangeSet()); + assertEquals(1, result2.getChangeSet().getData().size()); + } finally { + sync.close(); } } @Test - public void emptyResponseReturnsChangeSetWithNoFlags() throws Exception { - try (HttpServer server = HttpServer.start(Handlers.bodyJson("{}"))) { - FDv1PollingSynchronizer sync = makeSynchronizer(server); - try { - FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); - - assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); - assertNotNull(result.getChangeSet()); - assertEquals(ChangeSetType.Full, result.getChangeSet().getType()); - assertTrue(result.getChangeSet().getData().isEmpty()); - } finally { - sync.close(); - } - } - } + public void closeReturnsShutdown() throws Exception { + MockFetcher fetcher = new MockFetcher(); - // ---- Request path and method verification ---- + FDv1PollingSynchronizer sync = makeSynchronizer(fetcher, 60_000, 60_000); + sync.close(); - @Test - public void getRequestUsesCorrectPath() throws Exception { - try (HttpServer server = HttpServer.start(Handlers.bodyJson(SINGLE_FLAG_JSON))) { - FDv1PollingSynchronizer sync = makeSynchronizer(server); - try { - sync.next().get(5, TimeUnit.SECONDS); - - RequestInfo req = server.getRecorder().requireRequest(); - assertEquals("GET", req.getMethod()); - String expectedBase64 = LDUtil.urlSafeBase64(CONTEXT); - String expectedPath = StandardEndpoints.POLLING_REQUEST_GET_BASE_PATH + "/" + expectedBase64; - assertTrue("path should be " + expectedPath + " but was " + req.getPath(), - req.getPath().equals(expectedPath)); - } finally { - sync.close(); - } - } + FDv2SourceResult result = sync.next().get(1, TimeUnit.SECONDS); + assertEquals(SourceResultType.STATUS, result.getResultType()); + assertEquals(SourceSignal.SHUTDOWN, result.getStatus().getState()); } @Test - public void reportRequestUsesCorrectPathAndMethod() throws Exception { - try (HttpServer server = HttpServer.start(Handlers.bodyJson(SINGLE_FLAG_JSON))) { - FDv1PollingSynchronizer sync = makeSynchronizer(server, 0, 60_000, true, false, LOGGER); - try { - sync.next().get(5, TimeUnit.SECONDS); - - RequestInfo req = server.getRecorder().requireRequest(); - assertEquals("REPORT", req.getMethod()); - assertTrue("path should start with " + StandardEndpoints.POLLING_REQUEST_REPORT_BASE_PATH, - req.getPath().startsWith(StandardEndpoints.POLLING_REQUEST_REPORT_BASE_PATH)); - assertFalse("REPORT path should not contain context segment", - req.getPath().startsWith(StandardEndpoints.POLLING_REQUEST_GET_BASE_PATH)); - assertNotNull("body should contain serialized context", req.getBody()); - assertTrue("body should contain context key", - req.getBody().contains("test-context-key")); - } finally { - sync.close(); - } + public void invalidJsonReturnsInterrupted() throws Exception { + MockFetcher fetcher = new MockFetcher(); + fetcher.queueSuccess("not valid json"); + + FDv1PollingSynchronizer sync = makeSynchronizer(fetcher); + try { + FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); + assertEquals(SourceResultType.STATUS, result.getResultType()); + assertEquals(SourceSignal.INTERRUPTED, result.getStatus().getState()); + } finally { + sync.close(); } } @Test - public void reportRequestReturnsValidChangeSet() throws Exception { - try (HttpServer server = HttpServer.start(Handlers.bodyJson(VALID_FDV1_JSON))) { - FDv1PollingSynchronizer sync = makeSynchronizer(server, 0, 60_000, true, false, LOGGER); - try { - FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); - - assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); - assertNotNull(result.getChangeSet()); - assertEquals(2, result.getChangeSet().getData().size()); - assertFalse(result.isFdv1Fallback()); - } finally { - sync.close(); - } - } - } + public void emptyResponseReturnsChangeSetWithNoFlags() throws Exception { + MockFetcher fetcher = new MockFetcher(); + fetcher.queueSuccess("{}"); - // ---- withReasons query parameter ---- + FDv1PollingSynchronizer sync = makeSynchronizer(fetcher); + try { + FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); - @Test - public void evaluationReasonsAppendsQueryParam() throws Exception { - try (HttpServer server = HttpServer.start(Handlers.bodyJson(SINGLE_FLAG_JSON))) { - FDv1PollingSynchronizer sync = makeSynchronizer(server, 0, 60_000, false, true, LOGGER); - try { - sync.next().get(5, TimeUnit.SECONDS); - - RequestInfo req = server.getRecorder().requireRequest(); - assertTrue("query should contain withReasons=true", - req.getQuery() != null && req.getQuery().contains("withReasons=true")); - } finally { - sync.close(); - } + assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); + assertNotNull(result.getChangeSet()); + assertEquals(ChangeSetType.Full, result.getChangeSet().getType()); + assertTrue(result.getChangeSet().getData().isEmpty()); + } finally { + sync.close(); } } - // ---- Network error handling ---- - @Test public void networkErrorReturnsInterrupted() throws Exception { - FDv1PollingSynchronizer sync = new FDv1PollingSynchronizer( - CONTEXT, - URI.create("http://localhost:1"), - httpProperties(), - false, - false, - executor, - 0, - 60_000, - LOGGER); + MockFetcher fetcher = new MockFetcher(); + fetcher.queueError(new LDFailure("Exception while fetching flags", + new IOException("connection refused"), + LDFailure.FailureType.NETWORK_FAILURE)); + + FDv1PollingSynchronizer sync = makeSynchronizer(fetcher); try { FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); @@ -296,52 +220,45 @@ public void networkErrorReturnsInterrupted() throws Exception { } } - // ---- Terminal error stops polling ---- - @Test public void terminalErrorStopsPolling() throws Exception { - Handler sequence = Handlers.sequential( - Handlers.status(401), - Handlers.bodyJson(SINGLE_FLAG_JSON)); - - try (HttpServer server = HttpServer.start(sequence)) { - FDv1PollingSynchronizer sync = makeSynchronizer(server, 0, 100); - try { - FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); - assertEquals(SourceResultType.STATUS, result.getResultType()); - assertEquals(SourceSignal.TERMINAL_ERROR, result.getStatus().getState()); - - Thread.sleep(500); - - assertEquals("should have made exactly one request before stopping", - 1, server.getRecorder().count()); - } finally { - sync.close(); - } + MockFetcher fetcher = new MockFetcher(); + fetcher.queueError(new LDInvalidResponseCodeFailure( + "Unexpected response", 401, true)); + fetcher.queueSuccess(SINGLE_FLAG_JSON); + + FDv1PollingSynchronizer sync = makeSynchronizer(fetcher, 0, 100); + try { + FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); + assertEquals(SourceResultType.STATUS, result.getResultType()); + assertEquals(SourceSignal.TERMINAL_ERROR, result.getStatus().getState()); + + Thread.sleep(500); + + assertEquals("second response should still be in the queue (never fetched)", + 1, fetcher.responses.size()); + } finally { + sync.close(); } } - // ---- Repeated polling ---- - @Test public void pollsRepeatAtConfiguredInterval() throws Exception { - Handler sequence = Handlers.sequential( - Handlers.bodyJson(SINGLE_FLAG_JSON), - Handlers.bodyJson(VALID_FDV1_JSON)); - - try (HttpServer server = HttpServer.start(sequence)) { - FDv1PollingSynchronizer sync = makeSynchronizer(server, 0, 200); - try { - FDv2SourceResult result1 = sync.next().get(5, TimeUnit.SECONDS); - assertEquals(SourceResultType.CHANGE_SET, result1.getResultType()); - assertEquals(1, result1.getChangeSet().getData().size()); - - FDv2SourceResult result2 = sync.next().get(5, TimeUnit.SECONDS); - assertEquals(SourceResultType.CHANGE_SET, result2.getResultType()); - assertEquals(2, result2.getChangeSet().getData().size()); - } finally { - sync.close(); - } + MockFetcher fetcher = new MockFetcher(); + fetcher.queueSuccess(SINGLE_FLAG_JSON); + fetcher.queueSuccess(VALID_FDV1_JSON); + + FDv1PollingSynchronizer sync = makeSynchronizer(fetcher, 0, 200); + try { + FDv2SourceResult result1 = sync.next().get(5, TimeUnit.SECONDS); + assertEquals(SourceResultType.CHANGE_SET, result1.getResultType()); + assertEquals(1, result1.getChangeSet().getData().size()); + + FDv2SourceResult result2 = sync.next().get(5, TimeUnit.SECONDS); + assertEquals(SourceResultType.CHANGE_SET, result2.getResultType()); + assertEquals(2, result2.getChangeSet().getData().size()); + } finally { + sync.close(); } } } From b66ab9e6f844768322ec84d4f7cbacbd222619cd Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Wed, 1 Apr 2026 11:57:41 -0700 Subject: [PATCH 11/23] [SDK-1829] fix: detect FDv1 fallback on initializer result with non-empty selector The non-empty selector early return in runInitializers() bypassed the fdv1Fallback check, so a polling initializer that returned a fully current payload with x-ld-fd-fallback: true would silently proceed to FDv2 synchronizers instead of switching to the FDv1 fallback. Made-with: Cursor --- .../sdk/android/FDv2DataSource.java | 6 ++++ .../sdk/android/FDv2DataSourceTest.java | 30 +++++++++++++++++++ 2 files changed, 36 insertions(+) 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 2c0b47a1..51be5ea6 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 @@ -258,6 +258,12 @@ private void runInitializers( // A non-empty selector means the payload is fully current; the // initializer is done and synchronizers can take over from here. if (!changeSet.getSelector().isEmpty()) { + if (result.isFdv1Fallback() + && sourceManager.hasFDv1Fallback()) { + logger.info("Server signaled FDv1 fallback during initialization; " + + "switching to FDv1 synchronizer."); + sourceManager.fdv1Fallback(); + } sink.setStatus(DataSourceState.VALID, null); tryCompleteStart(true, null); return; 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 2eafa805..999239fc 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 @@ -1458,6 +1458,36 @@ public void fdv1FallbackDuringInitializationSkipsRemainingInitializers() throws stopDataSource(dataSource); } + @Test + public void fdv1FallbackDuringInitializationWithNonEmptySelectorSwitchesToFdv1() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + + MockQueuedSynchronizer fdv1Sync = new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(true), false)); + + // Initializer returns a fully current payload (non-empty selector) AND fdv1Fallback=true. + // The fallback should still be detected even though the non-empty selector would normally + // complete initialization immediately. + FDv2DataSource dataSource = new FDv2DataSource( + CONTEXT, + Collections.>singletonList( + () -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), true))), + Collections.>singletonList( + () -> new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), + () -> fdv1Sync, + sink, + executor, + logging.logger); + + AwaitableCallback startCallback = startDataSource(dataSource); + assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); + + // The FDv1 synchronizer should receive data, proving it was activated. + sink.awaitApplyCount(2, AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + stopDataSource(dataSource); + } + @Test public void fdv1FallbackSwitchesToFdv1Synchronizer() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); From e0eea5d0b59e1097a0fc81eb6d970e5e933e40d7 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Wed, 1 Apr 2026 14:03:50 -0700 Subject: [PATCH 12/23] [SDK-1829] fix: honor FDv1 fallback signal on terminal errors in synchronizer loop The fdv1Fallback check required the synchronizer to still be running, but TERMINAL_ERROR set running=false first, silently discarding the fallback signal. Removed the running guard so a terminal error response carrying x-ld-fd-fallback: true correctly transitions to the FDv1 synchronizer. Also added a test for the equivalent initializer path. Made-with: Cursor --- .../sdk/android/FDv2DataSource.java | 10 +-- .../sdk/android/FDv2DataSourceTest.java | 72 +++++++++++++++++++ 2 files changed, 77 insertions(+), 5 deletions(-) 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 51be5ea6..6972f75f 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 @@ -420,11 +420,11 @@ 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). Only act on the signal if the - // synchronizer is still running (not shut down or terminally errored), - // a fallback slot exists, and we aren't already on FDv1. - if (running - && result.isFdv1Fallback() + // 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("Server signaled FDv1 fallback; switching to FDv1 polling synchronizer."); 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 999239fc..fa3b0f76 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 @@ -1458,6 +1458,43 @@ public void fdv1FallbackDuringInitializationSkipsRemainingInitializers() throws stopDataSource(dataSource); } + @Test + public void fdv1FallbackOnTerminalErrorDuringInitializationSwitchesToFdv1() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + AtomicBoolean secondInitCalled = new AtomicBoolean(false); + + MockQueuedSynchronizer fdv1Sync = new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(true), false)); + + // First initializer returns TERMINAL_ERROR with fdv1Fallback=true. + // The fallback should be honored and remaining initializers skipped. + FDv2DataSource dataSource = new FDv2DataSource( + CONTEXT, + Arrays.>asList( + () -> new MockInitializer(FDv2SourceResult.status( + FDv2SourceResult.Status.terminalError(new RuntimeException("fail")), true)), + () -> { + secondInitCalled.set(true); + return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)); + }), + Collections.>singletonList( + () -> new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false), false))), + () -> fdv1Sync, + sink, + executor, + logging.logger); + + AwaitableCallback startCallback = startDataSource(dataSource); + assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); + + assertFalse(secondInitCalled.get()); + + // The FDv1 synchronizer should receive data, proving it was activated. + sink.awaitApplyCount(1, AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + stopDataSource(dataSource); + } + @Test public void fdv1FallbackDuringInitializationWithNonEmptySelectorSwitchesToFdv1() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); @@ -1518,6 +1555,41 @@ public void fdv1FallbackSwitchesToFdv1Synchronizer() throws Exception { stopDataSource(dataSource); } + @Test + public void fdv1FallbackOnTerminalErrorSwitchesToFdv1Synchronizer() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + + // FDv2 synchronizer returns a terminal error (e.g., HTTP 401) with fdv1Fallback=true. + // The fallback should still be honored even though the error is non-recoverable. + MockQueuedSynchronizer fdv2Sync = new MockQueuedSynchronizer( + FDv2SourceResult.status( + FDv2SourceResult.Status.terminalError(new RuntimeException("401")), + true)); + + MockQueuedSynchronizer fdv1Sync = new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(true), false)); + + FDv2DataSource dataSource = new FDv2DataSource( + CONTEXT, + Collections.>emptyList(), + Collections.>singletonList(() -> fdv2Sync), + () -> fdv1Sync, + sink, + executor, + logging.logger); + + AwaitableCallback startCallback = startDataSource(dataSource); + + // The terminal error from the FDv2 synchronizer produces INTERRUPTED first. + assertEquals(DataSourceState.INTERRUPTED, sink.awaitStatus(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + + // Then the FDv1 synchronizer takes over and produces VALID. + assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); + assertEquals(DataSourceState.VALID, sink.awaitStatus(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + + stopDataSource(dataSource); + } + @Test public void fdv1FallbackNotTriggeredWhenAlreadyOnFdv1() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); From 3b88fd6968f99c6f067b243a9da9b06ca7aa49ce Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Wed, 1 Apr 2026 14:22:17 -0700 Subject: [PATCH 13/23] [SDK-1829] refactor: remove ownsFetcher parameter from FDv1PollingSynchronizer The synchronizer always owns its fetcher in production, and the test mock's close() is already a no-op, so the flag was unnecessary. Made-with: Cursor --- .../sdk/android/FDv1PollingSynchronizer.java | 13 +++---------- .../sdk/android/FDv2DataSourceBuilder.java | 2 +- .../sdk/android/FDv1PollingSynchronizerTest.java | 2 +- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizer.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizer.java index 3e7bea85..81593674 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizer.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizer.java @@ -32,7 +32,6 @@ final class FDv1PollingSynchronizer implements Synchronizer { private final LDContext evaluationContext; private final FeatureFetcher fetcher; - private final boolean ownsFetcher; private final LDLogger logger; private final LDAsyncQueue resultQueue = new LDAsyncQueue<>(); @@ -44,8 +43,6 @@ final class FDv1PollingSynchronizer implements Synchronizer { /** * @param evaluationContext the context to evaluate flags for * @param fetcher the HTTP transport for FDv1 polling requests - * @param ownsFetcher true if this synchronizer should close the fetcher on shutdown; - * false if the fetcher is shared and managed externally * @param executor scheduler for recurring poll tasks * @param initialDelayMillis delay before the first poll in milliseconds * @param pollIntervalMillis delay between the end of one poll and the start of the next @@ -54,14 +51,12 @@ final class FDv1PollingSynchronizer implements Synchronizer { FDv1PollingSynchronizer( @NonNull LDContext evaluationContext, @NonNull FeatureFetcher fetcher, - boolean ownsFetcher, @NonNull ScheduledExecutorService executor, long initialDelayMillis, long pollIntervalMillis, @NonNull LDLogger logger) { this.evaluationContext = evaluationContext; this.fetcher = fetcher; - this.ownsFetcher = ownsFetcher; this.logger = logger; synchronized (taskLock) { @@ -185,11 +180,9 @@ public void close() { } private void closeFetcher() { - if (ownsFetcher) { - try { - fetcher.close(); - } catch (IOException ignored) { - } + try { + fetcher.close(); + } catch (IOException ignored) { } } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java index 47c0f8bc..6a7c2812 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java @@ -93,7 +93,7 @@ private Map makeDefaultModeTable() { ComponentConfigurer fdv1FallbackPollingSynchronizer = ctx -> { FeatureFetcher fetcher = new HttpFeatureFlagFetcher(ClientContextImpl.get(ctx)); return new FDv1PollingSynchronizer( - ctx.getEvaluationContext(), fetcher, true, + ctx.getEvaluationContext(), fetcher, sharedExecutor, 0, PollingDataSourceBuilder.DEFAULT_POLL_INTERVAL_MILLIS, ctx.getBaseLogger()); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizerTest.java index eb484dcd..4c08f769 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizerTest.java @@ -90,7 +90,7 @@ private FDv1PollingSynchronizer makeSynchronizer(MockFetcher fetcher) { private FDv1PollingSynchronizer makeSynchronizer(MockFetcher fetcher, long initialDelay, long pollInterval) { return new FDv1PollingSynchronizer( - CONTEXT, fetcher, false, executor, + CONTEXT, fetcher, executor, initialDelay, pollInterval, LOGGER); } From c89661afb72884bf6c5edaf3875c81073c87b6eb Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Wed, 1 Apr 2026 16:10:27 -0700 Subject: [PATCH 14/23] [SDK-1829] refactor: move error classification into fetch callback in FDv1PollingSynchronizer Process all results and errors directly inside the FeatureFetcher callback so the future carries a fully-formed FDv2SourceResult. This eliminates the need to unwrap ExecutionException to inspect application-level error types, keeping error classification at the callback layer where it belongs. Made-with: Cursor --- .../sdk/android/FDv1PollingSynchronizer.java | 114 +++++++++--------- 1 file changed, 59 insertions(+), 55 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizer.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizer.java index 81593674..dac95a8a 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizer.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizer.java @@ -11,10 +11,13 @@ import com.launchdarkly.sdk.fdv2.ChangeSet; import com.launchdarkly.sdk.fdv2.ChangeSetType; import com.launchdarkly.sdk.fdv2.Selector; +import com.launchdarkly.sdk.fdv2.SourceResultType; +import com.launchdarkly.sdk.fdv2.SourceSignal; import com.launchdarkly.sdk.json.SerializationException; import java.io.IOException; import java.util.Map; +import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; @@ -72,16 +75,15 @@ private void pollAndEnqueue() { try { FDv2SourceResult result = doPoll(); - if (result.getResultType() == com.launchdarkly.sdk.fdv2.SourceResultType.STATUS) { + if (result.getResultType() == SourceResultType.STATUS) { FDv2SourceResult.Status status = result.getStatus(); - if (status != null && status.getState() == com.launchdarkly.sdk.fdv2.SourceSignal.TERMINAL_ERROR) { + if (status != null && status.getState() == SourceSignal.TERMINAL_ERROR) { synchronized (taskLock) { if (scheduledTask != null) { scheduledTask.cancel(false); scheduledTask = null; } } - closeFetcher(); shutdownFuture.set(result); return; } @@ -94,70 +96,76 @@ private void pollAndEnqueue() { } } + /** + * Fetches flags via FDv1 polling and converts the result to an {@link FDv2SourceResult}. + *

+ * All result/error processing happens inside the {@link Callback} so the future carries a + * fully-formed {@link FDv2SourceResult}. This keeps application-level error classification + * (e.g. {@link LDInvalidResponseCodeFailure}) at the callback layer rather than unwrapping + * it from an {@link ExecutionException}. + */ private FDv2SourceResult doPoll() { - LDAwaitFuture jsonFuture = new LDAwaitFuture<>(); + LDAwaitFuture resultFuture = new LDAwaitFuture<>(); fetcher.fetch(evaluationContext, new Callback() { @Override - public void onSuccess(String result) { - jsonFuture.set(result); + public void onSuccess(String json) { + try { + logger.debug("FDv1 fallback polling response received"); + EnvironmentData envData = EnvironmentData.fromJson(json); + Map flags = envData.getAll(); + + ChangeSet> changeSet = new ChangeSet<>( + ChangeSetType.Full, + Selector.EMPTY, + flags, + null, + true); + resultFuture.set(FDv2SourceResult.changeSet(changeSet, false)); + } catch (SerializationException e) { + LDUtil.logExceptionAtErrorLevel(logger, e, "FDv1 fallback polling failed to parse response"); + LDFailure failure = new LDFailure( + "FDv1 fallback: invalid JSON response", e, LDFailure.FailureType.INVALID_RESPONSE_BODY); + resultFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), false)); + } } @Override public void onError(Throwable e) { - jsonFuture.setException(e); + if (e instanceof LDInvalidResponseCodeFailure) { + int code = ((LDInvalidResponseCodeFailure) e).getResponseCode(); + if (code == 400) { + logger.error("Received 400 response when fetching flag values. " + + "Please check recommended R8 and/or ProGuard settings"); + } + boolean recoverable = LDUtil.isHttpErrorRecoverable(code); + logger.warn("FDv1 fallback polling failed with HTTP {}", code); + LDFailure failure = new LDInvalidResponseCodeFailure( + "FDv1 fallback polling request failed", e, code, recoverable); + if (!recoverable) { + resultFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), false)); + } else { + resultFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), false)); + } + } else if (e instanceof IOException) { + LDUtil.logExceptionAtErrorLevel(logger, e, "FDv1 fallback polling failed with network error"); + resultFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(e), false)); + } else { + LDUtil.logExceptionAtErrorLevel(logger, e, "FDv1 fallback polling failed"); + resultFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(e), false)); + } } }); try { - String json = jsonFuture.get(); - logger.debug("FDv1 fallback polling response received"); - - EnvironmentData envData = EnvironmentData.fromJson(json); - Map flags = envData.getAll(); - - ChangeSet> changeSet = new ChangeSet<>( - ChangeSetType.Full, - Selector.EMPTY, - flags, - null, - true); - - return FDv2SourceResult.changeSet(changeSet, false); - + return resultFuture.get(); } catch (InterruptedException e) { return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(e), false); - } catch (java.util.concurrent.ExecutionException e) { + } catch (ExecutionException e) { + // Should not happen — all callback paths call set(), never setException() Throwable cause = e.getCause() != null ? e.getCause() : e; - - if (cause instanceof LDInvalidResponseCodeFailure) { - int code = ((LDInvalidResponseCodeFailure) cause).getResponseCode(); - if (code == 400) { - logger.error("Received 400 response when fetching flag values. " + - "Please check recommended R8 and/or ProGuard settings"); - } - boolean recoverable = LDUtil.isHttpErrorRecoverable(code); - logger.warn("FDv1 fallback polling failed with HTTP {}", code); - LDFailure failure = new LDInvalidResponseCodeFailure( - "FDv1 fallback polling request failed", cause, code, recoverable); - if (!recoverable) { - return FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), false); - } else { - return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), false); - } - } - - if (cause instanceof IOException) { - LDUtil.logExceptionAtErrorLevel(logger, cause, "FDv1 fallback polling failed with network error"); - } else { - LDUtil.logExceptionAtErrorLevel(logger, cause, "FDv1 fallback polling failed"); - } + LDUtil.logExceptionAtErrorLevel(logger, cause, "FDv1 fallback polling failed unexpectedly"); return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(cause), false); - } catch (SerializationException e) { - LDUtil.logExceptionAtErrorLevel(logger, e, "FDv1 fallback polling failed to parse response"); - LDFailure failure = new LDFailure( - "FDv1 fallback: invalid JSON response", e, LDFailure.FailureType.INVALID_RESPONSE_BODY); - return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), false); } } @@ -176,10 +184,6 @@ public void close() { } } shutdownFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown(), false)); - closeFetcher(); - } - - private void closeFetcher() { try { fetcher.close(); } catch (IOException ignored) { From 4af3c1949751d619a468d2f5c0879359069dd56c Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Thu, 2 Apr 2026 14:18:25 -0700 Subject: [PATCH 15/23] [SDK-1829] Refine error handling in FDv1PollingSynchronizer --- .../launchdarkly/sdk/android/FDv1PollingSynchronizer.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizer.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizer.java index dac95a8a..42fa15c7 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizer.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizer.java @@ -17,7 +17,6 @@ import java.io.IOException; import java.util.Map; -import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; @@ -102,7 +101,7 @@ private void pollAndEnqueue() { * All result/error processing happens inside the {@link Callback} so the future carries a * fully-formed {@link FDv2SourceResult}. This keeps application-level error classification * (e.g. {@link LDInvalidResponseCodeFailure}) at the callback layer rather than unwrapping - * it from an {@link ExecutionException}. + * it from a {@link java.util.concurrent.Future#get()} exception. */ private FDv2SourceResult doPoll() { LDAwaitFuture resultFuture = new LDAwaitFuture<>(); @@ -161,7 +160,7 @@ public void onError(Throwable e) { return resultFuture.get(); } catch (InterruptedException e) { return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(e), false); - } catch (ExecutionException e) { + } catch (Exception e) { // Should not happen — all callback paths call set(), never setException() Throwable cause = e.getCause() != null ? e.getCause() : e; LDUtil.logExceptionAtErrorLevel(logger, cause, "FDv1 fallback polling failed unexpectedly"); From 64f31443c8fcc9bf29dfe46f1f71807bc5bcc227 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Thu, 2 Apr 2026 17:00:40 -0700 Subject: [PATCH 16/23] [SDK-1829] refactor: consolidate FDv1 fallback check in runInitializers Move the FDv1 fallback check to a single location before the result type switch statement. This ensures the fallback signal is honored regardless of selector state or result type, eliminating duplicated logic and a potential path where tryCompleteStart could be skipped. Made-with: Cursor --- .../sdk/android/FDv2DataSource.java | 40 ++++++++++--------- 1 file changed, 22 insertions(+), 18 deletions(-) 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 dc7dbdba..f6ee0b79 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 @@ -8,6 +8,7 @@ import com.launchdarkly.sdk.android.subsystems.Callback; import com.launchdarkly.sdk.android.DataModel; import com.launchdarkly.sdk.fdv2.ChangeSet; +import com.launchdarkly.sdk.fdv2.SourceResultType; import com.launchdarkly.sdk.android.subsystems.DataSourceState; import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSinkV2; import com.launchdarkly.sdk.android.subsystems.FDv2SourceResult; @@ -248,6 +249,27 @@ private void runInitializers( try { FDv2SourceResult result = initializer.run().get(); + // 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.getResultType() == SourceResultType.CHANGE_SET) { + ChangeSet> changeSet = result.getChangeSet(); + if (changeSet != null) { + sink.apply(context, changeSet); + anyDataReceived = true; + } + } + logger.info("Server signaled FDv1 fallback during initialization; " + + "switching to FDv1 synchronizer."); + sourceManager.fdv1Fallback(); + if (anyDataReceived) { + sink.setStatus(DataSourceState.VALID, null); + tryCompleteStart(true, null); + } + return; + } + switch (result.getResultType()) { case CHANGE_SET: ChangeSet> changeSet = result.getChangeSet(); @@ -257,12 +279,6 @@ private void runInitializers( // A non-empty selector means the payload is fully current; the // initializer is done and synchronizers can take over from here. if (!changeSet.getSelector().isEmpty()) { - if (result.isFdv1Fallback() - && sourceManager.hasFDv1Fallback()) { - logger.info("Server signaled FDv1 fallback during initialization; " + - "switching to FDv1 synchronizer."); - sourceManager.fdv1Fallback(); - } sink.setStatus(DataSourceState.VALID, null); tryCompleteStart(true, null); return; @@ -287,18 +303,6 @@ private void runInitializers( } break; } - - if (result.isFdv1Fallback() - && sourceManager.hasFDv1Fallback()) { - logger.info("Server signaled FDv1 fallback during initialization; " + - "skipping remaining initializers."); - sourceManager.fdv1Fallback(); - if (anyDataReceived) { - sink.setStatus(DataSourceState.VALID, null); - tryCompleteStart(true, null); - } - return; - } } catch (ExecutionException e) { logger.warn("Initializer error: {}", e.getCause() != null ? e.getCause().toString() : e.toString()); sink.setStatus(DataSourceState.INTERRUPTED, e.getCause() != null ? e.getCause() : e); From 27ce5a5abcc91593bd404fce285601e55df9a66e Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Mon, 6 Apr 2026 09:24:18 -0700 Subject: [PATCH 17/23] [SDK-1829] refactor: internalize FDv1 fallback synchronizer and move default mode table into DataSystemComponents Move makeDefaultModeTable() from DataSystemBuilder into DataSystemComponents so the FDv1 fallback synchronizer can be fully package-private. Remove the public FDv1PollingSynchronizerBuilder and fdv1PollingSynchronizer() factory method to prevent external customization of the fallback path. Made-with: Cursor --- .../sdk/android/DataSystemComponents.java | 69 ++++++++++++++++--- .../sdk/android/FDv2DataSource.java | 8 ++- .../integrations/DataSystemBuilder.java | 54 +-------------- .../FDv1PollingSynchronizerBuilder.java | 38 ---------- 4 files changed, 64 insertions(+), 105 deletions(-) delete mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/FDv1PollingSynchronizerBuilder.java diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSystemComponents.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSystemComponents.java index 8f1a8de0..90203fad 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSystemComponents.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSystemComponents.java @@ -3,17 +3,23 @@ import com.launchdarkly.sdk.android.integrations.AutomaticModeSwitchingConfig; import com.launchdarkly.sdk.android.integrations.ConnectionModeBuilder; import com.launchdarkly.sdk.android.integrations.DataSystemBuilder; -import com.launchdarkly.sdk.android.integrations.FDv1PollingSynchronizerBuilder; import com.launchdarkly.sdk.android.integrations.PollingInitializerBuilder; import com.launchdarkly.sdk.android.integrations.PollingSynchronizerBuilder; import com.launchdarkly.sdk.android.integrations.StreamingSynchronizerBuilder; import com.launchdarkly.sdk.android.interfaces.ServiceEndpoints; import com.launchdarkly.sdk.android.subsystems.DataSourceBuildInputs; +import com.launchdarkly.sdk.android.subsystems.DataSourceBuilder; import com.launchdarkly.sdk.android.subsystems.Initializer; import com.launchdarkly.sdk.android.subsystems.Synchronizer; import com.launchdarkly.sdk.internal.http.HttpProperties; +import androidx.annotation.NonNull; + import java.net.URI; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; /** * Factory methods for FDv2 data source components used with the @@ -103,7 +109,7 @@ public Synchronizer build(DataSourceBuildInputs inputs) { } } - static final class FDv1PollingSynchronizerBuilderImpl extends FDv1PollingSynchronizerBuilder { + static final class FDv1PollingSynchronizerBuilderImpl implements DataSourceBuilder { @Override public Synchronizer build(DataSourceBuildInputs inputs) { FeatureFetcher fetcher = new HttpFeatureFlagFetcher( @@ -117,7 +123,7 @@ public Synchronizer build(DataSourceBuildInputs inputs) { return new FDv1PollingSynchronizer( inputs.getEvaluationContext(), fetcher, inputs.getSharedExecutor(), 0, - pollIntervalMillis, + PollingSynchronizerBuilder.DEFAULT_POLL_INTERVAL_MILLIS, inputs.getBaseLogger() ); } @@ -163,16 +169,57 @@ public static StreamingSynchronizerBuilder streamingSynchronizer() { } /** - * Returns a builder for a FDv1 polling synchronizer. + * Produces the default mode table used by {@link DataSystemBuilder#buildModeTable}. + * Defined here (rather than in {@code DataSystemBuilder}) because the FDv1 fallback + * synchronizer references package-private types that are not visible from the + * {@code integrations} package. *

- * A FDv1 polling synchronizer periodically polls LaunchDarkly for feature flag updates using the FDv1 endpoints. - * The poll interval can be configured via - * {@link FDv1PollingSynchronizerBuilder#pollIntervalMillis(int)}. - * - * @return a FDv1 polling synchronizer builder + * This method is public only for cross-package access within the SDK; it is not + * intended for use by application code. */ - public static FDv1PollingSynchronizerBuilder fdv1PollingSynchronizer() { - return new FDv1PollingSynchronizerBuilderImpl(); + @NonNull + public static Map makeDefaultModeTable() { + DataSourceBuilder pollingInitializer = pollingInitializer(); + DataSourceBuilder pollingSynchronizer = pollingSynchronizer(); + DataSourceBuilder streamingSynchronizer = streamingSynchronizer(); + DataSourceBuilder backgroundPollingSynchronizer = + pollingSynchronizer() + .pollIntervalMillis(LDConfig.DEFAULT_BACKGROUND_POLL_INTERVAL_MILLIS); + DataSourceBuilder fdv1FallbackPollingSynchronizer = + new FDv1PollingSynchronizerBuilderImpl(); + + Map table = new LinkedHashMap<>(); + table.put(ConnectionMode.STREAMING, new ModeDefinition( + // TODO: cacheInitializer — add once implemented + Arrays.asList(/* cacheInitializer, */ pollingInitializer), + Arrays.asList(streamingSynchronizer, pollingSynchronizer), + fdv1FallbackPollingSynchronizer + )); + table.put(ConnectionMode.POLLING, new ModeDefinition( + // TODO: Arrays.asList(cacheInitializer) — add once implemented + Collections.>emptyList(), + Collections.singletonList(pollingSynchronizer), + fdv1FallbackPollingSynchronizer + )); + table.put(ConnectionMode.OFFLINE, new ModeDefinition( + // TODO: Arrays.asList(cacheInitializer) — add once implemented + Collections.>emptyList(), + Collections.>emptyList(), + null + )); + table.put(ConnectionMode.ONE_SHOT, new ModeDefinition( + // TODO: cacheInitializer and streamingInitializer — add once implemented + Arrays.asList(/* cacheInitializer, */ pollingInitializer /*, streamingInitializer, */), + Collections.>emptyList(), + null + )); + table.put(ConnectionMode.BACKGROUND, new ModeDefinition( + // TODO: Arrays.asList(cacheInitializer) — add once implemented + Collections.>emptyList(), + Collections.singletonList(backgroundPollingSynchronizer), + fdv1FallbackPollingSynchronizer + )); + return table; } /** 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 f6ee0b79..b7eb2dd2 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 @@ -44,6 +44,9 @@ public interface DataSourceFactory { private final LDLogger logger; private final LDContext evaluationContext; private final DataSourceUpdateSinkV2 dataSourceUpdateSink; + private static final String FDV1_FALLBACK_MESSAGE = + "Server signaled FDv1 fallback; switching to FDv1 polling synchronizer."; + private final SourceManager sourceManager; private final long fallbackTimeoutSeconds; private final long recoveryTimeoutSeconds; @@ -260,8 +263,7 @@ private void runInitializers( anyDataReceived = true; } } - logger.info("Server signaled FDv1 fallback during initialization; " + - "switching to FDv1 synchronizer."); + logger.info(FDV1_FALLBACK_MESSAGE); sourceManager.fdv1Fallback(); if (anyDataReceived) { sink.setStatus(DataSourceState.VALID, null); @@ -430,7 +432,7 @@ private void runSynchronizers( if (result.isFdv1Fallback() && sourceManager.hasFDv1Fallback() && !sourceManager.isCurrentSynchronizerFDv1Fallback()) { - logger.info("Server signaled FDv1 fallback; switching to FDv1 polling synchronizer."); + logger.info(FDV1_FALLBACK_MESSAGE); sourceManager.fdv1Fallback(); running = false; } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java index 53b74bbf..9f472cbd 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java @@ -5,13 +5,11 @@ import com.launchdarkly.sdk.android.ConnectionMode; import com.launchdarkly.sdk.android.DataSystemComponents; -import com.launchdarkly.sdk.android.LDConfig; import com.launchdarkly.sdk.android.ModeDefinition; import com.launchdarkly.sdk.android.subsystems.DataSourceBuilder; import com.launchdarkly.sdk.android.subsystems.Initializer; import com.launchdarkly.sdk.android.subsystems.Synchronizer; -import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; @@ -243,7 +241,7 @@ public Map getConnectionModeOverrides() { */ @NonNull public Map buildModeTable(boolean disableBackgroundUpdating) { - Map table = makeDefaultModeTable(); + Map table = DataSystemComponents.makeDefaultModeTable(); for (Map.Entry entry : connectionModeOverrides.entrySet()) { ConnectionModeBuilder cmb = entry.getValue(); @@ -265,54 +263,4 @@ public Map buildModeTable(boolean disableBackgro return table; } - /** - * Produces the default mode table. - */ - @NonNull - private static Map makeDefaultModeTable() { - DataSourceBuilder pollingInitializer = DataSystemComponents.pollingInitializer(); - - DataSourceBuilder pollingSynchronizer = DataSystemComponents.pollingSynchronizer(); - - DataSourceBuilder streamingSynchronizer = DataSystemComponents.streamingSynchronizer(); - - DataSourceBuilder backgroundPollingSynchronizer = - DataSystemComponents.pollingSynchronizer() - .pollIntervalMillis(LDConfig.DEFAULT_BACKGROUND_POLL_INTERVAL_MILLIS); - - DataSourceBuilder fdv1FallbackPollingSynchronizer = DataSystemComponents.fdv1PollingSynchronizer(); - - Map table = new LinkedHashMap<>(); - table.put(ConnectionMode.STREAMING, new ModeDefinition( - // TODO: cacheInitializer — add once implemented - Arrays.asList(/* cacheInitializer, */ pollingInitializer), - Arrays.asList(streamingSynchronizer, pollingSynchronizer), - fdv1FallbackPollingSynchronizer - )); - table.put(ConnectionMode.POLLING, new ModeDefinition( - // TODO: Arrays.asList(cacheInitializer) — add once implemented - Collections.>emptyList(), - Collections.singletonList(pollingSynchronizer), - fdv1FallbackPollingSynchronizer - )); - table.put(ConnectionMode.OFFLINE, new ModeDefinition( - // TODO: Arrays.asList(cacheInitializer) — add once implemented - Collections.>emptyList(), - Collections.>emptyList(), - null - )); - table.put(ConnectionMode.ONE_SHOT, new ModeDefinition( - // TODO: cacheInitializer and streamingInitializer — add once implemented - Arrays.asList(/* cacheInitializer, */ pollingInitializer /*, streamingInitializer, */), - Collections.>emptyList(), - null - )); - table.put(ConnectionMode.BACKGROUND, new ModeDefinition( - // TODO: Arrays.asList(cacheInitializer) — add once implemented - Collections.>emptyList(), - Collections.singletonList(backgroundPollingSynchronizer), - fdv1FallbackPollingSynchronizer - )); - return table; - } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/FDv1PollingSynchronizerBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/FDv1PollingSynchronizerBuilder.java deleted file mode 100644 index adccb9a6..00000000 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/FDv1PollingSynchronizerBuilder.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.launchdarkly.sdk.android.integrations; - -import com.launchdarkly.sdk.android.subsystems.DataSourceBuilder; -import com.launchdarkly.sdk.android.subsystems.Synchronizer; - -/** - * Contains methods for configuring the FDv1 polling synchronizer used as a fallback - * when the server signals that FDv2 endpoints are unavailable. - *

- * This class is not stable, and not subject to any backwards compatibility guarantees or semantic versioning. - * It is in early access. If you want access to this feature please join the EAP. https://launchdarkly.com/docs/sdk/features/data-saving-mode - *

- * Obtain an instance from {@link com.launchdarkly.sdk.android.DataSystemComponents#fdv1PollingSynchronizer()}. - *

- * Note that this class is abstract; the actual implementation is created by calling - * {@link com.launchdarkly.sdk.android.DataSystemComponents#fdv1PollingSynchronizer()}. - * - * @see com.launchdarkly.sdk.android.DataSystemComponents - */ -public abstract class FDv1PollingSynchronizerBuilder implements DataSourceBuilder { - - /** - * The polling interval in milliseconds. - */ - protected int pollIntervalMillis = PollingSynchronizerBuilder.DEFAULT_POLL_INTERVAL_MILLIS; - - /** - * Sets the interval at which the FDv1 fallback synchronizer will poll for feature flag updates. - * - * @param pollIntervalMillis the polling interval in milliseconds - * @return this builder - */ - public FDv1PollingSynchronizerBuilder pollIntervalMillis(int pollIntervalMillis) { - this.pollIntervalMillis = Math.max(pollIntervalMillis, - PollingSynchronizerBuilder.DEFAULT_POLL_INTERVAL_MILLIS); - return this; - } -} From 61d8dcac9c63988fbd236e310e640aed66bc3045 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Mon, 6 Apr 2026 13:05:50 -0700 Subject: [PATCH 18/23] [SDK-1829] fix: FDv1 fallback respects per-mode poll interval and survives custom mode overrides The FDv1 fallback synchronizer now uses the background poll interval (60 min) when in background mode instead of always using the foreground rate (5 min). Customer mode overrides preserve the default FDv1 fallback when the custom mode has initializers or synchronizers. Consolidate DEFAULT_POLL_INTERVAL_MILLIS into LDConfig as a single source of truth. Made-with: Cursor --- .../sdk/android/DataSystemComponents.java | 22 ++++++--- .../launchdarkly/sdk/android/LDConfig.java | 6 +++ .../integrations/DataSystemBuilder.java | 10 ++++- .../PollingDataSourceBuilder.java | 12 ++--- .../PollingSynchronizerBuilder.java | 12 ++--- .../integrations/DataSystemBuilderTest.java | 45 +++++++++++++++++++ 6 files changed, 85 insertions(+), 22 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSystemComponents.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSystemComponents.java index 90203fad..0d51e68c 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSystemComponents.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSystemComponents.java @@ -110,6 +110,15 @@ public Synchronizer build(DataSourceBuildInputs inputs) { } static final class FDv1PollingSynchronizerBuilderImpl implements DataSourceBuilder { + + protected int pollIntervalMillis = LDConfig.DEFAULT_POLL_INTERVAL_MILLIS; + + public FDv1PollingSynchronizerBuilderImpl pollIntervalMillis(int pollIntervalMillis) { + this.pollIntervalMillis = pollIntervalMillis <= LDConfig.DEFAULT_POLL_INTERVAL_MILLIS ? + LDConfig.DEFAULT_POLL_INTERVAL_MILLIS : pollIntervalMillis; + return this; + } + @Override public Synchronizer build(DataSourceBuildInputs inputs) { FeatureFetcher fetcher = new HttpFeatureFlagFetcher( @@ -123,7 +132,7 @@ public Synchronizer build(DataSourceBuildInputs inputs) { return new FDv1PollingSynchronizer( inputs.getEvaluationContext(), fetcher, inputs.getSharedExecutor(), 0, - PollingSynchronizerBuilder.DEFAULT_POLL_INTERVAL_MILLIS, + pollIntervalMillis, inputs.getBaseLogger() ); } @@ -185,21 +194,24 @@ public static Map makeDefaultModeTable() { DataSourceBuilder backgroundPollingSynchronizer = pollingSynchronizer() .pollIntervalMillis(LDConfig.DEFAULT_BACKGROUND_POLL_INTERVAL_MILLIS); - DataSourceBuilder fdv1FallbackPollingSynchronizer = + DataSourceBuilder fdv1FallbackPollingSynchronizerForeground = new FDv1PollingSynchronizerBuilderImpl(); + DataSourceBuilder fdv1FallbackPollingSynchronizerBackground = + new FDv1PollingSynchronizerBuilderImpl().pollIntervalMillis(LDConfig.DEFAULT_BACKGROUND_POLL_INTERVAL_MILLIS); + Map table = new LinkedHashMap<>(); table.put(ConnectionMode.STREAMING, new ModeDefinition( // TODO: cacheInitializer — add once implemented Arrays.asList(/* cacheInitializer, */ pollingInitializer), Arrays.asList(streamingSynchronizer, pollingSynchronizer), - fdv1FallbackPollingSynchronizer + fdv1FallbackPollingSynchronizerForeground )); table.put(ConnectionMode.POLLING, new ModeDefinition( // TODO: Arrays.asList(cacheInitializer) — add once implemented Collections.>emptyList(), Collections.singletonList(pollingSynchronizer), - fdv1FallbackPollingSynchronizer + fdv1FallbackPollingSynchronizerForeground )); table.put(ConnectionMode.OFFLINE, new ModeDefinition( // TODO: Arrays.asList(cacheInitializer) — add once implemented @@ -217,7 +229,7 @@ public static Map makeDefaultModeTable() { // TODO: Arrays.asList(cacheInitializer) — add once implemented Collections.>emptyList(), Collections.singletonList(backgroundPollingSynchronizer), - fdv1FallbackPollingSynchronizer + fdv1FallbackPollingSynchronizerBackground )); return table; } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java index 2e0ebb49..12d4a875 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java @@ -36,6 +36,12 @@ * must be constructed with {@link LDConfig.Builder}. */ public class LDConfig { + + /** + * The default value for {@link com.launchdarkly.sdk.android.integrations.PollingDataSourceBuilder#pollIntervalMillis(int)}: 5 minutes (300,000 ms). + */ + public static final int DEFAULT_POLL_INTERVAL_MILLIS = 300_000; + /** * The default value for {@link com.launchdarkly.sdk.android.integrations.StreamingDataSourceBuilder#backgroundPollIntervalMillis(int)} * and {@link com.launchdarkly.sdk.android.integrations.PollingDataSourceBuilder#backgroundPollIntervalMillis(int)}: diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java index 9f472cbd..24b67890 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java @@ -245,10 +245,18 @@ public Map buildModeTable(boolean disableBackgro for (Map.Entry entry : connectionModeOverrides.entrySet()) { ConnectionModeBuilder cmb = entry.getValue(); + + DataSourceBuilder fdv1FallbackSynchronizer = null; + if (!cmb.getInitializers().isEmpty() || !cmb.getSynchronizers().isEmpty()) { + fdv1FallbackSynchronizer = table.get(entry.getKey()).getFdv1FallbackSynchronizer(); // use fdv1 fallback from default mode table + } else { + fdv1FallbackSynchronizer = null; + } + table.put(entry.getKey(), new ModeDefinition( cmb.getInitializers(), cmb.getSynchronizers(), - null + fdv1FallbackSynchronizer )); } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/PollingDataSourceBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/PollingDataSourceBuilder.java index 7e4ed4d9..8d152ccb 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/PollingDataSourceBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/PollingDataSourceBuilder.java @@ -31,10 +31,6 @@ * @since 3.3.0 */ public abstract class PollingDataSourceBuilder implements ComponentConfigurer { - /** - * The default value for {@link #pollIntervalMillis(int)}: 5 minutes. - */ - public static final int DEFAULT_POLL_INTERVAL_MILLIS = 300_000; /** * The background polling interval in millis @@ -44,7 +40,7 @@ public abstract class PollingDataSourceBuilder implements ComponentConfigurer - * The default value is {@link #DEFAULT_POLL_INTERVAL_MILLIS}. That is also the minimum value. + * The default value is {@link LDConfig#DEFAULT_POLL_INTERVAL_MILLIS}. That is also the minimum value. * * @param pollIntervalMillis the reconnect time base value in milliseconds * @return the builder * @see #backgroundPollIntervalMillis(int) */ public PollingDataSourceBuilder pollIntervalMillis(int pollIntervalMillis) { - this.pollIntervalMillis = pollIntervalMillis <= DEFAULT_POLL_INTERVAL_MILLIS ? - DEFAULT_POLL_INTERVAL_MILLIS : pollIntervalMillis; + this.pollIntervalMillis = pollIntervalMillis <= LDConfig.DEFAULT_POLL_INTERVAL_MILLIS ? + LDConfig.DEFAULT_POLL_INTERVAL_MILLIS : pollIntervalMillis; return this; } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/PollingSynchronizerBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/PollingSynchronizerBuilder.java index 07793c4d..c839b664 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/PollingSynchronizerBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/PollingSynchronizerBuilder.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.android.integrations; import com.launchdarkly.sdk.android.interfaces.ServiceEndpoints; +import com.launchdarkly.sdk.android.LDConfig; import com.launchdarkly.sdk.android.subsystems.DataSourceBuilder; import com.launchdarkly.sdk.android.subsystems.Synchronizer; @@ -32,15 +33,10 @@ */ public abstract class PollingSynchronizerBuilder implements DataSourceBuilder { - /** - * The default value for {@link #pollIntervalMillis(int)}: 5 minutes (300,000 ms). - */ - public static final int DEFAULT_POLL_INTERVAL_MILLIS = 300_000; - /** * The polling interval in milliseconds. */ - protected int pollIntervalMillis = DEFAULT_POLL_INTERVAL_MILLIS; + protected int pollIntervalMillis = LDConfig.DEFAULT_POLL_INTERVAL_MILLIS; /** * Per-source service endpoint override, or null to use the SDK-level endpoints. @@ -50,14 +46,14 @@ public abstract class PollingSynchronizerBuilder implements DataSourceBuilder - * The default and minimum value is {@link #DEFAULT_POLL_INTERVAL_MILLIS}. Values + * The default and minimum value is {@link LDConfig#DEFAULT_POLL_INTERVAL_MILLIS}. Values * less than this will be set to the default. * * @param pollIntervalMillis the polling interval in milliseconds * @return this builder */ public PollingSynchronizerBuilder pollIntervalMillis(int pollIntervalMillis) { - this.pollIntervalMillis = Math.max(pollIntervalMillis, DEFAULT_POLL_INTERVAL_MILLIS); + this.pollIntervalMillis = Math.max(pollIntervalMillis, LDConfig.DEFAULT_POLL_INTERVAL_MILLIS); return this; } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilderTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilderTest.java index f0def4c8..10b5dc05 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilderTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilderTest.java @@ -1,6 +1,8 @@ package com.launchdarkly.sdk.android.integrations; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; @@ -62,6 +64,7 @@ public void customizeConnectionMode_replacesModeDefinition() { ModeDefinition bg = table.get(ConnectionMode.BACKGROUND); assertTrue(bg.getInitializers().isEmpty()); assertTrue(bg.getSynchronizers().isEmpty()); + assertNull(bg.getFdv1FallbackSynchronizer()); } @Test @@ -76,6 +79,48 @@ public void disableBackgroundUpdating_clearsBackgroundPipelineEvenWithOverride() ModeDefinition bg = table.get(ConnectionMode.BACKGROUND); assertTrue(bg.getInitializers().isEmpty()); assertTrue(bg.getSynchronizers().isEmpty()); + assertNull(bg.getFdv1FallbackSynchronizer()); + } + + @Test + public void customizeConnectionMode_preservesFdv1FallbackWhenModeHasSynchronizers() { + DataSystemBuilder b = Components.dataSystem() + .customizeConnectionMode( + ConnectionMode.STREAMING, + DataSystemComponents.customMode() + .synchronizers(DataSystemComponents.pollingSynchronizer())); + Map table = b.buildModeTable(false); + ModeDefinition streaming = table.get(ConnectionMode.STREAMING); + assertEquals(0, streaming.getInitializers().size()); + assertEquals(1, streaming.getSynchronizers().size()); + assertNotNull(streaming.getFdv1FallbackSynchronizer()); + } + + @Test + public void customizeConnectionMode_preservesFdv1FallbackWhenModeHasOnlyInitializers() { + DataSystemBuilder b = Components.dataSystem() + .customizeConnectionMode( + ConnectionMode.STREAMING, + DataSystemComponents.customMode() + .initializers(DataSystemComponents.pollingInitializer())); + Map table = b.buildModeTable(false); + ModeDefinition streaming = table.get(ConnectionMode.STREAMING); + assertEquals(1, streaming.getInitializers().size()); + assertEquals(0, streaming.getSynchronizers().size()); + assertNotNull(streaming.getFdv1FallbackSynchronizer()); + } + + @Test + public void customizeConnectionMode_nullsFdv1FallbackWhenModeIsEmpty() { + DataSystemBuilder b = Components.dataSystem() + .customizeConnectionMode( + ConnectionMode.STREAMING, + DataSystemComponents.customMode()); + Map table = b.buildModeTable(false); + ModeDefinition streaming = table.get(ConnectionMode.STREAMING); + assertTrue(streaming.getInitializers().isEmpty()); + assertTrue(streaming.getSynchronizers().isEmpty()); + assertNull(streaming.getFdv1FallbackSynchronizer()); } @Test From 7f496063b5ab1067f8ea8fe1de1b68afdcc45a9e Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Mon, 6 Apr 2026 13:41:48 -0700 Subject: [PATCH 19/23] [SDK-1829] Minor fix to remove redundant else branch --- .../sdk/android/integrations/DataSystemBuilder.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java index 24b67890..89b0eb46 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java @@ -249,8 +249,6 @@ public Map buildModeTable(boolean disableBackgro DataSourceBuilder fdv1FallbackSynchronizer = null; if (!cmb.getInitializers().isEmpty() || !cmb.getSynchronizers().isEmpty()) { fdv1FallbackSynchronizer = table.get(entry.getKey()).getFdv1FallbackSynchronizer(); // use fdv1 fallback from default mode table - } else { - fdv1FallbackSynchronizer = null; } table.put(entry.getKey(), new ModeDefinition( From e65f13394c2986ab4479c5905bae58c37b0c88fc Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 7 Apr 2026 09:04:00 -0700 Subject: [PATCH 20/23] [SDK-1829] fix: null-safe default mode in buildModeTable Made-with: Cursor --- .../sdk/android/integrations/DataSystemBuilder.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java index 89b0eb46..321fcfd2 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java @@ -248,7 +248,10 @@ public Map buildModeTable(boolean disableBackgro DataSourceBuilder fdv1FallbackSynchronizer = null; if (!cmb.getInitializers().isEmpty() || !cmb.getSynchronizers().isEmpty()) { - fdv1FallbackSynchronizer = table.get(entry.getKey()).getFdv1FallbackSynchronizer(); // use fdv1 fallback from default mode table + ModeDefinition defaultForMode = table.get(entry.getKey()); + fdv1FallbackSynchronizer = defaultForMode != null + ? defaultForMode.getFdv1FallbackSynchronizer() + : null; } table.put(entry.getKey(), new ModeDefinition( From 3afd09a1980df61f483604ff0e00318573f71498 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 7 Apr 2026 13:13:17 -0700 Subject: [PATCH 21/23] [SDK-1829] refactor: inline default mode table in FDv2DataSourceBuilder Made-with: Cursor --- .../launchdarkly/sdk/android/FDv2DataSourceBuilder.java | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java index c7776295..ac792f7a 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java @@ -40,7 +40,7 @@ class FDv2DataSourceBuilder implements ComponentConfigurer, Closeabl private ScheduledExecutorService sharedExecutor; FDv2DataSourceBuilder() { - this(makeDefaultModeTable(), ConnectionMode.STREAMING, ModeResolutionTable.MOBILE); + this(DataSystemComponents.makeDefaultModeTable(), ConnectionMode.STREAMING, ModeResolutionTable.MOBILE); } FDv2DataSourceBuilder( @@ -181,9 +181,4 @@ private static ResolvedModeDefinition resolve( fdv1FallbackSynchronizer != null ? () -> fdv1FallbackSynchronizer.build(inputs) : null; return new ResolvedModeDefinition(initFactories, syncFactories, fdv1Factory); } - - private static Map makeDefaultModeTable() { - return new com.launchdarkly.sdk.android.integrations.DataSystemBuilder() - .buildModeTable(false); - } } From c6beb5c48677ae566745aa2304896ad953581889 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 7 Apr 2026 14:41:16 -0700 Subject: [PATCH 22/23] [SDK-1829] test: Added capability to support sdk harness test --- .../src/main/java/com/launchdarkly/sdktest/TestService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java b/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java index 1f8c644a..e2c7e75b 100644 --- a/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java +++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java @@ -40,7 +40,8 @@ public class TestService extends NanoHTTPD { "client-prereq-events", "evaluation-hooks", "track-hooks", - "client-per-context-summaries" + "client-per-context-summaries", + "client-event-source-http-errors" }; private static final String MIME_JSON = "application/json"; static final Gson gson = new GsonBuilder() From 16b9f8ff0b18ccc4106e2882653dd27a6f6973c3 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 7 Apr 2026 15:53:03 -0700 Subject: [PATCH 23/23] [SDK-1829] docs: clarify why empty mode override drops FDv1 fallback Made-with: Cursor --- .../sdk/android/integrations/DataSystemBuilder.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java index 321fcfd2..53a951d2 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java @@ -246,6 +246,11 @@ public Map buildModeTable(boolean disableBackgro for (Map.Entry entry : connectionModeOverrides.entrySet()) { ConnectionModeBuilder cmb = entry.getValue(); + // Carry forward the default FDv1 fallback synchronizer only when the + // override supplies at least one initializer or synchronizer. An entirely + // empty override means "do nothing in this mode" (like OFFLINE), so there is + // no synchronizer that could receive the x-ld-fd-fallback header and the + // fallback slot would never be reached by FDv2DataSource.runSynchronizers(). DataSourceBuilder fdv1FallbackSynchronizer = null; if (!cmb.getInitializers().isEmpty() || !cmb.getSynchronizers().isEmpty()) { ModeDefinition defaultForMode = table.get(entry.getKey());