flagkit 1.0.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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +196 -0
- data/lib/flagkit/client.rb +443 -0
- data/lib/flagkit/core/cache.rb +162 -0
- data/lib/flagkit/core/encrypted_cache.rb +227 -0
- data/lib/flagkit/core/event_persistence.rb +513 -0
- data/lib/flagkit/core/event_queue.rb +190 -0
- data/lib/flagkit/core/polling_manager.rb +112 -0
- data/lib/flagkit/core/streaming_manager.rb +469 -0
- data/lib/flagkit/error/error_code.rb +98 -0
- data/lib/flagkit/error/error_sanitizer.rb +48 -0
- data/lib/flagkit/error/flagkit_error.rb +95 -0
- data/lib/flagkit/http/circuit_breaker.rb +145 -0
- data/lib/flagkit/http/http_client.rb +312 -0
- data/lib/flagkit/options.rb +222 -0
- data/lib/flagkit/types/evaluation_context.rb +121 -0
- data/lib/flagkit/types/evaluation_reason.rb +22 -0
- data/lib/flagkit/types/evaluation_result.rb +77 -0
- data/lib/flagkit/types/flag_state.rb +100 -0
- data/lib/flagkit/types/flag_type.rb +46 -0
- data/lib/flagkit/utils/security.rb +528 -0
- data/lib/flagkit/utils/version.rb +116 -0
- data/lib/flagkit/version.rb +5 -0
- data/lib/flagkit.rb +166 -0
- metadata +200 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlagKit
|
|
4
|
+
module Core
|
|
5
|
+
# Manages background polling for flag updates.
|
|
6
|
+
class PollingManager
|
|
7
|
+
MAX_BACKOFF_MULTIPLIER = 4
|
|
8
|
+
|
|
9
|
+
attr_reader :interval, :running
|
|
10
|
+
|
|
11
|
+
# @param interval [Integer] Polling interval in seconds
|
|
12
|
+
# @param on_update [Proc] Callback when updates are received
|
|
13
|
+
# @param logger [Object, nil] Logger instance
|
|
14
|
+
def initialize(interval:, on_update:, logger: nil)
|
|
15
|
+
@interval = interval
|
|
16
|
+
@on_update = on_update
|
|
17
|
+
@logger = logger
|
|
18
|
+
@running = false
|
|
19
|
+
@last_update_time = nil
|
|
20
|
+
@consecutive_errors = 0
|
|
21
|
+
@mutex = Mutex.new
|
|
22
|
+
@thread = nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Starts the polling loop.
|
|
26
|
+
def start
|
|
27
|
+
@mutex.synchronize do
|
|
28
|
+
return if @running
|
|
29
|
+
|
|
30
|
+
@running = true
|
|
31
|
+
@thread = Thread.new { polling_loop }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Stops the polling loop.
|
|
36
|
+
def stop
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
@running = false
|
|
39
|
+
end
|
|
40
|
+
@thread&.join(5)
|
|
41
|
+
@thread = nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Checks if polling is running.
|
|
45
|
+
#
|
|
46
|
+
# @return [Boolean]
|
|
47
|
+
def running?
|
|
48
|
+
@mutex.synchronize { @running }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Gets the last update timestamp.
|
|
52
|
+
#
|
|
53
|
+
# @return [Time, nil]
|
|
54
|
+
def last_update_time
|
|
55
|
+
@mutex.synchronize { @last_update_time }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Manually triggers a poll.
|
|
59
|
+
#
|
|
60
|
+
# @return [Boolean] Whether the poll was successful
|
|
61
|
+
def poll_now
|
|
62
|
+
perform_poll
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def polling_loop
|
|
68
|
+
while running?
|
|
69
|
+
sleep(current_interval_with_jitter)
|
|
70
|
+
break unless running?
|
|
71
|
+
|
|
72
|
+
perform_poll
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def perform_poll
|
|
77
|
+
@on_update.call(@last_update_time)
|
|
78
|
+
@mutex.synchronize do
|
|
79
|
+
@last_update_time = Time.now
|
|
80
|
+
@consecutive_errors = 0
|
|
81
|
+
end
|
|
82
|
+
true
|
|
83
|
+
rescue StandardError => e
|
|
84
|
+
@mutex.synchronize do
|
|
85
|
+
@consecutive_errors += 1
|
|
86
|
+
end
|
|
87
|
+
log(:error, "Polling failed: #{e.message}")
|
|
88
|
+
false
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def current_interval_with_jitter
|
|
92
|
+
base = interval * backoff_multiplier
|
|
93
|
+
jitter = base * 0.1 * rand
|
|
94
|
+
base + jitter
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def backoff_multiplier
|
|
98
|
+
errors = @mutex.synchronize { @consecutive_errors }
|
|
99
|
+
[2**errors, MAX_BACKOFF_MULTIPLIER].min
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def log(level, message)
|
|
103
|
+
return unless @logger
|
|
104
|
+
|
|
105
|
+
@logger.send(level, "[FlagKit::PollingManager] #{message}")
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Alias for backward compatibility
|
|
111
|
+
PollingManager = Core::PollingManager
|
|
112
|
+
end
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module FlagKit
|
|
8
|
+
module Core
|
|
9
|
+
# Connection states for streaming
|
|
10
|
+
module StreamingState
|
|
11
|
+
DISCONNECTED = :disconnected
|
|
12
|
+
CONNECTING = :connecting
|
|
13
|
+
CONNECTED = :connected
|
|
14
|
+
RECONNECTING = :reconnecting
|
|
15
|
+
FAILED = :failed
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# SSE error codes from server
|
|
19
|
+
STREAM_ERROR_CODES = {
|
|
20
|
+
TOKEN_INVALID: 'TOKEN_INVALID',
|
|
21
|
+
TOKEN_EXPIRED: 'TOKEN_EXPIRED',
|
|
22
|
+
SUBSCRIPTION_SUSPENDED: 'SUBSCRIPTION_SUSPENDED',
|
|
23
|
+
CONNECTION_LIMIT: 'CONNECTION_LIMIT',
|
|
24
|
+
STREAMING_UNAVAILABLE: 'STREAMING_UNAVAILABLE'
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
# SSE error event data structure
|
|
28
|
+
class StreamErrorData
|
|
29
|
+
# @return [String] Error code (one of STREAM_ERROR_CODES values)
|
|
30
|
+
attr_reader :code
|
|
31
|
+
|
|
32
|
+
# @return [String] Human-readable error message
|
|
33
|
+
attr_reader :message
|
|
34
|
+
|
|
35
|
+
# @param code [String] Error code
|
|
36
|
+
# @param message [String] Error message
|
|
37
|
+
def initialize(code:, message:)
|
|
38
|
+
@code = code
|
|
39
|
+
@message = message
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Streaming configuration
|
|
44
|
+
class StreamingConfig
|
|
45
|
+
attr_reader :enabled, :reconnect_interval, :max_reconnect_attempts, :heartbeat_interval
|
|
46
|
+
|
|
47
|
+
def initialize(
|
|
48
|
+
enabled: true,
|
|
49
|
+
reconnect_interval: 3.0,
|
|
50
|
+
max_reconnect_attempts: 3,
|
|
51
|
+
heartbeat_interval: 30.0
|
|
52
|
+
)
|
|
53
|
+
@enabled = enabled
|
|
54
|
+
@reconnect_interval = reconnect_interval
|
|
55
|
+
@max_reconnect_attempts = max_reconnect_attempts
|
|
56
|
+
@heartbeat_interval = heartbeat_interval
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.default
|
|
60
|
+
new
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Manages Server-Sent Events (SSE) connection for real-time flag updates.
|
|
65
|
+
#
|
|
66
|
+
# Security: Uses token exchange pattern to avoid exposing API keys in URLs.
|
|
67
|
+
# 1. Fetches short-lived token via POST with API key in header
|
|
68
|
+
# 2. Connects to SSE endpoint with disposable token in URL
|
|
69
|
+
#
|
|
70
|
+
# Features:
|
|
71
|
+
# - Secure token-based authentication
|
|
72
|
+
# - Automatic token refresh before expiry
|
|
73
|
+
# - Automatic reconnection with exponential backoff
|
|
74
|
+
# - Graceful degradation to polling after max failures
|
|
75
|
+
# - Heartbeat monitoring for connection health
|
|
76
|
+
# - SSE error event handling with appropriate callbacks
|
|
77
|
+
class StreamingManager
|
|
78
|
+
attr_reader :state
|
|
79
|
+
|
|
80
|
+
# @param base_url [String] Base URL for API endpoints
|
|
81
|
+
# @param get_api_key [Proc] Callable that returns the current API key
|
|
82
|
+
# @param config [StreamingConfig, nil] Streaming configuration
|
|
83
|
+
# @param on_flag_update [Proc] Callback when flag is updated
|
|
84
|
+
# @param on_flag_delete [Proc] Callback when flag is deleted
|
|
85
|
+
# @param on_flags_reset [Proc] Callback when all flags are reset
|
|
86
|
+
# @param on_fallback_to_polling [Proc] Callback when streaming fails and falls back to polling
|
|
87
|
+
# @param on_subscription_error [Proc, nil] Callback when subscription error occurs (e.g., suspended)
|
|
88
|
+
# @param on_connection_limit_error [Proc, nil] Callback when connection limit is reached
|
|
89
|
+
# @param logger [Object, nil] Logger instance
|
|
90
|
+
def initialize(
|
|
91
|
+
base_url:,
|
|
92
|
+
get_api_key:,
|
|
93
|
+
config: nil,
|
|
94
|
+
on_flag_update:,
|
|
95
|
+
on_flag_delete:,
|
|
96
|
+
on_flags_reset:,
|
|
97
|
+
on_fallback_to_polling:,
|
|
98
|
+
on_subscription_error: nil,
|
|
99
|
+
on_connection_limit_error: nil,
|
|
100
|
+
logger: nil
|
|
101
|
+
)
|
|
102
|
+
@base_url = base_url
|
|
103
|
+
@get_api_key = get_api_key
|
|
104
|
+
@config = config || StreamingConfig.default
|
|
105
|
+
@on_flag_update = on_flag_update
|
|
106
|
+
@on_flag_delete = on_flag_delete
|
|
107
|
+
@on_flags_reset = on_flags_reset
|
|
108
|
+
@on_fallback_to_polling = on_fallback_to_polling
|
|
109
|
+
@on_subscription_error = on_subscription_error
|
|
110
|
+
@on_connection_limit_error = on_connection_limit_error
|
|
111
|
+
@logger = logger
|
|
112
|
+
|
|
113
|
+
@state = StreamingState::DISCONNECTED
|
|
114
|
+
@consecutive_failures = 0
|
|
115
|
+
@last_heartbeat = Time.now
|
|
116
|
+
@mutex = Mutex.new
|
|
117
|
+
@stop_requested = false
|
|
118
|
+
@connection_thread = nil
|
|
119
|
+
@token_refresh_thread = nil
|
|
120
|
+
@heartbeat_thread = nil
|
|
121
|
+
@retry_thread = nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def connected?
|
|
125
|
+
@state == StreamingState::CONNECTED
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def connect
|
|
129
|
+
@mutex.synchronize do
|
|
130
|
+
return if @state == StreamingState::CONNECTED || @state == StreamingState::CONNECTING
|
|
131
|
+
|
|
132
|
+
@state = StreamingState::CONNECTING
|
|
133
|
+
@stop_requested = false
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
@connection_thread = Thread.new { initiate_connection }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def disconnect
|
|
140
|
+
@mutex.synchronize do
|
|
141
|
+
cleanup
|
|
142
|
+
@state = StreamingState::DISCONNECTED
|
|
143
|
+
@consecutive_failures = 0
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
@logger&.debug('Streaming disconnected')
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def retry_connection
|
|
150
|
+
@mutex.synchronize do
|
|
151
|
+
return if @state == StreamingState::CONNECTED || @state == StreamingState::CONNECTING
|
|
152
|
+
|
|
153
|
+
@consecutive_failures = 0
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
connect
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def initiate_connection
|
|
162
|
+
# Step 1: Fetch short-lived stream token
|
|
163
|
+
token_response = fetch_stream_token
|
|
164
|
+
|
|
165
|
+
# Step 2: Schedule token refresh at 80% of TTL
|
|
166
|
+
schedule_token_refresh(token_response[:expires_in] * 0.8)
|
|
167
|
+
|
|
168
|
+
# Step 3: Create SSE connection with token
|
|
169
|
+
create_connection(token_response[:token])
|
|
170
|
+
rescue StandardError => e
|
|
171
|
+
@logger&.error("Failed to fetch stream token: #{e.message}")
|
|
172
|
+
handle_connection_failure
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def fetch_stream_token
|
|
176
|
+
token_uri = URI("#{@base_url}/sdk/stream/token")
|
|
177
|
+
|
|
178
|
+
http = Net::HTTP.new(token_uri.host, token_uri.port)
|
|
179
|
+
http.use_ssl = token_uri.scheme == 'https'
|
|
180
|
+
|
|
181
|
+
request = Net::HTTP::Post.new(token_uri.path)
|
|
182
|
+
request['Content-Type'] = 'application/json'
|
|
183
|
+
request['X-API-Key'] = @get_api_key.call
|
|
184
|
+
request.body = '{}'
|
|
185
|
+
|
|
186
|
+
response = http.request(request)
|
|
187
|
+
raise "Failed to fetch stream token: #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
188
|
+
|
|
189
|
+
data = JSON.parse(response.body)
|
|
190
|
+
{ token: data['token'], expires_in: data['expiresIn'] }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def schedule_token_refresh(delay)
|
|
194
|
+
@token_refresh_thread&.kill
|
|
195
|
+
|
|
196
|
+
@token_refresh_thread = Thread.new do
|
|
197
|
+
sleep(delay)
|
|
198
|
+
begin
|
|
199
|
+
token_response = fetch_stream_token
|
|
200
|
+
schedule_token_refresh(token_response[:expires_in] * 0.8)
|
|
201
|
+
rescue StandardError => e
|
|
202
|
+
@logger&.warn("Failed to refresh stream token, reconnecting: #{e.message}")
|
|
203
|
+
disconnect
|
|
204
|
+
connect
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def create_connection(token)
|
|
210
|
+
stream_uri = URI("#{@base_url}/sdk/stream?token=#{token}")
|
|
211
|
+
|
|
212
|
+
http = Net::HTTP.new(stream_uri.host, stream_uri.port)
|
|
213
|
+
http.use_ssl = stream_uri.scheme == 'https'
|
|
214
|
+
http.read_timeout = nil # No timeout for SSE
|
|
215
|
+
|
|
216
|
+
request = Net::HTTP::Get.new(stream_uri)
|
|
217
|
+
request['Accept'] = 'text/event-stream'
|
|
218
|
+
request['Cache-Control'] = 'no-cache'
|
|
219
|
+
|
|
220
|
+
http.request(request) do |response|
|
|
221
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
222
|
+
@logger&.error("SSE connection failed: #{response.code}")
|
|
223
|
+
handle_connection_failure
|
|
224
|
+
return
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
handle_open
|
|
228
|
+
read_events(response)
|
|
229
|
+
end
|
|
230
|
+
rescue StandardError => e
|
|
231
|
+
return if @stop_requested
|
|
232
|
+
|
|
233
|
+
@logger&.error("SSE connection error: #{e.message}")
|
|
234
|
+
handle_connection_failure
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def handle_open
|
|
238
|
+
@mutex.synchronize do
|
|
239
|
+
@state = StreamingState::CONNECTED
|
|
240
|
+
@consecutive_failures = 0
|
|
241
|
+
@last_heartbeat = Time.now
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
start_heartbeat_monitor
|
|
245
|
+
@logger&.info('Streaming connected')
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def read_events(response)
|
|
249
|
+
event_type = nil
|
|
250
|
+
data_buffer = +''
|
|
251
|
+
|
|
252
|
+
response.read_body do |chunk|
|
|
253
|
+
return if @stop_requested
|
|
254
|
+
|
|
255
|
+
chunk.each_line do |line|
|
|
256
|
+
line = line.strip
|
|
257
|
+
|
|
258
|
+
# Empty line = end of event
|
|
259
|
+
if line.empty?
|
|
260
|
+
if event_type && !data_buffer.empty?
|
|
261
|
+
process_event(event_type, data_buffer)
|
|
262
|
+
event_type = nil
|
|
263
|
+
data_buffer = +''
|
|
264
|
+
end
|
|
265
|
+
next
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Parse SSE format
|
|
269
|
+
if line.start_with?('event:')
|
|
270
|
+
event_type = line[6..].strip
|
|
271
|
+
elsif line.start_with?('data:')
|
|
272
|
+
data_buffer << line[5..].strip
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Connection closed
|
|
278
|
+
handle_connection_failure if @state == StreamingState::CONNECTED
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def process_event(event_type, data)
|
|
282
|
+
case event_type
|
|
283
|
+
when 'flag_updated'
|
|
284
|
+
flag_data = JSON.parse(data)
|
|
285
|
+
flag = Types::FlagState.new(
|
|
286
|
+
key: flag_data['key'],
|
|
287
|
+
value: flag_data['value'],
|
|
288
|
+
enabled: flag_data['enabled'] || true,
|
|
289
|
+
version: flag_data['version'] || 0,
|
|
290
|
+
flag_type: flag_data['flagType'],
|
|
291
|
+
last_modified: flag_data['lastModified']
|
|
292
|
+
)
|
|
293
|
+
@on_flag_update.call(flag)
|
|
294
|
+
|
|
295
|
+
when 'flag_deleted'
|
|
296
|
+
delete_data = JSON.parse(data)
|
|
297
|
+
@on_flag_delete.call(delete_data['key'])
|
|
298
|
+
|
|
299
|
+
when 'flags_reset'
|
|
300
|
+
flags_data = JSON.parse(data)
|
|
301
|
+
flags = flags_data.map do |f|
|
|
302
|
+
Types::FlagState.new(
|
|
303
|
+
key: f['key'],
|
|
304
|
+
value: f['value'],
|
|
305
|
+
enabled: f['enabled'] || true,
|
|
306
|
+
version: f['version'] || 0,
|
|
307
|
+
flag_type: f['flagType'],
|
|
308
|
+
last_modified: f['lastModified']
|
|
309
|
+
)
|
|
310
|
+
end
|
|
311
|
+
@on_flags_reset.call(flags)
|
|
312
|
+
|
|
313
|
+
when 'heartbeat'
|
|
314
|
+
@mutex.synchronize { @last_heartbeat = Time.now }
|
|
315
|
+
|
|
316
|
+
when 'error'
|
|
317
|
+
handle_stream_error(data)
|
|
318
|
+
end
|
|
319
|
+
rescue StandardError => e
|
|
320
|
+
@logger&.warn("Failed to process event #{event_type}: #{e.message}")
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Handles SSE error events from server.
|
|
324
|
+
#
|
|
325
|
+
# Error codes:
|
|
326
|
+
# - TOKEN_INVALID: Re-authenticate completely
|
|
327
|
+
# - TOKEN_EXPIRED: Refresh token and reconnect
|
|
328
|
+
# - SUBSCRIPTION_SUSPENDED: Notify user, fall back to cached values
|
|
329
|
+
# - CONNECTION_LIMIT: Implement backoff or close other connections
|
|
330
|
+
# - STREAMING_UNAVAILABLE: Fall back to polling
|
|
331
|
+
#
|
|
332
|
+
# @param data [String] JSON error data
|
|
333
|
+
def handle_stream_error(data)
|
|
334
|
+
error_data = JSON.parse(data)
|
|
335
|
+
code = error_data['code']
|
|
336
|
+
message = error_data['message']
|
|
337
|
+
|
|
338
|
+
stream_error = StreamErrorData.new(code: code, message: message)
|
|
339
|
+
|
|
340
|
+
@logger&.warn("SSE error event received: code=#{code}, message=#{message}")
|
|
341
|
+
|
|
342
|
+
case code
|
|
343
|
+
when STREAM_ERROR_CODES[:TOKEN_EXPIRED]
|
|
344
|
+
# Token expired, refresh and reconnect
|
|
345
|
+
@logger&.info('Stream token expired, refreshing...')
|
|
346
|
+
cleanup
|
|
347
|
+
connect # Will fetch new token
|
|
348
|
+
|
|
349
|
+
when STREAM_ERROR_CODES[:TOKEN_INVALID]
|
|
350
|
+
# Token is invalid, need full re-authentication
|
|
351
|
+
@logger&.error('Stream token invalid, re-authenticating...')
|
|
352
|
+
cleanup
|
|
353
|
+
connect # Will fetch new token
|
|
354
|
+
|
|
355
|
+
when STREAM_ERROR_CODES[:SUBSCRIPTION_SUSPENDED]
|
|
356
|
+
# Subscription issue - notify and fall back
|
|
357
|
+
@logger&.error("Subscription suspended: #{message}")
|
|
358
|
+
@on_subscription_error&.call(message)
|
|
359
|
+
cleanup
|
|
360
|
+
@mutex.synchronize { @state = StreamingState::FAILED }
|
|
361
|
+
@on_fallback_to_polling.call
|
|
362
|
+
|
|
363
|
+
when STREAM_ERROR_CODES[:CONNECTION_LIMIT]
|
|
364
|
+
# Too many connections - implement backoff
|
|
365
|
+
@logger&.warn('Connection limit reached, backing off...')
|
|
366
|
+
@on_connection_limit_error&.call
|
|
367
|
+
handle_connection_failure
|
|
368
|
+
|
|
369
|
+
when STREAM_ERROR_CODES[:STREAMING_UNAVAILABLE]
|
|
370
|
+
# Streaming not available - fall back to polling
|
|
371
|
+
@logger&.warn('Streaming service unavailable, falling back to polling')
|
|
372
|
+
cleanup
|
|
373
|
+
@mutex.synchronize { @state = StreamingState::FAILED }
|
|
374
|
+
@on_fallback_to_polling.call
|
|
375
|
+
|
|
376
|
+
else
|
|
377
|
+
@logger&.warn("Unknown stream error code: #{code}")
|
|
378
|
+
handle_connection_failure
|
|
379
|
+
end
|
|
380
|
+
rescue JSON::ParserError => e
|
|
381
|
+
@logger&.warn("Failed to parse stream error: #{e.message}")
|
|
382
|
+
handle_connection_failure
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def handle_connection_failure
|
|
386
|
+
@mutex.synchronize do
|
|
387
|
+
cleanup
|
|
388
|
+
@consecutive_failures += 1
|
|
389
|
+
failures = @consecutive_failures
|
|
390
|
+
max_attempts = @config.max_reconnect_attempts
|
|
391
|
+
|
|
392
|
+
if failures >= max_attempts
|
|
393
|
+
@state = StreamingState::FAILED
|
|
394
|
+
@logger&.warn("Streaming failed, falling back to polling. Failures: #{failures}")
|
|
395
|
+
@on_fallback_to_polling.call
|
|
396
|
+
schedule_streaming_retry
|
|
397
|
+
else
|
|
398
|
+
@state = StreamingState::RECONNECTING
|
|
399
|
+
schedule_reconnect
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def schedule_reconnect
|
|
405
|
+
delay = get_reconnect_delay
|
|
406
|
+
@logger&.debug("Scheduling reconnect in #{delay}s, attempt #{@consecutive_failures}")
|
|
407
|
+
|
|
408
|
+
Thread.new do
|
|
409
|
+
sleep(delay)
|
|
410
|
+
connect
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def get_reconnect_delay
|
|
415
|
+
base_delay = @config.reconnect_interval
|
|
416
|
+
backoff = 2**(@consecutive_failures - 1)
|
|
417
|
+
delay = base_delay * backoff
|
|
418
|
+
# Cap at 30 seconds
|
|
419
|
+
[delay, 30.0].min
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def schedule_streaming_retry
|
|
423
|
+
@retry_thread&.kill
|
|
424
|
+
|
|
425
|
+
@retry_thread = Thread.new do
|
|
426
|
+
sleep(300) # 5 minutes
|
|
427
|
+
@logger&.info('Retrying streaming connection')
|
|
428
|
+
retry_connection
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def start_heartbeat_monitor
|
|
433
|
+
stop_heartbeat_monitor
|
|
434
|
+
|
|
435
|
+
check_interval = @config.heartbeat_interval * 1.5
|
|
436
|
+
|
|
437
|
+
@heartbeat_thread = Thread.new do
|
|
438
|
+
sleep(check_interval)
|
|
439
|
+
|
|
440
|
+
time_since = Time.now - @last_heartbeat
|
|
441
|
+
threshold = @config.heartbeat_interval * 2
|
|
442
|
+
|
|
443
|
+
if time_since > threshold
|
|
444
|
+
@logger&.warn("Heartbeat timeout, reconnecting. Time since: #{time_since}s")
|
|
445
|
+
handle_connection_failure
|
|
446
|
+
else
|
|
447
|
+
start_heartbeat_monitor
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def stop_heartbeat_monitor
|
|
453
|
+
@heartbeat_thread&.kill
|
|
454
|
+
@heartbeat_thread = nil
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def cleanup
|
|
458
|
+
@stop_requested = true
|
|
459
|
+
@connection_thread&.kill
|
|
460
|
+
@connection_thread = nil
|
|
461
|
+
@token_refresh_thread&.kill
|
|
462
|
+
@token_refresh_thread = nil
|
|
463
|
+
stop_heartbeat_monitor
|
|
464
|
+
@retry_thread&.kill
|
|
465
|
+
@retry_thread = nil
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlagKit
|
|
4
|
+
# Error codes for FlagKit SDK errors.
|
|
5
|
+
module ErrorCode
|
|
6
|
+
# Initialization errors
|
|
7
|
+
INIT_FAILED = "INIT_FAILED"
|
|
8
|
+
INIT_TIMEOUT = "INIT_TIMEOUT"
|
|
9
|
+
INIT_ALREADY_INITIALIZED = "INIT_ALREADY_INITIALIZED"
|
|
10
|
+
INIT_NOT_INITIALIZED = "INIT_NOT_INITIALIZED"
|
|
11
|
+
|
|
12
|
+
# Authentication errors
|
|
13
|
+
AUTH_INVALID_KEY = "AUTH_INVALID_KEY"
|
|
14
|
+
AUTH_EXPIRED_KEY = "AUTH_EXPIRED_KEY"
|
|
15
|
+
AUTH_MISSING_KEY = "AUTH_MISSING_KEY"
|
|
16
|
+
AUTH_UNAUTHORIZED = "AUTH_UNAUTHORIZED"
|
|
17
|
+
AUTH_PERMISSION_DENIED = "AUTH_PERMISSION_DENIED"
|
|
18
|
+
AUTH_ENVIRONMENT_MISMATCH = "AUTH_ENVIRONMENT_MISMATCH"
|
|
19
|
+
AUTH_IP_RESTRICTED = "AUTH_IP_RESTRICTED"
|
|
20
|
+
AUTH_ORGANIZATION_REQUIRED = "AUTH_ORGANIZATION_REQUIRED"
|
|
21
|
+
AUTH_SUBSCRIPTION_SUSPENDED = "AUTH_SUBSCRIPTION_SUSPENDED"
|
|
22
|
+
|
|
23
|
+
# Network errors
|
|
24
|
+
NETWORK_ERROR = "NETWORK_ERROR"
|
|
25
|
+
NETWORK_TIMEOUT = "NETWORK_TIMEOUT"
|
|
26
|
+
NETWORK_RETRY_LIMIT = "NETWORK_RETRY_LIMIT"
|
|
27
|
+
NETWORK_INVALID_RESPONSE = "NETWORK_INVALID_RESPONSE"
|
|
28
|
+
NETWORK_SERVICE_UNAVAILABLE = "NETWORK_SERVICE_UNAVAILABLE"
|
|
29
|
+
|
|
30
|
+
# Evaluation errors
|
|
31
|
+
EVAL_FLAG_NOT_FOUND = "EVAL_FLAG_NOT_FOUND"
|
|
32
|
+
EVAL_TYPE_MISMATCH = "EVAL_TYPE_MISMATCH"
|
|
33
|
+
EVAL_INVALID_KEY = "EVAL_INVALID_KEY"
|
|
34
|
+
EVAL_INVALID_VALUE = "EVAL_INVALID_VALUE"
|
|
35
|
+
EVAL_DISABLED = "EVAL_DISABLED"
|
|
36
|
+
EVAL_ERROR = "EVAL_ERROR"
|
|
37
|
+
EVAL_CONTEXT_ERROR = "EVAL_CONTEXT_ERROR"
|
|
38
|
+
EVAL_DEFAULT_USED = "EVAL_DEFAULT_USED"
|
|
39
|
+
EVAL_STALE_VALUE = "EVAL_STALE_VALUE"
|
|
40
|
+
EVAL_CACHE_MISS = "EVAL_CACHE_MISS"
|
|
41
|
+
EVAL_NETWORK_ERROR = "EVAL_NETWORK_ERROR"
|
|
42
|
+
EVAL_PARSE_ERROR = "EVAL_PARSE_ERROR"
|
|
43
|
+
EVAL_TIMEOUT_ERROR = "EVAL_TIMEOUT_ERROR"
|
|
44
|
+
|
|
45
|
+
# Cache errors
|
|
46
|
+
CACHE_READ_ERROR = "CACHE_READ_ERROR"
|
|
47
|
+
CACHE_WRITE_ERROR = "CACHE_WRITE_ERROR"
|
|
48
|
+
CACHE_INVALID_DATA = "CACHE_INVALID_DATA"
|
|
49
|
+
CACHE_EXPIRED = "CACHE_EXPIRED"
|
|
50
|
+
CACHE_STORAGE_ERROR = "CACHE_STORAGE_ERROR"
|
|
51
|
+
|
|
52
|
+
# Event errors
|
|
53
|
+
EVENT_QUEUE_FULL = "EVENT_QUEUE_FULL"
|
|
54
|
+
EVENT_INVALID_TYPE = "EVENT_INVALID_TYPE"
|
|
55
|
+
EVENT_INVALID_DATA = "EVENT_INVALID_DATA"
|
|
56
|
+
EVENT_SEND_FAILED = "EVENT_SEND_FAILED"
|
|
57
|
+
EVENT_FLUSH_FAILED = "EVENT_FLUSH_FAILED"
|
|
58
|
+
EVENT_FLUSH_TIMEOUT = "EVENT_FLUSH_TIMEOUT"
|
|
59
|
+
|
|
60
|
+
# Circuit breaker errors
|
|
61
|
+
CIRCUIT_OPEN = "CIRCUIT_OPEN"
|
|
62
|
+
|
|
63
|
+
# Configuration errors
|
|
64
|
+
CONFIG_INVALID_URL = "CONFIG_INVALID_URL"
|
|
65
|
+
CONFIG_INVALID_INTERVAL = "CONFIG_INVALID_INTERVAL"
|
|
66
|
+
CONFIG_MISSING_REQUIRED = "CONFIG_MISSING_REQUIRED"
|
|
67
|
+
CONFIG_INVALID_API_KEY = "CONFIG_INVALID_API_KEY"
|
|
68
|
+
CONFIG_INVALID_BASE_URL = "CONFIG_INVALID_BASE_URL"
|
|
69
|
+
CONFIG_INVALID_POLLING_INTERVAL = "CONFIG_INVALID_POLLING_INTERVAL"
|
|
70
|
+
CONFIG_INVALID_CACHE_TTL = "CONFIG_INVALID_CACHE_TTL"
|
|
71
|
+
CONFIG_INVALID_BOOTSTRAP = "CONFIG_INVALID_BOOTSTRAP"
|
|
72
|
+
|
|
73
|
+
# Security errors
|
|
74
|
+
SECURITY_LOCAL_PORT_IN_PRODUCTION = "SECURITY_LOCAL_PORT_IN_PRODUCTION"
|
|
75
|
+
SECURITY_PII_DETECTED = "SECURITY_PII_DETECTED"
|
|
76
|
+
SECURITY_ENCRYPTION_ERROR = "SECURITY_ENCRYPTION_ERROR"
|
|
77
|
+
|
|
78
|
+
# Streaming errors (1800-1899)
|
|
79
|
+
STREAMING_TOKEN_INVALID = "STREAMING_TOKEN_INVALID"
|
|
80
|
+
STREAMING_TOKEN_EXPIRED = "STREAMING_TOKEN_EXPIRED"
|
|
81
|
+
STREAMING_SUBSCRIPTION_SUSPENDED = "STREAMING_SUBSCRIPTION_SUSPENDED"
|
|
82
|
+
STREAMING_CONNECTION_LIMIT = "STREAMING_CONNECTION_LIMIT"
|
|
83
|
+
STREAMING_UNAVAILABLE = "STREAMING_UNAVAILABLE"
|
|
84
|
+
|
|
85
|
+
RECOVERABLE_CODES = [
|
|
86
|
+
NETWORK_ERROR, NETWORK_TIMEOUT, NETWORK_RETRY_LIMIT,
|
|
87
|
+
NETWORK_SERVICE_UNAVAILABLE,
|
|
88
|
+
CIRCUIT_OPEN, CACHE_EXPIRED, EVAL_STALE_VALUE,
|
|
89
|
+
EVAL_CACHE_MISS, EVAL_NETWORK_ERROR, EVENT_SEND_FAILED,
|
|
90
|
+
STREAMING_TOKEN_INVALID, STREAMING_TOKEN_EXPIRED,
|
|
91
|
+
STREAMING_CONNECTION_LIMIT, STREAMING_UNAVAILABLE
|
|
92
|
+
].freeze
|
|
93
|
+
|
|
94
|
+
def self.recoverable?(code)
|
|
95
|
+
RECOVERABLE_CODES.include?(code)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|