launchdarkly-server-sdk 8.18.3 → 8.19.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4226a694d1de20c401da7e78771104c435b2fdb0dc463869ec579247397bc5ef
4
- data.tar.gz: 4eeba235b1ce96d33d5452ec6108bfc9fc2a10714a3bb253a2d5aa80f96ef380
3
+ metadata.gz: 298f46f5f43d9fa4ae2a0b464d7c08851e9fd8dd61aed07ca3cfb7cb18937135
4
+ data.tar.gz: 72365ba993ef0ff4b14d41bfdc96abe6a0aabb780262b206d1a82dcf55462d62
5
5
  SHA512:
6
- metadata.gz: 9e5c17df985d102185e213b31736692df18da0e053cd5b94d41f2102714253018fefe841a0ef49f62aa1c3f4a497f70b9005bdc131d6bbc9445f6c0493a23832
7
- data.tar.gz: 6a79cedd29e66574fa438372090f9c1c1b3abe4dea07b4e25409142a48b8ed1d1ad61c162ebacf7c9c6581cc0c558634f790d2b454fe7be246447a7c0cd10410
6
+ metadata.gz: 82681ba9cf9a951e673b38ae22ca9c2889efd382afce638144fbd7ec61be80ec863381b04c5a57c0881466e2736f7d12629ab9738ffd530b52312277a9ef0f40
7
+ data.tar.gz: be74527772fcfa14c543d27fc31980a1450d5c8cbc5e4d8ac0b3b1d7f204cfbf4ebf4f73e889e8f1200c88879fc49792e1a6404ad01d932a269c745834e49754
@@ -1,5 +1,6 @@
1
1
  require "ldclient-rb/impl/data_source"
2
2
  require "ldclient-rb/impl/repeating_task"
3
+ require "ldclient-rb/impl/retry_state"
3
4
  require "ldclient-rb/impl/util"
4
5
 
5
6
  require "concurrent/atomics"
@@ -16,7 +17,8 @@ module LaunchDarkly
16
17
  @initialized = Concurrent::AtomicBoolean.new(false)
17
18
  @started = Concurrent::AtomicBoolean.new(false)
18
19
  @ready = Concurrent::Event.new
19
- @task = Impl::RepeatingTask.new(@config.poll_interval, 0, -> { self.poll }, @config.logger, 'LD/PollingDataSource')
20
+ @retry_state = Impl::RetryState.for_polling(@config.poll_interval, @config.logger)
21
+ @task = Impl::RepeatingTask.new(-> { @retry_state.next_delay }, 0, -> { self.poll }, @config.logger, 'LD/PollingDataSource')
20
22
  end
21
23
 
22
24
  def initialized?
@@ -43,6 +45,7 @@ module LaunchDarkly
43
45
  update_sink_or_data_store.init(all_data)
44
46
  newly_initialized = @initialized.make_true
45
47
  end
48
+ @retry_state.record_success
46
49
  @config.data_source_update_sink&.update_status(LaunchDarkly::Interfaces::DataSource::Status::VALID, nil)
47
50
 
48
51
  if newly_initialized
@@ -50,7 +53,8 @@ module LaunchDarkly
50
53
  @ready.set
51
54
  end
52
55
  rescue JSON::ParserError => e
53
- @config.logger.error { "[LDClient] JSON parsing failed for polling response." }
56
+ @retry_state.record_failure(:normal)
57
+ @config.logger.error { "[LDClient] JSON parsing failed for polling response - #{Util.retry_message(@retry_state.next_delay)}" }
54
58
  error_info = LaunchDarkly::Interfaces::DataSource::ErrorInfo.new(
55
59
  LaunchDarkly::Interfaces::DataSource::ErrorInfo::INVALID_DATA,
56
60
  0,
@@ -59,25 +63,16 @@ module LaunchDarkly
59
63
  )
60
64
  @config.data_source_update_sink&.update_status(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED, error_info)
61
65
  rescue Impl::DataSource::UnexpectedResponseError => e
66
+ kind = Util.classify_http_status(e.status)
67
+ @retry_state.record_failure(kind)
62
68
  error_info = LaunchDarkly::Interfaces::DataSource::ErrorInfo.new(
63
69
  LaunchDarkly::Interfaces::DataSource::ErrorInfo::ERROR_RESPONSE, e.status, nil, Time.now)
