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.
@@ -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