featurehub-sdk 2.0.1 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,14 +5,21 @@ require "sinatra"
5
5
  require "featurehub-sdk"
6
6
  require "json"
7
7
 
8
- def configure_featurehub
8
+ def configure_featurehub(logger)
9
9
  puts "FeatureHub SDK Version is #{FeatureHub::Sdk::VERSION}"
10
10
 
11
- config = FeatureHub::Sdk::FeatureHubConfig.new
11
+ config = FeatureHub::Sdk::FeatureHubConfig.new(nil, nil, nil, nil, logger)
12
12
  repo = config.repository
13
13
 
14
14
  if ENV["FEATUREHUB_REDIS_STORE"]
15
- config.register_raw_update_listener(FeatureHub::Sdk::RedisSessionStore.new(ENV["FEATUREHUB_REDIS_STORE"], repo))
15
+ FeatureHub::Sdk::RedisSessionStore.new(ENV["FEATUREHUB_REDIS_STORE"], config,
16
+ { logger: logger, refresh_timeout: 3 })
17
+ end
18
+
19
+ if ENV["FEATUREHUB_MEMCACHE_STORE"]
20
+ FeatureHub::Sdk::MemcacheSessionStore.new(ENV["FEATUREHUB_MEMCACHE_STORE"],
21
+ config, { logger: logger,
22
+ refresh_timeout: 3 })
16
23
  end
17
24
 
18
25
  if ENV["FEATUREHUB_LOCAL_YAML"]
@@ -20,6 +27,7 @@ def configure_featurehub
20
27
  config.register_interceptor(FeatureHub::Sdk::LocalYamlValueInterceptor.new(watch: true))
21
28
  end
22
29
 
30
+ puts "connecting to FeatureHub"
23
31
  # connect to edge service
24
32
  config.init
25
33
  end
@@ -34,7 +42,9 @@ class App < Sinatra::Base
34
42
  rack = File.new("logs/rack.log", "a+")
35
43
  use Rack::CommonLogger, rack
36
44
 
37
- set :fh_config, configure_featurehub
45
+ logger = Logger.new($stdout)
46
+ set :logger, logger
47
+ set :fh_config, configure_featurehub(logger)
38
48
  set :users, {}
39
49
  end
40
50
 
@@ -42,10 +52,6 @@ class App < Sinatra::Base
42
52
  content_type "application/json"
43
53
  end
44
54
 
45
- get("/config/disconnect-edge") do
46
- settings.fh_config.close_edge
47
- end
48
-
49
55
  # Routes
50
56
  # "resolve" a specific todo for this user
51
57
  put("/todo/:user/:id/resolve") do
@@ -83,6 +89,7 @@ class App < Sinatra::Base
83
89
  if new_todo["title"].nil?
84
90
  status(400)
85
91
  else
92
+ settings.logger.debug("todo is #{new_todo}")
86
93
  todos.push(Todo.new(new_todo["id"] || 1, new_todo["title"], new_todo["resolved"] || false))
87
94
  todo_list(user)
88
95
  end
@@ -94,13 +101,26 @@ class App < Sinatra::Base
94
101
  end
95
102
 
96
103
  get("/health/readiness") do
97
- if config.repository.ready?
104
+ if settings.fh_config.repository.ready?
105
+ "ok"
106
+ else
107
+ status(500)
108
+ end
109
+ end
110
+
111
+ get("/health/liveness") do
112
+ if settings.fh_config.repository.ready?
98
113
  "ok"
99
114
  else
100
115
  status(500)
101
116
  end
102
117
  end
103
118
 
119
+ get("/health/disconnect") do
120
+ settings.fh_config.close_edge
121
+ status 200
122
+ end
123
+
104
124
  private
105
125
 
106
126
  def todo_list(user)
@@ -113,6 +133,7 @@ class App < Sinatra::Base
113
133
  new_todos.push(Todo.new(todo.id, process_title(ctx, todo.title), todo.resolved).to_h)
114
134
  end
