lago-ruby-client 1.45.0 → 1.47.0

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: 5b15e8ca518f9d0d1d09afc59ccd0c89088b37b1e77814f829d3f3e1db9bc032
4
- data.tar.gz: f43d20d1636ef1657e78224b9f9f505d88708762f540d21c3c8051eea26637fc
3
+ metadata.gz: f1ce7eaf525a2785545ea91a30464c1da9da45a09c95d01a6b062cd4b51a85f5
4
+ data.tar.gz: 6d8be436f3b5b4155d8e72e28aea99082fd40dfa1070a18758e40842ede13d34
5
5
  SHA512:
6
- metadata.gz: 4d3fb72ab5775cd0dc7368ef4c7b7977b3f2d461f3e1707e9c165580eabfc33c38f1d12668071eaae8aa870cc5461dbd60e6202d46d8450d6092b77882ed7a9b
7
- data.tar.gz: 56eb3276e804169e4dd1b7bfbaf1685d19c9a747fdb658204082f39faef5c00bff852ca3f7bb90c5e0a2d8c2ebb4e3361b176af9ac97a0cdffe9c66d6ff80cb7
6
+ metadata.gz: c10616c41e59442007247c9245bb91be08881744dbf91f9112dbe4043e7d19c33539aa50c8367f77a06a6d3fd49efa5d5a3add3f10842eaa05cc0d0ab0f2643d
7
+ data.tar.gz: adc378ba5f462f08880bbd3ff8b841b9432660a378f3dec36aeab4b4065e0ecaf07608a27ead50a49cea6eab701c250e0f2e89915d1d13df016f22c252dc95de
@@ -53,13 +53,22 @@ module Lago
53
53
  API_PATH = 'api/v1/'
54
54
 
55
55
  class Client
56
- attr_reader :api_key, :api_url, :use_ingest_service, :ingest_api_url
57
-
58
- def initialize(api_key: nil, api_url: nil, use_ingest_service: false, ingest_api_url: nil)
56
+ attr_reader :api_key,
57
+ :api_url,
58
+ :use_ingest_service,
59
+ :ingest_api_url,
60
+ :max_retries,
61
+ :retry_on_rate_limit,
62
+ :on_rate_limit_info
63
+
64
+ def initialize(api_key: nil, api_url: nil, use_ingest_service: false, ingest_api_url: nil, **options)
59
65
  @api_key = api_key
60
66
  @api_url = api_url
61
67
  @use_ingest_service = use_ingest_service
62
68
  @ingest_api_url = ingest_api_url
69
+ @max_retries = options.fetch(:max_retries, 3)
70
+ @retry_on_rate_limit = options.fetch(:retry_on_rate_limit, true)
71
+ @on_rate_limit_info = options[:on_rate_limit_info]
63
72
  end
64
73
 
65
74
  def base_api_url
@@ -6,84 +6,73 @@ module Lago
6
6
  module Api
7
7
  class Connection
8
8
  RESPONSE_SUCCESS_CODES = [200, 201, 202, 204].freeze
9
-
10
- def initialize(api_key, uri)
9
+ DEFAULT_MAX_RETRIES = 3
10
+ INITIAL_BACKOFF = 1
11
+ BACKOFF_MULTIPLIER = 2
12
+ MAX_RETRY_DELAY = 20
13
+
14
+ def initialize(
15
+ api_key,
16
+ uri,
17
+ max_retries: DEFAULT_MAX_RETRIES,
18
+ retry_on_rate_limit: true,
19
+ on_rate_limit_info: nil
20
+ )
11
21
  @api_key = api_key
12
22
  @uri = uri
23
+ @max_retries = max_retries
24
+ @retry_on_rate_limit = retry_on_rate_limit
25
+ @on_rate_limit_info = on_rate_limit_info
13
26
  end
14
27
 
15
28
  def post(body, path = uri.path)
16
- response = http_client.send_request(
17
- 'POST',
18
- path,
19
- prepare_payload(body),
20
- headers
21
- )
22
-
23
- handle_response(response)
29
+ method = 'POST'
30
+ execute_request(method:) do
31
+ http_client.send_request(method, path, prepare_payload(body), headers)
32
+ end
24
33
  end
25
34
 
26
35
  def put(path = uri.path, identifier:, body:)
