From 3e6e970e84eaf08a49827cf20d90e1b668361b11 Mon Sep 17 00:00:00 2001 From: Ben Papillon Date: Fri, 4 Sep 2026 16:53:14 -0700 Subject: [PATCH 1/2] coerce cached flag responses from serializing cache providers RedisCacheProvider round-trips values through JSON and hands back a Hash, but the flag-check cache path called .value on it, so every cache hit in Redis cache mode raised and fell back to the flag default. --- lib/schematic/schematic_client.rb | 13 ++++++-- test/custom.test.rb | 53 +++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/lib/schematic/schematic_client.rb b/lib/schematic/schematic_client.rb index 28e7e11..bcedb11 100644 --- a/lib/schematic/schematic_client.rb +++ b/lib/schematic/schematic_client.rb @@ -202,7 +202,7 @@ def check_flags(company: nil, user: nil, keys: nil) cache_key = build_cache_key(key, company, user) found = false @flag_check_cache_providers.each do |provider| - cached = provider.get(cache_key) + cached = coerce_cached_response(provider.get(cache_key)) next unless cached cached_results[key] = { flag: key, value: cached.value, reason: cached.reason } @@ -384,11 +384,20 @@ def close # --- Internal Flag Checking --- + # Cache providers that serialize (RedisCacheProvider) hand back a Hash, not + # the CheckFlagResponse that was stored; the in-memory cache returns the + # object itself. Normalize so callers can use the response API either way. + def coerce_cached_response(cached) + return cached unless cached.is_a?(Hash) + + CheckFlagResponse.new(cached) + end + def check_flag_via_api(flag_key, company, user) # Check cache cache_key = build_cache_key(flag_key, company, user) @flag_check_cache_providers.each do |provider| - cached = provider.get(cache_key) + cached = coerce_cached_response(provider.get(cache_key)) if cached @logger.debug("Flag '#{flag_key}' found in cache (value=#{cached.value})") return cached diff --git a/test/custom.test.rb b/test/custom.test.rb index 41bfadb..bf507b3 100644 --- a/test/custom.test.rb +++ b/test/custom.test.rb @@ -3522,3 +3522,56 @@ def build_buffer "expected every request to carry at most 100 events, got #{sizes.inspect}" end end + +# ============================================================================= +# SchematicClient - Serializing cache providers hand back a Hash +# ============================================================================= +describe "SchematicClient - cached flag responses from a serializing provider" do + # Mimics RedisCacheProvider: values round-trip through JSON, so #get returns + # a symbol-keyed Hash rather than the CheckFlagResponse that was stored. + class HashReturningCache + def initialize + @store = {} + end + + def get(key) + raw = @store[key] + raw && JSON.parse(raw, symbolize_names: true) + end + + def set(key, value, ttl: nil) + @store[key] = JSON.generate(value.respond_to?(:to_h) ? value.to_h : value) + end + + def delete(key) + @store.delete(key) + end + end + + after { WebMock.reset! } + + it "serves check_flag and check_flags from a Hash-returning cache" do + base_url = "https://api.schematichq.com" + client = Schematic::SchematicClient.new(api_key: "api_test_key_123", cache_providers: [HashReturningCache.new]) + + stub_request(:post, "#{base_url}/flags/hash-cached-flag/check") + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { data: { value: true, flag: "hash-cached-flag", reason: "match" }, params: {} }.to_json + ) + + assert client.check_flag("hash-cached-flag") + # Second call is served from the cache as a Hash; must not raise. + resp = client.check_flag_with_entitlement("hash-cached-flag") + assert_equal true, resp.value + assert_equal "match", resp.reason + assert_requested(:post, "#{base_url}/flags/hash-cached-flag/check", times: 1) + + results = client.check_flags(keys: ["hash-cached-flag"]) + assert_equal [{ flag: "hash-cached-flag", value: true, reason: "match" }], results + assert_requested(:post, "#{base_url}/flags/hash-cached-flag/check", times: 1) + + client.close + end +end From e85a09c4427d5c5ce41d696d994e74da502f0d33 Mon Sep 17 00:00:00 2001 From: Ben Papillon Date: Fri, 4 Sep 2026 17:14:20 -0700 Subject: [PATCH 2/2] chore(tests): satisfy rubocop in cached-response test --- test/custom.test.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/custom.test.rb b/test/custom.test.rb index bf507b3..ff2dd2c 100644 --- a/test/custom.test.rb +++ b/test/custom.test.rb @@ -3539,7 +3539,7 @@ def get(key) raw && JSON.parse(raw, symbolize_names: true) end - def set(key, value, ttl: nil) + def set(key, value, ttl: nil) # rubocop:disable Lint/UnusedMethodArgument @store[key] = JSON.generate(value.respond_to?(:to_h) ? value.to_h : value) end @@ -3564,11 +3564,13 @@ def delete(key) assert client.check_flag("hash-cached-flag") # Second call is served from the cache as a Hash; must not raise. resp = client.check_flag_with_entitlement("hash-cached-flag") - assert_equal true, resp.value + + assert resp.value assert_equal "match", resp.reason assert_requested(:post, "#{base_url}/flags/hash-cached-flag/check", times: 1) results = client.check_flags(keys: ["hash-cached-flag"]) + assert_equal [{ flag: "hash-cached-flag", value: true, reason: "match" }], results assert_requested(:post, "#{base_url}/flags/hash-cached-flag/check", times: 1)