lightrate-client 1.0.1 → 1.0.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c63b6519eeb427af97e5de527bc710be417a302dedde208d0915b259c9ef1ed7
4
- data.tar.gz: 62a4447c9e7f57358552a447aa06553a51a40a116c09d376e9a3e6d4c18ddcfe
3
+ metadata.gz: 603315821de731679dd12cf30666482ded677ed36e4fe4c29c6aae078ad7cac7
4
+ data.tar.gz: bb796d172549d872a3a278f765ebea08e7db53721874cf5be126856fb00f4c98
5
5
  SHA512:
6
- metadata.gz: 47024b5debeb8cbfef45d0cbdb7263bd43a7d4959acf37bf506541a12af07149dc27869775e73468793baddb0dc60d73ffa50fc4868f14fc99098b949ef42189
7
- data.tar.gz: d89180c3cb9399316b73903ec63078d9bd02a522a157781916f09c154658a82024ef87064752996a77144f7ac370e97176975bf409817c7960a5820e918a003f
6
+ metadata.gz: 9754e47d78fadbb327059923d779853b6526afbaa4337a724d8d142beb39ea44fd0865024d6befc10172be6849b303c92b35d8772a40deed7a0a6beaa5af7ad4
7
+ data.tar.gz: 4535cd08a0487c99c70a80fac6c20c1f3fbec7cd3f6fefe1343ccb87233bf4497edd01c6ab82d92856054239b927c85ffe54625a9e2c9a5c93c91c8fa230c9bc
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- lightrate-client (1.0.0)
4
+ lightrate-client (1.0.2)
5
5
  faraday (~> 2.0)
6
6
  faraday-retry (~> 2.0)
7
7
  json (~> 2.0)
@@ -103,8 +103,28 @@ begin
103
103
  puts " (These create separate buckets due to different HTTP methods)"
104
104
  puts
105
105
 
106
+ puts "7. Different users calling same operation:"
107
+ result7a = client.consume_local_bucket_token(
108
+ operation: 'send_notification',
109
+ user_identifier: 'user123'
110
+ )
111
+ result7b = client.consume_local_bucket_token(
112
+ operation: 'send_notification',
113
+ user_identifier: 'user456'
114
+ )
115
+ result7c = client.consume_local_bucket_token(
116
+ operation: 'send_notification',
117
+ user_identifier: 'user789'
118
+ )
119
+
120
+ puts " User 123 - Success: #{result7a.success} #{result7a.bucket_status}"
121
+ puts " User 456 - Success: #{result7b.success} #{result7b.bucket_status}"
122
+ puts " User 789 - Success: #{result7c.success} #{result7c.bucket_status}"
123
+ puts " (These create separate buckets due to different users)"
124
+ puts
125
+
106
126
  # Example 7: Direct API call using consume_tokens