36
+ method = 'PUT'
27
37
  uri_path = identifier.nil? ? path : "#{path}/#{CGI.escapeURIComponent(identifier)}"
28
- response = http_client.send_request(
29
- 'PUT',
30
- uri_path,
31
- prepare_payload(body),
32
- headers
33
- )
34
-
35
- handle_response(response)
38
+ execute_request(method:) do
39
+ http_client.send_request(method, uri_path, prepare_payload(body), headers)
40
+ end
36
41
  end
37
42
 
38
43
  def patch(path = uri.path, identifier:, body:)
44
+ method = 'PATCH'
39
45
  uri_path = identifier.nil? ? path : "#{path}/#{CGI.escapeURIComponent(identifier)}"
40
- response = http_client.send_request(
41
- 'PATCH',
42
- uri_path,
43
- prepare_payload(body),
44
- headers
45
- )
46
-
47
- handle_response(response)
46
+ execute_request(method:) do
47
+ http_client.send_request(method, uri_path, prepare_payload(body), headers)
48
+ end
48
49
  end
49
50
 
50
51
  def get(path = uri.path, identifier:)
52
+ method = 'GET'
51
53
  uri_path = identifier.nil? ? path : "#{path}/#{CGI.escapeURIComponent(identifier)}"
52
- response = http_client.send_request(
53
- 'GET',
54
- uri_path,
55
- prepare_payload(nil),
56
- headers
57
- )
58
-
59
- handle_response(response)
54
+ execute_request(method:) do
55
+ http_client.send_request(method, uri_path, prepare_payload(nil), headers)
56
+ end
60
57
  end
61
58
 
62
59
  def destroy(path = uri.path, identifier:, options: nil)
60
+ method = 'DELETE'
63
61
  uri_path = path
64
62
  uri_path += "/#{CGI.escapeURIComponent(identifier)}" if identifier
65
63
  uri_path += "?#{URI.encode_www_form(options)}" unless options.nil?
66
- response = http_client.send_request(
67
- 'DELETE',
68
- uri_path,
69
- prepare_payload(nil),
70
- headers
71
- )
72
-
73
- handle_response(response)
64
+ execute_request(method:) do
65
+ http_client.send_request(method, uri_path, prepare_payload(nil), headers)
66
+ end
74
67
  end
75
68
 
76
69
  def get_all(options, path = uri.path)
70
+ method = 'GET'
77
71
  uri_path = options.empty? ? path : "#{path}?#{URI.encode_www_form(options)}"
78
72
 
79
- response = http_client.send_request(
80
- 'GET',
81
- uri_path,
82
- prepare_payload(nil),
83
- headers
84
- )
85
-
86
- handle_response(response)
73
+ execute_request(method:) do
74
+ http_client.send_request(method, uri_path, prepare_payload(nil), headers)
75
+ end
87
76
  end
88
77
 
89
78
  private
@@ -98,14 +87,47 @@ module Lago
98
87
  }
99
88
  end
100
89
 
101
- def handle_response(response)
102
- raise_error(response) unless RESPONSE_SUCCESS_CODES.include?(response.code.to_i)
90
+ def execute_request(retry_count = 0, method: nil, &block)
91
+ response = yield
92
+ handle_response(response, retry_count, block, method:)
93
+ end
103
94
 
104
- response.body.empty? || JSON.parse(response.body)
95
+ def handle_response(response, retry_count, block, method: nil)
96
+ code = response.code.to_i
97
+
98
+ if code == 429 && @retry_on_rate_limit && retry_count < @max_retries
99
+ handle_rate_limit(response, retry_count, block, method:)
100
+ elsif !RESPONSE_SUCCESS_CODES.include?(code)
101
+ raise_error(response)
102
+ else
103
+ emit_rate_limit_info(response, method:)
104
+ parse_response_body(response)
105
+ end
105
106
  rescue JSON::ParserError
106
107
  response.body
107
108
  end
108
109
 
