diff --git a/src/main/java/dev/openfeature/sdk/MultiProviderHookExecutor.java b/src/main/java/dev/openfeature/sdk/MultiProviderHookExecutor.java
new file mode 100644
index 000000000..bf5b4da2f
--- /dev/null
+++ b/src/main/java/dev/openfeature/sdk/MultiProviderHookExecutor.java
@@ -0,0 +1,95 @@
+package dev.openfeature.sdk;
+
+import dev.openfeature.sdk.exceptions.ExceptionUtils;
+import dev.openfeature.sdk.exceptions.OpenFeatureError;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.function.BiFunction;
+
+/**
+ * Runs a single provider's own hooks around one evaluation, delegating to {@link HookSupport} so a
+ * child provider's hooks behave the same as when the provider is used standalone.
+ *
+ *
Internal. Public only so {@link dev.openfeature.sdk.multiprovider.MultiProvider} can
+ * reach it across packages; {@link HookSupport} and its collaborators are package-private. Not part
+ * of the public API; may change or be removed without notice.
+ *
+ * @hidden
+ */
+public final class MultiProviderHookExecutor {
+
+ private final HookSupport hookSupport = new HookSupport();
+
+ /**
+ * Runs {@code provider}'s hooks around the evaluation performed by {@code providerFunction}.
+ */
+ public ProviderEvaluation execute(
+ FeatureProvider provider,
+ String key,
+ T defaultValue,
+ FlagValueType type,
+ EvaluationContext context,
+ ClientMetadata clientMetadata,
+ Map hints,
+ BiFunction> providerFunction) {
+
+ List providerHooks = provider.getProviderHooks();
+ if (providerHooks == null || providerHooks.isEmpty()) {
+ return providerFunction.apply(provider, context);
+ }
+
+ HookSupportData data = new HookSupportData();
+ data.hints = hints == null ? Collections.emptyMap() : hints;
+ LayeredEvaluationContext layeredContext = new LayeredEvaluationContext(null, null, null, context);
+ data.evaluationContext = layeredContext;
+
+ hookSupport.setHooks(
+ data, providerHooks, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), type);
+ if (data.getHooks().isEmpty()) {
+ // no hooks support this type
+ return providerFunction.apply(provider, context);
+ }
+
+ SharedHookContext sharedHookContext =
+ new SharedHookContext<>(key, type, clientMetadata, provider.getMetadata(), defaultValue);
+ hookSupport.setHookContexts(data, sharedHookContext, layeredContext);
+
+ FlagEvaluationDetails details = null;
+ try {
+ hookSupport.executeBeforeHooks(data);
+ ProviderEvaluation providerEvaluation = providerFunction.apply(provider, data.getEvaluationContext());
+ details = FlagEvaluationDetails.from(providerEvaluation, key);
+ if (details.getErrorCode() != null) {
+ Exception error =
+ ExceptionUtils.instantiateErrorByErrorCode(details.getErrorCode(), details.getErrorMessage());
+ enrichDetailsWithErrorDefaults(defaultValue, details);
+ hookSupport.executeErrorHooks(data, error);
+ } else {
+ hookSupport.executeAfterHooks(data, details);
+ }
+ return providerEvaluation;
+ } catch (Exception e) {
+ if (details == null) {
+ details = FlagEvaluationDetails.builder().flagKey(key).build();
+ }
+ if (e instanceof OpenFeatureError) {
+ details.setErrorCode(((OpenFeatureError) e).getErrorCode());
+ } else {
+ details.setErrorCode(ErrorCode.GENERAL);
+ }
+ details.setErrorMessage(e.getMessage());
+ enrichDetailsWithErrorDefaults(defaultValue, details);
+ hookSupport.executeErrorHooks(data, e);
+ throw e;
+ } finally {
+ // details is always set by now: from the evaluation on success, or the catch on failure
+ hookSupport.executeAfterAllHooks(data, details);
+ }
+ }
+
+ private static void enrichDetailsWithErrorDefaults(T defaultValue, FlagEvaluationDetails details) {
+ details.setValue(defaultValue);
+ details.setReason(Reason.ERROR.toString());
+ }
+}
diff --git a/src/main/java/dev/openfeature/sdk/multiprovider/HookExecutionContext.java b/src/main/java/dev/openfeature/sdk/multiprovider/HookExecutionContext.java
new file mode 100644
index 000000000..5b0d501d9
--- /dev/null
+++ b/src/main/java/dev/openfeature/sdk/multiprovider/HookExecutionContext.java
@@ -0,0 +1,15 @@
+package dev.openfeature.sdk.multiprovider;
+
+import dev.openfeature.sdk.ClientMetadata;
+import java.util.Map;
+
+/** Captures hook lifecycle context (client metadata and hints) for per-provider hook execution. */
+final class HookExecutionContext {
+ final ClientMetadata clientMetadata;
+ final Map hints;
+
+ HookExecutionContext(ClientMetadata clientMetadata, Map hints) {
+ this.clientMetadata = clientMetadata;
+ this.hints = hints;
+ }
+}
diff --git a/src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java b/src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java
index 00611ed78..8335f234a 100644
--- a/src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java
+++ b/src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java
@@ -1,9 +1,15 @@
package dev.openfeature.sdk.multiprovider;
+import dev.openfeature.sdk.ClientMetadata;
import dev.openfeature.sdk.EvaluationContext;
import dev.openfeature.sdk.EventProvider;
import dev.openfeature.sdk.FeatureProvider;
+import dev.openfeature.sdk.FlagEvaluationDetails;
+import dev.openfeature.sdk.FlagValueType;
+import dev.openfeature.sdk.Hook;
+import dev.openfeature.sdk.HookContext;
import dev.openfeature.sdk.Metadata;
+import dev.openfeature.sdk.MultiProviderHookExecutor;
import dev.openfeature.sdk.ProviderEvaluation;
import dev.openfeature.sdk.Value;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
@@ -15,10 +21,12 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.Optional;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
+import java.util.function.BiFunction;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
@@ -28,6 +36,9 @@
*
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.
+ *
+ *
Hooks registered on the child providers are executed around each child evaluation, so a child
+ * provider's own hooks observe the evaluation it takes part in.
*/
@Slf4j
public class MultiProvider extends EventProvider {
@@ -40,6 +51,12 @@ public class MultiProvider extends EventProvider {
private final Map providers;
private final Strategy strategy;
+
+ // side-channel for hook-context not passed to resolvers; assumes before/resolve share a thread
+ private final ThreadLocal localHookExecutionContext = new ThreadLocal<>();
+ private final ClientMetadata hookClientMetadata = MultiProvider::getNAME;
+ private final MultiProviderHookExecutor hookExecutor = new MultiProviderHookExecutor();
+
private MultiProviderMetadata metadata;
/**
@@ -63,6 +80,37 @@ public MultiProvider(List providers, Strategy strategy) {
this.strategy = Objects.requireNonNull(strategy, "strategy must not be null");
}
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ private final List providerHooks = List.of(new Hook() {
+ @Override
+ public Optional before(HookContext ctx, Map hints) {
+ localHookExecutionContext.set(new HookExecutionContext(ctx.getClientMetadata(), snapshotHints(hints)));
+ return Optional.empty();
+ }
+
+ @Override
+ public void finallyAfter(HookContext ctx, FlagEvaluationDetails details, Map hints) {
+ localHookExecutionContext.remove();
+ }
+ });
+
+ /**
+ * Provider-level hooks for this MultiProvider. Includes a {@code before} hook that captures the
+ * {@link ClientMetadata} and hints from the SDK lifecycle for use during per-child hook execution.
+ */
+ @Override
+ public List getProviderHooks() {
+ return providerHooks;
+ }
+
+ // defensive copy: mutable hookHints may be read by parallel strategies; plain copy allows nulls
+ private static Map snapshotHints(Map hints) {
+ if (hints == null || hints.isEmpty()) {
+ return Collections.emptyMap();
+ }
+ return Collections.unmodifiableMap(new HashMap<>(hints));
+ }
+
protected static Map buildProviders(List providers) {
Map providersMap = new LinkedHashMap<>(providers.size());
for (FeatureProvider provider : providers) {
@@ -119,13 +167,11 @@ public void initialize(EvaluationContext evaluationContext, String domain) throw
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.
+ // re-throws any provider initialize exception, wrapped in an ExecutionException
result.get();
}
} catch (Exception e) {
- // If initialization fails for any provider, attempt to shut down via the
- // standard shutdown path to avoid a partial/limbo state.
+ // on any provider init failure, shut down via the standard path to avoid a limbo state
try {
shutdown();
} catch (Exception shutdownEx) {
@@ -147,29 +193,111 @@ public Metadata getMetadata() {
@Override
public ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) {
+ HookExecutionContext hookCtx = currentHookExecutionContext();
return strategy.evaluate(
- providers, key, defaultValue, ctx, p -> p.getBooleanEvaluation(key, defaultValue, ctx));
+ providers,
+ key,
+ defaultValue,
+ ctx,
+ provider -> evaluateChild(
+ hookCtx,
+ provider,
+ key,
+ defaultValue,
+ ctx,
+ FlagValueType.BOOLEAN,
+ (p, evaluationContext) -> p.getBooleanEvaluation(key, defaultValue, evaluationContext)));
}
@Override
public ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) {
- return strategy.evaluate(providers, key, defaultValue, ctx, p -> p.getStringEvaluation(key, defaultValue, ctx));
+ HookExecutionContext hookCtx = currentHookExecutionContext();
+ return strategy.evaluate(
+ providers,
+ key,
+ defaultValue,
+ ctx,
+ provider -> evaluateChild(
+ hookCtx,
+ provider,
+ key,
+ defaultValue,
+ ctx,
+ FlagValueType.STRING,
+ (p, evaluationContext) -> p.getStringEvaluation(key, defaultValue, evaluationContext)));
}
@Override
public ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) {
+ HookExecutionContext hookCtx = currentHookExecutionContext();
return strategy.evaluate(
- providers, key, defaultValue, ctx, p -> p.getIntegerEvaluation(key, defaultValue, ctx));
+ providers,
+ key,
+ defaultValue,
+ ctx,
+ provider -> evaluateChild(
+ hookCtx,
+ provider,
+ key,
+ defaultValue,
+ ctx,
+ FlagValueType.INTEGER,
+ (p, evaluationContext) -> p.getIntegerEvaluation(key, defaultValue, evaluationContext)));
}
@Override
public ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) {
- return strategy.evaluate(providers, key, defaultValue, ctx, p -> p.getDoubleEvaluation(key, defaultValue, ctx));
+ HookExecutionContext hookCtx = currentHookExecutionContext();
+ return strategy.evaluate(
+ providers,
+ key,
+ defaultValue,
+ ctx,
+ provider -> evaluateChild(
+ hookCtx,
+ provider,
+ key,
+ defaultValue,
+ ctx,
+ FlagValueType.DOUBLE,
+ (p, evaluationContext) -> p.getDoubleEvaluation(key, defaultValue, evaluationContext)));
}
@Override
public ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx) {
- return strategy.evaluate(providers, key, defaultValue, ctx, p -> p.getObjectEvaluation(key, defaultValue, ctx));
+ HookExecutionContext hookCtx = currentHookExecutionContext();
+ return strategy.evaluate(
+ providers,
+ key,
+ defaultValue,
+ ctx,
+ provider -> evaluateChild(
+ hookCtx,
+ provider,
+ key,
+ defaultValue,
+ ctx,
+ FlagValueType.OBJECT,
+ (p, evaluationContext) -> p.getObjectEvaluation(key, defaultValue, evaluationContext)));
+ }
+
+ // runs the child's own hooks; caller-thread snapshot
+ private ProviderEvaluation evaluateChild(
+ HookExecutionContext hookExecutionContext,
+ FeatureProvider provider,
+ String key,
+ T defaultValue,
+ EvaluationContext ctx,
+ FlagValueType type,
+ BiFunction> providerFunction) {
+ ClientMetadata clientMetadata =
+ hookExecutionContext != null ? hookExecutionContext.clientMetadata : hookClientMetadata;
+ Map hints = hookExecutionContext != null ? hookExecutionContext.hints : Collections.emptyMap();
+ return hookExecutor.execute(provider, key, defaultValue, type, ctx, clientMetadata, hints, providerFunction);
+ }
+
+ private HookExecutionContext currentHookExecutionContext() {
+ return localHookExecutionContext.get();
}
@Override
diff --git a/src/test/java/dev/openfeature/sdk/MultiProviderHookExecutorTest.java b/src/test/java/dev/openfeature/sdk/MultiProviderHookExecutorTest.java
new file mode 100644
index 000000000..fcbc2fc3f
--- /dev/null
+++ b/src/test/java/dev/openfeature/sdk/MultiProviderHookExecutorTest.java
@@ -0,0 +1,472 @@
+package dev.openfeature.sdk;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import dev.openfeature.sdk.exceptions.FlagNotFoundError;
+import dev.openfeature.sdk.exceptions.TypeMismatchError;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.jupiter.api.Test;
+
+class MultiProviderHookExecutorTest {
+
+ private final MultiProviderHookExecutor executor = new MultiProviderHookExecutor();
+ private final ClientMetadata clientMetadata = () -> "test";
+
+ @Test
+ void shortCircuitsDirectlyWhenProviderHasNoHooks() {
+ AtomicBoolean called = new AtomicBoolean(false);
+ ProviderEvaluation result = executor.execute(
+ stubProvider("p", Collections.emptyList()),
+ "flag",
+ "default",
+ FlagValueType.STRING,
+ null,
+ clientMetadata,
+ null,
+ (p, ctx) -> {
+ called.set(true);
+ return ProviderEvaluation.builder().value("direct").build();
+ });
+
+ assertTrue(called.get());
+ assertEquals("direct", result.getValue());
+ }
+
+ @Test
+ void shortCircuitsWhenProviderReturnsNullHookList() {
+ AtomicBoolean called = new AtomicBoolean(false);
+ ProviderEvaluation result = executor.execute(
+ stubProvider("p", null),
+ "flag",
+ "default",
+ FlagValueType.STRING,
+ null,
+ clientMetadata,
+ null,
+ (p, ctx) -> {
+ called.set(true);
+ return ProviderEvaluation.builder().value("direct").build();
+ });
+
+ assertTrue(called.get());
+ assertEquals("direct", result.getValue());
+ }
+
+ @Test
+ void runsHooksInSameOrderAsStandaloneEvaluation() {
+ // SDK order (see HookSupport): before reversed, after/finally in registration order
+ List calls = new ArrayList<>();
+ Hook first = orderRecordingHook(calls, "first");
+ Hook second = orderRecordingHook(calls, "second");
+
+ executor.execute(
+ stubProvider("p", List.of(first, second)),
+ "flag",
+ "default",
+ FlagValueType.STRING,
+ null,
+ clientMetadata,
+ null,
+ (p, ctx) -> ProviderEvaluation.builder().value("ok").build());
+
+ assertEquals(
+ List.of(
+ "before:second",
+ "before:first",
+ "after:first",
+ "after:second",
+ "finally:first",
+ "finally:second"),
+ calls);
+ }
+
+ @Test
+ void shortCircuitsWhenNoHooksSupportTheFlagType() {
+ AtomicBoolean called = new AtomicBoolean(false);
+ Hook boolOnlyHook = new Hook() {
+ @Override
+ public boolean supportsFlagValueType(FlagValueType type) {
+ return type == FlagValueType.BOOLEAN;
+ }
+ };
+ ProviderEvaluation result = executor.execute(
+ stubProvider("p", List.of(boolOnlyHook)),
+ "flag",
+ "default",
+ FlagValueType.STRING,
+ null,
+ clientMetadata,
+ null,
+ (p, ctx) -> {
+ called.set(true);
+ return ProviderEvaluation.builder().value("direct").build();
+ });
+
+ assertTrue(called.get());
+ assertEquals("direct", result.getValue());
+ }
+
+ @Test
+ @SuppressWarnings("rawtypes")
+ void toleratesNullReturnedFromBeforeHook() {
+ Hook nullBeforeHook = new Hook() {
+ @Override
+ public Optional before(HookContext ctx, Map hints) {
+ return null;
+ }
+ };
+ ProviderEvaluation result = executor.execute(
+ stubProvider("p", List.of(nullBeforeHook)),
+ "flag",
+ "default",
+ FlagValueType.STRING,
+ null,
+ clientMetadata,
+ null,
+ (p, ctx) -> ProviderEvaluation.builder().value("ok").build());
+
+ assertEquals("ok", result.getValue());
+ }
+
+ @Test
+ void swallowsExceptionThrownFromErrorHook() {
+ AtomicBoolean errorHookCalled = new AtomicBoolean(false);
+ Hook throwingErrorHook = new Hook() {
+ @Override
+ public void error(HookContext ctx, Exception error, Map hints) {
+ errorHookCalled.set(true);
+ throw new RuntimeException("error hook exploded");
+ }
+ };
+ RuntimeException providerEx = new RuntimeException("provider failed");
+
+ RuntimeException thrown = assertThrows(
+ RuntimeException.class,
+ () -> executor.execute(
+ stubProvider("p", List.of(throwingErrorHook)),
+ "flag",
+ "default",
+ FlagValueType.STRING,
+ null,
+ clientMetadata,
+ null,
+ (p, ctx) -> {
+ throw providerEx;
+ }));
+
+ assertTrue(errorHookCalled.get(), "error() hook should have been called");
+ assertEquals(providerEx, thrown, "original provider exception must propagate");
+ }
+
+ @Test
+ void swallowsExceptionThrownFromFinallyAfterHook() {
+ Hook throwingFinallyHook = new Hook() {
+ @Override
+ public void finallyAfter(
+ HookContext ctx, FlagEvaluationDetails details, Map hints) {
+ throw new RuntimeException("finallyAfter exploded");
+ }
+ };
+
+ assertDoesNotThrow(() -> executor.execute(
+ stubProvider("p", List.of(throwingFinallyHook)),
+ "flag",
+ "default",
+ FlagValueType.STRING,
+ null,
+ clientMetadata,
+ null,
+ (p, ctx) -> ProviderEvaluation.builder().value("ok").build()));
+ }
+
+ @Test
+ void finallyAfterReceivesSyntheticDetailsWhenBeforeThrows() {
+ AtomicReference> captured = new AtomicReference<>();
+ Hook hook = new Hook() {
+ @Override
+ public Optional before(HookContext ctx, Map hints) {
+ throw new RuntimeException("before failed");
+ }
+
+ @Override
+ public void finallyAfter(
+ HookContext ctx, FlagEvaluationDetails details, Map hints) {
+ captured.set(details);
+ }
+ };
+
+ assertThrows(
+ RuntimeException.class,
+ () -> executor.execute(
+ stubProvider("p", List.of(hook)),
+ "flag",
+ "fallback",
+ FlagValueType.STRING,
+ null,
+ clientMetadata,
+ null,
+ (p, ctx) ->
+ ProviderEvaluation.builder().value("ok").build()));
+
+ assertNotNull(captured.get(), "finallyAfter must be called even when before() throws");
+ assertEquals("flag", captured.get().getFlagKey());
+ assertEquals("fallback", captured.get().getValue());
+ }
+
+ @Test
+ void passesDefaultValueThroughToHookContext() {
+ AtomicReference