Skip to content

Commit 2d2ea3c

Browse files
committed
fix: Honor x-ld-fd-fallback header in fdv2 initializer phase
Prior to this change, the Ruby SDK only inspected the x-ld-fd-fallback response header on FDv2 synchronizer responses. If an initializer received the header, the signal was silently dropped and the SDK would continue attempting subsequent initializers and FDv2 synchronizers rather than reverting to FDv1. The Initializer fetch contract now returns a FetchResult that pairs the existing Result<Basis> with a fallback_to_fdv1 boolean. The FDv2 data system branches on the new flag, applying any accompanying Basis before swapping the synchronizer list for the FDv1 fallback builder, so evaluations can serve the server-provided payload while FDv1 spins up. When no FDv1 fallback is configured, the data system logs and clears the synchronizer list, mirroring the synchronizer-triggered path. Update.revert_to_fdv1 is renamed to Update.fallback_to_fdv1 with an alias retained for backwards compatibility while FDv2 is in early access. A shared LaunchDarkly::Impl::DataSystem.fdv1_fallback_requested? helper replaces the duplicated header-string checks across the polling and streaming data sources.
1 parent 80b8e4e commit 2d2ea3c

13 files changed

Lines changed: 481 additions & 152 deletions

lib/ldclient-rb/impl/data_system.rb

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -247,8 +247,9 @@ class Update
247247
# @return [LaunchDarkly::Interfaces::DataSource::ErrorInfo, nil] Error information if applicable
248248
attr_reader :error
249249

250-
# @return [Boolean] Whether to revert to FDv1
251-
attr_reader :revert_to_fdv1
250+
# @return [Boolean] Whether the LaunchDarkly server has instructed the SDK to
251+
# fall back to the FDv1 protocol.
252+
attr_reader :fallback_to_fdv1
252253

253254
# @return [String, nil] The environment ID if available
254255
attr_reader :environment_id
@@ -257,16 +258,20 @@ class Update
257258
# @param state [Symbol] The state of the data source
258259
# @param change_set [ChangeSet, nil] The change set if available
259260
# @param error [LaunchDarkly::Interfaces::DataSource::ErrorInfo, nil] Error information if applicable
260-
# @param revert_to_fdv1 [Boolean] Whether to revert to FDv1
261+
# @param fallback_to_fdv1 [Boolean] Whether to fall back to FDv1
261262
# @param environment_id [String, nil] The environment ID if available
262263
#
263-
def initialize(state:, change_set: nil, error: nil, revert_to_fdv1: false, environment_id: nil)
264+
def initialize(state:, change_set: nil, error: nil, fallback_to_fdv1: false, environment_id: nil)
264265
@state = state
265266
@change_set = change_set
266267
@error = error
267-
@revert_to_fdv1 = revert_to_fdv1
268+
@fallback_to_fdv1 = fallback_to_fdv1
268269
@environment_id = environment_id
269270
end
271+
272+
# Deprecated alias retained for backwards compatibility while FDv2 is in early access.
273+
# @deprecated Prefer {#fallback_to_fdv1}.
274+
alias_method :revert_to_fdv1, :fallback_to_fdv1
270275
end
271276

272277
#

lib/ldclient-rb/impl/data_system/fdv2.rb

Lines changed: 62 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -214,8 +214,23 @@ def run_main_loop
214214
nil
215215
)
216216

217-
# Run initializers first
218-
run_initializers
217+
# Run initializers first. If an initializer signals the
218+
# server-directed FDv1 Fallback Directive, switch terminally to
219+
# the FDv1 Fallback Synchronizer (or transition to OFF if none
220+
# is configured) before entering the synchronizer phase.
221+
if run_initializers
222+
if @fdv1_fallback_synchronizer_builder
223+
@logger.warn { "[LDClient] Falling back to FDv1 protocol" }
224+
@synchronizer_builders = [@fdv1_fallback_synchronizer_builder]
225+
else
226+
@logger.warn { "[LDClient] Initializer requested FDv1 fallback but none configured" }
227+
@synchronizer_builders = []
228+
@data_source_status_provider.update_status(
229+
LaunchDarkly::Interfaces::DataSource::Status::OFF,
230+
@data_source_status_provider.status.last_error
231+
)
232+
end
233+
end
219234

