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 +4 -4
- data/lib/ldclient-rb/impl/data_source/polling.rb +13 -18
- data/lib/ldclient-rb/impl/data_source/stream.rb +79 -45
- data/lib/ldclient-rb/impl/data_source.rb +3 -0
- data/lib/ldclient-rb/impl/data_system/streaming.rb +2 -1
- data/lib/ldclient-rb/impl/repeating_task.rb +25 -8
- data/lib/ldclient-rb/impl/retry_state.rb +240 -0
- data/lib/ldclient-rb/impl/util.rb +61 -4
- data/lib/ldclient-rb/interfaces/data_source.rb +13 -7
- data/lib/ldclient-rb/ldclient.rb +3 -2
- data/lib/ldclient-rb/version.rb +1 -1
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 298f46f5f43d9fa4ae2a0b464d7c08851e9fd8dd61aed07ca3cfb7cb18937135
|
|
4
|
+
data.tar.gz: 72365ba993ef0ff4b14d41bfdc96abe6a0aabb780262b206d1a82dcf55462d62
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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.
|
|
65
|
-
@config.logger.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
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
|
|
9
|
-
#
|
|
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
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
|
165
|
-
# {INITIALIZING} until it
|
|
166
|
-
#
|
|
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
|
|
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
|
|
194
|
-
#
|
|
195
|
-
#
|
|
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
|
|
data/lib/ldclient-rb/ldclient.rb
CHANGED
|
@@ -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
|
-
#
|
|
307
|
-
# any call to {#variation} or {#variation_detail} will
|
|
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
|
data/lib/ldclient-rb/version.rb
CHANGED
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.
|
|
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-
|
|
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:
|
|
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:
|
|
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
|