107
- puts "7. Direct API call using consume_tokens:"
127
+ puts "8. Direct API call using consume_tokens:"
108
128
  api_response = client.consume_tokens(
109
129
  operation: 'send_notification',
110
130
  user_identifier: 'user789',
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'lightrate_client'
5
+
6
+ # This example demonstrates making repeated calls to the same HTTP endpoint
7
+ # using consume_local_bucket_token. It shows how the local bucket cache
8
+ # reduces API calls and improves performance by reusing tokens locally.
9
+
10
+ # Create a client with default bucket size
11
+ # Note: Both API key and application ID are required for all requests
12
+ client = LightrateClient::Client.new(
13
+ ENV['LIGHTRATE_API_KEY'] || 'your_api_key_here',
14
+ ENV['LIGHTRATE_APPLICATION_ID'] || 'your_application_id_here',
15
+ default_local_bucket_size: 10, # Fetch 10 tokens at a time
16
+ logger: ENV['DEBUG'] ? Logger.new(STDOUT) : nil
17
+ )
18
+
19
+ puts "=" * 80
20
+ puts "Repeated HTTP Target Example"
21
+ puts "=" * 80
22
+ puts
23
+
24
+ # Simulate making 20 calls to the same HTTP endpoint
25
+ target_path = '/posts'
26
+ target_method = 'GET'
27
+ user_id = 'user_12345'
28
+
29
+ puts "Making 20 calls to #{target_method} #{target_path} for user #{user_id}"
30
+ puts
31
+ puts "Breakdown:"
32
+ puts " - First call: Will fetch tokens from API (expected to return 10 tokens)"
33
+ puts " - Calls 2-10: Should consume from local bucket (no API calls)"
34
+ puts " - Call 11: Local bucket empty, will fetch more from API"
35
+ puts " - And so on..."
36
+ puts
37
+ puts "-" * 80
38
+ puts
39
+
40
+ api_calls = 0
41
+ cache_hits = 0
42
+ call_times = []
43
+
44
+ 20.times do |i|
45
+ call_number = i + 1
46
+ start_time = Time.now
47
+
48
+ # Make the API call through the client
49
+ result = client.consume_local_bucket_token(
50
+ path: target_path,
51
+ http_method: target_method,
52
+ user_identifier: user_id
53
+ )
54
+
55
+ elapsed_time = (Time.now - start_time) * 1000 # Convert to milliseconds
56
+
57
+ call_times << elapsed_time
58
+
59
+ if result.used_local_token
60
+ cache_hits += 1
61
+ cache_status = "✓ Local cache hit"
62
+ else
63
+ api_calls += 1
64
+ cache_status = "✗ API call made"
65
+ end
66
+
67
+ if result.bucket_status
68
+ tokens_remaining = result.bucket_status[:tokens_remaining]
69
+ max_tokens = result.bucket_status[:max_tokens]
70
+ bucket_info = " | Bucket: #{tokens_remaining}/#{max_tokens} tokens"
71
+ else
72
+ bucket_info = ""
73
+ end
74
+
75
+ printf "Call %2d: %s (%.2f ms)%s\n",
76
+ call_number,
77
+ cache_status,
78
+ elapsed_time,
79
+ bucket_info
80
+ end
81
+
82
+ puts
83
+ puts "-" * 80
84
+ puts "Summary"
85
+ puts "-" * 80
86
+ puts "Total calls: 20"
87
+ puts "API calls made: #{api_calls}"
88
+ puts "Cache hits: #{cache_hits}"
89
+ puts "Cache hit rate: #{(cache_hits.to_f / 20 * 100).round(1)}%"
90
+ puts
91
+ puts "Timing statistics:"
92
+ puts " Average time: #{(call_times.sum / call_times.length).round(2)} ms"
93
+ puts " Fastest call: #{call_times.min.round(2)} ms"
94
+ puts " Slowest call: #{call_times.max.round(2)} ms"
95
+ puts
96
+
97
+ # Show the difference between cached and non-cached calls
98
+ if call_times.length > 0
99
+ api_call_times = []
100
+ cache_call_times = []
101
+
102
+ 20.times do |i|
103
+ if i == 0 || i % 10 == 0 # API calls happen on first call and when bucket is empty
104
+ api_call_times << call_times[i]
105
+ else
106
+ cache_call_times << call_times[i]
107
+ end
108
+ end
109
+
110
+ if cache_call_times.any?
111
+ avg_cache_time = cache_call_times.sum / cache_call_times.length
112
+ avg_api_time = api_call_times.sum / api_call_times.length if api_call_times.any?
113
+
114
+ if avg_api_time
115
+ speedup = avg_api_time / avg_cache_time
116
+ puts "Performance:"
117
+ puts " Average cached call: #{avg_cache_time.round(2)} ms"
118
+ puts " Average API call: #{avg_api_time.round(2)} ms"
119
+ puts " Speed improvement: #{speedup.round(1)}x faster with cache"
120
+ end
121
+ end
122
+ end
123
+
124
+ puts
125
+ puts "=" * 80
@@ -37,61 +37,50 @@ module LightrateClient
37
37
  # @param user_identifier [String] The user identifier
38
38
  # @param tokens_requested [Integer] Number of tokens to consume
39
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)
40
+ # Synchronize the entire process to prevent race conditions
41
+ # First, try to find an existing bucket that matches this request
42
+ bucket = find_bucket_by_matcher(user_identifier, operation, path, http_method)
43
+
44
+ if bucket && bucket.check_and_consume_token
45
+ return LightrateClient::ConsumeLocalBucketTokenResponse.new(
46
+ success: true,
47
+ used_local_token: true,
48
+ bucket_status: bucket.status
49
+ )
50
+ end
51
+
52
+ # No matching bucket or bucket is empty - make API call to get tokens and rule info
53
+ tokens_to_fetch = @configuration.default_local_bucket_size
54
+
55
+ # Make the API call
56
+ response = consume_tokens(operation: operation, path: path, http_method: http_method, user_identifier: user_identifier, tokens_requested: tokens_to_fetch)
57
+
58
+ if response.rule.is_default
59
+ return LightrateClient::ConsumeLocalBucketTokenResponse.new(
60
+ success: response.tokens_consumed > 0,
61
+ used_local_token: false,
62
+ bucket_status: nil
63
+ )
64
+ end
42
65
 
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
66
+ bucket = fill_bucket_and_create_if_not_exists(user_identifier, response.rule, response.tokens_consumed)
67
+
68
+ tokens_available = bucket.check_and_consume_token
69
+
70
+ return LightrateClient::ConsumeLocalBucketTokenResponse.new(
71
+ success: tokens_available,
72
+ used_local_token: false,
73
+ bucket_status: bucket.status
74
+ )
75
+ end
76
+
77
+ def consume_token_from_bucket(bucket, provided_tokens = 0)
45
78
  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