220235
# Run synchronizers
221236
run_synchronizers
@@ -228,39 +243,76 @@ def run_main_loop
228243
#
229244
# Run initializers to get initial data.
230245
#
231-
# @return [void]
246+
# Each initializer is tried in order until one succeeds, the system
247+
# is stopped, or an initializer signals the server-directed FDv1
248+
# Fallback Directive. When fallback is signalled alongside a valid
249+
# payload, that payload is applied before returning so evaluations
250+
# can serve the server-provided data while the FDv1 synchronizer
251+
# spins up. The method returns true when fallback was requested so
252+
# that the caller can switch the synchronizer list.
253+
#
254+
# @return [Boolean] true when an initializer requested FDv1 fallback.
232255
#
233256
def run_initializers
234-
return unless @data_system_config.initializers
257+
return false unless @data_system_config.initializers
235258

236259
@data_system_config.initializers.each do |initializer_builder|
237-
return if @stop_event.set?
260+
return false if @stop_event.set?
238261

239262
begin
240263
initializer = initializer_builder.build(@sdk_key, @config)
241264
@logger.info { "[LDClient] Attempting to initialize via #{initializer.name}" }
242265

243-
basis_result = initializer.fetch(@store)
266+
fetch_result = initializer.fetch(@store)
267+
fallback = fetch_result.respond_to?(:fallback_to_fdv1) && fetch_result.fallback_to_fdv1
268+
# Support legacy implementations that return a bare Result.
269+
basis_result = fetch_result.respond_to?(:result) ? fetch_result.result : fetch_result
244270

245271
if basis_result.success?
246272
basis = basis_result.value
247273
@logger.info { "[LDClient] Initialized via #{initializer.name}" }
248274

249-
# Apply the basis to the store
275+
# Apply the basis to the store regardless of whether
276+
# fallback was signalled -- if the server returned a valid
277+
# payload alongside the directive we still want evaluations
278+
# to serve that data.
250279
@store.apply(basis.change_set, basis.persist)
251280

252-
# Set ready event if and only if a selector is defined for the changeset
281+
# Set ready event if and only if a selector is defined for the changeset.
282+
# Even when fallback is requested, the payload that arrived with the directive
283+
# has been applied to the store, so evaluations can serve it while the FDv1
284+
# synchronizer spins up.
253285
if basis.change_set.selector && basis.change_set.selector.defined?
254286
@ready_event.set
255-
return
287+
return fallback ? true : false
256288
end
257289
else
258290
@logger.warn { "[LDClient] Initializer #{initializer.name} failed: #{basis_result.error}" }
291+
if fallback
292+
# Record the underlying initializer error so that, if no FDv1 fallback is
293+
# configured, the subsequent transition to OFF carries it as last_error.
294+
@data_source_status_provider.update_status(
295+
LaunchDarkly::Interfaces::DataSource::Status::INITIALIZING,
296+
LaunchDarkly::Interfaces::DataSource::ErrorInfo.new(
297+
LaunchDarkly::Interfaces::DataSource::ErrorInfo::UNKNOWN,
298+
0,
299+
basis_result.error || "",
300+
Time.now
301+
)
302+
)
303+
end
259304
end
305+
306+
# Honor the FDv1 Fallback Directive even on an error or undefined-selector path:
307+
# the directive takes precedence over the regular failover algorithm, so we must
308+
# not fall through to the next initializer.
309+
return true if fallback
260310
rescue => e
261311
@logger.error { "[LDClient] Initializer failed with exception: #{e.message}" }
262312
end
263313
end
314+
315+
false
264316
end
265317

266318
#
@@ -410,7 +462,7 @@ def consume_synchronizer_results(synchronizer, check_recovery: false)
410462
# Update status
411463
@data_source_status_provider.update_status(update.state, update.error)
412464

413-
return SyncResult::FDV1 if update.revert_to_fdv1
465+
return SyncResult::FDV1 if update.fallback_to_fdv1
414466

415467
return SyncResult::REMOVE if update.state == LaunchDarkly::Interfaces::DataSource::Status::OFF
416468
end

lib/ldclient-rb/impl/data_system/polling.rb

Lines changed: 53 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,18 @@ module DataSystem
2121
LD_ENVID_HEADER = "X-LD-EnvID"
2222
LD_FD_FALLBACK_HEADER = "X-LD-FD-Fallback"
2323

