From 97daa01d8b92bb328624fcfa81e02c88b88004db Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Tue, 21 Jul 2026 14:01:18 -0400 Subject: [PATCH] feat(openfeature): map Source.Fallback.Reason to OpenFeature codes (SDK-130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: every fallback path collapsed to ErrorCode.FLAG_NOT_FOUND via result.isFallback(). Callers debugging a missing-context-key or backend-error scenario had no way to tell it from a typo in the flag name. After: dispatch on result.getSource(). When it's a Source.Fallback, translate the reason to the OpenFeature ErrorCode + Reason the spec assigns to it: FLAG_NOT_FOUND -> ErrorCode.FLAG_NOT_FOUND, DEFAULT MISSING_CONTEXT_KEY -> ErrorCode.TARGETING_KEY_MISSING, ERROR NO_ROLLOUT_MATCH -> no error, DEFAULT (spec: not an error) BACKEND_ERROR -> ErrorCode.GENERAL, ERROR unrecognized reason -> ErrorCode.GENERAL (fail closed — a new reason added to the base SDK enum without wiring here surfaces as an error rather than a silent success) Bump base pom dep from mixpanel-java 1.8.0 -> 1.10.0 (assumed next tag containing SDK-79 / #89 — release manager may adjust). Bump wrapper version 0.1.1 -> 0.2.0 (new capability surface). Tests: 4 new cases covering each reason. Existing FLAG_NOT_FOUND tests still pass — the 1-arg SelectedVariant constructor now defaults source to Fallback(FLAG_NOT_FOUND). Note: this PR is intentionally branched off fix/sdk-79-variant-source-fallback-reason (#89) since the Source model class it depends on doesn't exist on master yet. When #89 merges, rebase this branch onto master and only the wrapper-side diff remains. Co-Authored-By: Claude Opus 4.7 --- openfeature-provider/pom.xml | 10 ++- .../openfeature/MixpanelProvider.java | 43 ++++++++++++- .../openfeature/MixpanelProviderTest.java | 61 +++++++++++++++++++ 3 files changed, 110 insertions(+), 4 deletions(-) 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