110
+ def handle_rate_limit(response, retry_count, block, method: nil)
111
+ reset_seconds = extract_reset_seconds(response, retry_count)
112
+ sleep(reset_seconds)
113
+ execute_request(retry_count + 1, method:, &block)
114
+ end
115
+
116
+ def emit_rate_limit_info(response, method: nil)
117
+ return if @on_rate_limit_info.nil?
118
+
119
+ info = Lago::Api::RateLimitInfo.parse(response, method:, url: uri.to_s)
120
+ return if info.nil?
121
+
122
+ @on_rate_limit_info.call(info)
123
+ rescue StandardError => e
124
+ warn("Lago: on_rate_limit_info callback raised: #{e.class}: #{e.message}")
125
+ end
126
+
127
+ def parse_response_body(response)
128
+ response.body.empty? || JSON.parse(response.body)
129
+ end
130
+
109
131
  def http_client
110
132
  http_client = Net::HTTP.new(uri.hostname, uri.port)
111
133
  http_client.use_ssl = true if uri.scheme == 'https'
@@ -120,7 +142,42 @@ module Lago
120
142
  end
121
143
 
122
144
  def raise_error(response)
123
- raise Lago::Api::HttpError.new(response.code.to_i, response.body, uri)
145
+ code = response.code.to_i
146
+
147
+ raise Lago::Api::HttpError.new(code, response.body, uri) unless code == 429
148
+
149
+ limit, remaining, reset = parse_rate_limit_headers(response)
150
+ raise Lago::Api::RateLimitError.new(
151
+ code,
152
+ response.body,
153
+ uri,
154
+ limit:,
155
+ remaining:,
156
+ reset:
157
+ )
158
+ end
159
+
160
+ def parse_rate_limit_headers(response)
161
+ limit = response['x-ratelimit-limit']&.to_i
162
+ remaining = response['x-ratelimit-remaining']&.to_i
163
+ reset = response['x-ratelimit-reset']&.to_i
164
+
165
+ [limit, remaining, reset]
166
+ end
167
+
168
+ def extract_reset_seconds(response, retry_count)
169
+ delay = if response['x-ratelimit-reset']
170
+ [response['x-ratelimit-reset'].to_i, INITIAL_BACKOFF].max
171
+ else
172
+ # Exponential backoff if header is missing
173
+ calculate_backoff(retry_count)
174
+ end
175
+
176
+ [delay, MAX_RETRY_DELAY].min
177
+ end
178
+
179
+ def calculate_backoff(retry_count)
180
+ INITIAL_BACKOFF * (BACKOFF_MULTIPLIER**retry_count)
124
181
  end
125
182
  end