79
+ fetch_required = !bucket.has_tokens? || bucket.expired?
57
80
 
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
81
+ token_available = bucket.check_and_consume_token
82
+
83
+ [token_available, fetch_required]
95
84
  end
96
85
  end
97
86
 
@@ -102,7 +91,8 @@ module LightrateClient
102
91
  path: path,
103
92
  http_method: http_method,
104
93
  user_identifier: user_identifier,
105
- tokens_requested: tokens_requested
94
+ tokens_requested: tokens_requested,
95
+ tokens_requested_for_default_bucket_match: 1
106
96
  )
107
97
  consume_tokens_with_request(request)
108
98
  end
@@ -117,6 +107,8 @@ module LightrateClient
117
107
  raise ArgumentError, "Request validation failed" unless request.valid?
118
108
 
119
109
  response = post("/api/v1/tokens/consume", request.to_h)
110
+ # Parse JSON response if it's a string
111
+ response = JSON.parse(response) if response.is_a?(String)
120
112
  LightrateClient::ConsumeTokensResponse.from_hash(response)
121
113
  end
122
114
 
@@ -125,37 +117,26 @@ module LightrateClient
125
117
  @buckets_mutex = Mutex.new
126
118
  end
127
119
 
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]
120
+ def fill_bucket_and_create_if_not_exists(user_identifier, rule, initial_tokens)
121
+ bucket_key = "#{user_identifier}:rule:#{rule.id}"
134
122
 
135
123
  @buckets_mutex.synchronize do
136
- # Check again inside the mutex to prevent duplicate creation
124
+ return @token_buckets[bucket_key] if @token_buckets[bucket_key] && !@token_buckets[bucket_key].expired?
125
+
137
126
  @token_buckets[bucket_key] ||= begin
