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,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlagKit
4
+ # Sanitizes error messages to remove sensitive information.
5
+ #
6
+ # This utility removes potentially sensitive data from error messages
7
+ # to prevent information leakage in logs, error reports, and user-facing messages.
8
+ module ErrorSanitizer
9
+ # Patterns for sanitizing sensitive information.
10
+ # Each entry is [pattern, replacement].
11
+ PATTERNS = [
12
+ # Unix-style paths
13
+ [%r{/(?:[\w.-]+/)+[\w.-]+}, "[PATH]"],
14
+ # Windows-style paths
15
+ [/[A-Za-z]:\\(?:[\w.-]+\\)+[\w.-]*/, "[PATH]"],
16
+ # IP addresses
17
+ [/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/, "[IP]"],
18
+ # SDK API keys
19
+ [/sdk_[a-zA-Z0-9_-]{8,}/, "sdk_[REDACTED]"],
20
+ # Server API keys
21
+ [/srv_[a-zA-Z0-9_-]{8,}/, "srv_[REDACTED]"],
22
+ # CLI API keys
23
+ [/cli_[a-zA-Z0-9_-]{8,}/, "cli_[REDACTED]"],
24
+ # Email addresses
25
+ [/[\w.-]+@[\w.-]+\.\w+/, "[EMAIL]"],
26
+ # Database connection strings
27
+ [%r{(?:postgres|mysql|mongodb|redis)://[^\s]+}i, "[CONNECTION_STRING]"]
28
+ ].freeze
29
+
30
+ class << self
31
+ # Sanitizes a message by removing sensitive information.
32
+ #
33
+ # @param message [String] The message to sanitize
34
+ # @param enabled [Boolean] Whether sanitization is enabled
35
+ # @return [String] The sanitized message
36
+ def sanitize(message, enabled: true)
37
+ return message unless enabled
38
+ return message if message.nil? || message.empty?
39
+
40
+ result = message.dup
41
+ PATTERNS.each do |pattern, replacement|
42
+ result.gsub!(pattern, replacement)
43
+ end
44
+ result
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlagKit
4
+ # Base exception for all FlagKit SDK errors.
5
+ class FlagKitError < StandardError
6
+ attr_reader :code, :details, :original_message
7
+
8
+ class << self
9
+ # @return [Boolean] Whether error sanitization is enabled
10
+ attr_accessor :sanitization_enabled
11
+
12
+ # @return [Boolean] Whether to preserve the original message
13
+ attr_accessor :preserve_original
14
+ end
15
+
16
+ # Default sanitization settings
17
+ @sanitization_enabled = true
18
+ @preserve_original = false
19
+
20
+ # @param code [String] The error code
21
+ # @param message [String] The error message
22
+ # @param cause [Exception, nil] The underlying cause
23
+ # @param sanitize [Boolean, nil] Override sanitization setting for this error
24
+ def initialize(code, message, cause: nil, sanitize: nil)
25
+ @code = code
26
+ @cause = cause
27
+ @details = {}
28
+
29
+ should_sanitize = sanitize.nil? ? self.class.sanitization_enabled : sanitize
30
+ sanitized_message = FlagKit::ErrorSanitizer.sanitize(message, enabled: should_sanitize)
31
+
32
+ @original_message = message if self.class.preserve_original && should_sanitize
33
+
34
+ super("[#{code}] #{sanitized_message}")
35
+ end
36
+
37
+ # @return [Boolean] Whether the error is recoverable
38
+ def recoverable?
39
+ ErrorCode.recoverable?(code)
40
+ end
41
+
42
+ # @return [Exception, nil] The underlying cause
43
+ def cause
44
+ @cause || super
45
+ end
46
+
47
+ # Adds details to the error.
48
+ #
49
+ # @param details [Hash] The details to add
50
+ # @return [self]
51
+ def with_details(details)
52
+ @details.merge!(details)
53
+ self
54
+ end
55
+
56
+ class << self
57
+ # Creates an initialization error.
58
+ def init_error(message)
59
+ new(ErrorCode::INIT_FAILED, message)
60
+ end
61
+
62
+ # Creates an authentication error.
63
+ def auth_error(code, message)
64
+ new(code, message)
65
+ end
66
+
67
+ # Creates a network error.
68
+ def network_error(message, cause: nil)
69
+ new(ErrorCode::NETWORK_ERROR, message, cause: cause)
70
+ end
71
+
72
+ # Creates an evaluation error.
73
+ def eval_error(code, message)
74
+ new(code, message)
75
+ end
76
+
77
+ # Creates a configuration error.
78
+ def config_error(code, message)
79
+ new(code, message)
80
+ end
81
+
82
+ # Creates a security error.
83
+ def security_error(code, message)
84
+ new(code, message)
85
+ end
86
+ end
87
+ end
88
+
89
+ # Alias for backward compatibility - use Error as the exception class name
90
+ Error = FlagKitError
91
+
92
+ # SecurityError for strict PII mode and other security violations
93
+ class SecurityError < FlagKitError
94
+ end
95
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlagKit
4
+ module Http
5
+ # Circuit breaker pattern implementation for resilient HTTP calls.
6
+ class CircuitBreaker
7
+ # Circuit breaker states
8
+ module State
9
+ CLOSED = :closed
10
+ OPEN = :open
11
+ HALF_OPEN = :half_open
12
+ end
13
+
14
+ attr_reader :state, :failure_threshold, :reset_timeout
15
+
16
+ # @param failure_threshold [Integer] Number of failures before opening
17
+ # @param reset_timeout [Integer] Seconds to wait before half-open
18
+ def initialize(failure_threshold: 5, reset_timeout: 30)
19
+ @failure_threshold = failure_threshold
20
+ @reset_timeout = reset_timeout
21
+ @state = State::CLOSED
22
+ @failure_count = 0
23
+ @last_failure_time = nil
24
+ @mutex = Mutex.new
25
+ end
26
+
27
+ # Checks if the circuit allows requests.
28
+ #
29
+ # @return [Boolean]
30
+ def allow_request?
31
+ @mutex.synchronize do
32
+ case @state
33
+ when State::CLOSED
34
+ true
35
+ when State::OPEN
36
+ check_reset_timeout
37
+ @state != State::OPEN
38
+ when State::HALF_OPEN
39
+ true
40
+ end
41
+ end
42
+ end
43
+
44
+ # Records a successful request.
45
+ def record_success
46
+ @mutex.synchronize do
47
+ @failure_count = 0
48
+ @state = State::CLOSED
49
+ end
50
+ end
51
+
52
+ # Records a failed request.
53
+ def record_failure
54
+ @mutex.synchronize do
55
+ @failure_count += 1
56
+ @last_failure_time = Time.now
57
+
58
+ if @failure_count >= failure_threshold
59
+ @state = State::OPEN
60
+ end
61
+ end
62
+ end
63
+
64
+ # Checks if the circuit is open.
65
+ #
66
+ # @return [Boolean]
67
+ def open?
68
+ @mutex.synchronize do
69
+ check_reset_timeout
70
+ @state == State::OPEN
71
+ end
72
+ end
73
+
74
+ # Checks if the circuit is closed.
75
+ #
76
+ # @return [Boolean]
77
+ def closed?
78
+ @mutex.synchronize do
79
+ @state == State::CLOSED
80
+ end
81
+ end
82
+
83
+ # Checks if the circuit is half-open.
84
+ #
85
+ # @return [Boolean]
86
+ def half_open?
87
+ @mutex.synchronize do
88
+ check_reset_timeout
89
+ @state == State::HALF_OPEN
90
+ end
91
+ end
92
+
93
+ # Resets the circuit breaker to closed state.
94
+ def reset
95
+ @mutex.synchronize do
96
+ @state = State::CLOSED
97
+ @failure_count = 0
98
+ @last_failure_time = nil
99
+ end
100
+ end
101
+
102
+ # Returns the current failure count.
103
+ #
104
+ # @return [Integer]
105
+ def failure_count
106
+ @mutex.synchronize do
107
+ @failure_count
108
+ end
109
+ end
110
+
111
+ # Executes a block with circuit breaker protection.
112
+ #
113
+ # @yield The block to execute
114
+ # @return [Object] The block result
115
+ # @raise [Error] If the circuit is open
116
+ def call
117
+ unless allow_request?
118
+ raise FlagKit::Error.new(ErrorCode::CIRCUIT_OPEN, "Circuit breaker is open")
119
+ end
120
+
121
+ begin
122
+ result = yield
123
+ record_success
124
+ result
125
+ rescue StandardError => e
126
+ record_failure
127
+ raise e
128
+ end
129
+ end
130
+
131
+ private
132
+
133
+ def check_reset_timeout
134
+ return unless @state == State::OPEN && @last_failure_time
135
+
136
+ if Time.now - @last_failure_time >= reset_timeout
137
+ @state = State::HALF_OPEN
138
+ end
139
+ end
140
+ end
141
+ end
142
+
143
+ # Alias for backward compatibility
144
+ CircuitBreaker = Http::CircuitBreaker
145
+ end
@@ -0,0 +1,312 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module FlagKit
7
+ module Http
8
+ # Usage metrics extracted from response headers.
9
+ #
10
+ # Contains information about API usage limits and subscription status.
11
+ class UsageMetrics
12
+ # @return [Float, nil] Percentage of API call limit used this period (0-150+)
13
+ attr_reader :api_usage_percent
14
+
15
+ # @return [Float, nil] Percentage of evaluation limit used (0-150+)
16
+ attr_reader :evaluation_usage_percent
17
+
18
+ # @return [Boolean] Whether approaching rate limit threshold
19
+ attr_reader :rate_limit_warning
20
+
21
+ # @return [String, nil] Current subscription status: active, trial, past_due, suspended, cancelled
22
+ attr_reader :subscription_status
23
+
24
+ VALID_SUBSCRIPTION_STATUSES = %w[active trial past_due suspended cancelled].freeze
25
+
26
+ # @param api_usage_percent [Float, nil] API usage percentage
27
+ # @param evaluation_usage_percent [Float, nil] Evaluation usage percentage
28
+ # @param rate_limit_warning [Boolean] Rate limit warning flag
29
+ # @param subscription_status [String, nil] Subscription status
30
+ def initialize(api_usage_percent: nil, evaluation_usage_percent: nil, rate_limit_warning: false, subscription_status: nil)
31
+ @api_usage_percent = api_usage_percent
32
+ @evaluation_usage_percent = evaluation_usage_percent
33
+ @rate_limit_warning = rate_limit_warning
34
+ @subscription_status = subscription_status if subscription_status.nil? || VALID_SUBSCRIPTION_STATUSES.include?(subscription_status)
35
+ end
36
+ end
37
+
38
+ # HTTP client with retry logic, circuit breaker integration,
39
+ # request signing, and key rotation support.
40
+ class HttpClient
41
+ BASE_URL = "https://api.flagkit.dev/api/v1"
42
+ BASE_RETRY_DELAY = 1.0
43
+ MAX_RETRY_DELAY = 30.0
44
+ RETRY_MULTIPLIER = 2.0
45
+ JITTER_FACTOR = 0.1
46
+
47
+ attr_reader :timeout, :retry_attempts, :circuit_breaker
48
+
49
+ # Returns the base URL for the given local port, or the default production URL.
50
+ #
51
+ # @param local_port [Integer, nil] The local port number
52
+ # @return [String] The base URL
53
+ def self.get_base_url(local_port)
54
+ local_port ? "http://localhost:#{local_port}/api/v1" : BASE_URL
55
+ end
56
+
57
+ # Returns the currently active API key.
58
+ #
59
+ # @return [String] The current API key
60
+ def api_key
61
+ @current_api_key
62
+ end
63
+
64
+ # Returns the key identifier for the current API key.
65
+ #
66
+ # @return [String] The key ID (first 8 characters)
67
+ def key_id
68
+ Utils::Security.get_key_id(@current_api_key)
69
+ end
70
+
71
+ # Checks if key rotation is currently active.
72
+ #
73
+ # @return [Boolean] true if within the key rotation grace period
74
+ def in_key_rotation?
75
+ return false unless @key_rotation_timestamp
76
+
77
+ elapsed = Time.now - @key_rotation_timestamp
78
+ elapsed < @key_rotation_grace_period
79
+ end
80
+
81
+ # @param api_key [String] The API key
82
+ # @param timeout [Integer] Request timeout in seconds
83
+ # @param retry_attempts [Integer] Number of retry attempts
84
+ # @param circuit_breaker [CircuitBreaker] The circuit breaker
85
+ # @param logger [Object, nil] Logger instance
86
+ # @param local_port [Integer, nil] Local development server port
87
+ # @param secondary_api_key [String, nil] Secondary API key for rotation
88
+ # @param key_rotation_grace_period [Integer] Grace period in seconds
89
+ # @param enable_request_signing [Boolean] Enable HMAC-SHA256 request signing
90
+ # @param on_usage_update [Proc, nil] Callback for usage metrics updates
91
+ def initialize(
92
+ api_key:,
93
+ timeout:,
94
+ retry_attempts:,
95
+ circuit_breaker:,
96
+ logger: nil,
97
+ local_port: nil,
98
+ secondary_api_key: nil,
99
+ key_rotation_grace_period: 300,
100
+ enable_request_signing: true,
101
+ on_usage_update: nil
102
+ )
103
+ @base_url = self.class.get_base_url(local_port)
104
+ @primary_api_key = api_key
105
+ @secondary_api_key = secondary_api_key
106
+ @current_api_key = api_key
107
+ @key_rotation_grace_period = key_rotation_grace_period
108
+ @key_rotation_timestamp = nil
109
+ @enable_request_signing = enable_request_signing
110
+ @timeout = timeout
111
+ @retry_attempts = retry_attempts
112
+ @circuit_breaker = circuit_breaker
113
+ @logger = logger
114
+ @on_usage_update = on_usage_update
115
+ @connection = build_connection
116
+ end
117
+
118
+ # Makes a GET request.
119
+ #
120
+ # @param path [String] The request path
121
+ # @param params [Hash] Query parameters
122
+ # @return [Hash] The response body
123
+ def get(path, params = {})
124
+ request(:get, path, params: params)
125
+ end
126
+
127
+ # Makes a POST request with automatic request signing.
128
+ #
129
+ # @param path [String] The request path
130
+ # @param body [Hash] The request body
131
+ # @return [Hash] The response body
132
+ def post(path, body = {})
133
+ signing_headers = {}
134
+
135
+ if @enable_request_signing && !body.empty?
136
+ body_string = body.to_json
137
+ sig_data = Utils::Security.create_request_signature(body_string, @current_api_key)
138
+ signing_headers["X-Signature"] = sig_data[:signature]
139
+ signing_headers["X-Timestamp"] = sig_data[:timestamp].to_s
140
+ signing_headers["X-Key-Id"] = sig_data[:key_id]
141
+ end
142
+
143
+ request(:post, path, body: body, extra_headers: signing_headers)
144
+ end
145
+
146
+ private
147
+
148
+ # Rotates to the secondary API key on authentication failure.
149
+ #
150
+ # @return [Boolean] true if rotation was performed
151
+ def rotate_to_secondary_key
152
+ return false unless @secondary_api_key
153
+ return false if @current_api_key == @secondary_api_key
154
+
155
+ log(:info, "Rotating to secondary API key due to authentication failure")
156
+ @current_api_key = @secondary_api_key
157
+ @key_rotation_timestamp = Time.now
158
+ rebuild_connection
159
+ true
160
+ end
161
+
162
+ def rebuild_connection
163
+ @connection = build_connection
164
+ end
165
+
166
+ def build_connection
167
+ Faraday.new(url: @base_url) do |conn|
168
+ conn.options.timeout = timeout
169
+ conn.options.open_timeout = timeout
170
+ conn.headers["Content-Type"] = "application/json"
171
+ conn.headers["Accept"] = "application/json"
172
+ conn.headers["X-API-Key"] = @current_api_key
173
+ conn.headers["User-Agent"] = "FlagKit-Ruby/#{VERSION}"
174
+ conn.headers["X-FlagKit-SDK-Version"] = VERSION
175
+ conn.headers["X-FlagKit-SDK-Language"] = "ruby"
176
+ conn.adapter Faraday.default_adapter
177
+ end
178
+ end
179
+
180
+ def request(method, path, params: nil, body: nil, extra_headers: {})
181
+ unless circuit_breaker.allow_request?
182
+ raise FlagKit::Error.new(ErrorCode::CIRCUIT_OPEN, "Circuit breaker is open")
183
+ end
184
+
185
+ attempts = 0
186
+ last_error = nil
187
+
188
+ loop do
189
+ attempts += 1
190
+ begin
191
+ response = execute_request(method, path, params, body, extra_headers)
192
+ circuit_breaker.record_success
193
+ return parse_response(response)
194
+ rescue Faraday::TimeoutError => e
195
+ last_error = FlagKit::Error.network_error("Request timed out", cause: e)
196
+ rescue Faraday::ConnectionFailed => e
197
+ last_error = FlagKit::Error.network_error("Connection failed: #{e.message}", cause: e)
198
+ rescue FlagKit::Error => e
199
+ last_error = e
200
+
201
+ # Handle 401 errors with key rotation
202
+ if e.code == ErrorCode::AUTH_INVALID_KEY && @secondary_api_key
203
+ if rotate_to_secondary_key
204
+ log(:debug, "Retrying request with secondary API key")
205
+ attempts -= 1 # Don't count rotation retry against attempt limit
206
+ next
207
+ end
208
+ end
209
+
210
+ # Don't retry on non-recoverable errors
211
+ raise e unless e.recoverable?
212
+ rescue StandardError => e
213
+ last_error = FlagKit::Error.network_error("Request failed: #{e.message}", cause: e)
214
+ end
215
+
216
+ if attempts >= retry_attempts
217
+ circuit_breaker.record_failure
218
+ raise last_error
219
+ end
220
+
221
+ sleep(calculate_backoff(attempts))
222
+ end
223
+ end
224
+
225
+ def execute_request(method, path, params, body, extra_headers = {})
226
+ @connection.run_request(method, path, body&.to_json, nil) do |req|
227
+ req.params.update(params) if params
228
+ extra_headers.each { |k, v| req.headers[k] = v }
229
+ end
230
+ end
231
+
232
+ def parse_response(response)
233
+ # Extract and process usage metrics from headers
234
+ usage_metrics = extract_usage_metrics(response.headers)
235
+ if usage_metrics && @on_usage_update
236
+ @on_usage_update.call(usage_metrics)
237
+ end
238
+
239
+ case response.status
240
+ when 200..299
241
+ return {} if response.body.nil? || response.body.empty?
242
+
243
+ JSON.parse(response.body)
244
+ when 401
245
+ raise FlagKit::Error.auth_error(ErrorCode::AUTH_INVALID_KEY, "Invalid API key")
246
+ when 403
247
+ raise FlagKit::Error.auth_error(ErrorCode::AUTH_PERMISSION_DENIED, "Permission denied")
248
+ when 404
249
+ raise FlagKit::Error.new(ErrorCode::EVAL_FLAG_NOT_FOUND, "Resource not found")
250
+ when 429
251
+ raise FlagKit::Error.new(ErrorCode::NETWORK_RETRY_LIMIT, "Rate limit exceeded")
252
+ when 500..599
253
+ raise FlagKit::Error.network_error("Server error: #{response.status}")
254
+ else
255
+ raise FlagKit::Error.network_error("Unexpected response status: #{response.status}")
256
+ end
257
+ end
258
+
259
+ # Extracts usage metrics from response headers.
260
+ #
261
+ # @param headers [Hash] Response headers
262
+ # @return [UsageMetrics, nil] Usage metrics if any usage headers present
263
+ def extract_usage_metrics(headers)
264
+ api_usage = headers["x-api-usage-percent"]
265
+ eval_usage = headers["x-evaluation-usage-percent"]
266
+ rate_limit_warning = headers["x-rate-limit-warning"]
267
+ subscription_status = headers["x-subscription-status"]
268
+
269
+ # Return nil if no usage headers present
270
+ return nil unless api_usage || eval_usage || rate_limit_warning || subscription_status
271
+
272
+ api_usage_percent = api_usage ? (Float(api_usage) rescue nil) : nil
273
+ evaluation_usage_percent = eval_usage ? (Float(eval_usage) rescue nil) : nil
274
+ warning_flag = rate_limit_warning == "true"
275
+
276
+ # Log warnings for high usage
277
+ if api_usage_percent && api_usage_percent >= 80
278
+ log(:warn, "API usage at #{api_usage_percent}%")
279
+ end
280
+ if evaluation_usage_percent && evaluation_usage_percent >= 80
281
+ log(:warn, "Evaluation usage at #{evaluation_usage_percent}%")
282
+ end
283
+ if subscription_status == "suspended"
284
+ log(:error, "Subscription suspended - service degraded")
285
+ end
286
+
287
+ UsageMetrics.new(
288
+ api_usage_percent: api_usage_percent,
289
+ evaluation_usage_percent: evaluation_usage_percent,
290
+ rate_limit_warning: warning_flag,
291
+ subscription_status: subscription_status
292
+ )
293
+ end
294
+
295
+ def calculate_backoff(attempt)
296
+ delay = BASE_RETRY_DELAY * (RETRY_MULTIPLIER**(attempt - 1))
297
+ delay = [delay, MAX_RETRY_DELAY].min
298
+ jitter = delay * JITTER_FACTOR * rand
299
+ delay + jitter
300
+ end
301
+
302
+ def log(level, message)
303
+ return unless @logger
304
+
305
+ @logger.send(level, "[FlagKit::HttpClient] #{message}")
306
+ end
307
+ end
308
+ end
309
+
310
+ # Alias for backward compatibility
311
+ HttpClient = Http::HttpClient
312
+ end