126
183
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ module Lago
6
+ module Api
7
+ # Ready-to-use +on_rate_limit_info+ callable that logs a warning each time
8
+ # rate limit usage crosses one of the configured thresholds.
9
+ #
10
+ # Example:
11
+ # client = Lago::Api::Client.new(
12
+ # api_key: '...',
13
+ # on_rate_limit_info: Lago::Api::LoggingRateLimitObserver.new,
14
+ # )
15
+ class LoggingRateLimitObserver
16
+ DEFAULT_THRESHOLDS = [0.80, 0.90, 0.95].freeze
17
+
18
+ def initialize(thresholds: DEFAULT_THRESHOLDS, logger: nil, level: Logger::WARN)
19
+ @thresholds = thresholds.sort.reverse
20
+ @logger = logger || default_logger
21
+ @level = level
22
+ end
23
+
24
+ def call(info)
25
+ pct = info.usage_pct
26
+ return if pct.nil?
27
+
28
+ return unless @thresholds.any? { |threshold| pct >= threshold }
29
+
30
+ @logger.add(
31
+ @level,
32
+ format(
33
+ 'Lago rate limit at %<pct>.0f%% (limit=%<limit>s, remaining=%<remaining>s, ' \
34
+ 'reset=%<reset>ss, %<method>s %<url>s)',
35
+ pct: pct * 100,
36
+ limit: info.limit.inspect,
37
+ remaining: info.remaining.inspect,
38
+ reset: info.reset.inspect,
39
+ method: info.method,
40
+ url: info.url,
41
+ ),
42
+ )
43
+ end
44
+
45
+ private
46
+
47
+ def default_logger
48
+ logger = Logger.new($stderr)
49
+ logger.progname = 'lago_ruby_client.rate_limit'
50
+ logger
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lago
4
+ module Api
5
+ class RateLimitError < HttpError
6
+ attr_reader :limit, :remaining, :reset
7
+
8
+ def initialize(code, body, uri, **options)
9
+ super(code, body, uri)
10
+ @limit = options[:limit]
11
+ @remaining = options[:remaining]
12
+ @reset = options[:reset]
13
+ end
14
+
15
+ def message
16
+ base_message = "HTTP #{error_code} - URI: #{uri}.\nError: #{error_body}"
17
+ return base_message unless reset
18
+
19
+ "#{base_message}\nRate limit will reset in #{reset} seconds."
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lago
4
+ module Api
5
+ # Parsed rate limit headers from a Lago API response.
6
+ #
7
+ # Delivered to the +on_rate_limit_info+ callback after every successful
8
+ # request so callers can build observability around the rate limit
9
+ # (warn at thresholds, emit metrics, etc.).
10
+ class RateLimitInfo
11
+ attr_reader :limit, :remaining, :reset, :method, :url
12
+
13
+ # Parses x-ratelimit-* headers from a Net::HTTPResponse-like object.
14
+ # Returns +nil+ when no rate limit headers are present.
15
+ def self.parse(response, method:, url:)
16
+ limit = response['x-ratelimit-limit']
17
+ remaining = response['x-ratelimit-remaining']
18
+ reset = response['x-ratelimit-reset']
19
+
20
+ return nil if limit.nil? && remaining.nil? && reset.nil?
21
+
22
+ new(
23
+ limit: limit&.to_i,
24
+ remaining: remaining&.to_i,
25
+ reset: reset&.to_i,
26
+ method:,
27
+ url:,
28
+ )
29
+ end
30
+
31
+ def initialize(limit:, remaining:, reset:, method:, url:)
32
+ @limit = limit
33
+ @remaining = remaining
34
+ @reset = reset
35
+ @method = method
36
+ @url = url
37
+ end
38
+
39
+ # Returns the fraction of the rate limit currently used as a Float in
40
+ # [0.0, 1.0], or +nil+ when the headers aren't usable (missing limit,
41
+ # zero limit, missing remaining).
42
+ def usage_pct
43
+ return nil if limit.nil? || remaining.nil? || limit.to_i <= 0
44
+
45
+ 1.0 - (remaining.to_f / limit)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -59,7 +59,13 @@ module Lago
59
59
  def connection
60
60
  uri = URI.join(client.base_api_url, api_resource)
61
61
 
62
- Lago::Api::Connection.new(client.api_key, uri)
62
+ Lago::Api::Connection.new(
63
+ client.api_key,
64
+ uri,
65
+ max_retries: client.max_retries,
66
+ retry_on_rate_limit: client.retry_on_rate_limit,
67
+ on_rate_limit_info: client.on_rate_limit_info,
68
+ )
63
69
  end
64
70
  end
65
71
  end
@@ -20,10 +20,15 @@ module Lago
20
20
 
21
21
  def current_usage( # rubocop:disable Metrics/ParameterLists
22
22
  external_customer_id, external_subscription_id, apply_taxes: nil,
23
+ charge_id: nil, charge_code: nil, billable_metric_code: nil, group: nil,
23
24
  filter_by_charge_id: nil, filter_by_charge_code: nil, filter_by_group: nil, full_usage: nil
24
25
  )
25
26
  query_params = { external_subscription_id: external_subscription_id }
26
27
  query_params[:apply_taxes] = apply_taxes unless apply_taxes.nil?
28
+ query_params[:charge_id] = charge_id unless charge_id.nil?
29
+ query_params[:charge_code] = charge_code unless charge_code.nil?
30
+ query_params[:billable_metric_code] = billable_metric_code unless billable_metric_code.nil?
31
+ group&.each { |k, v| query_params[:"group[#{k}]"] = v }
27
32
  query_params[:filter_by_charge_id] = filter_by_charge_id unless filter_by_charge_id.nil?
28
33
  query_params[:filter_by_charge_code] = filter_by_charge_code unless filter_by_charge_code.nil?
29
34
  filter_by_group&.each { |k, v| query_params[:"filter_by_group[#{k}]"] = v }
@@ -16,7 +16,13 @@ module Lago
16
16
 
17
17
  def create(params)
18
18
  uri = URI("#{client.base_ingest_api_url}#{api_resource}")
