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,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
|