Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 3.5.2 - 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.5.1 - 2026-02-06

1. Fix `posthog-rails` deployment
Expand Down
3 changes: 2 additions & 1 deletion lib/posthog/feature_flag.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
21 changes: 17 additions & 4 deletions lib/posthog/feature_flags.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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] || {}
Expand Down Expand Up @@ -309,8 +311,19 @@ 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] || {})

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}")
Expand Down
2 changes: 1 addition & 1 deletion lib/posthog/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module PostHog
VERSION = '3.5.1'
VERSION = '3.5.2'
end
213 changes: 213 additions & 0 deletions spec/posthog/feature_flag_error_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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