From dbe6f04e65a7697809b32511dd567ca7b9ac4d12 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Mon, 29 Jun 2026 11:06:45 -0400 Subject: [PATCH 1/4] fix(flags): tag fallback_reason so OpenFeature can distinguish causes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SelectedVariant now carries two source fields: `variant_source` (local | remote | fallback) and `fallback_reason` (FLAG_NOT_FOUND | MISSING_CONTEXT_KEY | NO_ROLLOUT_MATCH | BACKEND_ERROR | NOT_READY, set only when source is fallback). Three behaviorally distinct outcomes — flag-not-found, no-rollout-match, and missing-context-key — previously all returned the bare fallback. The OpenFeature wrapper collapsed them to FLAG_NOT_FOUND, sending callers chasing the flag name when the real cause was usually a rule miss or absent context. The wrapper now dispatches on fallback_reason and maps each to the spec-correct OpenFeature response. Most notably, NO_ROLLOUT_MATCH becomes `reason: DEFAULT` with no error code instead of FLAG_NOT_FOUND. Constant names align with mixpanel-php for consistency across SDKs. Linear: SDK-79 Co-Authored-By: Claude Opus 4.7 --- .../flags/local_flags_provider.rb | 13 +-- .../flags/remote_flags_provider.rb | 14 +++- lib/mixpanel-ruby/flags/types.rb | 65 +++++++++++++-- .../lib/mixpanel/openfeature/provider.rb | 24 +++++- .../mixpanel_openfeature_provider_spec.rb | 82 ++++++++++++++++++- spec/mixpanel-ruby/flags/local_flags_spec.rb | 45 ++++++++++ 6 files changed, 220 insertions(+), 23 deletions(-) diff --git a/lib/mixpanel-ruby/flags/local_flags_provider.rb b/lib/mixpanel-ruby/flags/local_flags_provider.rb index 9bf1bb0..4c86ca8 100644 --- a/lib/mixpanel-ruby/flags/local_flags_provider.rb +++ b/lib/mixpanel-ruby/flags/local_flags_provider.rb @@ -102,11 +102,11 @@ def get_variant_value(flag_key, fallback_value, context, report_exposure: true) def get_variant(flag_key, fallback_variant, context, report_exposure: true) flag = @flag_definitions[flag_key] - return fallback_variant unless flag + return fallback_variant.as_fallback(FallbackReason::FLAG_NOT_FOUND) unless flag context_key = flag['context'] unless context.key?(context_key) || context.key?(context_key.to_sym) - return fallback_variant + return fallback_variant.as_fallback(FallbackReason::MISSING_CONTEXT_KEY) end context_value = context[context_key] || context[context_key.to_sym] @@ -118,10 +118,10 @@ def get_variant(flag_key, fallback_variant, context, report_exposure: true) selected_variant = get_assigned_variant(flag, context_value, flag_key, rollout) if rollout end - return fallback_variant unless selected_variant + return fallback_variant.as_fallback(FallbackReason::NO_ROLLOUT_MATCH) unless selected_variant track_exposure_event(flag_key, selected_variant, context) if report_exposure - selected_variant + selected_variant.with_source(VariantSource::LOCAL) end # Get all variants for user context @@ -130,10 +130,11 @@ def get_variant(flag_key, fallback_variant, context, report_exposure: true) # @return [Hash] Map of flag_key => SelectedVariant def get_all_variants(context) variants = {} + fallback = SelectedVariant.new(variant_value: nil) @flag_definitions.each_key do |flag_key| - variant = get_variant(flag_key, nil, context, report_exposure: false) - variants[flag_key] = variant if variant + variant = get_variant(flag_key, fallback, context, report_exposure: false) + variants[flag_key] = variant if variant.variant_source == VariantSource::LOCAL end variants diff --git a/lib/mixpanel-ruby/flags/remote_flags_provider.rb b/lib/mixpanel-ruby/flags/remote_flags_provider.rb index eaa6471..913a872 100644 --- a/lib/mixpanel-ruby/flags/remote_flags_provider.rb +++ b/lib/mixpanel-ruby/flags/remote_flags_provider.rb @@ -59,13 +59,18 @@ def get_variant(flag_key, fallback_variant, context, report_exposure: true) flags = response['flags'] || {} selected_variant_data = flags[flag_key] - return fallback_variant unless selected_variant_data + # The /flags endpoint only returns variants the user is enrolled in, + # so a missing key could mean the flag doesn't exist OR the user + # isn't in any rollout. The remote SDK can't tell them apart without + # server-side help — surface as FLAG_NOT_FOUND for now. + return fallback_variant.as_fallback(FallbackReason::FLAG_NOT_FOUND) unless selected_variant_data selected_variant = SelectedVariant.new( variant_key: selected_variant_data['variant_key'], variant_value: selected_variant_data['variant_value'], experiment_id: selected_variant_data['experiment_id'], - is_experiment_active: selected_variant_data['is_experiment_active'] + is_experiment_active: selected_variant_data['is_experiment_active'], + variant_source: VariantSource::REMOTE ) track_exposure_event(flag_key, selected_variant, context, latency_ms) if report_exposure @@ -73,7 +78,7 @@ def get_variant(flag_key, fallback_variant, context, report_exposure: true) return selected_variant rescue MixpanelError => e @error_handler.handle(e) - return fallback_variant + return fallback_variant.as_fallback(FallbackReason::BACKEND_ERROR) end # Check if flag is enabled (for boolean flags) @@ -104,7 +109,8 @@ def get_all_variants(context) variant_key: variant_data['variant_key'], variant_value: variant_data['variant_value'], experiment_id: variant_data['experiment_id'], - is_experiment_active: variant_data['is_experiment_active'] + is_experiment_active: variant_data['is_experiment_active'], + variant_source: VariantSource::REMOTE ) end diff --git a/lib/mixpanel-ruby/flags/types.rb b/lib/mixpanel-ruby/flags/types.rb index f996804..dd9f2d2 100644 --- a/lib/mixpanel-ruby/flags/types.rb +++ b/lib/mixpanel-ruby/flags/types.rb @@ -1,35 +1,82 @@ module Mixpanel module Flags + # Where a SelectedVariant came from. Set by the providers on every returned + # variant — coarse-grained (local / remote / fallback). For the specific + # reason behind a fallback, see {FallbackReason}. + module VariantSource + LOCAL = 'local'.freeze + REMOTE = 'remote'.freeze + FALLBACK = 'fallback'.freeze + end + + # Why the SDK returned the developer fallback. Only meaningful when + # SelectedVariant#variant_source == VariantSource::FALLBACK. Matches the + # constant set used by mixpanel-php so the OpenFeature wrapper can map to + # the spec-correct error code instead of collapsing every fallback to + # FLAG_NOT_FOUND. + module FallbackReason + FLAG_NOT_FOUND = 'FLAG_NOT_FOUND'.freeze + MISSING_CONTEXT_KEY = 'MISSING_CONTEXT_KEY'.freeze + NO_ROLLOUT_MATCH = 'NO_ROLLOUT_MATCH'.freeze + BACKEND_ERROR = 'BACKEND_ERROR'.freeze + NOT_READY = 'NOT_READY'.freeze + end + # Selected variant returned from flag evaluation class SelectedVariant attr_accessor :variant_key, :variant_value, :experiment_id, - :is_experiment_active, :is_qa_tester + :is_experiment_active, :is_qa_tester, + :variant_source, :fallback_reason - # @param variant_key [String, nil] The variant key - # @param variant_value [Object] The variant value (any type) - # @param experiment_id [String, nil] Associated experiment ID - # @param is_experiment_active [Boolean, nil] Whether experiment is active - # @param is_qa_tester [Boolean, nil] Whether user is a QA tester def initialize(variant_key: nil, variant_value: nil, experiment_id: nil, - is_experiment_active: nil, is_qa_tester: nil) + is_experiment_active: nil, is_qa_tester: nil, + variant_source: nil, fallback_reason: nil) @variant_key = variant_key @variant_value = variant_value @experiment_id = experiment_id @is_experiment_active = is_experiment_active @is_qa_tester = is_qa_tester + @variant_source = variant_source + @fallback_reason = fallback_reason + end + + # Return a copy of this variant tagged with the given source. Clears + # fallback_reason — use {#as_fallback} when returning a fallback. + def with_source(source) + copy_with(variant_source: source, fallback_reason: nil) + end + + # Return a copy tagged as a fallback with the given reason. + def as_fallback(reason) + copy_with(variant_source: VariantSource::FALLBACK, fallback_reason: reason) end # Convert to hash representation - # @return [Hash] def to_h { variant_key: @variant_key, variant_value: @variant_value, experiment_id: @experiment_id, is_experiment_active: @is_experiment_active, - is_qa_tester: @is_qa_tester + is_qa_tester: @is_qa_tester, + variant_source: @variant_source, + fallback_reason: @fallback_reason }.compact end + + private + + def copy_with(variant_source: @variant_source, fallback_reason: @fallback_reason) + SelectedVariant.new( + variant_key: @variant_key, + variant_value: @variant_value, + experiment_id: @experiment_id, + is_experiment_active: @is_experiment_active, + is_qa_tester: @is_qa_tester, + variant_source: variant_source, + fallback_reason: fallback_reason + ) + end end end end diff --git a/openfeature-provider/lib/mixpanel/openfeature/provider.rb b/openfeature-provider/lib/mixpanel/openfeature/provider.rb index 38aa349..061b660 100644 --- a/openfeature-provider/lib/mixpanel/openfeature/provider.rb +++ b/openfeature-provider/lib/mixpanel/openfeature/provider.rb @@ -73,12 +73,34 @@ def resolve(flag_key, default_value, expected_type, evaluation_context) return error_result(default_value, ::OpenFeature::SDK::Provider::ErrorCode::GENERAL) end - if result.equal?(fallback) + # variant_source distinguishes local / remote / fallback. When fallback, + # fallback_reason carries the specific reason (PHP-aligned constants) + # so we can map each to the spec-correct OpenFeature response instead + # of collapsing every fallback to FLAG_NOT_FOUND. + case result.fallback_reason + when ::Mixpanel::Flags::FallbackReason::FLAG_NOT_FOUND return ::OpenFeature::SDK::Provider::ResolutionDetails.new( value: default_value, error_code: ::OpenFeature::SDK::Provider::ErrorCode::FLAG_NOT_FOUND, reason: ::OpenFeature::SDK::Provider::Reason::DEFAULT ) + when ::Mixpanel::Flags::FallbackReason::MISSING_CONTEXT_KEY + return ::OpenFeature::SDK::Provider::ResolutionDetails.new( + value: default_value, + error_code: ::OpenFeature::SDK::Provider::ErrorCode::TARGETING_KEY_MISSING, + reason: ::OpenFeature::SDK::Provider::Reason::ERROR + ) + when ::Mixpanel::Flags::FallbackReason::NO_ROLLOUT_MATCH + # Flag exists, user just didn't match any rollout — per the + # OpenFeature spec this is `reason: DEFAULT` with no error. + return ::OpenFeature::SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: ::OpenFeature::SDK::Provider::Reason::DEFAULT + ) + when ::Mixpanel::Flags::FallbackReason::BACKEND_ERROR + return error_result(default_value, ::OpenFeature::SDK::Provider::ErrorCode::GENERAL) + when ::Mixpanel::Flags::FallbackReason::NOT_READY + return error_result(default_value, ::OpenFeature::SDK::Provider::ErrorCode::PROVIDER_NOT_READY) end value = result.variant_value diff --git a/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb b/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb index ea7bb24..de63a32 100644 --- a/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb +++ b/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb @@ -75,15 +75,25 @@ def setup_flag(flag_key, value, variant_key: 'variant-key') allow(mock_flags).to receive(:get_variant) do |key, fallback, _ctx, **_kwargs| if key == flag_key - Mixpanel::Flags::SelectedVariant.new(variant_key: variant_key, variant_value: value) + Mixpanel::Flags::SelectedVariant.new( + variant_key: variant_key, + variant_value: value, + variant_source: Mixpanel::Flags::VariantSource::LOCAL + ) else - fallback + fallback.as_fallback(Mixpanel::Flags::FallbackReason::FLAG_NOT_FOUND) end end end + def setup_fallback(reason) + allow(mock_flags).to receive(:get_variant) do |_key, fallback, _ctx, **_kwargs| + fallback.as_fallback(reason) + end + end + def setup_flag_not_found - allow(mock_flags).to receive(:get_variant) { |_key, fallback, _ctx, **_kwargs| fallback } + setup_fallback(Mixpanel::Flags::FallbackReason::FLAG_NOT_FOUND) end # --- Metadata --- @@ -305,6 +315,72 @@ def setup_flag_not_found end end + # --- NO_ROLLOUT_MATCH (flag exists, no rollout matched) --- + + describe 'no rollout match' do + before { setup_fallback(Mixpanel::Flags::FallbackReason::NO_ROLLOUT_MATCH) } + + it 'returns DEFAULT reason without an error code' do + result = provider.fetch_boolean_value(flag_key: 'flag', default_value: true) + expect(result.value).to be true + expect(result.reason).to eq('DEFAULT') + expect(result.error_code).to be_nil + end + + it 'works for strings' do + result = provider.fetch_string_value(flag_key: 'flag', default_value: 'default') + expect(result.value).to eq('default') + expect(result.reason).to eq('DEFAULT') + expect(result.error_code).to be_nil + end + end + + # --- MISSING_CONTEXT_KEY --- + + describe 'missing context key' do + before { setup_fallback(Mixpanel::Flags::FallbackReason::MISSING_CONTEXT_KEY) } + + it 'returns TARGETING_KEY_MISSING for boolean' do + result = provider.fetch_boolean_value(flag_key: 'flag', default_value: false) + expect(result.value).to be false + expect(result.error_code).to eq('TARGETING_KEY_MISSING') + expect(result.reason).to eq('ERROR') + end + + it 'returns TARGETING_KEY_MISSING for string' do + result = provider.fetch_string_value(flag_key: 'flag', default_value: 'default') + expect(result.value).to eq('default') + expect(result.error_code).to eq('TARGETING_KEY_MISSING') + expect(result.reason).to eq('ERROR') + end + end + + # --- BACKEND_ERROR --- + + describe 'backend error' do + before { setup_fallback(Mixpanel::Flags::FallbackReason::BACKEND_ERROR) } + + it 'maps to GENERAL' do + result = provider.fetch_string_value(flag_key: 'flag', default_value: 'default') + expect(result.value).to eq('default') + expect(result.error_code).to eq('GENERAL') + expect(result.reason).to eq('ERROR') + end + end + + # --- NOT_READY --- + + describe 'not ready' do + before { setup_fallback(Mixpanel::Flags::FallbackReason::NOT_READY) } + + it 'maps to PROVIDER_NOT_READY' do + result = provider.fetch_boolean_value(flag_key: 'flag', default_value: true) + expect(result.value).to be true + expect(result.error_code).to eq('PROVIDER_NOT_READY') + expect(result.reason).to eq('ERROR') + end + end + # --- PROVIDER_NOT_READY --- describe 'provider not ready' do diff --git a/spec/mixpanel-ruby/flags/local_flags_spec.rb b/spec/mixpanel-ruby/flags/local_flags_spec.rb index 6969521..3e0b082 100644 --- a/spec/mixpanel-ruby/flags/local_flags_spec.rb +++ b/spec/mixpanel-ruby/flags/local_flags_spec.rb @@ -720,6 +720,51 @@ def user_context_with_properties(properties) end end + describe '#get_variant variant_source / fallback_reason tagging' do + let(:fallback) { Mixpanel::Flags::SelectedVariant.new(variant_value: 'fb') } + + it 'tags matched variants as LOCAL with no fallback_reason' do + flag = create_test_flag(rollout_percentage: 100.0) + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + result = provider.get_variant('test_flag', fallback, test_context) + expect(result.variant_source).to eq(Mixpanel::Flags::VariantSource::LOCAL) + expect(result.fallback_reason).to be_nil + expect(result.variant_key).not_to be_nil + end + + it 'tags missing flag as FALLBACK / FLAG_NOT_FOUND' do + stub_flag_definitions([]) + provider.start_polling_for_definitions! + + result = provider.get_variant('missing', fallback, test_context) + expect(result.variant_source).to eq(Mixpanel::Flags::VariantSource::FALLBACK) + expect(result.fallback_reason).to eq(Mixpanel::Flags::FallbackReason::FLAG_NOT_FOUND) + expect(result.variant_value).to eq('fb') + end + + it 'tags missing context as FALLBACK / MISSING_CONTEXT_KEY' do + flag = create_test_flag(context: 'distinct_id') + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + result = provider.get_variant('test_flag', fallback, {}) + expect(result.variant_source).to eq(Mixpanel::Flags::VariantSource::FALLBACK) + expect(result.fallback_reason).to eq(Mixpanel::Flags::FallbackReason::MISSING_CONTEXT_KEY) + end + + it 'tags no-rollout-match as FALLBACK / NO_ROLLOUT_MATCH' do + flag = create_test_flag(rollout_percentage: 0.0) + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + result = provider.get_variant('test_flag', fallback, test_context) + expect(result.variant_source).to eq(Mixpanel::Flags::VariantSource::FALLBACK) + expect(result.fallback_reason).to eq(Mixpanel::Flags::FallbackReason::NO_ROLLOUT_MATCH) + end + end + describe 'polling' do it 'uses most recent polled flag definitions' do flag_v1 = create_test_flag(rollout_percentage: 0.0) From 0b137d4555c5aa77643707c35d381d6705105ed7 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Mon, 29 Jun 2026 15:37:17 -0400 Subject: [PATCH 2/4] fix(flags): upgrade FallbackReason to a value object and forward backend message (SDK-79, SDK-83) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SDK-79 made fallback_reason a PHP-aligned string constant on every returned fallback variant. That covers the local-eval cases (flag not found, no rollout match, missing context key) but couldn't carry detail — when the remote /flags endpoint returned an error response, the SDK still had nowhere to attach the backend's message. Upgrade FallbackReason from a module of string constants to a small value object with `kind` (the discriminator) and optional `message`. Frozen singletons for the no-detail reasons (flag_not_found, no_rollout_match, not_ready); factory methods for the ones that carry detail (missing_context_key(key), backend_error(message)). SDK-83: RemoteFlagsProvider's rescue MixpanelError now tags the fallback with FallbackReason.backend_error(e.message) so the wrapper can forward the backend's response into ResolutionDetails#error_message. Without this the caller sees a bare GENERAL error and has to dig through logs to find out the backend rejected the request (e.g. "distinct_id must be provided in evalContext as a string"). Matches Go's existing behavior — Python and Node will follow in their PRs. Wrapper dispatches on reason.kind, forwards reason.message into error_message for backend_error and missing_context_key. Linear: SDK-79, SDK-83 Co-Authored-By: Claude Opus 4.7 --- .../flags/local_flags_provider.rb | 6 +-- .../flags/remote_flags_provider.rb | 9 +++- lib/mixpanel-ruby/flags/types.rb | 54 +++++++++++++++---- .../lib/mixpanel/openfeature/provider.rb | 36 +++++++------ .../mixpanel_openfeature_provider_spec.rb | 26 +++++---- spec/mixpanel-ruby/flags/local_flags_spec.rb | 14 ++--- spec/mixpanel-ruby/flags/remote_flags_spec.rb | 12 +++++ 7 files changed, 112 insertions(+), 45 deletions(-) diff --git a/lib/mixpanel-ruby/flags/local_flags_provider.rb b/lib/mixpanel-ruby/flags/local_flags_provider.rb index 4c86ca8..1506ccd 100644 --- a/lib/mixpanel-ruby/flags/local_flags_provider.rb +++ b/lib/mixpanel-ruby/flags/local_flags_provider.rb @@ -102,11 +102,11 @@ def get_variant_value(flag_key, fallback_value, context, report_exposure: true) def get_variant(flag_key, fallback_variant, context, report_exposure: true) flag = @flag_definitions[flag_key] - return fallback_variant.as_fallback(FallbackReason::FLAG_NOT_FOUND) unless flag + return fallback_variant.as_fallback(FallbackReason.flag_not_found) unless flag context_key = flag['context'] unless context.key?(context_key) || context.key?(context_key.to_sym) - return fallback_variant.as_fallback(FallbackReason::MISSING_CONTEXT_KEY) + return fallback_variant.as_fallback(FallbackReason.missing_context_key(context_key)) end context_value = context[context_key] || context[context_key.to_sym] @@ -118,7 +118,7 @@ def get_variant(flag_key, fallback_variant, context, report_exposure: true) selected_variant = get_assigned_variant(flag, context_value, flag_key, rollout) if rollout end - return fallback_variant.as_fallback(FallbackReason::NO_ROLLOUT_MATCH) unless selected_variant + return fallback_variant.as_fallback(FallbackReason.no_rollout_match) unless selected_variant track_exposure_event(flag_key, selected_variant, context) if report_exposure selected_variant.with_source(VariantSource::LOCAL) diff --git a/lib/mixpanel-ruby/flags/remote_flags_provider.rb b/lib/mixpanel-ruby/flags/remote_flags_provider.rb index 913a872..84187b3 100644 --- a/lib/mixpanel-ruby/flags/remote_flags_provider.rb +++ b/lib/mixpanel-ruby/flags/remote_flags_provider.rb @@ -63,7 +63,7 @@ def get_variant(flag_key, fallback_variant, context, report_exposure: true) # so a missing key could mean the flag doesn't exist OR the user # isn't in any rollout. The remote SDK can't tell them apart without # server-side help — surface as FLAG_NOT_FOUND for now. - return fallback_variant.as_fallback(FallbackReason::FLAG_NOT_FOUND) unless selected_variant_data + return fallback_variant.as_fallback(FallbackReason.flag_not_found) unless selected_variant_data selected_variant = SelectedVariant.new( variant_key: selected_variant_data['variant_key'], @@ -78,7 +78,12 @@ def get_variant(flag_key, fallback_variant, context, report_exposure: true) return selected_variant rescue MixpanelError => e @error_handler.handle(e) - return fallback_variant.as_fallback(FallbackReason::BACKEND_ERROR) + # Attach the backend's message so the OpenFeature wrapper can forward + # it into ResolutionDetails#error_message — without this the caller + # sees a bare GENERAL error and has to dig through logs to find out + # the backend rejected the request (e.g. "distinct_id must be + # provided in evalContext as a string"). SDK-83. + return fallback_variant.as_fallback(FallbackReason.backend_error(e.message)) end # Check if flag is enabled (for boolean flags) diff --git a/lib/mixpanel-ruby/flags/types.rb b/lib/mixpanel-ruby/flags/types.rb index dd9f2d2..a316108 100644 --- a/lib/mixpanel-ruby/flags/types.rb +++ b/lib/mixpanel-ruby/flags/types.rb @@ -10,16 +10,50 @@ module VariantSource end # Why the SDK returned the developer fallback. Only meaningful when - # SelectedVariant#variant_source == VariantSource::FALLBACK. Matches the - # constant set used by mixpanel-php so the OpenFeature wrapper can map to - # the spec-correct error code instead of collapsing every fallback to - # FLAG_NOT_FOUND. - module FallbackReason - FLAG_NOT_FOUND = 'FLAG_NOT_FOUND'.freeze - MISSING_CONTEXT_KEY = 'MISSING_CONTEXT_KEY'.freeze - NO_ROLLOUT_MATCH = 'NO_ROLLOUT_MATCH'.freeze - BACKEND_ERROR = 'BACKEND_ERROR'.freeze - NOT_READY = 'NOT_READY'.freeze + # SelectedVariant#variant_source == VariantSource::FALLBACK. + # + # `kind` is the discriminator (matches the PHP constant set). `message` + # is set on the reasons that carry useful detail (BACKEND_ERROR with the + # backend's response, MISSING_CONTEXT_KEY with the missing attribute); + # nil otherwise. The OpenFeature wrapper dispatches on kind and forwards + # message into ResolutionDetails#error_message. + class FallbackReason + KINDS = %i[flag_not_found missing_context_key no_rollout_match backend_error not_ready].freeze + + attr_reader :kind, :message + + def initialize(kind, message: nil) + raise ArgumentError, "Unknown FallbackReason kind: #{kind.inspect}" unless KINDS.include?(kind) + + @kind = kind + @message = message + freeze + end + + def ==(other) + other.is_a?(FallbackReason) && other.kind == @kind && other.message == @message + end + alias_method :eql?, :== + + def hash + [self.class, @kind, @message].hash + end + + def to_h + { kind: @kind, message: @message }.compact + end + + # Factory methods. Reasons without meaningful detail return a frozen + # singleton; reasons with detail allocate per call. + def self.flag_not_found; FLAG_NOT_FOUND; end + def self.no_rollout_match; NO_ROLLOUT_MATCH; end + def self.not_ready; NOT_READY; end + def self.missing_context_key(key = nil); new(:missing_context_key, message: key); end + def self.backend_error(message); new(:backend_error, message: message); end + + FLAG_NOT_FOUND = new(:flag_not_found) + NO_ROLLOUT_MATCH = new(:no_rollout_match) + NOT_READY = new(:not_ready) end # Selected variant returned from flag evaluation diff --git a/openfeature-provider/lib/mixpanel/openfeature/provider.rb b/openfeature-provider/lib/mixpanel/openfeature/provider.rb index 061b660..3cb4736 100644 --- a/openfeature-provider/lib/mixpanel/openfeature/provider.rb +++ b/openfeature-provider/lib/mixpanel/openfeature/provider.rb @@ -69,37 +69,42 @@ def resolve(flag_key, default_value, expected_type, evaluation_context) begin result = @flags_provider.get_variant(flag_key, fallback, context, report_exposure: true) - rescue StandardError - return error_result(default_value, ::OpenFeature::SDK::Provider::ErrorCode::GENERAL) + rescue StandardError => e + return error_result(default_value, ::OpenFeature::SDK::Provider::ErrorCode::GENERAL, e.message) end # variant_source distinguishes local / remote / fallback. When fallback, # fallback_reason carries the specific reason (PHP-aligned constants) # so we can map each to the spec-correct OpenFeature response instead - # of collapsing every fallback to FLAG_NOT_FOUND. - case result.fallback_reason - when ::Mixpanel::Flags::FallbackReason::FLAG_NOT_FOUND + # of collapsing every fallback to FLAG_NOT_FOUND. SDK-83: BACKEND_ERROR + # also carries the backend message, forwarded as error_message. + case result.fallback_reason&.kind + when :flag_not_found return ::OpenFeature::SDK::Provider::ResolutionDetails.new( value: default_value, error_code: ::OpenFeature::SDK::Provider::ErrorCode::FLAG_NOT_FOUND, reason: ::OpenFeature::SDK::Provider::Reason::DEFAULT ) - when ::Mixpanel::Flags::FallbackReason::MISSING_CONTEXT_KEY - return ::OpenFeature::SDK::Provider::ResolutionDetails.new( - value: default_value, - error_code: ::OpenFeature::SDK::Provider::ErrorCode::TARGETING_KEY_MISSING, - reason: ::OpenFeature::SDK::Provider::Reason::ERROR + when :missing_context_key + return error_result( + default_value, + ::OpenFeature::SDK::Provider::ErrorCode::TARGETING_KEY_MISSING, + result.fallback_reason.message ) - when ::Mixpanel::Flags::FallbackReason::NO_ROLLOUT_MATCH + when :no_rollout_match # Flag exists, user just didn't match any rollout — per the # OpenFeature spec this is `reason: DEFAULT` with no error. return ::OpenFeature::SDK::Provider::ResolutionDetails.new( value: default_value, reason: ::OpenFeature::SDK::Provider::Reason::DEFAULT ) - when ::Mixpanel::Flags::FallbackReason::BACKEND_ERROR - return error_result(default_value, ::OpenFeature::SDK::Provider::ErrorCode::GENERAL) - when ::Mixpanel::Flags::FallbackReason::NOT_READY + when :backend_error + return error_result( + default_value, + ::OpenFeature::SDK::Provider::ErrorCode::GENERAL, + result.fallback_reason.message + ) + when :not_ready return error_result(default_value, ::OpenFeature::SDK::Provider::ErrorCode::PROVIDER_NOT_READY) end @@ -180,10 +185,11 @@ def flags_ready? end end - def error_result(default_value, error_code) + def error_result(default_value, error_code, error_message = nil) ::OpenFeature::SDK::Provider::ResolutionDetails.new( value: default_value, error_code: error_code, + error_message: error_message, reason: ::OpenFeature::SDK::Provider::Reason::ERROR ) end diff --git a/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb b/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb index de63a32..a93fe93 100644 --- a/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb +++ b/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb @@ -81,7 +81,7 @@ def setup_flag(flag_key, value, variant_key: 'variant-key') variant_source: Mixpanel::Flags::VariantSource::LOCAL ) else - fallback.as_fallback(Mixpanel::Flags::FallbackReason::FLAG_NOT_FOUND) + fallback.as_fallback(Mixpanel::Flags::FallbackReason.flag_not_found) end end end @@ -93,7 +93,7 @@ def setup_fallback(reason) end def setup_flag_not_found - setup_fallback(Mixpanel::Flags::FallbackReason::FLAG_NOT_FOUND) + setup_fallback(Mixpanel::Flags::FallbackReason.flag_not_found) end # --- Metadata --- @@ -318,7 +318,7 @@ def setup_flag_not_found # --- NO_ROLLOUT_MATCH (flag exists, no rollout matched) --- describe 'no rollout match' do - before { setup_fallback(Mixpanel::Flags::FallbackReason::NO_ROLLOUT_MATCH) } + before { setup_fallback(Mixpanel::Flags::FallbackReason.no_rollout_match) } it 'returns DEFAULT reason without an error code' do result = provider.fetch_boolean_value(flag_key: 'flag', default_value: true) @@ -338,7 +338,7 @@ def setup_flag_not_found # --- MISSING_CONTEXT_KEY --- describe 'missing context key' do - before { setup_fallback(Mixpanel::Flags::FallbackReason::MISSING_CONTEXT_KEY) } + before { setup_fallback(Mixpanel::Flags::FallbackReason.missing_context_key('distinct_id')) } it 'returns TARGETING_KEY_MISSING for boolean' do result = provider.fetch_boolean_value(flag_key: 'flag', default_value: false) @@ -347,31 +347,39 @@ def setup_flag_not_found expect(result.reason).to eq('ERROR') end - it 'returns TARGETING_KEY_MISSING for string' do + it 'returns TARGETING_KEY_MISSING for string and forwards the missing key as error_message' do result = provider.fetch_string_value(flag_key: 'flag', default_value: 'default') expect(result.value).to eq('default') expect(result.error_code).to eq('TARGETING_KEY_MISSING') + expect(result.error_message).to eq('distinct_id') expect(result.reason).to eq('ERROR') end end - # --- BACKEND_ERROR --- + # --- BACKEND_ERROR (SDK-83: forwards the backend's response message) --- describe 'backend error' do - before { setup_fallback(Mixpanel::Flags::FallbackReason::BACKEND_ERROR) } + before do + setup_fallback( + Mixpanel::Flags::FallbackReason.backend_error( + 'HTTP 400: distinct_id must be provided in evalContext as a string' + ) + ) + end - it 'maps to GENERAL' do + it 'maps to GENERAL and forwards the backend message as error_message' do result = provider.fetch_string_value(flag_key: 'flag', default_value: 'default') expect(result.value).to eq('default') expect(result.error_code).to eq('GENERAL') expect(result.reason).to eq('ERROR') + expect(result.error_message).to include('distinct_id must be provided') end end # --- NOT_READY --- describe 'not ready' do - before { setup_fallback(Mixpanel::Flags::FallbackReason::NOT_READY) } + before { setup_fallback(Mixpanel::Flags::FallbackReason.not_ready) } it 'maps to PROVIDER_NOT_READY' do result = provider.fetch_boolean_value(flag_key: 'flag', default_value: true) diff --git a/spec/mixpanel-ruby/flags/local_flags_spec.rb b/spec/mixpanel-ruby/flags/local_flags_spec.rb index 3e0b082..0b6e391 100644 --- a/spec/mixpanel-ruby/flags/local_flags_spec.rb +++ b/spec/mixpanel-ruby/flags/local_flags_spec.rb @@ -734,34 +734,36 @@ def user_context_with_properties(properties) expect(result.variant_key).not_to be_nil end - it 'tags missing flag as FALLBACK / FLAG_NOT_FOUND' do + it 'tags missing flag as FALLBACK / flag_not_found' do stub_flag_definitions([]) provider.start_polling_for_definitions! result = provider.get_variant('missing', fallback, test_context) expect(result.variant_source).to eq(Mixpanel::Flags::VariantSource::FALLBACK) - expect(result.fallback_reason).to eq(Mixpanel::Flags::FallbackReason::FLAG_NOT_FOUND) + expect(result.fallback_reason.kind).to eq(:flag_not_found) + expect(result.fallback_reason.message).to be_nil expect(result.variant_value).to eq('fb') end - it 'tags missing context as FALLBACK / MISSING_CONTEXT_KEY' do + it 'tags missing context as FALLBACK / missing_context_key with the missing attribute' do flag = create_test_flag(context: 'distinct_id') stub_flag_definitions([flag]) provider.start_polling_for_definitions! result = provider.get_variant('test_flag', fallback, {}) expect(result.variant_source).to eq(Mixpanel::Flags::VariantSource::FALLBACK) - expect(result.fallback_reason).to eq(Mixpanel::Flags::FallbackReason::MISSING_CONTEXT_KEY) + expect(result.fallback_reason.kind).to eq(:missing_context_key) + expect(result.fallback_reason.message).to eq('distinct_id') end - it 'tags no-rollout-match as FALLBACK / NO_ROLLOUT_MATCH' do + it 'tags no-rollout-match as FALLBACK / no_rollout_match' do flag = create_test_flag(rollout_percentage: 0.0) stub_flag_definitions([flag]) provider.start_polling_for_definitions! result = provider.get_variant('test_flag', fallback, test_context) expect(result.variant_source).to eq(Mixpanel::Flags::VariantSource::FALLBACK) - expect(result.fallback_reason).to eq(Mixpanel::Flags::FallbackReason::NO_ROLLOUT_MATCH) + expect(result.fallback_reason.kind).to eq(:no_rollout_match) end end diff --git a/spec/mixpanel-ruby/flags/remote_flags_spec.rb b/spec/mixpanel-ruby/flags/remote_flags_spec.rb index 8e7308b..0413eea 100644 --- a/spec/mixpanel-ruby/flags/remote_flags_spec.rb +++ b/spec/mixpanel-ruby/flags/remote_flags_spec.rb @@ -257,6 +257,18 @@ def stub_flags_request_error(error) provider.get_variant('any-flag', fallback_variant, test_context) end + + it 'tags the fallback as BACKEND_ERROR with the response body on HTTP error (SDK-83)' do + stub_request(:get, %r{https://api\.mixpanel\.com/flags}) + .to_return(status: 400, body: 'distinct_id must be provided in evalContext as a string') + + fallback_variant = Mixpanel::Flags::SelectedVariant.new(variant_value: 'fb') + result = provider.get_variant('any-flag', fallback_variant, test_context, report_exposure: false) + + expect(result.variant_source).to eq(Mixpanel::Flags::VariantSource::FALLBACK) + expect(result.fallback_reason.kind).to eq(:backend_error) + expect(result.fallback_reason.message).to include('distinct_id must be provided') + end end describe '#is_enabled' do From bb46240130e706ccbeb1ddd727dfd1a8624e3d25 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Tue, 30 Jun 2026 10:57:29 -0400 Subject: [PATCH 3/4] fix(flags): drop unused :not_ready FallbackReason kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wrapper short-circuits to PROVIDER_NOT_READY at the top of resolve when flags_ready? is false, so no producer ever constructs a FallbackReason with kind :not_ready — the case was dead, same pattern Swift PR #745 / Android PR #981 / Python PR #180 cleaned up. Remove :not_ready from the KINDS array, drop the not_ready factory and NOT_READY singleton, and drop the :not_ready arm from the wrapper's case dispatch. The 'provider not ready' spec covers the short-circuit path unchanged. Co-Authored-By: Claude Opus 4.7 --- lib/mixpanel-ruby/flags/types.rb | 8 +++++--- .../lib/mixpanel/openfeature/provider.rb | 5 +++-- .../spec/mixpanel_openfeature_provider_spec.rb | 16 ++++------------ 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/lib/mixpanel-ruby/flags/types.rb b/lib/mixpanel-ruby/flags/types.rb index a316108..14328c7 100644 --- a/lib/mixpanel-ruby/flags/types.rb +++ b/lib/mixpanel-ruby/flags/types.rb @@ -17,8 +17,12 @@ module VariantSource # backend's response, MISSING_CONTEXT_KEY with the missing attribute); # nil otherwise. The OpenFeature wrapper dispatches on kind and forwards # message into ResolutionDetails#error_message. + # + # Note: the wrapper handles PROVIDER_NOT_READY by short-circuiting before + # invoking the provider (see flags_ready? check), so there is no :not_ready + # kind here — no producer would ever construct it. class FallbackReason - KINDS = %i[flag_not_found missing_context_key no_rollout_match backend_error not_ready].freeze + KINDS = %i[flag_not_found missing_context_key no_rollout_match backend_error].freeze attr_reader :kind, :message @@ -47,13 +51,11 @@ def to_h # singleton; reasons with detail allocate per call. def self.flag_not_found; FLAG_NOT_FOUND; end def self.no_rollout_match; NO_ROLLOUT_MATCH; end - def self.not_ready; NOT_READY; end def self.missing_context_key(key = nil); new(:missing_context_key, message: key); end def self.backend_error(message); new(:backend_error, message: message); end FLAG_NOT_FOUND = new(:flag_not_found) NO_ROLLOUT_MATCH = new(:no_rollout_match) - NOT_READY = new(:not_ready) end # Selected variant returned from flag evaluation diff --git a/openfeature-provider/lib/mixpanel/openfeature/provider.rb b/openfeature-provider/lib/mixpanel/openfeature/provider.rb index 3cb4736..19b1dff 100644 --- a/openfeature-provider/lib/mixpanel/openfeature/provider.rb +++ b/openfeature-provider/lib/mixpanel/openfeature/provider.rb @@ -104,9 +104,10 @@ def resolve(flag_key, default_value, expected_type, evaluation_context) ::OpenFeature::SDK::Provider::ErrorCode::GENERAL, result.fallback_reason.message ) - when :not_ready - return error_result(default_value, ::OpenFeature::SDK::Provider::ErrorCode::PROVIDER_NOT_READY) end + # PROVIDER_NOT_READY is handled at the top of resolve via flags_ready?; + # no producer constructs a :not_ready FallbackReason, so there's no + # dispatch arm for it here. value = result.variant_value diff --git a/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb b/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb index a93fe93..389a060 100644 --- a/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb +++ b/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb @@ -376,18 +376,10 @@ def setup_flag_not_found end end - # --- NOT_READY --- - - describe 'not ready' do - before { setup_fallback(Mixpanel::Flags::FallbackReason.not_ready) } - - it 'maps to PROVIDER_NOT_READY' do - result = provider.fetch_boolean_value(flag_key: 'flag', default_value: true) - expect(result.value).to be true - expect(result.error_code).to eq('PROVIDER_NOT_READY') - expect(result.reason).to eq('ERROR') - end - end + # Not-ready handling is covered by the `provider not ready` describe block + # below, which exercises the wrapper's flags_ready? short-circuit. The + # provider never stamps a :not_ready fallback reason, so there is no + # producer-side dispatch to test here. # --- PROVIDER_NOT_READY --- From d3565aec44b57e764462923804e822a767878e4e Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Tue, 30 Jun 2026 11:07:21 -0400 Subject: [PATCH 4/4] chore: drop verbose NOT_READY comments Comments were explaining the absence of a case to a hypothetical cross-SDK reader. The absence is self-explanatory. Co-Authored-By: Claude Opus 4.7 --- lib/mixpanel-ruby/flags/types.rb | 4 ---- openfeature-provider/lib/mixpanel/openfeature/provider.rb | 3 --- .../spec/mixpanel_openfeature_provider_spec.rb | 5 ----- 3 files changed, 12 deletions(-) diff --git a/lib/mixpanel-ruby/flags/types.rb b/lib/mixpanel-ruby/flags/types.rb index 14328c7..c84ad96 100644 --- a/lib/mixpanel-ruby/flags/types.rb +++ b/lib/mixpanel-ruby/flags/types.rb @@ -17,10 +17,6 @@ module VariantSource # backend's response, MISSING_CONTEXT_KEY with the missing attribute); # nil otherwise. The OpenFeature wrapper dispatches on kind and forwards # message into ResolutionDetails#error_message. - # - # Note: the wrapper handles PROVIDER_NOT_READY by short-circuiting before - # invoking the provider (see flags_ready? check), so there is no :not_ready - # kind here — no producer would ever construct it. class FallbackReason KINDS = %i[flag_not_found missing_context_key no_rollout_match backend_error].freeze diff --git a/openfeature-provider/lib/mixpanel/openfeature/provider.rb b/openfeature-provider/lib/mixpanel/openfeature/provider.rb index 19b1dff..6014d5e 100644 --- a/openfeature-provider/lib/mixpanel/openfeature/provider.rb +++ b/openfeature-provider/lib/mixpanel/openfeature/provider.rb @@ -105,9 +105,6 @@ def resolve(flag_key, default_value, expected_type, evaluation_context) result.fallback_reason.message ) end - # PROVIDER_NOT_READY is handled at the top of resolve via flags_ready?; - # no producer constructs a :not_ready FallbackReason, so there's no - # dispatch arm for it here. value = result.variant_value diff --git a/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb b/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb index 389a060..6070bdc 100644 --- a/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb +++ b/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb @@ -376,11 +376,6 @@ def setup_flag_not_found end end - # Not-ready handling is covered by the `provider not ready` describe block - # below, which exercises the wrapper's flags_ready? short-circuit. The - # provider never stamps a :not_ready fallback reason, so there is no - # producer-side dispatch to test here. - # --- PROVIDER_NOT_READY --- describe 'provider not ready' do