64
- message = Util.http_error_message(e.status, "polling request", "will retry")
65
- @config.logger.error { "[LDClient] #{message}" }
66
-
67
- if Util.http_error_recoverable?(e.status)
68
- @config.data_source_update_sink&.update_status(
69
- LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED,
70
- error_info
71
- )
72
- else
73
- # Publish the OFF status before releasing anyone waiting on the
74
- # ready event, so a client that returns from start can rely on the
75
- # data source status already reflecting the failure.
76
- stop_with_error_info error_info
77
- @ready.set # if client was waiting on us, make it stop waiting - has no effect if already set
78
- end
70
+ message = Util.http_error_retry_message(e.status, "polling request", @retry_state.next_delay)
71
+ @config.logger.public_send(Util.failure_log_level(kind)) { "[LDClient] #{message}" }
72
+ @config.data_source_update_sink&.update_status(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED, error_info)
79
73
  rescue StandardError => e
80
- Impl::Util.log_exception(@config.logger, "Exception while polling", e)
74
+ @retry_state.record_failure(:normal)
75
+ Impl::Util.log_exception(@config.logger, "Exception while polling - #{Util.retry_message(@retry_state.next_delay)}", e)
81
76
  @config.data_source_update_sink&.update_status(
82
77
  LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED,
83
78
  LaunchDarkly::Interfaces::DataSource::ErrorInfo.new(LaunchDarkly::Interfaces::DataSource::ErrorInfo::UNKNOWN, 0, e.to_s, Time.now)
@@ -1,5 +1,6 @@
1
1
  require "ldclient-rb/impl/data_source"
2
2
  require "ldclient-rb/impl/model/serialization"
3
+ require "ldclient-rb/impl/retry_state"
3
4
  require "ldclient-rb/impl/util"
4
5
  require "ldclient-rb/in_memory_store"
5
6
 
@@ -31,6 +32,8 @@ module LaunchDarkly
31
32
  @started = Concurrent::AtomicBoolean.new(false)
32
33
  @stopped = Concurrent::AtomicBoolean.new(false)
33
34
  @ready = Concurrent::Event.new
35
+ @stop_event = Concurrent::Event.new
36
+ @retry_state = Impl::RetryState.for_streaming(@config.initial_reconnect_delay, @config.logger)
34
37
  @connection_attempt_start_time = 0
35
38
  @connection_attempt_started_monotonic = nil
36
39
  end
@@ -51,7 +54,8 @@ module LaunchDarkly
51
54
  connect_timeout: @config.connect_timeout,
52
55
  logger: @config.logger,
53
56
  socket_factory: @config.socket_factory,
54
- reconnect_time: @config.initial_reconnect_delay,
57
+ # The SDK waits in its error handler before each reconnect, so the SSE client adds no delay.
58
+ reconnect_time: 0,
55
59
  }
56
60
  log_connection_started
57
61
 
@@ -59,39 +63,10 @@ module LaunchDarkly
59
63
  @es = SSE::Client.new(uri, **opts) do |conn|
60
64
  conn.on_connect { |response_headers| DataSource.record_environment_id(@data_source_update_sink, response_headers) }
61
65
  conn.on_event { |event| process_message(event) }
62
- conn.on_error { |err|
63
- log_connection_result(false)
64
- case err
65
- when SSE::Errors::HTTPStatusError
66
- status = err.status
67
- error_info = LaunchDarkly::Interfaces::DataSource::ErrorInfo.new(
68
- LaunchDarkly::Interfaces::DataSource::ErrorInfo::ERROR_RESPONSE, status, nil, Time.now)
69
- message = Util.http_error_message(status, "streaming connection", "will retry")
70
- @config.logger.error { "[LDClient] #{message}" }
71
-
72
- if Util.http_error_recoverable?(status)
73
- @data_source_update_sink&.update_status(
74
- LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED,
75
- error_info
76
- )
77
- else
78
- @ready.set # if client was waiting on us, make it stop waiting - has no effect if already set
79
- stop_with_error_info error_info
80
- end
81
- when SSE::Errors::HTTPContentTypeError, SSE::Errors::HTTPProxyError, SSE::Errors::ReadTimeoutError
82
- @data_source_update_sink&.update_status(
83
- LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED,
84
- LaunchDarkly::Interfaces::DataSource::ErrorInfo.new(LaunchDarkly::Interfaces::DataSource::ErrorInfo::NETWORK_ERROR, 0, err.to_s, Time.now)
85
- )
86
-
87
- else
88
- @data_source_update_sink&.update_status(
89
- LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED,
90
- LaunchDarkly::Interfaces::DataSource::ErrorInfo.new(LaunchDarkly::Interfaces::DataSource::ErrorInfo::UNKNOWN, 0, err.to_s, Time.now)
91
- )
92
- end
93
- }
66
+ conn.on_error { |err| handle_error(err) }
94
67
  end
68
+ # stop can run before @es is set, and then it has no client to close.
69
+ @es.close if @stopped.value
95
70
 
96
71
  @ready
97
72
  end
