diff --git a/openfeature-provider/pom.xml b/openfeature-provider/pom.xml index 924aa3c..25893dc 100644 --- a/openfeature-provider/pom.xml +++ b/openfeature-provider/pom.xml @@ -4,7 +4,7 @@ com.mixpanel mixpanel-java-openfeature - 0.1.1 + 0.2.0 jar Mixpanel Java SDK - OpenFeature Provider @@ -126,7 +126,13 @@ com.mixpanel mixpanel-java - 1.8.0 + + 1.10.0 diff --git a/openfeature-provider/src/main/java/com/mixpanel/openfeature/MixpanelProvider.java b/openfeature-provider/src/main/java/com/mixpanel/openfeature/MixpanelProvider.java index 251eea9..5cf3615 100644 --- a/openfeature-provider/src/main/java/com/mixpanel/openfeature/MixpanelProvider.java +++ b/openfeature-provider/src/main/java/com/mixpanel/openfeature/MixpanelProvider.java @@ -4,6 +4,7 @@ import com.mixpanel.mixpanelapi.featureflags.config.LocalFlagsConfig; import com.mixpanel.mixpanelapi.featureflags.config.RemoteFlagsConfig; import com.mixpanel.mixpanelapi.featureflags.model.SelectedVariant; +import com.mixpanel.mixpanelapi.featureflags.model.Source; import com.mixpanel.mixpanelapi.featureflags.provider.BaseFlagsProvider; import com.mixpanel.mixpanelapi.featureflags.provider.LocalFlagsProvider; import dev.openfeature.sdk.*; @@ -131,8 +132,14 @@ private ProviderEvaluation evaluate(String key, T defaultValue, Evaluatio return errorResult(defaultValue, ErrorCode.GENERAL, e.getMessage()); } - if (result.isFallback()) { - return errorResult(defaultValue, ErrorCode.FLAG_NOT_FOUND, "Flag not found: " + key); + // A fallback source means the SDK had no real variant to serve. + // The reason discriminates why (flag missing, context key missing, + // no rollout matched, backend error) so we translate each to the + // OpenFeature error the spec assigns to it instead of collapsing + // every fallback to FLAG_NOT_FOUND. + Source source = result.getSource(); + if (source instanceof Source.Fallback) { + return mapFallback(((Source.Fallback) source).reason, key, defaultValue); } T value = mapper.apply(result); @@ -144,12 +151,44 @@ private ProviderEvaluation evaluate(String key, T defaultValue, Evaluatio return successResult(value, result.getVariantKey()); } + private ProviderEvaluation mapFallback(Source.Fallback.Reason reason, String flagKey, T defaultValue) { + switch (reason) { + case FLAG_NOT_FOUND: + return errorResult(defaultValue, ErrorCode.FLAG_NOT_FOUND, "Flag not found: " + flagKey); + case MISSING_CONTEXT_KEY: + return errorResult(defaultValue, ErrorCode.TARGETING_KEY_MISSING, + "Missing targeting key for flag: " + flagKey); + case NO_ROLLOUT_MATCH: + // Flag exists but no rollout matched — per the OpenFeature spec + // this is DEFAULT with no error, distinct from FLAG_NOT_FOUND. + return defaultResult(defaultValue); + case BACKEND_ERROR: + return errorResult(defaultValue, ErrorCode.GENERAL, + "Backend error evaluating flag: " + flagKey); + default: + // Fail closed on unrecognized reasons so a new value added to + // the base SDK enum without wiring here doesn't silently + // produce a successful-looking evaluation. + return errorResult(defaultValue, ErrorCode.GENERAL, + "Unrecognized fallback reason " + reason + " for flag: " + flagKey); + } + } + + private ProviderEvaluation defaultResult(T defaultValue) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason("DEFAULT") + .build(); + } + private SelectedVariant fetchVariant(String key, EvaluationContext ctx) { SelectedVariant fallback = new SelectedVariant<>(null); return flagsProvider.getVariant(key, fallback, convertContext(ctx), true); } private ProviderEvaluation errorResult(T defaultValue, ErrorCode errorCode, String errorMessage) { + // FLAG_NOT_FOUND is spec-defined DEFAULT (flag missing → fell back to + // default); every other error condition is ERROR reason. String reason = errorCode == ErrorCode.FLAG_NOT_FOUND ? "DEFAULT" : "ERROR"; return ProviderEvaluation.builder() .value(defaultValue) diff --git a/openfeature-provider/src/test/java/com/mixpanel/openfeature/MixpanelProviderTest.java b/openfeature-provider/src/test/java/com/mixpanel/openfeature/MixpanelProviderTest.java index c5870bd..9f9a82b 100644 --- a/openfeature-provider/src/test/java/com/mixpanel/openfeature/MixpanelProviderTest.java +++ b/openfeature-provider/src/test/java/com/mixpanel/openfeature/MixpanelProviderTest.java @@ -1,6 +1,7 @@ package com.mixpanel.openfeature; import com.mixpanel.mixpanelapi.featureflags.model.SelectedVariant; +import com.mixpanel.mixpanelapi.featureflags.model.Source; import com.mixpanel.mixpanelapi.featureflags.provider.BaseFlagsProvider; import com.mixpanel.mixpanelapi.featureflags.provider.LocalFlagsProvider; import dev.openfeature.sdk.*; @@ -548,6 +549,66 @@ public void testNullVariantKeyTreatedAsFallbackOnObjectEvaluation() { assertEquals("DEFAULT", result.getReason()); } + // Fallback.Reason mapping (SDK-79 / SDK-130) + + @Test + public void testFallbackWithFlagNotFoundReasonMapsToFlagNotFound() { + SelectedVariant fallback = new SelectedVariant<>(null, false, null, null, null, + Source.fallback(Source.Fallback.Reason.FLAG_NOT_FOUND)); + when(mockFlagsProvider.getVariant(eq("missing"), any(SelectedVariant.class), anyMap(), eq(true))) + .thenReturn(fallback); + + ProviderEvaluation result = provider.getBooleanEvaluation("missing", false, new ImmutableContext()); + + assertFalse(result.getValue()); + assertEquals(ErrorCode.FLAG_NOT_FOUND, result.getErrorCode()); + assertEquals("DEFAULT", result.getReason()); + } + + @Test + public void testFallbackWithMissingContextKeyMapsToTargetingKeyMissing() { + SelectedVariant fallback = new SelectedVariant<>(null, false, null, null, null, + Source.fallback(Source.Fallback.Reason.MISSING_CONTEXT_KEY)); + when(mockFlagsProvider.getVariant(eq("needs-key"), any(SelectedVariant.class), anyMap(), eq(true))) + .thenReturn(fallback); + + ProviderEvaluation result = provider.getBooleanEvaluation("needs-key", false, new ImmutableContext()); + + assertFalse(result.getValue()); + assertEquals(ErrorCode.TARGETING_KEY_MISSING, result.getErrorCode()); + assertEquals("ERROR", result.getReason()); + } + + @Test + public void testFallbackWithNoRolloutMatchReturnsDefaultWithoutError() { + SelectedVariant fallback = new SelectedVariant<>(null, "fallback-val", null, null, null, + Source.fallback(Source.Fallback.Reason.NO_ROLLOUT_MATCH)); + when(mockFlagsProvider.getVariant(eq("no-match"), any(SelectedVariant.class), anyMap(), eq(true))) + .thenReturn(fallback); + + ProviderEvaluation result = provider.getStringEvaluation("no-match", "fallback-val", new ImmutableContext()); + + // NO_ROLLOUT_MATCH is spec: DEFAULT reason with no error — the flag + // exists, the user just didn't match any rollout. + assertEquals("fallback-val", result.getValue()); + assertNull(result.getErrorCode()); + assertEquals("DEFAULT", result.getReason()); + } + + @Test + public void testFallbackWithBackendErrorMapsToGeneral() { + SelectedVariant fallback = new SelectedVariant<>(null, false, null, null, null, + Source.fallback(Source.Fallback.Reason.BACKEND_ERROR)); + when(mockFlagsProvider.getVariant(eq("backend-fail"), any(SelectedVariant.class), anyMap(), eq(true))) + .thenReturn(fallback); + + ProviderEvaluation result = provider.getBooleanEvaluation("backend-fail", false, new ImmutableContext()); + + assertFalse(result.getValue()); + assertEquals(ErrorCode.GENERAL, result.getErrorCode()); + assertEquals("ERROR", result.getReason()); + } + // Shutdown @Test