lightrate-client 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,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/retry"
5
+ require "json"
6
+ require "time"
7
+
8
+ module LightrateClient
9
+ class Client
10
+ attr_reader :configuration, :token_buckets
11
+
12
+ def initialize(api_key = nil, application_id = nil, options = {})
13
+ if api_key
14
+ # Create a new configuration with the provided API key and application ID
15
+ @configuration = LightrateClient::Configuration.new.tap do |c|
16
+ c.api_key = api_key
17
+ c.application_id = application_id
18
+ c.timeout = options[:timeout] || LightrateClient.configuration.timeout
19
+ c.retry_attempts = options[:retry_attempts] || LightrateClient.configuration.retry_attempts
20
+ c.logger = options[:logger] || LightrateClient.configuration.logger
21
+ c.default_local_bucket_size = options[:default_local_bucket_size] || LightrateClient.configuration.default_local_bucket_size
22
+ end
23
+ else
24
+ @configuration = options.is_a?(LightrateClient::Configuration) ? options : LightrateClient.configuration
25
+ end
26
+
27
+
28
+ validate_configuration!
29
+ setup_connection
30
+ setup_token_buckets
31
+ end
32
+
33
+ # Consume tokens by operation or path using local bucket
34
+ # @param operation [String, nil] The operation name (mutually exclusive with path)
35
+ # @param path [String, nil] The API path (mutually exclusive with operation)
36
+ # @param http_method [String, nil] The HTTP method (required when path is provided)
37
+ # @param user_identifier [String] The user identifier
38
+ # @param tokens_requested [Integer] Number of tokens to consume
39
+ def consume_local_bucket_token(operation: nil, path: nil, http_method: nil, user_identifier:)
40
+ # Get or create bucket for this user/operation/path combination
41
+ bucket = get_or_create_bucket(user_identifier, operation, path, http_method)
42
+
43
+ # Use the bucket's mutex to synchronize the entire operation
44
+ # This prevents race conditions between multiple threads trying to consume from the same bucket
45
+ bucket.synchronize do
46
+ # Try to consume a token atomically first
47
+ has_tokens, consumed_successfully = bucket.check_and_consume_token
48
+
49
+ # If we successfully consumed a local token, return success
50
+ if consumed_successfully
51
+ return LightrateClient::ConsumeLocalBucketTokenResponse.new(
52
+ success: true,
53
+ used_local_token: true,
54
+ bucket_status: bucket.status
55
+ )
56
+ end
57
+
58
+ # No local tokens available, need to fetch from API
59
+ tokens_to_fetch = get_bucket_size_for_operation(operation, path)
60
+
61
+ # Make API call
62
+ request = LightrateClient::ConsumeTokensRequest.new(
63
+ application_id: @configuration.application_id,
64
+ operation: operation,
65
+ path: path,
66
+ http_method: http_method,
67
+ user_identifier: user_identifier,
68
+ tokens_requested: tokens_to_fetch
69
+ )
70
+
71
+ # Make the API call
72
+ response = post("/api/v1/tokens/consume", request.to_h)
73
+ tokens_consumed = response['tokensConsumed']&.to_i || 0
74
+
75
+ # If we got tokens from API, refill the bucket and try to consume
76
+ if tokens_consumed > 0
77
+ tokens_added, has_tokens_after_refill = bucket.refill_and_check(tokens_consumed)
78
+
79
+ # Try to consume a token after refilling
80
+ _, final_consumed = bucket.check_and_consume_token
81
+
82
+ return LightrateClient::ConsumeLocalBucketTokenResponse.new(
83
+ success: final_consumed,
84
+ used_local_token: false,
85
+ bucket_status: bucket.status
86
+ )
87
+ else
88
+ # No tokens available from API
89
+ return LightrateClient::ConsumeLocalBucketTokenResponse.new(
90
+ success: false,
91
+ used_local_token: false,
92
+ bucket_status: bucket.status
93
+ )
94
+ end
95
+ end
96
+ end
97
+
98
+ def consume_tokens(operation: nil, path: nil, http_method: nil, user_identifier:, tokens_requested:)
99
+ request = LightrateClient::ConsumeTokensRequest.new(
100
+ application_id: @configuration.application_id,
101
+ operation: operation,
102
+ path: path,
103
+ http_method: http_method,
104
+ user_identifier: user_identifier,
105
+ tokens_requested: tokens_requested
106
+ )
107
+ consume_tokens_with_request(request)
108
+ end
109
+
110
+ private
111
+
112
+ # Consume tokens from the token bucket using a request object
113
+ # @param request [ConsumeTokensRequest] The token consumption request
114
+ # @return [ConsumeTokensResponse] The response indicating success/failure and remaining tokens
115
+ def consume_tokens_with_request(request)
116
+ raise ArgumentError, "Invalid request" unless request.is_a?(LightrateClient::ConsumeTokensRequest)
117
+ raise ArgumentError, "Request validation failed" unless request.valid?
118
+
119
+ response = post("/api/v1/tokens/consume", request.to_h)
120
+ LightrateClient::ConsumeTokensResponse.from_hash(response)
121
+ end
122
+
123
+ def setup_token_buckets
124
+ @token_buckets = {}
125
+ @buckets_mutex = Mutex.new
126
+ end
127
+
128
+ def get_or_create_bucket(user_identifier, operation, path, http_method = nil)
129
+ # Create a unique key for this user/operation/path combination
130
+ bucket_key = create_bucket_key(user_identifier, operation, path, http_method)
131
+
132
+ # Double-checked locking pattern for thread-safe bucket creation
133
+ return @token_buckets[bucket_key] if @token_buckets[bucket_key]
134
+
135
+ @buckets_mutex.synchronize do
136
+ # Check again inside the mutex to prevent duplicate creation
137
+ @token_buckets[bucket_key] ||= begin
138
+ bucket_size = get_bucket_size_for_operation(operation, path)
139
+ TokenBucket.new(bucket_size)
140
+ end
141
+ end
142
+
143
+ @token_buckets[bucket_key]
144
+ end
145
+
146
+ def get_bucket_size_for_operation(operation, path)
147
+ # Always use the default bucket size for all operations and paths
148
+ @configuration.default_local_bucket_size
149
+ end
150
+
151
+ def create_bucket_key(user_identifier, operation, path, http_method = nil)
152
+ # Create a unique key that combines user, operation, and path
153
+ if operation
154
+ "#{user_identifier}:operation:#{operation}"
155
+ elsif path
156
+ "#{user_identifier}:path:#{path}:#{http_method}"
157
+ else
158
+ raise ArgumentError, "Either operation or path must be specified"
159
+ end
160
+ end
161
+
162
+ def validate_configuration!
163
+ raise ConfigurationError, "API key is required" unless configuration.api_key
164
+ raise ConfigurationError, "Application ID is required" unless configuration.application_id
165
+ end
166
+
167
+ def setup_connection
168
+ @connection = Faraday.new(url: "https://api.lightrate.lightbournetechnologies.ca") do |conn|
169
+ conn.request :json
170
+ conn.response :json, content_type: /\bjson$/
171
+ conn.response :logger, configuration.logger if configuration.logger
172
+ conn.use Faraday::Retry::Middleware, retry_options
173
+ conn.adapter Faraday.default_adapter
174
+ end
175
+ end
176
+
177
+ def retry_options
178
+ {
179
+ max: configuration.retry_attempts,
180
+ interval: 0.5,
181
+ backoff_factor: 2,
182
+ retry_if: ->(env, _exception) { should_retry?(env) }
183
+ }
184
+ end
185
+
186
+ def should_retry?(env)
187
+ status = env.status
188
+ [429, 500, 502, 503, 504].include?(status)
189
+ end
190
+
191
+ def get(path, params = {})
192
+ request(:get, path, params: params)
193
+ end
194
+
195
+ def post(path, body = {})
196
+ request(:post, path, body: body)
197
+ end
198
+
199
+ def request(method, path, **options)
200
+ response = @connection.public_send(method, path) do |req|
201
+ req.headers["Authorization"] = "Bearer #{configuration.api_key}"
202
+ req.headers["User-Agent"] = "lightrate-client-ruby/#{VERSION}"
203
+ req.headers["Accept"] = "application/json"
204
+ req.headers["Content-Type"] = "application/json"
205
+
206
+ req.params.merge!(options[:params]) if options[:params]
207
+ req.body = options[:body].to_json if options[:body]
208
+
209
+ req.options.timeout = configuration.timeout
210
+ end
211
+
212
+ handle_response(response)
213
+ rescue Faraday::TimeoutError
214
+ raise TimeoutError, "Request timed out after #{configuration.timeout} seconds"
215
+ rescue Faraday::ConnectionFailed, Faraday::SSLError => e
216
+ raise NetworkError, "Network error: #{e.message}"
217
+ end
218
+
219
+ def handle_response(response)
220
+ case response.status
221
+ when 200..299
222
+ response.body
223
+ when 400
224
+ raise BadRequestError.new("Bad Request", response.status, response.body)
225
+ when 401
226
+ raise UnauthorizedError.new("Unauthorized", response.status, response.body)
227
+ when 403
228
+ raise ForbiddenError.new("Forbidden", response.status, response.body)
229
+ when 404
230
+ raise NotFoundError.new("Not Found", response.status, response.body)
231
+ when 422
232
+ raise UnprocessableEntityError.new("Unprocessable Entity", response.status, response.body)
233
+ when 429
234
+ raise TooManyRequestsError.new("Too Many Requests", response.status, response.body)
235
+ when 500
236
+ raise InternalServerError.new("Internal Server Error", response.status, response.body)
237
+ when 503
238
+ raise ServiceUnavailableError.new("Service Unavailable", response.status, response.body)
239
+ else
240
+ raise APIError.new("API Error: #{response.status}", response.status, response.body)
241
+ end
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LightrateClient
4
+ class Configuration
5
+ attr_accessor :api_key, :application_id, :timeout, :retry_attempts, :logger, :default_local_bucket_size
6
+
7
+ def initialize
8
+ @timeout = 30
9
+ @retry_attempts = 3
10
+ @logger = nil
11
+ @default_local_bucket_size = 5
12
+ end
13
+
14
+ def valid?
15
+ api_key && application_id
16
+ end
17
+
18
+ def to_h
19
+ {
20
+ api_key: "******",
21
+ application_id: application_id,
22
+ timeout: timeout,
23
+ retry_attempts: retry_attempts,
24
+ logger: logger,
25
+ default_local_bucket_size: default_local_bucket_size
26
+ }
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LightrateClient
4
+ class Error < StandardError; end
5
+
6
+ class ConfigurationError < Error; end
7
+
8
+ class AuthenticationError < Error; end
9
+
10
+ class APIError < Error
11
+ attr_reader :status_code, :response_body
12
+
13
+ def initialize(message, status_code = nil, response_body = nil)
14
+ super(message)
15
+ @status_code = status_code
16
+ @response_body = response_body
17
+ end
18
+ end
19
+
20
+ class BadRequestError < APIError; end
21
+ class UnauthorizedError < APIError; end
22
+ class ForbiddenError < APIError; end
23
+ class NotFoundError < APIError; end
24
+ class UnprocessableEntityError < APIError; end
25
+ class TooManyRequestsError < APIError; end
26
+ class InternalServerError < APIError; end
27
+ class ServiceUnavailableError < APIError; end
28
+
29
+ class NetworkError < Error; end
30
+ class TimeoutError < Error; end
31
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LightrateClient
4
+ # Request types
5
+ class ConsumeTokensRequest
6
+ attr_accessor :application_id, :operation, :path, :http_method, :user_identifier, :tokens_requested, :timestamp
7
+
8
+ def initialize(application_id:, operation: nil, path: nil, http_method: nil, user_identifier:, tokens_requested:, timestamp: nil)
9
+ @application_id = application_id
10
+ @operation = operation
11
+ @path = path
12
+ @http_method = http_method
13
+ @user_identifier = user_identifier
14
+ @tokens_requested = tokens_requested
15
+ @timestamp = timestamp || Time.now
16
+ end
17
+
18
+ def to_h
19
+ {
20
+ applicationId: @application_id,
21
+ operation: @operation,
22
+ path: @path,
23
+ httpMethod: @http_method,
24
+ userIdentifier: @user_identifier,
25
+ tokensRequested: @tokens_requested,
26
+ timestamp: @timestamp
27
+ }.compact
28
+ end
29
+
30
+ def valid?
31
+ return false if @application_id.nil? || @application_id.empty?
32
+ return false if @user_identifier.nil? || @user_identifier.empty?
33
+ return false if @tokens_requested.nil? || @tokens_requested <= 0
34
+ return false if @operation.nil? && @path.nil?
35
+ return false if @operation && @path
36
+ return false if @path && @http_method.nil?
37
+
38
+ true
39
+ end
40
+ end
41
+
42
+ # Response types
43
+ class ConsumeTokensResponse
44
+ attr_reader :tokens_remaining, :tokens_consumed, :throttles, :rule
45
+
46
+ def initialize(tokens_remaining:, tokens_consumed:, throttles: 0, rule: nil)
47
+ @tokens_remaining = tokens_remaining
48
+ @tokens_consumed = tokens_consumed
49
+ @throttles = throttles
50
+ @rule = rule
51
+ end
52
+
53
+ def self.from_hash(hash)
54
+ rule = nil
55
+ if hash['rule'] || hash[:rule]
56
+ rule_hash = hash['rule'] || hash[:rule]
57
+ rule = Rule.from_hash(rule_hash)
58
+ end
59
+
60
+ new(
61
+ tokens_remaining: hash['tokensRemaining'] || hash[:tokens_remaining],
62
+ tokens_consumed: hash['tokensConsumed'] || hash[:tokens_consumed],
63
+ throttles: hash['throttles'] || hash[:throttles] || 0,
64
+ rule: rule
65
+ )
66
+ end
67
+ end
68
+
69
+ class ConsumeLocalBucketTokenResponse
70
+ attr_reader :success, :used_local_token, :bucket_status
71
+
72
+ def initialize(success:, used_local_token: false, bucket_status: nil)
73
+ @success = success
74
+ @used_local_token = used_local_token
75
+ @bucket_status = bucket_status
76
+ end
77
+
78
+ # Indicates if this request required fetching tokens from the server
79
+ def required_fetch?
80
+ !@used_local_token
81
+ end
82
+
83
+ # Indicates if there were no more tokens available locally before this request
84
+ def was_bucket_empty?
85
+ !@used_local_token
86
+ end
87
+ end
88
+
89
+ class Rule
90
+ attr_reader :id, :name, :refill_rate, :burst_rate, :is_default
91
+
92
+ def initialize(id:, name:, refill_rate:, burst_rate:, is_default: false)
93
+ @id = id
94
+ @name = name
95
+ @refill_rate = refill_rate
96
+ @burst_rate = burst_rate
97
+ @is_default = is_default
98
+ end
99
+
100
+ def self.from_hash(hash)
101
+ new(
102
+ id: hash['id'] || hash[:id],
103
+ name: hash['name'] || hash[:name],
104
+ refill_rate: hash['refillRate'] || hash[:refill_rate],
105
+ burst_rate: hash['burstRate'] || hash[:burst_rate],
106
+ is_default: hash['isDefault'] || hash[:is_default] || false
107
+ )
108
+ end
109
+ end
110
+
111
+ # Token bucket for local token management
112
+ class TokenBucket
113
+ attr_reader :available_tokens, :max_tokens
114
+
115
+ def initialize(max_tokens)
116
+ @max_tokens = max_tokens
117
+ @available_tokens = 0
118
+ @mutex = Mutex.new
119
+ end
120
+
121
+ # Check if tokens are available locally (caller must hold lock)
122
+ # @return [Boolean] true if tokens are available
123
+ def has_tokens?
124
+ @available_tokens > 0
125
+ end
126
+
127
+ # Consume one token from the bucket (caller must hold lock)
128
+ # @return [Boolean] true if token was consumed, false if no tokens available
129
+ def consume_token
130
+ return false if @available_tokens <= 0
131
+
132
+ @available_tokens -= 1
133
+ true
134
+ end
135
+
136
+ # Consume multiple tokens from the bucket (caller must hold lock)
137
+ # @param count [Integer] Number of tokens to consume
138
+ # @return [Integer] Number of tokens actually consumed
139
+ def consume_tokens(count)
140
+ return 0 if count <= 0 || @available_tokens <= 0
141
+
142
+ tokens_to_consume = [count, @available_tokens].min
143
+ @available_tokens -= tokens_to_consume
144
+ tokens_to_consume
145
+ end
146
+
147
+ # Refill the bucket with tokens from the server (caller must hold lock)
148
+ # @param tokens_to_fetch [Integer] Number of tokens to fetch
149
+ # @return [Integer] Number of tokens actually added to the bucket
150
+ def refill(tokens_to_fetch)
151
+ tokens_to_add = [tokens_to_fetch, @max_tokens - @available_tokens].min
152
+ @available_tokens += tokens_to_add
153
+ tokens_to_add
154
+ end
155
+
156
+ # Get current bucket status (caller must hold lock)
157
+ # @return [Hash] Current bucket status with tokens_remaining and max_tokens
158
+ def status
159
+ {
160
+ tokens_remaining: @available_tokens,
161
+ max_tokens: @max_tokens
162
+ }
163
+ end
164
+
165
+ # Reset bucket to empty state (caller must hold lock)
166
+ def reset
167
+ @available_tokens = 0
168
+ end
169
+
170
+ # Check tokens and consume atomically (caller must hold lock)
171
+ # This prevents race conditions between checking and consuming
172
+ # @return [Array] [has_tokens, consumed_successfully]
173
+ def check_and_consume_token
174
+ has_tokens = @available_tokens > 0
175
+ if has_tokens
176
+ @available_tokens -= 1
177
+ [true, true]
178
+ else
179
+ [false, false]
180
+ end
181
+ end
182
+
183
+ # Refill and check tokens atomically (caller must hold lock)
184
+ # @param tokens_to_fetch [Integer] Number of tokens to fetch
185
+ # @return [Array] [tokens_added, has_tokens_after_refill]
186
+ def refill_and_check(tokens_to_fetch)
187
+ tokens_to_add = [tokens_to_fetch, @max_tokens - @available_tokens].min
188
+ @available_tokens += tokens_to_add
189
+ has_tokens_after = @available_tokens > 0
190
+ [tokens_to_add, has_tokens_after]
191
+ end
192
+
193
+ # Synchronize access to this bucket for thread-safe operations
194
+ # @yield Block to execute under bucket lock
195
+ def synchronize(&block)
196
+ @mutex.synchronize(&block)
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LightrateClient
4
+ VERSION = "1.0.1"
5
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lightrate_client/version"
4
+ require_relative "lightrate_client/client"
5
+ require_relative "lightrate_client/errors"
6
+ require_relative "lightrate_client/configuration"
7
+ require_relative "lightrate_client/types"
8
+
9
+ module LightrateClient
10
+ class << self
11
+ def configure
12
+ yield(configuration)
13
+ end
14
+
15
+ def configuration
16
+ @configuration ||= Configuration.new
17
+ end
18
+
19
+ def client
20
+ @client ||= Client.new
21
+ end
22
+
23
+ # Create a new client with API key and application ID
24
+ def new_client(api_key, application_id, **options)
25
+ Client.new(api_key, application_id, options)
26
+ end
27
+
28
+ def reset!
29
+ @configuration = nil
30
+ @client = nil
31
+ end
32
+ end
33
+ end