19
- connection = Lago::Api::Connection.new(client.api_key, uri)
19
+ connection = Lago::Api::Connection.new(
20
+ client.api_key,
21
+ uri,
22
+ max_retries: client.max_retries,
23
+ retry_on_rate_limit: client.retry_on_rate_limit,
24
+ on_rate_limit_info: client.on_rate_limit_info,
25
+ )
20
26
 
21
27
  payload = whitelist_params(params)
22
28
  response = connection.post(payload, uri)[root_name]
@@ -8,7 +8,13 @@ module Lago
8
8
  class Nested < Base
9
9
  def initialize(client)
10
10
  super(client)
11
- @connection = Lago::Api::Connection.new(client.api_key, client.base_api_url)
11
+ @connection = Lago::Api::Connection.new(
12
+ client.api_key,
13
+ client.base_api_url,
14
+ max_retries: client.max_retries,
15
+ retry_on_rate_limit: client.retry_on_rate_limit,
16
+ on_rate_limit_info: client.on_rate_limit_info,
17
+ )
12
18
  end
13
19
 
14
20
  def create(*parent_ids, params)
data/lib/lago/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lago
4
- VERSION = '1.45.0'
4
+ VERSION = '1.47.0'
5
5
  end
@@ -11,3 +11,6 @@ require 'lago/version'
11
11
  require 'lago/api/client'
12
12
  require 'lago/api/connection'
13
13
  require 'lago/api/http_error'
14
+ require 'lago/api/rate_limit_error'
15
+ require 'lago/api/rate_limit_info'
16
+ require 'lago/api/logging_rate_limit_observer'
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lago-ruby-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.45.0
4
+ version: 1.47.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lovro Colic
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2026-04-07 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: jwt
@@ -52,6 +51,20 @@ dependencies:
52
51
  - - ">="
53
52
  - !ruby/object:Gem::Version
54
53
  version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: benchmark
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
55
68
  - !ruby/object:Gem::Dependency
56
69
  name: bigdecimal
57
70
  requirement: !ruby/object:Gem::Requirement
@@ -192,6 +205,20 @@ dependencies:
192
205
  - - "~>"
193
206
  - !ruby/object:Gem::Version
194
207
  version: '13.0'
208
+ - !ruby/object:Gem::Dependency
209
+ name: readline
210
+ requirement: !ruby/object:Gem::Requirement
211
+ requirements:
212
+ - - ">="
213
+ - !ruby/object:Gem::Version
214
+ version: '0'
215
+ type: :development
216
+ prerelease: false
217
+ version_requirements: !ruby/object:Gem::Requirement
218
+ requirements:
219
+ - - ">="
220
+ - !ruby/object:Gem::Version
221
+ version: '0'
195
222
  - !ruby/object:Gem::Dependency
196
223
  name: rspec
197
224
  requirement: !ruby/object:Gem::Requirement
@@ -276,7 +303,6 @@ dependencies:
276
303
  - - ">="
277
304
  - !ruby/object:Gem::Version
278
305
  version: '0'
279
- description:
280
306
  email:
281
307
  - lovro@getlago.com
282
308
  executables: []
@@ -287,6 +313,9 @@ files:
287
313
  - lib/lago/api/client.rb
288
314
  - lib/lago/api/connection.rb
289
315
  - lib/lago/api/http_error.rb
316
+ - lib/lago/api/logging_rate_limit_observer.rb
317
+ - lib/lago/api/rate_limit_error.rb
318
+ - lib/lago/api/rate_limit_info.rb
290
319
  - lib/lago/api/resources/activity_log.rb
291
320
  - lib/lago/api/resources/add_on.rb
292
321
  - lib/lago/api/resources/api_log.rb
@@ -341,7 +370,6 @@ metadata:
341
370
  homepage_uri: https://www.getlago.com/
342
371
  source_code_uri: https://github.com/getlago/lago-ruby-client
343
372
  documentation_uri: https://doc.getlago.com
344
- post_install_message:
345
373
  rdoc_options: []
346
374
  require_paths:
347
375
  - lib
@@ -356,8 +384,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
356
384
  - !ruby/object:Gem::Version
357
385
  version: '0'
358
386
  requirements: []
359
- rubygems_version: 3.3.27
360
- signing_key:
387
+ rubygems_version: 4.0.6
361
388
  specification_version: 4
362
389
  summary: Lago Rest API client
363
390
  test_files: []