builder()
+ .errorMessage("Flag not found in any provider")
+ .errorCode(FLAG_NOT_FOUND)
+ .build();
+ }
+}
diff --git a/src/main/java/dev/openfeature/sdk/multiprovider/FirstSuccessfulStrategy.java b/src/main/java/dev/openfeature/sdk/multiprovider/FirstSuccessfulStrategy.java
new file mode 100644
index 000000000..2adaf3978
--- /dev/null
+++ b/src/main/java/dev/openfeature/sdk/multiprovider/FirstSuccessfulStrategy.java
@@ -0,0 +1,51 @@
+package dev.openfeature.sdk.multiprovider;
+
+import dev.openfeature.sdk.ErrorCode;
+import dev.openfeature.sdk.EvaluationContext;
+import dev.openfeature.sdk.FeatureProvider;
+import dev.openfeature.sdk.ProviderEvaluation;
+import java.util.Map;
+import java.util.function.Function;
+import lombok.NoArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * First Successful Strategy.
+ *
+ * Similar to “First Match”, except that errors from evaluated providers do not halt execution.
+ * Instead, it returns the first successful result from a provider. If no provider successfully
+ * responds, it returns a {@code GENERAL} error result.
+ */
+@Slf4j
+@NoArgsConstructor
+public class FirstSuccessfulStrategy implements Strategy {
+
+ @Override
+ public ProviderEvaluation evaluate(
+ Map providers,
+ String key,
+ T defaultValue,
+ EvaluationContext ctx,
+ Function> providerFunction) {
+ for (FeatureProvider provider : providers.values()) {
+ try {
+ ProviderEvaluation res = providerFunction.apply(provider);
+ if (res.getErrorCode() == null) {
+ // First successful result (no error code)
+ return res;
+ }
+ } catch (Exception e) {
+ log.debug(
+ "evaluation exception for key {} in provider {}",
+ key,
+ provider.getMetadata().getName(),
+ e);
+ }
+ }
+
+ return ProviderEvaluation.builder()
+ .errorMessage("No provider successfully responded")
+ .errorCode(ErrorCode.GENERAL)
+ .build();
+ }
+}
diff --git a/src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java b/src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java
new file mode 100644
index 000000000..803dd9458
--- /dev/null
+++ b/src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java
@@ -0,0 +1,186 @@
+package dev.openfeature.sdk.multiprovider;
+
+import dev.openfeature.sdk.EvaluationContext;
+import dev.openfeature.sdk.EventProvider;
+import dev.openfeature.sdk.FeatureProvider;
+import dev.openfeature.sdk.Metadata;
+import dev.openfeature.sdk.ProviderEvaluation;
+import dev.openfeature.sdk.Value;
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Experimental: Provider implementation for multi-provider.
+ *
+ * This provider delegates flag evaluations to multiple underlying providers using a configurable
+ * {@link Strategy}. It also exposes combined metadata containing the original metadata of each
+ * underlying provider.
+ */
+@Slf4j
+public class MultiProvider extends EventProvider {
+
+ @Getter
+ private static final String NAME = "multiprovider";
+
+ public static final int INIT_THREADS_COUNT = 8;
+
+ private final Map providers;
+ private final Strategy strategy;
+ private MultiProviderMetadata metadata;
+
+ /**
+ * Constructs a MultiProvider with the given list of FeatureProviders, by default uses
+ * {@link FirstMatchStrategy}.
+ *
+ * @param providers the list of FeatureProviders to initialize the MultiProvider with
+ */
+ public MultiProvider(List providers) {
+ this(providers, null);
+ }
+
+ /**
+ * Constructs a MultiProvider with the given list of FeatureProviders and a strategy.
+ *
+ * @param providers the list of FeatureProviders to initialize the MultiProvider with
+ * @param strategy the strategy (if {@code null}, {@link FirstMatchStrategy} is used)
+ */
+ public MultiProvider(List providers, Strategy strategy) {
+ this.providers = buildProviders(providers);
+ if (strategy != null) {
+ this.strategy = strategy;
+ } else {
+ this.strategy = new FirstMatchStrategy();
+ }
+ }
+
+ protected static Map buildProviders(List providers) {
+ Map providersMap = new LinkedHashMap<>(providers.size());
+ for (FeatureProvider provider : providers) {
+ FeatureProvider prevProvider =
+ providersMap.put(provider.getMetadata().getName(), provider);
+ if (prevProvider != null) {
+ log.warn("duplicated provider name: {}", provider.getMetadata().getName());
+ }
+ }
+ return Collections.unmodifiableMap(providersMap);
+ }
+
+ /**
+ * Initialize the provider.
+ *
+ * @param evaluationContext evaluation context
+ * @throws Exception on error (e.g. wrapped {@link java.util.concurrent.ExecutionException}
+ * from a failing provider)
+ */
+ @Override
+ public void initialize(EvaluationContext evaluationContext) throws Exception {
+ var metadataBuilder = MultiProviderMetadata.builder().name(NAME);
+ HashMap providersMetadata = new HashMap<>();
+
+ if (providers.isEmpty()) {
+ metadataBuilder.originalMetadata(Collections.unmodifiableMap(providersMetadata));
+ metadata = metadataBuilder.build();
+ return;
+ }
+
+ ExecutorService executorService = Executors.newFixedThreadPool(Math.min(INIT_THREADS_COUNT, providers.size()));
+ try {
+ Collection> tasks = new ArrayList<>(providers.size());
+ for (FeatureProvider provider : providers.values()) {
+ tasks.add(() -> {
+ provider.initialize(evaluationContext);
+ return null;
+ });
+ Metadata providerMetadata = provider.getMetadata();
+ providersMetadata.put(providerMetadata.getName(), providerMetadata);
+ }
+
+ metadataBuilder.originalMetadata(Collections.unmodifiableMap(providersMetadata));
+
+ List> results = executorService.invokeAll(tasks);
+ for (Future result : results) {
+ // This will re-throw any exception from the provider's initialize method,
+ // wrapped in an ExecutionException.
+ result.get();
+ }
+ } catch (Exception e) {
+ // If initialization fails for any provider, attempt to shut down all providers
+ // to avoid a partial/limbo state.
+ for (FeatureProvider provider : providers.values()) {
+ try {
+ provider.shutdown();
+ } catch (Exception shutdownEx) {
+ log.error(
+ "error shutting down provider {} after failed initialize",
+ provider.getMetadata().getName(),
+ shutdownEx);
+ }
+ }
+ throw e;
+ } finally {
+ executorService.shutdown();
+ }
+
+ metadata = metadataBuilder.build();
+ }
+
+ @SuppressFBWarnings(value = "EI_EXPOSE_REP")
+ @Override
+ public Metadata getMetadata() {
+ return metadata;
+ }
+
+ @Override
+ public ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) {
+ return strategy.evaluate(
+ providers, key, defaultValue, ctx, p -> p.getBooleanEvaluation(key, defaultValue, ctx));
+ }
+
+ @Override
+ public ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) {
+ return strategy.evaluate(providers, key, defaultValue, ctx, p -> p.getStringEvaluation(key, defaultValue, ctx));
+ }
+
+ @Override
+ public ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) {
+ return strategy.evaluate(
+ providers, key, defaultValue, ctx, p -> p.getIntegerEvaluation(key, defaultValue, ctx));
+ }
+
+ @Override
+ public ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) {
+ return strategy.evaluate(providers, key, defaultValue, ctx, p -> p.getDoubleEvaluation(key, defaultValue, ctx));
+ }
+
+ @Override
+ public ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx) {
+ return strategy.evaluate(providers, key, defaultValue, ctx, p -> p.getObjectEvaluation(key, defaultValue, ctx));
+ }
+
+ @Override
+ public void shutdown() {
+ log.debug("shutdown begin");
+ for (FeatureProvider provider : providers.values()) {
+ try {
+ provider.shutdown();
+ } catch (Exception e) {
+ log.error("error shutdown provider {}", provider.getMetadata().getName(), e);
+ }
+ }
+ log.debug("shutdown end");
+ // Important: ensure EventProvider's executor is also shut down
+ super.shutdown();
+ }
+}
diff --git a/src/main/java/dev/openfeature/sdk/multiprovider/MultiProviderMetadata.java b/src/main/java/dev/openfeature/sdk/multiprovider/MultiProviderMetadata.java
new file mode 100644
index 000000000..2f1bbfd46
--- /dev/null
+++ b/src/main/java/dev/openfeature/sdk/multiprovider/MultiProviderMetadata.java
@@ -0,0 +1,20 @@
+package dev.openfeature.sdk.multiprovider;
+
+import dev.openfeature.sdk.Metadata;
+import java.util.Map;
+import lombok.Builder;
+import lombok.Value;
+
+/**
+ * Metadata for {@link MultiProvider}.
+ *
+ * Contains the multiprovider's own name and a map of the original metadata from each underlying
+ * provider.
+ */
+@Value
+@Builder
+public class MultiProviderMetadata implements Metadata {
+
+ String name;
+ Map originalMetadata;
+}
diff --git a/src/main/java/dev/openfeature/sdk/multiprovider/Strategy.java b/src/main/java/dev/openfeature/sdk/multiprovider/Strategy.java
new file mode 100644
index 000000000..4c25fe8f0
--- /dev/null
+++ b/src/main/java/dev/openfeature/sdk/multiprovider/Strategy.java
@@ -0,0 +1,38 @@
+package dev.openfeature.sdk.multiprovider;
+
+import dev.openfeature.sdk.EvaluationContext;
+import dev.openfeature.sdk.FeatureProvider;
+import dev.openfeature.sdk.ProviderEvaluation;
+import java.util.Map;
+import java.util.function.Function;
+
+/**
+ * Strategy for determining how to evaluate a flag across multiple providers.
+ *
+ * Implementations decide how to:
+ *
+ * - Order or select providers
+ * - Handle {@code FLAG_NOT_FOUND} results
+ * - Handle errors and exceptions from providers
+ *
+ */
+public interface Strategy {
+
+ /**
+ * Evaluate a flag across multiple providers.
+ *
+ * @param providers ordered map of provider name to provider
+ * @param key the flag key to evaluate
+ * @param defaultValue the default value to fall back to
+ * @param ctx the evaluation context (may be {@code null})
+ * @param providerFunction function that executes the provider evaluation for the given key
+ * @param the flag value type
+ * @return the resolved {@link ProviderEvaluation}
+ */
+ ProviderEvaluation evaluate(
+ Map providers,
+ String key,
+ T defaultValue,
+ EvaluationContext ctx,
+ Function> providerFunction);
+}
diff --git a/src/test/java/dev/openfeature/sdk/multiprovider/BaseStrategyTest.java b/src/test/java/dev/openfeature/sdk/multiprovider/BaseStrategyTest.java
new file mode 100644
index 000000000..405a2f094
--- /dev/null
+++ b/src/test/java/dev/openfeature/sdk/multiprovider/BaseStrategyTest.java
@@ -0,0 +1,214 @@
+package dev.openfeature.sdk.multiprovider;
+
+import static dev.openfeature.sdk.ErrorCode.FLAG_NOT_FOUND;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import dev.openfeature.sdk.ErrorCode;
+import dev.openfeature.sdk.EvaluationContext;
+import dev.openfeature.sdk.FeatureProvider;
+import dev.openfeature.sdk.Metadata;
+import dev.openfeature.sdk.MutableContext;
+import dev.openfeature.sdk.ProviderEvaluation;
+import dev.openfeature.sdk.Value;
+import dev.openfeature.sdk.providers.memory.Flag;
+import dev.openfeature.sdk.providers.memory.InMemoryProvider;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import org.junit.jupiter.api.BeforeEach;
+
+public abstract class BaseStrategyTest {
+
+ protected FeatureProvider mockProvider1;
+ protected FeatureProvider mockProvider2;
+ protected FeatureProvider mockProvider3;
+
+ protected Metadata mockMetaData1;
+ protected Metadata mockMetaData2;
+ protected Metadata mockMetaData3;
+
+ protected InMemoryProvider inMemoryProvider1;
+ protected InMemoryProvider inMemoryProvider2;
+
+ protected Map orderedProviders;
+
+ protected EvaluationContext contextWithNewProvider;
+
+ protected static final String FLAG_KEY = "test-flag";
+ protected static final String DEFAULT_STRING = "default";
+ protected static final boolean DEFAULT_BOOLEAN = false;
+ protected static final int DEFAULT_INTEGER = 0;
+ protected static final double DEFAULT_DOUBLE = 0.0;
+
+ @BeforeEach
+ void setUp() {
+ setupMockProviders();
+ setupInMemoryProviders();
+ setupOrderedProviders();
+ setupEvaluationContexts();
+ }
+
+ protected void setupMockProviders() {
+ mockProvider1 = mock(FeatureProvider.class);
+ mockProvider2 = mock(FeatureProvider.class);
+ mockProvider3 = mock(FeatureProvider.class);
+ mockMetaData1 = mock(Metadata.class);
+ mockMetaData2 = mock(Metadata.class);
+ mockMetaData3 = mock(Metadata.class);
+ when(mockMetaData1.getName()).thenReturn("provider1");
+ when(mockMetaData2.getName()).thenReturn("provider2");
+ when(mockMetaData3.getName()).thenReturn("provider3");
+ when(mockProvider1.getMetadata()).thenReturn(mockMetaData1);
+ when(mockProvider2.getMetadata()).thenReturn(mockMetaData2);
+ when(mockProvider3.getMetadata()).thenReturn(mockMetaData3);
+ }
+
+ protected void setupInMemoryProviders() {
+ Map> flags1 = createFlags1();
+ Map> flags2 = createFlags2();
+
+ inMemoryProvider1 = new InMemoryProvider(flags1) {
+ @Override
+ public Metadata getMetadata() {
+ return () -> "old-provider";
+ }
+ };
+
+ inMemoryProvider2 = new InMemoryProvider(flags2) {
+ @Override
+ public Metadata getMetadata() {
+ return () -> "new-provider";
+ }
+ };
+ }
+
+ protected void setupOrderedProviders() {
+ orderedProviders = new LinkedHashMap<>();
+ orderedProviders.put("provider1", mockProvider1);
+ orderedProviders.put("provider2", mockProvider2);
+ orderedProviders.put("provider3", mockProvider3);
+ }
+
+ protected void setupEvaluationContexts() {
+ contextWithNewProvider = new MutableContext().add("provider", "new-provider");
+ }
+
+ protected Map> createFlags1() {
+ Map> flags = new HashMap<>();
+
+ flags.put(
+ "b1",
+ Flag.builder()
+ .variant("on", true)
+ .variant("off", false)
+ .defaultVariant("on")
+ .build());
+
+ flags.put(
+ "i1",
+ Flag.builder().variant("default", 1).defaultVariant("default").build());
+
+ flags.put(
+ "d1",
+ Flag.builder().variant("default", 1.0).defaultVariant("default").build());
+
+ flags.put(
+ "s1",
+ Flag.builder()
+ .variant("default", "str1")
+ .defaultVariant("default")
+ .build());
+
+ flags.put(
+ "o1",
+ Flag.builder()
+ .variant("default", new Value("v1"))
+ .defaultVariant("default")
+ .build());
+
+ return flags;
+ }
+
+ protected Map> createFlags2() {
+ Map> flags = new HashMap<>();
+
+ flags.put(
+ "b1",
+ Flag.builder()
+ .variant("on", true)
+ .variant("off", false)
+ .defaultVariant("off")
+ .build());
+
+ flags.put(
+ "i1",
+ Flag.builder().variant("default", 2).defaultVariant("default").build());
+
+ flags.put(
+ "d1",
+ Flag.builder().variant("default", 2.0).defaultVariant("default").build());
+
+ flags.put(
+ "s1",
+ Flag.builder()
+ .variant("default", "str2")
+ .defaultVariant("default")
+ .build());
+
+ flags.put(
+ "o1",
+ Flag.builder()
+ .variant("default", new Value("v2"))
+ .defaultVariant("default")
+ .build());
+
+ flags.put(
+ "s2",
+ Flag.builder()
+ .variant("default", "s2str2")
+ .defaultVariant("default")
+ .build());
+
+ return flags;
+ }
+
+ protected ProviderEvaluation createErrorResult(ErrorCode errorCode) {
+ ProviderEvaluation result = mock(ProviderEvaluation.class);
+ when(result.getErrorCode()).thenReturn(errorCode);
+ return result;
+ }
+
+ protected void setupProviderFlagNotFound(FeatureProvider provider) {
+ ProviderEvaluation stringResult = createErrorResult(FLAG_NOT_FOUND);
+ ProviderEvaluation booleanResult = createErrorResult(FLAG_NOT_FOUND);
+ ProviderEvaluation integerResult = createErrorResult(FLAG_NOT_FOUND);
+ ProviderEvaluation doubleResult = createErrorResult(FLAG_NOT_FOUND);
+ ProviderEvaluation objectResult = createErrorResult(FLAG_NOT_FOUND);
+
+ when(provider.getStringEvaluation(BaseStrategyTest.FLAG_KEY, DEFAULT_STRING, null))
+ .thenReturn(stringResult);
+ when(provider.getBooleanEvaluation(BaseStrategyTest.FLAG_KEY, DEFAULT_BOOLEAN, null))
+ .thenReturn(booleanResult);
+ when(provider.getIntegerEvaluation(BaseStrategyTest.FLAG_KEY, DEFAULT_INTEGER, null))
+ .thenReturn(integerResult);
+ when(provider.getDoubleEvaluation(BaseStrategyTest.FLAG_KEY, DEFAULT_DOUBLE, null))
+ .thenReturn(doubleResult);
+ when(provider.getObjectEvaluation(BaseStrategyTest.FLAG_KEY, null, null))
+ .thenReturn(objectResult);
+ }
+
+ protected void setupProviderError(FeatureProvider provider, ErrorCode errorCode) {
+ ProviderEvaluation result = createErrorResult(errorCode);
+ when(provider.getStringEvaluation(BaseStrategyTest.FLAG_KEY, DEFAULT_STRING, null))
+ .thenReturn(result);
+ }
+
+ protected void setupProviderSuccess(FeatureProvider provider, String value) {
+ ProviderEvaluation result = mock(ProviderEvaluation.class);
+ when(result.getErrorCode()).thenReturn(null);
+ when(result.getValue()).thenReturn(value);
+ when(provider.getStringEvaluation(BaseStrategyTest.FLAG_KEY, DEFAULT_STRING, null))
+ .thenReturn(result);
+ }
+}
diff --git a/src/test/java/dev/openfeature/sdk/multiprovider/FirstMatchStrategyTest.java b/src/test/java/dev/openfeature/sdk/multiprovider/FirstMatchStrategyTest.java
new file mode 100644
index 000000000..0205961cd
--- /dev/null
+++ b/src/test/java/dev/openfeature/sdk/multiprovider/FirstMatchStrategyTest.java
@@ -0,0 +1,91 @@
+package dev.openfeature.sdk.multiprovider;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import dev.openfeature.sdk.ErrorCode;
+import dev.openfeature.sdk.ProviderEvaluation;
+import org.junit.jupiter.api.Test;
+
+class FirstMatchStrategyTest extends BaseStrategyTest {
+
+ private final FirstMatchStrategy strategy = new FirstMatchStrategy();
+
+ @Test
+ void shouldSkipFlagNotFoundAndReturnFirstMatch() {
+ setupProviderFlagNotFound(mockProvider1);
+ setupProviderSuccess(mockProvider2, "success");
+
+ ProviderEvaluation result = strategy.evaluate(
+ orderedProviders,
+ FLAG_KEY,
+ DEFAULT_STRING,
+ null,
+ p -> p.getStringEvaluation(FLAG_KEY, DEFAULT_STRING, null));
+
+ assertNotNull(result);
+ assertEquals("success", result.getValue());
+ assertNull(result.getErrorCode());
+ }
+
+ @Test
+ void shouldReturnFirstNonFlagNotFoundError() {
+ setupProviderError(mockProvider1, ErrorCode.PARSE_ERROR);
+ setupProviderSuccess(mockProvider2, "success");
+ ProviderEvaluation result = strategy.evaluate(
+ orderedProviders,
+ FLAG_KEY,
+ DEFAULT_STRING,
+ null,
+ p -> p.getStringEvaluation(FLAG_KEY, DEFAULT_STRING, null));
+ assertEquals(ErrorCode.PARSE_ERROR, result.getErrorCode());
+ }
+
+ @Test
+ void shouldReturnSuccessWhenFirstProviderSucceeds() {
+ setupProviderSuccess(mockProvider1, "first-success");
+ setupProviderFlagNotFound(mockProvider2);
+ ProviderEvaluation result = strategy.evaluate(
+ orderedProviders,
+ FLAG_KEY,
+ DEFAULT_STRING,
+ null,
+ p -> p.getStringEvaluation(FLAG_KEY, DEFAULT_STRING, null));
+
+ assertNotNull(result);
+ assertEquals("first-success", result.getValue());
+ assertNull(result.getErrorCode());
+ }
+
+ @Test
+ void shouldThrowFlagNotFoundWhenAllProvidersReturnFlagNotFound() {
+ setupProviderFlagNotFound(mockProvider1);
+ setupProviderFlagNotFound(mockProvider2);
+ setupProviderFlagNotFound(mockProvider3);
+ ProviderEvaluation providerEvaluation = strategy.evaluate(
+ orderedProviders,
+ FLAG_KEY,
+ DEFAULT_STRING,
+ null,
+ p -> p.getStringEvaluation(FLAG_KEY, DEFAULT_STRING, null));
+
+ assertEquals(ErrorCode.FLAG_NOT_FOUND, providerEvaluation.getErrorCode());
+ assertEquals("Flag not found in any provider", providerEvaluation.getErrorMessage());
+ }
+
+ @Test
+ void shouldSkipMultipleFlagNotFoundAndReturnFirstOtherError() {
+ setupProviderFlagNotFound(mockProvider1);
+ setupProviderFlagNotFound(mockProvider2);
+ setupProviderError(mockProvider3, ErrorCode.PARSE_ERROR);
+ ProviderEvaluation result = strategy.evaluate(
+ orderedProviders,
+ FLAG_KEY,
+ DEFAULT_STRING,
+ null,
+ p -> p.getStringEvaluation(FLAG_KEY, DEFAULT_STRING, null));
+ assertNotNull(result);
+ assertEquals(ErrorCode.PARSE_ERROR, result.getErrorCode());
+ }
+}
diff --git a/src/test/java/dev/openfeature/sdk/multiprovider/FirstSuccessfulStrategyTest.java b/src/test/java/dev/openfeature/sdk/multiprovider/FirstSuccessfulStrategyTest.java
new file mode 100644
index 000000000..a47af8a2f
--- /dev/null
+++ b/src/test/java/dev/openfeature/sdk/multiprovider/FirstSuccessfulStrategyTest.java
@@ -0,0 +1,78 @@
+package dev.openfeature.sdk.multiprovider;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import dev.openfeature.sdk.ErrorCode;
+import dev.openfeature.sdk.ProviderEvaluation;
+import org.junit.jupiter.api.Test;
+
+class FirstSuccessfulStrategyTest extends BaseStrategyTest {
+
+ private final FirstSuccessfulStrategy strategy = new FirstSuccessfulStrategy();
+
+ @Test
+ void shouldSkipFlagNotFoundAndReturnFirstSuccess() {
+ setupProviderFlagNotFound(mockProvider1);
+ setupProviderSuccess(mockProvider2, "success");
+ ProviderEvaluation result = strategy.evaluate(
+ orderedProviders,
+ FLAG_KEY,
+ DEFAULT_STRING,
+ null,
+ p -> p.getStringEvaluation(FLAG_KEY, DEFAULT_STRING, null));
+ assertNotNull(result);
+ assertEquals("success", result.getValue());
+ assertNull(result.getErrorCode());
+ }
+
+ @Test
+ void shouldThrowGeneralErrorWhenAllProvidersFail() {
+ setupProviderFlagNotFound(mockProvider1);
+ setupProviderError(mockProvider2, ErrorCode.PARSE_ERROR);
+ setupProviderError(mockProvider3, ErrorCode.TYPE_MISMATCH);
+ ProviderEvaluation providerEvaluation = strategy.evaluate(
+ orderedProviders,
+ FLAG_KEY,
+ DEFAULT_STRING,
+ null,
+ p -> p.getStringEvaluation(FLAG_KEY, DEFAULT_STRING, null));
+
+ assertEquals(ErrorCode.GENERAL, providerEvaluation.getErrorCode());
+ assertEquals("No provider successfully responded", providerEvaluation.getErrorMessage());
+ }
+
+ @Test
+ void shouldSkipProvidersThatOnlyReturnErrors() {
+ setupProviderError(mockProvider1, ErrorCode.INVALID_CONTEXT);
+ setupProviderError(mockProvider2, ErrorCode.PROVIDER_NOT_READY);
+ setupProviderError(mockProvider3, ErrorCode.GENERAL);
+
+ ProviderEvaluation providerEvaluation = strategy.evaluate(
+ orderedProviders,
+ FLAG_KEY,
+ DEFAULT_STRING,
+ null,
+ p -> p.getStringEvaluation(FLAG_KEY, DEFAULT_STRING, null));
+
+ assertEquals(ErrorCode.GENERAL, providerEvaluation.getErrorCode());
+ assertEquals("No provider successfully responded", providerEvaluation.getErrorMessage());
+ }
+
+ @Test
+ void shouldThrowGeneralErrorForNonExistentFlag() {
+ orderedProviders.clear();
+ orderedProviders.put("old-provider", inMemoryProvider1);
+ orderedProviders.put("new-provider", inMemoryProvider2);
+ ProviderEvaluation providerEvaluation = strategy.evaluate(
+ orderedProviders,
+ FLAG_KEY,
+ DEFAULT_STRING,
+ null,
+ p -> p.getStringEvaluation(FLAG_KEY, DEFAULT_STRING, null));
+
+ assertEquals(ErrorCode.GENERAL, providerEvaluation.getErrorCode());
+ assertEquals("No provider successfully responded", providerEvaluation.getErrorMessage());
+ }
+}
diff --git a/src/test/java/dev/openfeature/sdk/multiprovider/MultiProviderTest.java b/src/test/java/dev/openfeature/sdk/multiprovider/MultiProviderTest.java
new file mode 100644
index 000000000..e4a387e45
--- /dev/null
+++ b/src/test/java/dev/openfeature/sdk/multiprovider/MultiProviderTest.java
@@ -0,0 +1,163 @@
+package dev.openfeature.sdk.multiprovider;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import dev.openfeature.sdk.ErrorCode;
+import dev.openfeature.sdk.EvaluationContext;
+import dev.openfeature.sdk.FeatureProvider;
+import dev.openfeature.sdk.Metadata;
+import dev.openfeature.sdk.MutableContext;
+import dev.openfeature.sdk.ProviderEvaluation;
+import dev.openfeature.sdk.Value;
+import dev.openfeature.sdk.exceptions.GeneralError;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.function.Function;
+import lombok.SneakyThrows;
+import org.junit.jupiter.api.Test;
+
+class MultiProviderTest extends BaseStrategyTest {
+
+ @Test
+ void shouldInitializeWithEmptyProvidersList() {
+ MultiProvider multiProvider = new MultiProvider(new ArrayList<>());
+ assertDoesNotThrow(() -> multiProvider.initialize(null));
+ assertNotNull(multiProvider.getMetadata());
+ assertEquals(MultiProvider.getNAME(), multiProvider.getMetadata().getName());
+ }
+
+ @SneakyThrows
+ @Test
+ void shouldInitializeSuccessfully() {
+ List providers = new ArrayList<>(2);
+ providers.add(mockProvider1);
+ providers.add(mockProvider2);
+ Strategy strategy = mock(Strategy.class);
+ MultiProvider multiProvider = new MultiProvider(providers, strategy);
+ multiProvider.initialize(null);
+
+ MultiProviderMetadata metadata = (MultiProviderMetadata) multiProvider.getMetadata();
+ Map map = metadata.getOriginalMetadata();
+ assertEquals(mockMetaData1, map.get(mockProvider1.getMetadata().getName()));
+ assertEquals(mockMetaData2, map.get(mockProvider2.getMetadata().getName()));
+ assertEquals("multiprovider", multiProvider.getMetadata().getName());
+ }
+
+ @SneakyThrows
+ @Test
+ void shouldHandleInitializationFailure() {
+ doThrow(new GeneralError()).when(mockProvider1).initialize(any());
+ doThrow(new GeneralError()).when(mockProvider1).shutdown();
+ List providers = new ArrayList<>(2);
+ providers.add(mockProvider1);
+ providers.add(mockProvider2);
+ Strategy strategy = mock(Strategy.class);
+ MultiProvider multiProvider = new MultiProvider(providers, strategy);
+ assertThrows(ExecutionException.class, () -> multiProvider.initialize(null));
+ assertDoesNotThrow(multiProvider::shutdown);
+ }
+
+ @Test
+ void shouldHandleDuplicateProviderNames() {
+ when(mockProvider1.getMetadata()).thenReturn(() -> "provider");
+ when(mockProvider2.getMetadata()).thenReturn(() -> "provider");
+ List providers = new ArrayList<>(2);
+ providers.add(mockProvider1);
+ providers.add(mockProvider2);
+ assertDoesNotThrow(() -> new MultiProvider(providers, null).initialize(null));
+ }
+
+ @SneakyThrows
+ @Test
+ void shouldRetrieveCorrectMetadataName() {
+ List providers = new ArrayList<>();
+ providers.add(mockProvider1);
+ Strategy mockStrategy = mock(Strategy.class);
+ MultiProvider multiProvider = new MultiProvider(providers, mockStrategy);
+ multiProvider.initialize(null);
+ MultiProviderMetadata metadata = (MultiProviderMetadata) multiProvider.getMetadata();
+ Map map = metadata.getOriginalMetadata();
+ assertEquals(mockMetaData1, map.get(mockProvider1.getMetadata().getName()));
+ }
+
+ @SneakyThrows
+ @Test
+ void shouldUseDefaultFirstMatchStrategy() {
+ List providers = new ArrayList<>(2);
+ providers.add(inMemoryProvider1);
+ providers.add(inMemoryProvider2);
+ MultiProvider multiProvider = new MultiProvider(providers);
+ multiProvider.initialize(null);
+ assertEquals(true, multiProvider.getBooleanEvaluation("b1", false, null).getValue());
+ assertEquals(1, multiProvider.getIntegerEvaluation("i1", 0, null).getValue());
+ assertEquals(1.0, multiProvider.getDoubleEvaluation("d1", 0.0, null).getValue());
+ assertEquals("str1", multiProvider.getStringEvaluation("s1", "", null).getValue());
+ assertEquals(
+ "v1",
+ multiProvider.getObjectEvaluation("o1", null, null).getValue().asString());
+
+ ProviderEvaluation providerEvaluation = multiProvider.getStringEvaluation("non-existing", "", null);
+ assertEquals(ErrorCode.FLAG_NOT_FOUND, providerEvaluation.getErrorCode());
+ assertEquals("Flag not found in any provider", providerEvaluation.getErrorMessage());
+ }
+
+ @SneakyThrows
+ @Test
+ void shouldWorkWithCustomStrategy() {
+ Strategy customStrategy = new Strategy() {
+ final FirstMatchStrategy fallbackStrategy = new FirstMatchStrategy();
+
+ @Override
+ public ProviderEvaluation evaluate(
+ Map providers,
+ String key,
+ T defaultValue,
+ EvaluationContext ctx,
+ Function> providerFunction) {
+
+ Value contextProvider = null;
+ if (ctx != null) {
+ contextProvider = ctx.getValue("provider");
+ }
+
+ if (contextProvider != null && "new-provider".equals(contextProvider.asString())) {
+ return providerFunction.apply(providers.get("new-provider"));
+ }
+ return fallbackStrategy.evaluate(providers, key, defaultValue, ctx, providerFunction);
+ }
+ };
+
+ List providers = new ArrayList<>(2);
+ providers.add(inMemoryProvider1);
+ providers.add(inMemoryProvider2);
+ MultiProvider multiProvider = new MultiProvider(providers, customStrategy);
+ multiProvider.initialize(null);
+ EvaluationContext context = new MutableContext().add("provider", "new-provider");
+ assertEquals(
+ false, multiProvider.getBooleanEvaluation("b1", true, context).getValue());
+ assertEquals(true, multiProvider.getBooleanEvaluation("b1", true, null).getValue());
+ }
+
+ @SneakyThrows
+ @Test
+ void shouldSupportAllEvaluationTypes() {
+ List providers = new ArrayList<>(1);
+ providers.add(inMemoryProvider1);
+ MultiProvider multiProvider = new MultiProvider(providers);
+ multiProvider.initialize(null);
+ assertNotNull(multiProvider.getBooleanEvaluation("b1", false, null));
+ assertNotNull(multiProvider.getIntegerEvaluation("i1", 0, null));
+ assertNotNull(multiProvider.getDoubleEvaluation("d1", 0.0, null));
+ assertNotNull(multiProvider.getStringEvaluation("s1", "", null));
+ assertNotNull(multiProvider.getObjectEvaluation("o1", null, null));
+ }
+}