115
135
 
136
+ settings.logger.debug("todos #{new_todos}")
116
137
  new_todos.to_json
117
138
  end
118
139
 
@@ -139,15 +160,6 @@ class App < Sinatra::Base
139
160
 
140
161
  new_title = new_title.upcase if ctx.enabled?("FEATURE_TITLE_TO_UPPERCASE")
141
162
 
142
- # puts("features via repository: #{settings.fh_config.repository.features}")
143
- # puts("features via edge service: #{settings.fh_config.get_or_create_edge_service.repository}")
144
-
145
- # puts("enabled? #{ctx.repo.features}")
146
- puts(ctx.enabled?("FEATURE_TITLE_TO_UPPERCASE"))
147
- puts(ctx.flag("FEATURE_TITLE_TO_UPPERCASE"))
148
- puts(settings.fh_config.repository.feature("FEATURE_TITLE_TO_UPPERCASE").feature_type)
149
- puts(settings.fh_config.repository.feature("FEATURE_TITLE_TO_UPPERCASE").flag)
150
-
151
- new_title&.strip
163
+ new_title
152
164
  end
153
165
  end
@@ -11,15 +11,6 @@ events {
11
11
  http {
12
12
 
13
13
 
14
- passenger_user_switching on;
15
- passenger_user root;
16
- passenger_default_user root;
17
-
18
- passenger_max_pool_size 6;
19
-
20
- passenger_disable_security_update_check on;
21
- passenger_disable_anonymous_telemetry on;
22
-
23
14
  include /etc/nginx/conf/vhosts/*.conf;
24
15
  include /etc/nginx/conf.d/*.conf;
25
16
 
@@ -33,8 +24,9 @@ http {
33
24
  access_log /dev/stdout;
34
25
  listen 8099 default_server;
35
26
  server_name localhost;
36
- root /app/featurehub;
37
- passenger_app_root /app/featurehub;
27
+ root /app/featurehub/examples/sinatra;
28
+ passenger_app_root /app/featurehub/examples/sinatra;
29
+ passenger_preload_bundler on;
38
30
  passenger_enabled on;
39
31
  passenger_startup_file config.ru;
40
32
  passenger_app_type rack;
@@ -0,0 +1,16 @@
1
+ server {
2
+ listen 80;
3
+ server_name localhost;
4
+ root /app/featurehub/examples/sinatra;
5
+
6
+ passenger_enabled on;
7
+ passenger_user root;
8
+
9
+ # If this is a Ruby app, specify a Ruby version:
10
+ # For Ruby 3.3
11
+ passenger_ruby /usr/bin/ruby3.3;
12
+
13
+ # Nginx has a default limit of 1 MB for request bodies, which also applies
14
+ # to file uploads. The following line enables uploads of up to 50 MB:
15
+ #client_max_body_size 50M;
16
+ }
@@ -1,6 +1,6 @@
1
1
  services:
2
2
  party-server:
3
- image: featurehub/party-server:1.9.4
3
+ image: featurehub/party-server:latest
4
4
  ports:
5
5
  - 8085:8085
6
6
  user: 999:999
@@ -12,13 +12,41 @@ services:
12
12
  image: redis:latest
13
13
  ports:
14
14
  - 6379:6379
15
- ruby-sinatra:
16
- image: ruby-sinatra:latest
15
+ memcache:
16
+ image: memcached:1.6-alpine
17
+ ports:
18
+ - 11211:11211
19
+ sinatra:
20
+ image: todo-server:e2e
21
+ ports:
22
+ - 8099:8099
23
+ environment:
24
+ RAILS_ENV: production
25
+ FEATUREHUB_CLIENT_API_KEY: "a1286d73-a23b-4f5b-8fdf-04dd03668fb9/xPlhDYnWlKjjZ3maUPquhOUOmk50gn*Hh8Q5qY6JEURrxtEEjzk"
26
+ FEATUREHUB_EDGE_URL: http://party-server:8085
27
+ FEATUREHUB_BASE_URL: http://party-server:8085
28
+ sinatra-redis:
29
+ image: todo-server:e2e
30
+ ports:
31
+ - 8099:8099
32
+ environment:
33
+ FEATUREHUB_POLLING_INTERVAL: "3"
34
+ FEATUREHUB_CLIENT_API_KEY: "a1286d73-a23b-4f5b-8fdf-04dd03668fb9/xPlhDYnWlKjjZ3maUPquhOUOmk50gn*Hh8Q5qY6JEURrxtEEjzk"
35
+ FEATUREHUB_EDGE_URL: http://party-server:8085
36
+ FEATUREHUB_BASE_URL: http://party-server:8085
37
+ FEATUREHUB_REDIS_STORE: redis://redis:6379
38
+ sinatra-memcache:
39
+ image: todo-server:e2e
17
40
  ports:
18
41
  - 8099:8099
19
42
  environment:
43
+ RAILS_ENV: production
44
+ FEATUREHUB_POLLING_INTERVAL: "3"
45
+ FEATUREHUB_CLIENT_API_KEY: "a1286d73-a23b-4f5b-8fdf-04dd03668fb9/xPlhDYnWlKjjZ3maUPquhOUOmk50gn*Hh8Q5qY6JEURrxtEEjzk"
20
46
  FEATUREHUB_EDGE_URL: http://party-server:8085
21
47
  FEATUREHUB_BASE_URL: http://party-server:8085
22
- FEATUREHUB_REDIS_STORE: redis
48
+ FEATUREHUB_MEMCACHE_STORE: memcache
49
+ depends_on:
50
+ - memcache
23
51
  volumes:
24
52
  featurehub-h2-data:
@@ -1,5 +1,20 @@
1
- #!/bin/sh
1
+ #!/bin/bash
2
2
  RACK_ENV=development
3
- #export FEATUREHUB_REDIS_STORE=redis://localhost:6379
4
- export FEATUREHUB_LOCAL_YAML=feature-flags.yaml
5
- bundle exec thin -R thin.ru -a 0.0.0.0 -p 8099 start
3
+ if [[ "$1" == "redis" ]]; then
4
+ echo "detected redis"
5
+ export FEATUREHUB_REDIS_STORE=redis://localhost:6379
6
+ fi
7
+ if [[ "$1" == "memcache" ]]; then
8
+ echo "detected memcache"
9
+ export FEATUREHUB_MEMCACHE_STORE=localhost:11211
10
+ fi
11
+ if [[ "$1" == "local" ]]; then
12
+ echo "detected local yaml"
13
+ export FEATUREHUB_LOCAL_YAML=feature-flags.yaml
14
+ fi
15
+ PORT=8099
16
+ if [[ "$2" == "sec" ]]; then
17
+ PORT=8100
18
+ fi
19
+
20
+ bundle exec thin -R thin.ru -a 0.0.0.0 -p $PORT start
@@ -35,11 +35,12 @@ Gem::Specification.new do |spec|
35
35
  spec.require_paths = ["lib"]
36
36
 
37
37
  spec.add_dependency "concurrent-ruby", "~> 1.3"
38
- spec.add_dependency "faraday", "~> 2"
39
- spec.add_dependency "ld-eventsource", "~> 2.5.1"
38
+ spec.add_dependency "faraday"
39
+ spec.add_dependency "ld-eventsource", ">= 2.5.1", "< 2.7.0"
40
40
  spec.add_dependency "murmurhash3", "~> 0.1.7"
41
41
  spec.add_dependency "sem_version", "~> 2.0.0"
42
42
 
43
43
  # we will dynamically determine if redis is available
44
+ spec.add_development_dependency "dalli", "~> 4"
44
45
  spec.add_development_dependency "redis", "~> 5"
45
46
  end
@@ -171,7 +171,7 @@ module FeatureHub
171
171
  end
172
172
 
173
173
  def build
174
- @edge.poll
174
+ @edge&.poll
175
175
  self
176
176
  end
177
177
 
@@ -190,6 +190,8 @@ module FeatureHub
190
190
  end
191
191
 
192
192
  def build
193
+ return self unless @edge
194
+
193
195
  new_header = @attributes.map { |k, v| "#{k}=#{URI.encode_www_form_component(v[0].to_s)}" } * "&"
194
196
 
195
197
  if @old_header.nil? && new_header.empty?
@@ -25,12 +25,16 @@ module FeatureHub
25
25
 
26
26
  # central dispatch class for FeatureHub SDK
27
27
  class FeatureHubConfig
28
+ FALLBACK_ENVIRONMENT_ID = "569b0129-d53d-4516-a818-9154af601047"
29
+
28
30
  attr_reader :edge_url, :api_keys, :client_evaluated, :logger
29
31
 
30
32
  def initialize(edge_url = nil, api_keys = nil, repository = nil, edge_provider = nil, logger = nil) # rubocop:disable Metrics/ParameterLists
31
33
  @logger = logger
32
34
  @repository = repository || FeatureHub::Sdk::FeatureHubRepository.new(nil, @logger)
33
35
 
36
+ @closed = false
37
+
34
38
  resolved_url = resolve_edge_url(edge_url)
35
39
  resolved_keys = resolve_api_keys(api_keys)
36
40
 
@@ -84,6 +88,8 @@ module FeatureHub
84
88
 
85
89
  # rubocop:disable Naming/AccessorMethodName
86
90
  def get_or_create_edge_service
91
+ return nil if @closed
92
+
87
93
  @edge_service = create_edge_service if @edge_service.nil?
88
94
 
89
95
  @edge_service
@@ -91,6 +97,7 @@ module FeatureHub
91
97
  # rubocop:enable Naming/AccessorMethodName
92
98
 
93
99
  def edge_service_provider(edge_provider = nil)
100
+ return nil if @closed
94
101
  return @edge_service_provider if edge_provider.nil?
95
102
 
96
103
  @edge_service_provider = edge_provider
@@ -103,7 +110,8 @@ module FeatureHub
103
110
  edge_provider
104
111
  end
105
112
 
106
- def use_polling_edge_service(interval = ENV.fetch("FEATUREHUB_POLL_INTERVAL", "30").to_i)
113
+ def use_polling_edge_service(interval = ENV.fetch("FEATUREHUB_POLL_INTERVAL",
114
+ ENV.fetch("FEATUREHUB_POLLING_INTERVAL", "30")).to_i)
107
115
  @interval = interval
108
116
  @edge_service_provider = method(:create_polling_edge_provider)
109
117
  end
@@ -111,32 +119,43 @@ module FeatureHub
111
119
  def new_context
112
120
  get_or_create_edge_service
113
121
 
122
+ edge_service = @closed ? nil : @edge_service
123
+
114
124
  if @client_evaluated
115
- ClientEvalFeatureContext.new(@repository, @edge_service)
125
+ ClientEvalFeatureContext.new(@repository, edge_service)
116
126
  else
117
- ServerEvalFeatureContext.new(@repository, @edge_service)
127
+ ServerEvalFeatureContext.new(@repository, edge_service)
118
128
  end
119
129
  end
120
130
 
121
131
  def close
122
132
  unless @repository.nil?
133
+ @logger&.info("[featurehubsdk] repository now closed")
123
134
  @repository.close
124
135
  @repository = nil
125
136
  end
126
137
 
127
- return if @edge_service.nil?
128
-
129
- @edge_service.close
130
- @edge_service = nil
138
+ close_edge
131
139
  end
132
140
 
133
141
  def close_edge
142
+ return if @closed
143
+
144
+ @logger&.info("[featurehubsdk] edge now closed")
145
+ @closed = true
134
146
  return if @edge_service.nil?
135
147
 
136
148
  @edge_service.close
137
149
  @edge_service = nil
138
150
  end
139
151
 
152
+ def environment_id
153
+ return FALLBACK_ENVIRONMENT_ID if @api_keys.empty?
154
+
155
+ parts = @api_keys.first.split("/")
156
+ parts.length == 3 ? parts[1] : parts[0]
157
+ end
158
+
140
159
  private
141
160
 
142
161
  def create_edge_service
@@ -148,7 +167,12 @@ module FeatureHub
148
167
  end
149
168
 
150
169
  def create_default_provider(repo, api_keys, edge_url, logger)
151
- FeatureHub::Sdk::StreamingEdgeService.new(repo, api_keys, edge_url, logger)
170
+ if ENV["FEATUREHUB_POLL_INTERVAL"] || ENV["FEATUREHUB_POLLING_INTERVAL"]
171
+ use_polling_edge_service
172
+ create_polling_edge_provider(repo, api_keys, edge_url, logger)
173
+ else
174
+ FeatureHub::Sdk::StreamingEdgeService.new(repo, api_keys, edge_url, logger)
175
+ end
152
176
  end
153
177
 
154
178
  def resolve_edge_url(edge_url)
@@ -43,6 +43,11 @@ module FeatureHub
43
43
  exists?(fs) ? fs["type"] : nil
44
44
  end
45
45
 
46
+ def feature_properties
47
+ fs = feature_state
48
+ (exists?(fs) ? fs["fp"] : {}) || {}
49
+ end
50
+
46
51
  def with_context(ctx)
47
52
  FeatureStateHolder.new(@key, @repo, nil, self, ctx)
48
53
  end
@@ -92,6 +97,12 @@ module FeatureHub
92
97
  !value.nil?
93
98
  end
94
99
 
100
+ def phantom?
101
+ return @parent_state&.phantom? unless @parent_state.nil?
102
+
103
+ @internal_feature_state.empty?
104
+ end
105
+
95
106
  def top_feature_state
96
107
  return @parent_state&.top_feature_state if @parent_state
97
108
 
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "concurrent-ruby"
5
+ require_relative "session_store_helpers"
6
+
7
+ module FeatureHub
8
+ module Sdk
9
+ # Optional configuration for MemcacheSessionStore.
10
+ class MemcacheSessionStoreOptions
11
+ attr_reader :prefix, :backoff_timeout, :retry_update_count, :refresh_timeout, :logger
12
+
13
+ def initialize(opts = nil)
14
+ opts ||= {}
15
+ @prefix = opts[:prefix] || "featurehub"
16
+ @backoff_timeout = opts[:backoff_timeout] || 500
17
+ @retry_update_count = opts[:retry_update_count] || 10
18
+ @refresh_timeout = opts[:refresh_timeout] || 300
19
+ @logger = opts[:logger]
20
+ end
21
+ end
22
+
23
+ # Persists feature values from a FeatureHubRepository in Memcache so they survive
24
+ # process restarts and are shared across multiple processes.
25
+ #
26
+ # Uses SHA256-based change detection and compare-and-set for multi-process safety.
27
+ #
28
+ # WARNING: Do not use with server-evaluated features. Each server-evaluated context
29
+ # sends different resolved values; storing them in a shared Memcache key will cause
30
+ # processes to overwrite each other's feature states.
31
+ #
32
+ # On initialization the store reads any previously saved features from Memcache and
33
+ # replays them into the repository. A periodic timer re-reads the SHA key so that
34
+ # updates published by other processes are picked up automatically.
35
+ class MemcacheSessionStore < RawUpdateFeatureListener
36
+ include SessionStoreHelpers
37
+
38
+ SOURCE = "memcache-store"
39
+
40
+ # @param connection_or_client [String, Dalli::Client] Memcache connection string or existing client
41
+ # @param config [FeatureHubConfig] SDK config (provides repository and environment_id)
42
+ # @param opts [MemcacheSessionStoreOptions, Hash, nil] optional configuration
43
+ def initialize(connection_or_client, config, opts = nil)
44
+ super()
45
+
46
+ options = opts.is_a?(MemcacheSessionStoreOptions) ? opts : MemcacheSessionStoreOptions.new(opts)
47
+
48
+ @repository = config.repository
49
+ @environment_id = config.environment_id
50
+ @prefix = options.prefix
51
+ @backoff_timeout = options.backoff_timeout
52
+ @retry_update_count = options.retry_update_count
53
+ @refresh_timeout = options.refresh_timeout
54
+ @internal_sha = nil
55
+ @mutex = Mutex.new
56
+ @task = nil
57
+ @logger = options.logger
58
+
59
+ return unless dalli_available?
60
+
61
+ @dalli = if connection_or_client.is_a?(String)
62
+ Dalli::Client.new(connection_or_client, serializer: JSON)
63
+ else
64
+ connection_or_client
65
+ end
66
+
67
+ @logger&.debug("[featurehubsdk] started memcache store")
68
+ Concurrent::Future.execute { load_from_memcache }
69
+ start_timer
70
+ end
71
+
72
+ def process_updates(features, source)
73
+ return if source == SOURCE || !dalli_available?
74
+
75
+ incoming_sha = calculate_sha(features)
76
+ return if incoming_sha == @dalli.get(sha_key)
77
+
78
+ perform_store_with_retry do |memcache_features|
79
+ has_newer = features.any? do |f|
80
+ existing = memcache_features.find { |mf| mf["id"] == f["id"] }
81
+ existing.nil? || version_of(f) > version_of(existing)
82
+ end
83
+ has_newer ? merge_features(memcache_features, features) : nil
84
+ end
85
+ end
86
+
87
+ def process_update(feature, source)
88
+ return if source == SOURCE || !dalli_available?
89
+
90
+ perform_store_with_retry do |memcache_features|
91
+ existing = memcache_features.find { |f| f["id"] == feature["id"] }
92
+ next nil if existing && version_of(existing) >= version_of(feature)
93
+
94
+ merge_features(memcache_features, [feature])
95
+ end
96
+ end
97
+
98
+ def delete_feature(feature, source)
99
+ return if source == SOURCE || !dalli_available? || !feature["id"]
100
+
101
+ perform_store_with_retry do |memcache_features|
102
+ updated = memcache_features.reject { |f| f["id"] == feature["id"] }
103
+ updated.length < memcache_features.length ? updated : nil
104
+ end
105
+ end
106
+
107
+ def close
108
+ return if @task.nil?
109
+
110
+ @task.shutdown
111
+ @task = nil
112
+ end
113
+
114
+ private
115
+
116
+ def dalli_available?
117
+ @dalli_available ||= begin
118
+ require "dalli"
119
+ true
120
+ rescue LoadError
121
+ false
122
+ end
123
+ end
124
+
125
+ def load_from_memcache
126
+ sha = @dalli.get(sha_key)
127
+ @mutex.synchronize { @internal_sha = sha }
128
+
129
+ features = read_features_from_memcache
130
+ return if features.empty?
131
+
132
+ @logger&.debug("[featurehubsdk] loading #{features.size} feature(s) from memcache")
133
+ @repository.notify("features", features, SOURCE)
134
+ end
135
+
136
+ def start_timer
137
+ @task = Concurrent::TimerTask.new(execution_interval: @refresh_timeout, run_now: false) do
138
+ check_for_updates
139
+ end
140
+ @task.execute
141
+ end
142
+
143
+ def check_for_updates
144
+ return unless dalli_available?
145
+
146
+ current_sha = @dalli.get(sha_key)
147
+ stored_sha = @mutex.synchronize { @internal_sha }
148
+ return if current_sha == stored_sha
149
+
150
+ features = read_features_from_memcache
151
+ @logger&.debug("[featurehubsdk] detected memcache change, reloading #{features.size} feature(s)")
152
+ @repository.notify("features", features, SOURCE)
153
+ @mutex.synchronize { @internal_sha = current_sha }
154
+ end
155
+
156
+ # Computes what to write by yielding the current memcache features to the block.
157
+ # The block returns the new features array to store, or nil to abort.
158
+ # Uses compare-and-set with retry to handle multi-process contention.
159
+ def perform_store_with_retry
160
+ attempt = 0
161
+ while attempt < @retry_update_count
162
+ memcache_features = read_features_from_memcache
163
+ new_features = yield(memcache_features)
164
+ return if new_features.nil?
165
+
166
+ new_sha = calculate_sha(new_features)
167
+ current_internal = @mutex.synchronize { @internal_sha }
168
+ return if new_sha == current_internal
169
+
170
+ current_sha = @dalli.get(sha_key)
171
+
172
+ if current_sha != current_internal
173
+ # Another process updated memcache — reload and recheck on next attempt
174
+ sleep(@backoff_timeout / 1000.0) unless attempt == @retry_update_count - 1
175
+ attempt += 1
176
+ next
177
+ end
178
+
179
+ stored = attempt_atomic_write(current_sha, new_sha, current_internal)
180
+
181
+ if stored
182
+ @dalli.set(features_key, new_features.to_json)
183
+ @mutex.synchronize { @internal_sha = new_sha }
184
+ return
185
+ end
186
+
187
+ sleep(@backoff_timeout / 1000.0) unless attempt == @retry_update_count - 1
188
+ attempt += 1
189
+ end
190
+
191
+ @logger&.warn("[featurehubsdk] failed to update memcache after #{@retry_update_count} retries")
192
+ end
193
+
194
+ def attempt_atomic_write(current_sha, new_sha, current_internal)
195
+ if current_sha.nil?
196
+ !!@dalli.add(sha_key, new_sha)
197
+ else
198
+ stored = false
199
+ @dalli.cas(sha_key) do |current|
200
+ if current == current_internal
201
+ stored = true
202
+ new_sha
203
+ else
204
+ current # write back same value — harmless no-op if CAS token still valid
205
+ end
206
+ end
207
+ stored
208
+ end
209
+ end
210
+
211
+ def read_features_from_memcache
212
+ json = @dalli.get(features_key)
213
+ return [] unless json
214
+
215
+ JSON.parse(json)
216
+ rescue JSON::ParserError
217
+ []
218
+ end
219
+ end
220
+ end
221
+ end
@@ -108,21 +108,27 @@ module FeatureHub
108
108
  headers["if-none-match"] = @etag unless @etag.nil?
109
109
 
110
110
  @logger&.debug("polling for #{url}")
111
- resp = @conn.get url, {}, headers
112
- case resp.status
113
- when 200
114
- success(resp)
115
- when 236
116
- stopped_task
117
- success(resp)
118
- when 404, 400 # no such key
119
- @repository.notify("failed", nil, "polling")
120
- cancel_task
121
- @logger&.error("featurehub: key does not exist, stopping polling")
122
- when 503 # dacha busy
123
- @logger&.debug("featurehub: dacha is busy, trying again")
124
- else
125
- @logger&.debug("featurehub: unknown error #{resp.status}") if resp.status != 304
111
+ begin
112
+ resp = @conn.get url, {}, headers
113
+ case resp.status
114
+ when 200
115
+ success(resp)
116
+ when 236
117
+ stopped_task
118
+ success(resp)
119
+ when 404, 400 # no such key
120
+ @repository.notify("failed", nil, "polling")
121
+ cancel_task
122
+ @logger&.error("featurehub: key does not exist, stopping polling")
123
+ when 503 # dacha busy
124
+ @logger&.debug("featurehub: dacha is busy, trying again")
125
+ else
126
+ @logger&.debug("featurehub: unknown error #{resp.status}") if resp.status != 304
127
+ end
128
+ rescue StandardError => e
129
+ # we can get timeout errors for transient network failures, this should not prevent the
130
+ # next poll from happening however
131
+ @logger&.error("featurehub: failed to connect or similar error #{e&.message}")
126
132
  end
127
133
  end
128
134