24+
#
25+
# Reports whether the response headers signal that the SDK should fall
26+
# back to the FDv1 protocol.
27+
#
28+
# @param headers [Hash, nil]
29+
# @return [Boolean]
30+
#
31+
def self.fdv1_fallback_requested?(headers)
32+
return false unless headers
33+
headers[LD_FD_FALLBACK_HEADER] == 'true'
34+
end
35+
2436
#
2537
# PollingDataSource is a data source that can retrieve information from
2638
# LaunchDarkly either as an Initializer or as a Synchronizer.
@@ -46,10 +58,12 @@ def initialize(poll_interval, requester, logger)
4658
end
4759

4860
#
49-
# Fetch returns a Basis, or an error if the Basis could not be retrieved.
61+
# Fetch returns a {LaunchDarkly::Interfaces::DataSystem::FetchResult}
62+
# wrapping a Basis (or an error) and the FDv1 Fallback Directive
63+
# signal carried on the server response.
5064
#
5165
# @param ss [LaunchDarkly::Interfaces::DataSystem::SelectorStore]
52-
# @return [LaunchDarkly::Interfaces::DataSystem::Basis, nil]
66+
# @return [LaunchDarkly::Interfaces::DataSystem::FetchResult]
5367
#
5468
def fetch(ss)
5569
poll(ss)
@@ -73,13 +87,8 @@ def sync(ss)
7387
result = @requester.fetch(ss.selector)
7488

7589
if !result.success?
76-
fallback = false
77-
envid = nil
78-
79-
if result.headers
80-
fallback = result.headers[LD_FD_FALLBACK_HEADER] == 'true'
81-
envid = result.headers[LD_ENVID_HEADER]
82-
end
90+
fallback = LaunchDarkly::Impl::DataSystem.fdv1_fallback_requested?(result.headers)
91+
envid = result.headers ? result.headers[LD_ENVID_HEADER] : nil
8392

8493
if result.exception.is_a?(LaunchDarkly::Impl::DataSource::UnexpectedResponseError)
8594
error_info = LaunchDarkly::Interfaces::DataSource::ErrorInfo.new(
@@ -99,7 +108,7 @@ def sync(ss)
99108
state: LaunchDarkly::Interfaces::DataSource::Status::OFF,
100109
error: error_info,
101110
environment_id: envid,
102-
revert_to_fdv1: true
111+
fallback_to_fdv1: true
103112
)
104113
break
105114
end
@@ -108,7 +117,7 @@ def sync(ss)
108117
state: LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED,
109118
error: error_info,
110119
environment_id: envid,
111-
revert_to_fdv1: false
120+
fallback_to_fdv1: false
112121
)
113122
@interrupt_event.wait(@poll_interval)
114123
next
@@ -118,7 +127,7 @@ def sync(ss)
118127
state: LaunchDarkly::Interfaces::DataSource::Status::OFF,
119128
error: error_info,
120129
environment_id: envid,
121-
revert_to_fdv1: fallback
130+
fallback_to_fdv1: fallback
122131
)
123132
break
124133
end
@@ -136,7 +145,7 @@ def sync(ss)
136145
state: LaunchDarkly::Interfaces::DataSource::Status::OFF,
137146
error: error_info,
138147
environment_id: envid,
139-
revert_to_fdv1: true
148+
fallback_to_fdv1: true
140149
)
141150
break
142151
end
@@ -145,16 +154,16 @@ def sync(ss)
145154
state: LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED,
146155
error: error_info,
147156
environment_id: envid,
148-
revert_to_fdv1: false
157+
fallback_to_fdv1: false
149158
)
150159
else
151160
change_set, headers = result.value
152-
fallback = headers[LD_FD_FALLBACK_HEADER] == 'true'
161+
fallback = LaunchDarkly::Impl::DataSystem.fdv1_fallback_requested?(headers)
153162
yield LaunchDarkly::Interfaces::DataSystem::Update.new(
154163
state: LaunchDarkly::Interfaces::DataSource::Status::VALID,
155164
change_set: change_set,
156165
environment_id: headers[LD_ENVID_HEADER],
157-
revert_to_fdv1: fallback
166+
fallback_to_fdv1: fallback
158167
)
159168
end
160169

@@ -177,22 +186,39 @@ def stop
177186

178187
#
179188
# @param ss [LaunchDarkly::Interfaces::DataSystem::SelectorStore]
180-
# @return [LaunchDarkly::Result<LaunchDarkly::Interfaces::DataSystem::Basis, String>]
189+
# @return [LaunchDarkly::Interfaces::DataSystem::FetchResult]
181190
#
182191
private def poll(ss)
183192
result = @requester.fetch(ss.selector)
184193