138
- bucket_size = get_bucket_size_for_operation(operation, path)
139
- TokenBucket.new(bucket_size)
127
+ bucket_size = @configuration.default_local_bucket_size
128
+ TokenBucket.new(bucket_size, rule_id: rule.id, matcher: rule.matcher, http_method: rule.http_method, user_identifier: user_identifier)
140
129
  end
141
130
  end
142
-
143
- @token_buckets[bucket_key]
144
- end
145
131
 
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
132
+ @token_buckets[bucket_key].refill(initial_tokens)
133
+
134
+ @token_buckets[bucket_key]
149
135
  end
150
136
 
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"
137
+ def find_bucket_by_matcher(user_identifier, operation, path, http_method)
138
+ @token_buckets.values.find do |bucket|
139
+ bucket.matches?(operation, path, http_method) && bucket.user_identifier == user_identifier
159
140
  end
160
141
  end
161
142
 
@@ -3,15 +3,16 @@
3
3
  module LightrateClient
4
4
  # Request types
5
5
  class ConsumeTokensRequest
6
- attr_accessor :application_id, :operation, :path, :http_method, :user_identifier, :tokens_requested, :timestamp
6
+ attr_accessor :application_id, :operation, :path, :http_method, :user_identifier, :tokens_requested, :tokens_requested_for_default_bucket_match, :timestamp
7
7
 
8
- def initialize(application_id:, operation: nil, path: nil, http_method: nil, user_identifier:, tokens_requested:, timestamp: nil)
8
+ def initialize(application_id:, operation: nil, path: nil, http_method: nil, user_identifier:, tokens_requested:, tokens_requested_for_default_bucket_match: nil, timestamp: nil)
9
9
  @application_id = application_id
10
10
  @operation = operation
11
11
  @path = path
12
12
  @http_method = http_method
13
13
  @user_identifier = user_identifier
14
14
  @tokens_requested = tokens_requested
15
+ @tokens_requested_for_default_bucket_match = tokens_requested_for_default_bucket_match
15
16
  @timestamp = timestamp || Time.now
16
17
  end
17
18
 
@@ -23,6 +24,7 @@ module LightrateClient
23
24
  httpMethod: @http_method,
24
25
  userIdentifier: @user_identifier,
25
26
  tokensRequested: @tokens_requested,
27
+ tokensRequestedForDefaultBucketMatch: @tokens_requested_for_default_bucket_match,
26
28
  timestamp: @timestamp
27
29
  }.compact
28
30
  end
@@ -87,14 +89,16 @@ module LightrateClient
87
89
  end
88
90
 
89
91
  class Rule
90
- attr_reader :id, :name, :refill_rate, :burst_rate, :is_default
92
+ attr_reader :id, :name, :refill_rate, :burst_rate, :is_default, :matcher, :http_method
91
93
 
92
- def initialize(id:, name:, refill_rate:, burst_rate:, is_default: false)
94
+ def initialize(id:, name:, refill_rate:, burst_rate:, is_default: false, matcher: nil, http_method: nil)
93
95
  @id = id
94
96
  @name = name
95
97
  @refill_rate = refill_rate
96
98
  @burst_rate = burst_rate
97
99
  @is_default = is_default
100
+ @matcher = matcher
101
+ @http_method = http_method
98
102
  end
99
103
 
100
104
  def self.from_hash(hash)
@@ -103,18 +107,25 @@ module LightrateClient
103
107
  name: hash['name'] || hash[:name],
104
108
  refill_rate: hash['refillRate'] || hash[:refill_rate],
105
109
  burst_rate: hash['burstRate'] || hash[:burst_rate],
106
- is_default: hash['isDefault'] || hash[:is_default] || false
110
+ is_default: hash['isDefault'] || hash[:is_default] || false,
111
+ matcher: hash['matcher'] || hash[:matcher],
112
+ http_method: hash['httpMethod'] || hash[:http_method]
107
113
  )
108
114
  end
109
115
  end
110
116
 
111
117
  # Token bucket for local token management