@@ -106,10 +81,73 @@ module LaunchDarkly
106
81
  # @param [LaunchDarkly::Interfaces::DataSource::ErrorInfo, nil] error_info
107
82
  #
108
83
  def stop_with_error_info(error_info = nil)
109
- if @stopped.make_true
110
- @es.close
111
- @data_source_update_sink&.update_status(LaunchDarkly::Interfaces::DataSource::Status::OFF, error_info)
112
- @config.logger.info { "[LDClient] Stream connection stopped" }
84
+ return unless @stopped.make_true
85
+
86
+ @stop_event.set
87
+ begin
88
+ @es&.close
89
+ rescue StandardError => e
90
+ Impl::Util.log_exception(@config.logger, "Error while closing stream connection", e)
91
+ end
92
+ @data_source_update_sink&.update_status(LaunchDarkly::Interfaces::DataSource::Status::OFF, error_info)
93
+ @config.logger.info { "[LDClient] Stream connection stopped" }
94
+ end
95
+
96
+ def handle_error(err)
97
+ status = 0
98
+ message = err.to_s
99
+ kind = :normal
100
+ level = :warn
101
+
102
+ case err
103
+ when SSE::Errors::HTTPStatusError
104
+ status = err.status
105
+ message = nil
106
+ kind = Util.classify_http_status(status)
107
+ level = Util.failure_log_level(kind)
108
+ error_kind = LaunchDarkly::Interfaces::DataSource::ErrorInfo::ERROR_RESPONSE
109
+ description = Util.http_error_description(status, "streaming connection")
110
+ when JSON::ParserError
111
+ level = :error
112
+ error_kind = LaunchDarkly::Interfaces::DataSource::ErrorInfo::INVALID_DATA
113
+ description = "Invalid data on streaming connection: #{err}"
114
+ when SSE::Errors::StreamClosedByServerError
115
+ error_kind = LaunchDarkly::Interfaces::DataSource::ErrorInfo::NETWORK_ERROR
116
+ description = "The server closed the streaming connection"
117
+ when SSE::Errors::HTTPContentTypeError, SSE::Errors::HTTPProxyError, SSE::Errors::ReadTimeoutError
118
+ error_kind = LaunchDarkly::Interfaces::DataSource::ErrorInfo::NETWORK_ERROR
119
+ description = "Error on streaming connection: #{err}"
120
+ else
121
+ error_kind = LaunchDarkly::Interfaces::DataSource::ErrorInfo::UNKNOWN
122
+ description = "Error on streaming connection: #{err}"
123
+ end
124
+
125
+ error_info = LaunchDarkly::Interfaces::DataSource::ErrorInfo.new(error_kind, status, message, Time.now)
126
+ handle_failure(kind, error_info, level, description)
127
+ end
128
+
129
+ #
130
+ # Records a failure, logs and reports it, and waits before the next attempt. The SSE client calls this on
131
+ # its worker thread before it reconnects, so the wait is the reconnect delay, and {#stop} ends it at once.
132
+ #
133
+ # @param kind [Symbol] `:normal` or `:unexpected`
134
+ # @param error_info [LaunchDarkly::Interfaces::DataSource::ErrorInfo]
135
+ # @param level [Symbol] `:error` or `:warn`
136
+ # @param description [String] what failed, for the log message
137
+ #
138
+ def handle_failure(kind, error_info, level, description)
139
+ return if @stopped.value
140
+
141
+ @retry_state.record_failure(kind)
142
+ delay = @retry_state.next_delay
143
+ begin
144
+ log_connection_result(false)
145
+ @config.logger.public_send(level) { "[LDClient] #{description} - #{Util.retry_message(delay)}" }
146
+ @data_source_update_sink&.update_status(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED, error_info)
147
+ ensure
148
+ # The SSE client reconnects as soon as this returns, also when it returns by raising.
149
+ @stop_event.wait([delay, Util::TIMEOUT_MAX].min)
150
+ log_connection_started
113
151
  end
114
152
  end
115
153
 
@@ -131,6 +169,7 @@ module LaunchDarkly
131
169
  def process_message(message)
132
170
  log_connection_result(true)
133
171
  method = message.type
172
+ known_type = [PUT, PATCH, DELETE].include?(method)
134
173
  @config.logger.debug { "[LDClient] Stream received #{method} message: #{message.data}" }
135
174
 
136
175
  begin
@@ -164,16 +203,11 @@ module LaunchDarkly
164
203
  @config.logger.warn { "[LDClient] Unknown message received: #{method}" }
165
204
  end
166
205
 
206
+ # Only a data payload shows that the stream is healthy.
207
+ @retry_state.record_success if known_type
167
208
  @data_source_update_sink&.update_status(LaunchDarkly::Interfaces::DataSource::Status::VALID, nil)
