From 40c59007cbfb043702286eccf940308402e16a46 Mon Sep 17 00:00:00 2001 From: Matheus Veras Batista Date: Fri, 6 Feb 2026 14:16:58 -0300 Subject: [PATCH 1/3] filter out failed flags on merge --- lib/posthog/feature_flag.rb | 3 +- lib/posthog/feature_flags.rb | 15 +- spec/posthog/feature_flag_error_spec.rb | 213 ++++++++++++++++++++++++ 3 files changed, 226 insertions(+), 5 deletions(-) diff --git a/lib/posthog/feature_flag.rb b/lib/posthog/feature_flag.rb index 475670d..af7bcdd 100644 --- a/lib/posthog/feature_flag.rb +++ b/lib/posthog/feature_flag.rb @@ -3,7 +3,7 @@ # Represents a feature flag returned by /flags v2 module PostHog class FeatureFlag - attr_reader :key, :enabled, :variant, :reason, :metadata + attr_reader :key, :enabled, :variant, :reason, :metadata, :failed def initialize(json) json.transform_keys!(&:to_s) @@ -12,6 +12,7 @@ def initialize(json) @variant = json['variant'] @reason = json['reason'] ? EvaluationReason.new(json['reason']) : nil @metadata = json['metadata'] ? FeatureFlagMetadata.new(json['metadata'].transform_keys(&:to_s)) : nil + @failed = json['failed'] end # TODO: Rename to `value` in future version diff --git a/lib/posthog/feature_flags.rb b/lib/posthog/feature_flags.rb index ca76c0b..b2e79bb 100644 --- a/lib/posthog/feature_flags.rb +++ b/lib/posthog/feature_flags.rb @@ -131,8 +131,10 @@ def get_flags(distinct_id, groups = {}, person_properties = {}, group_properties FeatureFlag.new(flag) end flags_response[:flags] = flags_hash - flags_response[:featureFlags] = flags_hash.transform_values(&:get_value).transform_keys(&:to_sym) - flags_response[:featureFlagPayloads] = flags_hash.transform_values(&:payload).transform_keys(&:to_sym) + # Filter out flags that failed evaluation to avoid overwriting cached values + successful_flags = flags_hash.reject { |_, flag| flag.failed == true } + flags_response[:featureFlags] = successful_flags.transform_values(&:get_value).transform_keys(&:to_sym) + flags_response[:featureFlagPayloads] = successful_flags.transform_values(&:payload).transform_keys(&:to_sym) elsif flags_response[:featureFlags] # v3 format flags_response[:featureFlags] = flags_response[:featureFlags] || {} @@ -309,8 +311,13 @@ def get_all_flags_and_payloads( flags = {} payloads = {} else - flags = stringify_keys(flags_and_payloads[:featureFlags] || {}) - payloads = stringify_keys(flags_and_payloads[:featureFlagPayloads] || {}) + server_flags = stringify_keys(flags_and_payloads[:featureFlags] || {}) + server_payloads = stringify_keys(flags_and_payloads[:featureFlagPayloads] || {}) + # Merge server results into locally-evaluated results so that flags which + # failed server-side evaluation (already filtered out by get_flags) don't + # overwrite valid locally-evaluated values. + flags = stringify_keys(flags).merge(server_flags) + payloads = stringify_keys(payloads).merge(server_payloads) end rescue StandardError => e @on_error.call(-1, "Error computing flag remotely: #{e}") diff --git a/spec/posthog/feature_flag_error_spec.rb b/spec/posthog/feature_flag_error_spec.rb index eb53d95..716cf74 100644 --- a/spec/posthog/feature_flag_error_spec.rb +++ b/spec/posthog/feature_flag_error_spec.rb @@ -270,5 +270,218 @@ module PostHog expect(FeatureFlagError.api_error(503)).to eq('api_error_503') end end + + describe 'failed flag filtering' do + context 'when a flag has failed=true' do + it 'excludes the failed flag even when enabled is true, and preserves non-failed flags' do + flags_response = { + 'flags' => { + 'my-flag' => { + 'key' => 'my-flag', + 'enabled' => true, + 'variant' => nil, + 'reason' => { 'code' => 'database_error', 'description' => 'Database connection error' }, + 'metadata' => { 'id' => 1, 'version' => 1, 'payload' => nil }, + 'failed' => true + }, + 'good-flag' => { + 'key' => 'good-flag', + 'enabled' => true, + 'variant' => nil, + 'reason' => { 'code' => 'condition_match', 'description' => 'Matched', 'condition_index' => 0 }, + 'metadata' => { 'id' => 2, 'version' => 1, 'payload' => nil }, + 'failed' => false + } + }, + 'errorsWhileComputingFlags' => true, + 'requestId' => 'test-request-id' + } + + stub_request(:post, flags_endpoint) + .to_return(status: 200, body: flags_response.to_json) + + # The failed flag has enabled=true, but should be filtered out and treated as missing. + # Without filtering, this would return true. + result = client.get_feature_flag('my-flag', 'test-user') + expect(result).to eq(false) + captured_message = client.dequeue_last_message + expect(captured_message[:properties]['$feature_flag_error']).to include(FeatureFlagError::FLAG_MISSING) + + # The non-failed flag should return its value normally + good_result = client.get_feature_flag('good-flag', 'test-user') + expect(good_result).to eq(true) + end + + it 'excludes a failed flag with a variant from the response' do + flags_response = { + 'flags' => { + 'variant-flag' => { + 'key' => 'variant-flag', + 'enabled' => true, + 'variant' => 'test-variant', + 'reason' => { 'code' => 'timeout', 'description' => 'Database statement timed out' }, + 'metadata' => { 'id' => 3, 'version' => 1, 'payload' => nil }, + 'failed' => true + } + }, + 'errorsWhileComputingFlags' => true, + 'requestId' => 'test-request-id' + } + + stub_request(:post, flags_endpoint) + .to_return(status: 200, body: flags_response.to_json) + + # Without filtering, this would return 'test-variant'. + result = client.get_feature_flag('variant-flag', 'test-user') + expect(result).to eq(false) + captured_message = client.dequeue_last_message + expect(captured_message[:properties]['$feature_flag_error']).to include(FeatureFlagError::FLAG_MISSING) + end + + it 'excludes failed flags from get_all_flags results' do + flags_response = { + 'flags' => { + 'failed-flag' => { + 'key' => 'failed-flag', + 'enabled' => true, + 'variant' => nil, + 'reason' => { 'code' => 'database_error', 'description' => 'Database connection error' }, + 'metadata' => { 'id' => 1, 'version' => 1, 'payload' => nil }, + 'failed' => true + }, + 'ok-flag' => { + 'key' => 'ok-flag', + 'enabled' => true, + 'variant' => nil, + 'reason' => { 'code' => 'condition_match', 'description' => 'Matched', 'condition_index' => 0 }, + 'metadata' => { 'id' => 2, 'version' => 1, 'payload' => nil }, + 'failed' => false + } + }, + 'errorsWhileComputingFlags' => true, + 'requestId' => 'test-request-id' + } + + stub_request(:post, flags_endpoint) + .to_return(status: 200, body: flags_response.to_json) + + all_flags = client.get_all_flags('test-user') + + # failed-flag should be excluded despite having enabled=true + expect(all_flags).not_to have_key('failed-flag') + # ok-flag should be present with its value + expect(all_flags['ok-flag']).to eq(true) + end + end + + context 'when a locally-evaluated flag fails on the server during fallback' do + it 'preserves the locally-evaluated true value instead of overwriting with failed false' do + # Setup: two flags in local definitions + # - beta-feature: simple flag, 100% rollout → evaluates locally to true + # - server-only-flag: has experience continuity → requires server evaluation, triggers fallback + api_feature_flag_res = { + 'flags' => [ + { + 'id' => 1, + 'name' => 'Beta Feature', + 'key' => 'beta-feature', + 'active' => true, + 'is_simple_flag' => true, + 'rollout_percentage' => 100, + 'filters' => { + 'groups' => [ + { + 'properties' => [], + 'rollout_percentage' => 100 + } + ] + } + }, + { + 'id' => 2, + 'name' => 'Server Only Flag', + 'key' => 'server-only-flag', + 'active' => true, + 'is_simple_flag' => false, + 'ensure_experience_continuity' => true, + 'filters' => { + 'groups' => [ + { + 'properties' => [], + 'rollout_percentage' => 100 + } + ] + } + } + ] + } + + stub_request(:get, feature_flag_endpoint) + .to_return(status: 200, body: api_feature_flag_res.to_json) + + # Server response: beta-feature failed (transient DB error), server-only-flag succeeded + flags_response = { + 'flags' => { + 'beta-feature' => { + 'key' => 'beta-feature', + 'enabled' => false, + 'variant' => nil, + 'reason' => { 'code' => 'database_error', 'description' => 'Database connection error' }, + 'metadata' => { 'id' => 1, 'version' => 1, 'payload' => nil }, + 'failed' => true + }, + 'server-only-flag' => { + 'key' => 'server-only-flag', + 'enabled' => true, + 'variant' => nil, + 'reason' => { 'code' => 'condition_match', 'description' => 'Matched', 'condition_index' => 0 }, + 'metadata' => { 'id' => 2, 'version' => 1, 'payload' => nil }, + 'failed' => false + } + }, + 'errorsWhileComputingFlags' => true, + 'requestId' => 'test-request-id' + } + + stub_request(:post, flags_endpoint) + .to_return(status: 200, body: flags_response.to_json) + + new_client = Client.new(api_key: API_KEY, personal_api_key: API_KEY, test_mode: true) + + # get_all_flags triggers local eval for both flags: + # - beta-feature evaluates locally to true + # - server-only-flag raises RequiresServerEvaluation → triggers server fallback + # Server returns beta-feature as failed: true, enabled: false + # The locally-evaluated true must be preserved, NOT overwritten by the failed false. + all_flags = new_client.get_all_flags('test-user') + + expect(all_flags['beta-feature']).to eq(true) + expect(all_flags['server-only-flag']).to eq(true) + end + end + + context 'when the failed field is absent (backward compatibility)' do + it 'includes flags without a failed field normally' do + flags_response = { + 'flags' => { + 'legacy-flag' => { + 'key' => 'legacy-flag', + 'enabled' => true, + 'variant' => nil, + 'reason' => { 'code' => 'condition_match', 'description' => 'Matched', 'condition_index' => 0 }, + 'metadata' => { 'id' => 1, 'version' => 1, 'payload' => nil } + } + }, + 'requestId' => 'test-request-id' + } + + stub_request(:post, flags_endpoint) + .to_return(status: 200, body: flags_response.to_json) + + result = client.get_feature_flag('legacy-flag', 'test-user') + expect(result).to eq(true) + end + end + end end end From 66d28302ebc32217984d3bd11c8e8d0184573b72 Mon Sep 17 00:00:00 2001 From: Matheus Veras Batista Date: Fri, 6 Feb 2026 15:33:20 -0300 Subject: [PATCH 2/3] only merge on errors --- lib/posthog/feature_flags.rb | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/lib/posthog/feature_flags.rb b/lib/posthog/feature_flags.rb index b2e79bb..3e5d3c3 100644 --- a/lib/posthog/feature_flags.rb +++ b/lib/posthog/feature_flags.rb @@ -313,11 +313,17 @@ def get_all_flags_and_payloads( else server_flags = stringify_keys(flags_and_payloads[:featureFlags] || {}) server_payloads = stringify_keys(flags_and_payloads[:featureFlagPayloads] || {}) - # Merge server results into locally-evaluated results so that flags which - # failed server-side evaluation (already filtered out by get_flags) don't - # overwrite valid locally-evaluated values. - flags = stringify_keys(flags).merge(server_flags) - payloads = stringify_keys(payloads).merge(server_payloads) + + if errors_while_computing + # Merge server results into locally-evaluated results so that flags which + # failed server-side evaluation (already filtered out by get_flags) don't + # overwrite valid locally-evaluated values. + flags = stringify_keys(flags).merge(server_flags) + payloads = stringify_keys(payloads).merge(server_payloads) + else + flags = server_flags + payloads = server_payloads + end end rescue StandardError => e @on_error.call(-1, "Error computing flag remotely: #{e}") From 65d826a27df5cd0136ab546cc7203a33ff89f506 Mon Sep 17 00:00:00 2001 From: Matheus Veras Batista Date: Fri, 6 Feb 2026 15:58:36 -0300 Subject: [PATCH 3/3] bump version and changelog --- CHANGELOG.md | 4 ++++ lib/posthog/version.rb | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11e1a37..90dc936 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 3.5.0 - 2026-02-06 + +1. fix: Filter out failed flag evaluations to prevent cached values from being overwritten during transient server errors ([#96](https://github.com/PostHog/posthog-ruby/pull/96)) + ## 3.4.0 - 2025-12-04 1. feat: Add ETag support for feature flag definitions polling ([#84](https://github.com/PostHog/posthog-ruby/pull/84)) diff --git a/lib/posthog/version.rb b/lib/posthog/version.rb index c75d9eb..5a4b6c8 100644 --- a/lib/posthog/version.rb +++ b/lib/posthog/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module PostHog - VERSION = '3.4.0' + VERSION = '3.5.0' end