112
118
  class TokenBucket
113
- attr_reader :available_tokens, :max_tokens
119
+ attr_reader :available_tokens, :max_tokens, :rule_id, :matcher, :http_method, :last_accessed_at, :user_identifier
114
120
 
115
- def initialize(max_tokens)
121
+ def initialize(max_tokens, rule_id:, matcher:, http_method: nil, user_identifier:)
116
122
  @max_tokens = max_tokens
117
123
  @available_tokens = 0
124
+ @rule_id = rule_id
125
+ @matcher = matcher
126
+ @http_method = http_method
127
+ @last_accessed_at = Time.now
128
+ @user_identifier = user_identifier
118
129
  @mutex = Mutex.new
119
130
  end
120
131
 
@@ -145,10 +156,11 @@ module LightrateClient
145
156
  end
146
157
 
147
158
  # Refill the bucket with tokens from the server (caller must hold lock)
148
- # @param tokens_to_fetch [Integer] Number of tokens to fetch
159
+ # @param tokens_to_add [Integer] Number of tokens to add
149
160
  # @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
161
+ def refill(tokens_to_add)
162
+ touch
163
+ tokens_to_add = [tokens_to_add, @max_tokens - @available_tokens].min
152
164
  @available_tokens += tokens_to_add
153
165
  tokens_to_add
154
166
  end
@@ -167,29 +179,62 @@ module LightrateClient
167
179
  @available_tokens = 0
168
180
  end
169
181
 
182
+ # Check if this bucket matches the given request
183
+ def matches?(operation, path, http_method)
184
+ return false if expired?
185
+ return false unless @matcher
186
+
187
+ begin
188
+ matcher_regex = Regexp.new(@matcher)
189
+
190
+ # For operation-based requests, match against operation
191
+ if operation
192
+ return matcher_regex.match?(operation) && @http_method.nil?
193
+ end
194
+
195
+ # For path-based requests, match against path and HTTP method
196
+ if path
197
+ return matcher_regex.match?(path) && @http_method == http_method
198
+ end
199
+
200
+ false
201
+ rescue RegexpError
202
+ # If matcher is not a valid regex, fall back to exact match
203
+ if operation
204
+ return @matcher == operation && @http_method.nil?
205
+ elsif path
206
+ return @matcher == path && @http_method == http_method
207
+ end
208
+ false
209
+ end
210
+ end
211
+
212
+ # Check if bucket has expired (not accessed in 60 seconds)
213
+ def expired?
214
+ Time.now - @last_accessed_at > 60
215
+ end
216
+
217
+ # Update last accessed time
218
+ def touch
219
+ @last_accessed_at = Time.now
220
+ end
221
+
170
222
  # Check tokens and consume atomically (caller must hold lock)
171
223
  # This prevents race conditions between checking and consuming
172
224
  # @return [Array] [has_tokens, consumed_successfully]
173
225
  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]
226
+ synchronize do
227
+ touch
228
+ has_tokens = @available_tokens > 0
229
+ if has_tokens
230
+ @available_tokens -= 1
231
+ true
232
+ else
233
+ false
234
+ end
180
235
  end
181
236
  end
182
237
 
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
238
  # Synchronize access to this bucket for thread-safe operations
194
239
  # @yield Block to execute under bucket lock
195
240
  def synchronize(&block)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LightrateClient
4
- VERSION = "1.0.1"
4
+ VERSION = "1.0.2"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lightrate-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lightbourne Technologies
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-10-16 00:00:00.000000000 Z
11
+ date: 2025-11-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -179,6 +179,7 @@ files:
179
179
  - README.md
180
180
  - Rakefile
181
181
  - examples/basic_usage.rb
182
+ - examples/repeated_calls_example.rb
182
183
  - lib/lightrate_client.rb
183
184
  - lib/lightrate_client/client.rb
184
185
  - lib/lightrate_client/configuration.rb