168
- rescue JSON::ParserError => e
209
+ rescue JSON::ParserError
169
210
  @config.logger.error { "[LDClient] JSON parsing failed for method #{method}. Ignoring event." }
170
- error_info = LaunchDarkly::Interfaces::DataSource::ErrorInfo.new(
171
- LaunchDarkly::Interfaces::DataSource::ErrorInfo::INVALID_DATA,
172
- 0,
173
- e.to_s,
174
- Time.now
175
- )
176
- @data_source_update_sink&.update_status(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED, error_info)
177
211
 
178
212
  # Re-raise the exception so the SSE implementation can catch it and restart the stream.
179
213
  raise
@@ -138,6 +138,9 @@ module LaunchDarkly
138
138
  @mutex.synchronize do
139
139
  old_status = @current_status
140
140
 
141
+ # OFF is final: no status is reported after it.
142
+ return if old_status.state == LaunchDarkly::Interfaces::DataSource::Status::OFF
143
+
141
144
  if new_state == LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED && old_status.state == LaunchDarkly::Interfaces::DataSource::Status::INITIALIZING
142
145
  # See {LaunchDarkly::Interfaces::DataSource::UpdateSink#update_status} for more information
143
146
  new_state = LaunchDarkly::Interfaces::DataSource::Status::INITIALIZING
@@ -334,7 +334,8 @@ module LaunchDarkly
334
334
 
335
335
  @logger.warn { "[LDClient] #{error_info.message}" }
336
336
 
337
- when SSE::Errors::HTTPContentTypeError, SSE::Errors::HTTPProxyError, SSE::Errors::ReadTimeoutError
337
+ when SSE::Errors::HTTPContentTypeError, SSE::Errors::HTTPProxyError, SSE::Errors::ReadTimeoutError,
338
+ SSE::Errors::StreamClosedByServerError
338
339
  @logger.warn { "[LDClient] Network error on stream connection: #{error}, will retry" }
339
340
 