194+
# On success, the requester returns headers as the second element of the value tuple;
195+
# on failure, headers ride on Result.headers. Check both so the fallback signal is
196+
# surfaced regardless of outcome.
197+
response_headers = nil
198+
if result.success?
199+
_, response_headers = result.value
200+
else
201+
response_headers = result.headers
202+
end
203+
fallback = LaunchDarkly::Impl::DataSystem.fdv1_fallback_requested?(response_headers)
204+
185205
unless result.success?
186206
if result.exception.is_a?(LaunchDarkly::Impl::DataSource::UnexpectedResponseError)
187207
status_code = result.exception.status
188208
http_error_message_result = Impl::Util.http_error_message(
189209
status_code, "polling request", "will retry"
190210
)
191211
@logger.warn { "[LDClient] #{http_error_message_result}" } if Impl::Util.http_error_recoverable?(status_code)
192-
return LaunchDarkly::Result.fail(http_error_message_result, result.exception)
212+
return LaunchDarkly::Interfaces::DataSystem::FetchResult.new(
213+
result: LaunchDarkly::Result.fail(http_error_message_result, result.exception),
214+
fallback_to_fdv1: fallback
215+
)
193216
end
194217

195-
return LaunchDarkly::Result.fail(result.error || 'Failed to request payload', result.exception)
218+
return LaunchDarkly::Interfaces::DataSystem::FetchResult.new(
219+
result: LaunchDarkly::Result.fail(result.error || 'Failed to request payload', result.exception),
220+
fallback_to_fdv1: fallback
221+
)
196222
end
197223

198224
change_set, headers = result.value
@@ -206,12 +232,18 @@ def stop
206232
environment_id: env_id
207233
)
208234

209-
LaunchDarkly::Result.success(basis)
235+
LaunchDarkly::Interfaces::DataSystem::FetchResult.new(
236+
result: LaunchDarkly::Result.success(basis),
237+
fallback_to_fdv1: fallback
238+
)
210239
rescue => e
211240
msg = "Error: Exception encountered when updating flags. #{e}"
212241
@logger.error { "[LDClient] #{msg}" }
213242
@logger.debug { "[LDClient] Exception trace: #{e.backtrace}" }
214-
LaunchDarkly::Result.fail(msg, e)
243+
LaunchDarkly::Interfaces::DataSystem::FetchResult.new(
244+
result: LaunchDarkly::Result.fail(msg, e),
245+
fallback_to_fdv1: false
246+
)
215247
end
216248
end
217249

lib/ldclient-rb/impl/data_system/streaming.rb

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,11 @@ def sync(ss)
9090
envid = headers[LD_ENVID_HEADER] || envid
9191

9292
# Check for fallback header on connection
93-
if headers[LD_FD_FALLBACK_HEADER] == 'true'
93+
if LaunchDarkly::Impl::DataSystem.fdv1_fallback_requested?(headers)
9494
log_connection_result(true)
9595
yield LaunchDarkly::Interfaces::DataSystem::Update.new(
9696
state: LaunchDarkly::Interfaces::DataSource::Status::OFF,
97-
revert_to_fdv1: true,
97+
fallback_to_fdv1: true,
9898
environment_id: envid
9999
)
100100
stop
@@ -150,10 +150,7 @@ def sync(ss)
150150
# Extract envid and fallback from error headers if available
151151
if error.respond_to?(:headers) && error.headers
152152
envid = error.headers[LD_ENVID_HEADER] || envid
153-
154-
if error.headers[LD_FD_FALLBACK_HEADER] == 'true'
155-
fallback = true
156-
end
153+
fallback = true if LaunchDarkly::Impl::DataSystem.fdv1_fallback_requested?(error.headers)
157154
end
158155

159156
update = handle_error(error, envid, fallback)
@@ -286,7 +283,7 @@ def stop
286283
update = LaunchDarkly::Interfaces::DataSystem::Update.new(
287284
state: LaunchDarkly::Interfaces::DataSource::Status::OFF,
288285
error: error_info,
289-
revert_to_fdv1: true,
286+
fallback_to_fdv1: true,
290287
environment_id: envid
291288
)
292289
stop

0 commit comments

Comments
 (0)