340
341
  update = LaunchDarkly::Interfaces::DataSystem::Update.new(
@@ -5,8 +5,10 @@ require "concurrent/atomics"
5
5
  module LaunchDarkly
6
6
  module Impl
7
7
  #
8
- # Runs a task again and again on a worker thread, with a fixed interval
9
- # between runs.
8
+ # Runs a task again and again on a worker thread.
9
+ #
10
+ # The interval is read after each run, and the wait starts when the run
11
+ # returns.
10
12
  #
11
13
  # The worker waits on an event instead of calling `sleep`, so `stop` can
12
14
  # wake it at once even if `stop` runs before the worker starts waiting.
@@ -14,10 +16,15 @@ module LaunchDarkly
14
16
  # @private
15
17
  #
16
18
  class RepeatingTask
19
+ # The wait, in seconds, when a callable interval raises or returns a value that is not a usable
20
+ # number. It must not be 0: a broken callable would then run the task back to back, which for
21
+ # polling is a tight loop of HTTP requests.
22
+ FALLBACK_INTERVAL = 1
23
+
17
24
  attr_reader :name
18
25
 
19
26
  #
20
- # @param interval [Numeric] seconds between the start of one run and the start of the next
27
+ # @param interval [Numeric, #call] seconds between runs, or an object that returns them
21
28
  # @param start_delay [Numeric, nil] seconds to wait before the first run
22
29
  # @param task [Proc] the code to run
23
30
  # @param logger [Logger]
@@ -39,22 +46,32 @@ module LaunchDarkly
39
46
  @stop_event.wait(@start_delay) unless @start_delay.nil? || @start_delay == 0
40
47
 
41
48
  until @stopped.value do
42
- started_at = Impl::Util.monotonic_seconds
43
49
  begin
44
50
  @task.call
45
51
  rescue => e
46
52
  Impl::Util.log_exception(@logger, "Uncaught exception from repeating task", e)
47
53
  end
48
- # The run must be measured on the monotonic clock: a wall-clock step would
49
- # make this wait negative (immediate re-run) or stretch it by the step size.
50
- delta = @interval - (Impl::Util.monotonic_seconds - started_at)
51
- @stop_event.wait(delta) if delta > 0
54
+ delay = next_delay
55
+ @stop_event.wait([delay, Util::TIMEOUT_MAX].min) if delay > 0
52
56
  end
53
57
  end
54
58
 
55
59
  @worker.name = @name
56
60
  end
57
61
 
62
+ private def next_delay
63
+ return @interval if @interval.is_a?(Numeric)
64
+
65
+ delay = @interval.call
66
+ return delay if delay.is_a?(Numeric) && delay >= 0
67
+
68
+ @logger.error { "[LDClient] Repeating task #{@name} got an invalid interval #{delay.inspect}; waiting #{FALLBACK_INTERVAL}s" }
69
+ FALLBACK_INTERVAL
70
+ rescue => e
71
+ Impl::Util.log_exception(@logger, "Exception while reading the interval of repeating task #{@name}", e)
72
+ FALLBACK_INTERVAL
73
+ end
74
+
58
75
  #
59
76
  # Stops the worker thread and waits for it to finish.
60
77
  #
@@ -0,0 +1,240 @@
1
+ require "ldclient-rb/config"
2
+ require "ldclient-rb/impl/util"
3
+
4
+ module LaunchDarkly
5
+ module Impl
6
+ #
7
+ # Computes how long to wait before a failed operation is tried again.
8
+ #
9
+ # Each failure is `:normal` or `:unexpected`. After a normal failure, the wait starts at the normal
10
+ # initial delay and doubles with each failure, up to the normal ceiling. An unexpected failure moves
11
+ # the state to the extended regime: the wait starts at the extended initial delay and doubles up to
12
+ # the extended ceiling. The extended bounds stay in place until the reset policy is satisfied, so a
13
+ # normal failure that follows cannot lower them. When the policy is satisfied, the state returns to
14
+ # the normal regime and the delay sequence starts over.
15
+ #
16
+ # A random jitter of up to half of each delay is subtracted, so that many callers do not all try
17
+ # again at the same moment. The wait never falls below the operating cadence.
18
+ #
19
+ # After each outcome, call {#record_failure} or {#record_success}, then read {#next_delay} for the
20
+ # wait before the next operation. An instance is not thread-safe.
21
+ #
22
+ # @private
23
+ #
24
+ class RetryState
25
+ # The longest normal delay for streaming, in seconds.
26
+ NORMAL_STREAMING_CEILING_DELAY = 30
27
+
28
+ # The delay bounds of the extended regime, in seconds.
29
+ EXTENDED_INITIAL_DELAY = 5 * 60
30
+ EXTENDED_CEILING_DELAY = 60 * 60
31
+
32
+ # How long a stream must operate without a failure before its retry state resets, in seconds.
33
+ STREAMING_RESET_INTERVAL = 60
34
+
35
+ # How many polls in a row must succeed before polling's retry state resets.
36
+ POLLING_RESET_SUCCESSES = 2
37
+
38
+ MONOTONIC_CLOCK = -> { Impl::Util.monotonic_seconds }
39
+ private_constant :MONOTONIC_CLOCK
40
+
41
+ #
42
+ # Builds the retry state for a streaming data source.
43
+ #
44
+ # A healthy stream never waits, so the operating cadence is zero. A delay that is not a
45
+ # positive, finite number is replaced by the default. A delay longer than a ceiling raises that
46
+ # ceiling.
47
+ #
48
+ # @param initial_reconnect_delay [Numeric] seconds
49
+ # @param logger [Logger]
50
+ # @param clock [#call] returns monotonic seconds
51
+ # @param random [#rand] returns a Float in [0, 1)
52
+ # @return [RetryState]
53
+ #
54
+ def self.for_streaming(initial_reconnect_delay, logger, clock: MONOTONIC_CLOCK, random: Random.new)
55
+ delay = usable_delay(initial_reconnect_delay, Config.default_initial_reconnect_delay, "initial_reconnect_delay", logger)
56
+ new(
57
+ normal_initial_delay: delay,
58
+ normal_ceiling_delay: NORMAL_STREAMING_CEILING_DELAY,
59
+ extended_initial_delay: [EXTENDED_INITIAL_DELAY, delay].max,
60
+ extended_ceiling_delay: EXTENDED_CEILING_DELAY,
61
+ reset_policy: AfterHealthyFor.new(STREAMING_RESET_INTERVAL, clock),
62
+ operating_cadence: 0,
63
+ random: random
64
+ )
65
+ end
66
+
67
+ #
68
+ # Builds the retry state for a polling data source.
69
+ #
70
+ # The poll interval is both the operating cadence and the normal ceiling, so a normal failure
71
+ # waits the interval. An interval that is not a positive, finite number is replaced by the
72
+ # default. No wait is shorter than the interval, so the interval wins over the extended ceiling.
73
+ #
74
+ # @param poll_interval [Numeric] seconds
75
+ # @param logger [Logger]
76
+ # @param random [#rand] returns a Float in [0, 1)
77
+ # @return [RetryState]
78
+ #
79
+ def self.for_polling(poll_interval, logger, random: Random.new)
80
+ interval = usable_delay(poll_interval, Config.default_poll_interval, "poll_interval", logger)
81
+ new(
82
+ normal_initial_delay: interval,
83
+ normal_ceiling_delay: interval,
84
+ extended_initial_delay: [EXTENDED_INITIAL_DELAY, interval].max,
85
+ extended_ceiling_delay: EXTENDED_CEILING_DELAY,
86
+ reset_policy: AfterConsecutiveSuccesses.new(POLLING_RESET_SUCCESSES),
87
+ operating_cadence: interval,
88
+ random: random
89
+ )
90
+ end
91
+
92
+ private_class_method def self.usable_delay(value, default, name, logger)
93
+ return value if value.is_a?(Numeric) && value.real? && value > 0 && value.finite?
94
+
95
+ logger.warn { "[LDClient] #{name} must be a positive, finite number of seconds; using the default of #{default}s" }
96
+ default
97
+ end
98
+
99
+ #
100
+ # @param normal_initial_delay [Numeric] the first normal delay, in seconds
101
+ # @param normal_ceiling_delay [Numeric] the longest normal delay, in seconds
102
+ # @param extended_initial_delay [Numeric] the first extended delay, in seconds
103
+ # @param extended_ceiling_delay [Numeric] the longest extended delay, in seconds
104
+ # @param reset_policy [AfterHealthyFor, AfterConsecutiveSuccesses] decides when the state resets
105
+ # @param operating_cadence [Numeric] the wait between healthy operations, in seconds; no wait is
106
+ # shorter than this
107
+ # @param random [#rand] returns a Float in [0, 1)
108
+ #
109
+ def initialize(normal_initial_delay:, normal_ceiling_delay:, extended_initial_delay:, extended_ceiling_delay:,
110
+ reset_policy:, operating_cadence: 0, random: Random.new)
111
+ @normal_initial_delay = normal_initial_delay
112
+ @normal_ceiling_delay = normal_ceiling_delay
113
+ @extended_initial_delay = extended_initial_delay
114
+ @extended_ceiling_delay = extended_ceiling_delay
115
+ @reset_policy = reset_policy
116
+ @operating_cadence = operating_cadence
117
+ @random = random
118
+
119
+ @attempts = 0
120
+ @extended = false
121
+ @min_delay = @normal_initial_delay
122
+ @max_delay = [@normal_ceiling_delay, @normal_initial_delay].max
123
+ @next_delay = @operating_cadence
124
+ end
125
+
126
+ #
127
+ # @return [Numeric] the wait before the next operation, in seconds, as the last recorded
128
+ # outcome decided it
129
+ #
130
+ attr_reader :next_delay
131
+
132
+ #
133
+ # Records a failed attempt and decides the wait before the next one.
134
+ #
135
+ # @param kind [Symbol] `:normal` or `:unexpected`
136
+ # @return [void]
137
+ #
138
+ def record_failure(kind)
139
+ # A caller can record nothing while it is healthy, so a time-based reset can only be noticed here.
140
+ reset_if_due
141
+ @reset_policy.note_failure
142
+
143
+ if kind == :unexpected && !@extended
144
+ # Only the move to the extended regime starts the sequence over. A later unexpected failure
145
+ # keeps counting up.
146
+ @extended = true
147
+ @min_delay = @extended_initial_delay
148
+ @max_delay = [@extended_ceiling_delay, @min_delay].max
149
+ @attempts = 1
150
+ else
151
+ @attempts += 1
152
+ end
153
+
154
+ delay = [@min_delay * (2**(@attempts - 1)), @max_delay].min
155
+ jitter = @random.rand * delay / 2
156
+ @next_delay = [delay - jitter, @operating_cadence].max
157
+ end
158
+
159
+ #
160
+ # Records a successful operation, and resets the retry state if the reset policy is satisfied.
161
+ #
162
+ # The next wait returns to the operating cadence even when the state has not reset, because a
163
+ # backoff delay applies to a retry and not to every operation.
164
+ #
165
+ # @return [void]
166
+ #
167
+ def record_success
168
+ @reset_policy.note_healthy
169
+ reset_if_due
170
+ @next_delay = @operating_cadence
171
+ end
172
+
173
+ private def reset_if_due
174
+ return unless @reset_policy.satisfied?
175
+
176
+ @attempts = 0
177
+ @extended = false
178
+ @min_delay = @normal_initial_delay
179
+ @max_delay = [@normal_ceiling_delay, @normal_initial_delay].max
180
+ end
181
+
182
+ #
183
+ # Resets once the component has operated without a failure for a number of seconds.
184
+ #
185
+ # @private
186
+ #
187
+ class AfterHealthyFor
188
+ #
189
+ # @param seconds [Numeric]
190
+ # @param clock [#call] returns monotonic seconds
191
+ #
192
+ def initialize(seconds, clock)
193
+ @healthy_seconds = seconds
194
+ @clock = clock
195
+ @healthy_since = nil
196
+ end
197
+
198
+ # A later call during the same healthy stretch does not move its start.
199
+ def note_healthy
200
+ @healthy_since = @clock.call if @healthy_since.nil?
201
+ end
202
+
203
+ def note_failure
204
+ @healthy_since = nil
205
+ end
206
+
207
+ def satisfied?
208
+ !@healthy_since.nil? && @clock.call - @healthy_since >= @healthy_seconds
209
+ end
210
+ end
211
+
212
+ #
213
+ # Resets once a number of operations in a row have succeeded.
214
+ #
215
+ # @private
216
+ #
217
+ class AfterConsecutiveSuccesses
218
+ #
219
+ # @param count [Integer]
220
+ #
221
+ def initialize(count)
222
+ @count = count
223
+ @successes = 0
224
+ end
225
+
226
+ def note_healthy
227
+ @successes += 1
228
+ end
229
+
230
+ def note_failure
231
+ @successes = 0
232
+ end
233
+
234
+ def satisfied?
235
+ @successes >= @count
236
+ end
237
+ end
238
+ end
239
+ end
240
+ end
@@ -4,6 +4,10 @@ require "http"
4
4
  module LaunchDarkly
5
5
  module Impl
6
6
  module Util
7
+ # The longest wait to pass to `Concurrent::Event#wait`, in seconds. A wait above about 1e19 seconds
8
+ # raises `RangeError`. About 31 years is the same in practice as honoring a longer wait.
9
+ TIMEOUT_MAX = 1_000_000_000
10
+
7
11
  def self.bool?(aObject)
8
12
  [true,false].include? aObject
9
13
  end
@@ -162,19 +166,72 @@ module LaunchDarkly
162
166
  logger.debug { "[LDClient] Exception trace: #{exc.backtrace}" }
163
167
  end
164
168
 
165
- def self.http_error_recoverable?(status)
166
- if status >= 400 && status < 500
167
- status == 400 || status == 408 || status == 429
169
+ # The 4xx statuses that are still normal failures. Every other 4xx is unexpected.
170
+ NORMAL_4XX_STATUSES = [400, 408, 429].freeze
171
+ private_constant :NORMAL_4XX_STATUSES
172
+
173
+ #
174
+ # Classifies an HTTP error status.
175
+ #
176
+ # `400`, `408`, `429` and every status outside the 4xx range are normal. Every other `4xx`,
177
+ # including `401` and `403`, is unexpected.
178
+ #
179
+ # @param status [Integer]
180
+ # @return [Symbol] `:normal` or `:unexpected`
181
+ #
182
+ def self.classify_http_status(status)
183
+ if status >= 400 && status < 500 && !NORMAL_4XX_STATUSES.include?(status)
184
+ :unexpected
168
185
  else
169
- true
186
+ :normal
170
187
  end
171
188
  end
172
189
 
190
+ def self.http_error_recoverable?(status)
191
+ classify_http_status(status) == :normal
192
+ end
193
+
173
194
  def self.http_error_message(status, context, recoverable_message)
174
195
  desc = (status == 401 || status == 403) ? " (invalid SDK key)" : ""
175
196
  message = http_error_recoverable?(status) ? recoverable_message : "giving up permanently"
176
197
  "HTTP error #{status}#{desc} for #{context} - #{message}"
177
198
  end
199
+
200
+ #
201
+ # @param status [Integer]
202
+ # @param context [String] what failed, such as "polling request"
203
+ # @return [String]
204
+ #
205
+ def self.http_error_description(status, context)
206
+ desc = (status == 401 || status == 403) ? " (invalid SDK key)" : ""
207
+ "HTTP error #{status}#{desc} for #{context}"
208
+ end
209
+
210
+ #
211
+ # @param status [Integer]
212
+ # @param context [String] what failed, such as "polling request"
213
+ # @param delay [Numeric] seconds until the next attempt
214
+ # @return [String]
215
+ #
216
+ def self.http_error_retry_message(status, context, delay)
217
+ "#{http_error_description(status, context)} - #{retry_message(delay)}"
218
+ end
219
+
220
+ #
221
+ # @param kind [Symbol] `:normal` or `:unexpected`
222
+ # @return [Symbol] the log level for a failure of this kind
223
+ #
224
+ def self.failure_log_level(kind)
225
+ kind == :unexpected ? :error : :warn
226
+ end
227
+
228
+ #
229
+ # @param delay [Numeric] seconds until the next attempt
230
+ # @return [String]
231
+ #
232
+ def self.retry_message(delay)
233
+ format("will retry in %.1fs", delay)
234
+ end
178
235
  end
179
236
  end
180
237
  end
@@ -148,6 +148,8 @@ module LaunchDarkly
148
148
  # {Status::INITIALIZING} because {Status::INTERRUPTED} is only meaningful
149
149
  # after a successful startup.
150
150
  #
151
+ # After a {Status::OFF} status, the SDK ignores every later update.
152
+ #
151
153
  # @param new_state [Symbol]
152
154
  # @param new_error [ErrorInfo, nil]
153
155
  #
@@ -161,9 +163,10 @@ module LaunchDarkly
161
163
  #
162
164
  # The initial state of the data source when the SDK is being initialized.
163
165
  #
164
- # If it encounters an error that requires it to retry initialization, the state will remain at
165
- # {INITIALIZING} until it either succeeds and becomes {VALID}, or permanently fails and
166
- # becomes {OFF}.
166
+ # If it encounters an error while it initializes, it retries, and the state remains at
167
+ # {INITIALIZING} until it succeeds and becomes {VALID}. This is also true for an error that needs
168
+ # to be fixed, such as a rejected SDK key. In the FDv2 data system, it can instead become {OFF}
169
+ # after an error it does not retry.
167
170
  #
168
171
 
169
172
  INITIALIZING = :initializing
@@ -183,16 +186,19 @@ module LaunchDarkly
183
186
  #
184
187
  # In streaming mode, this means that the stream connection failed, or had to be dropped due to some
185
188
  # other error, and will be retried after a backoff delay. In polling mode, it means that the last poll
186
- # request failed, and a new poll request will be made after the configured polling interval.
189
+ # request failed, and a new poll request will be made after the polling interval, or after a longer
190
+ # backoff delay for unexpected error types.
187
191
  #
188
192
  INTERRUPTED = :interrupted
189
193
 
190
194
  #
191
195
  # Indicates that the data source has been permanently shut down.
192
196
  #
193
- # This could be because it encountered an unrecoverable error (for instance, the LaunchDarkly service
194
- # rejected the SDK key; an invalid SDK key will never become valid), or because the SDK client was
195
- # explicitly shut down.
197
+ # This could be because the SDK client was explicitly shut down. In the FDv2 data system, it can also
198
+ # mean the data source stopped after an error it does not retry.
199
+ #
200
+ # Unless the SDK is configured with {LaunchDarkly::Config#data_system_config}, no other state follows
201
+ # this one.
196
202
  #
197
203
  OFF = :off
198
204
 
@@ -303,8 +303,9 @@ module LaunchDarkly
303
303
  # If this returns false, it means that the client did not succeed in connecting to
304
304
  # LaunchDarkly within the time limit that you specified in the constructor. It could
305
305
  # still succeed in connecting at a later time (on another thread), or it could have
306
- # given up permanently (for instance, if your SDK key is invalid). In the meantime,
307
- # any call to {#variation} or {#variation_detail} will behave as follows:
306
+ # received an error that needs to be fixed (for instance, if your SDK key is
307
+ # invalid). In the meantime, any call to {#variation} or {#variation_detail} will
308
+ # behave as follows:
308
309
  #
309
310
  # 1. It will check whether the feature store already contains data (that is, you
310
311
  # are using a database-backed store and it was populated by a previous run of this
@@ -1,3 +1,3 @@
1
1
  module LaunchDarkly
2
- VERSION = "8.18.3" # x-release-please-version
2
+ VERSION = "8.19.0" # x-release-please-version
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: launchdarkly-server-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 8.18.3
4
+ version: 8.19.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - LaunchDarkly
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-09 00:00:00.000000000 Z
11
+ date: 2026-10-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: aws-sdk-dynamodb
@@ -252,14 +252,14 @@ dependencies:
252
252
  requirements:
253
253
  - - '='
254
254
  - !ruby/object:Gem::Version
255
- version: 2.6.0
255
+ version: 3.0.1
256
256
  type: :runtime
257
257
  prerelease: false
258
258
  version_requirements: !ruby/object:Gem::Requirement
259
259
  requirements:
260
260
  - - '='
261
261
  - !ruby/object:Gem::Version
262
- version: 2.6.0
262
+ version: 3.0.1
263
263
  - !ruby/object:Gem::Dependency
264
264
  name: observer
265
265
  requirement: !ruby/object:Gem::Requirement
@@ -437,6 +437,7 @@ files:
437
437
  - lib/ldclient-rb/impl/model/serialization.rb
438
438
  - lib/ldclient-rb/impl/non_blocking_thread_pool.rb
439
439
  - lib/ldclient-rb/impl/repeating_task.rb
440
+ - lib/ldclient-rb/impl/retry_state.rb
440
441
  - lib/ldclient-rb/impl/sampler.rb
441
442
  - lib/ldclient-rb/impl/simple_lru_cache.rb
442
443
  - lib/ldclient-rb/impl/store_client_wrapper.rb