lutaml-hal 0.2.1 → 0.2.3

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.
@@ -1,37 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'cgi'
4
- require_relative 'errors'
5
- require_relative 'endpoint_configuration'
6
- require_relative 'cache/cache_manager'
7
4
 
8
5
  module Lutaml
9
6
  module Hal
10
- # Register to map URL patterns to model classes with EndpointParameter support
11
7
  class ModelRegister
12
8
  attr_accessor :models, :client, :register_name, :cache_manager
13
9
 
14
10
  def initialize(name:, client: nil, cache: nil)
15
11
  @register_name = name
16
- # If `client` is not set, it can be set later
17
12
  @client = client
18
13
  @models = {}
19
14
  @cache_manager = Cache::CacheManager.new(cache, client: @client) if cache
15
+ @single_flight = SingleFlight.new
20
16
  end
21
17
 
22
- # Register a model with its URL pattern and parameters
23
18
  def add_endpoint(id:, type:, url:, model:, parameters: [])
24
19
  @models ||= {}
25
20
 
26
21
  raise "Model with ID #{id} already registered" if @models[id]
27
22
 
28
- # Validate all parameters
29
23
  parameters.each(&:validate!)
30
24
 
31
- # Ensure path parameters in URL have corresponding parameter definitions
32
25
  validate_path_parameters(url, parameters)
33
26
 
34
- # Check for duplicate endpoints
35
27
  if @models.values.any? do |m|
36
28
  m[:url] == url && m[:type] == type && parameters_match?(m[:parameters], parameters)
37
29
  end
@@ -47,7 +39,6 @@ module Lutaml
47
39
  }
48
40
  end
49
41
 
50
- # Register an endpoint using block configuration syntax
51
42
  def register_endpoint(id, model, type: :index)
52
43
  config = EndpointConfiguration.new
53
44
  yield(config) if block_given?
@@ -63,149 +54,55 @@ module Lutaml
63
54
  )
64
55
  end
65
56
 
66
- # Resolve and cast data to the appropriate model based on URL
67
57
  def fetch(endpoint_id, **params)
68
58
  endpoint = @models[endpoint_id] || raise("Unknown endpoint: #{endpoint_id}")
69
59
  raise 'Client not configured' unless client
70
60
 
71
- # Process parameters through EndpointParameter objects
72
61
  processed_params = process_parameters(endpoint[:parameters], params)
73
62
 
74
- # Build URL with path parameters
75
63
  url = build_url_with_path_params(endpoint[:url], processed_params[:path])
76
-
77
- # Add query parameters
78
64
  final_url = build_url_with_query_params(url, processed_params[:query])
79
65
 
80
- realized_model = nil
81
-
82
- # Check cache first
83
- if @cache_manager&.available?
84
- cached_entry = @cache_manager.get(final_url)
85
- if cached_entry
86
- debug_log("Cache hit for fetch: #{final_url}")
87
- realized_model = cached_entry.hal_resource
88
- # Return cached model directly if valid
89
- mark_model_links_with_register(realized_model)
90
- return realized_model
91
- end
92
- end
93
-
94
- # Make request if not cached
95
- # Add conditional headers if we have cached metadata
96
- request_headers = processed_params[:headers].dup
97
- if @cache_manager&.available?
98
- conditional_headers = @cache_manager.conditional_request_headers(final_url)
99
- request_headers.merge!(conditional_headers) if conditional_headers
100
- end
101
-
102
- # Make request with headers
103
- response = if request_headers.any?
104
- client.get_with_headers(final_url, request_headers)
105
- else
106
- client.get(final_url)
107
- end
108
-
109
- # Handle 304 Not Modified
110
- if response.respond_to?(:status) && response.status == 304
111
- @cache_manager&.refresh_entry(final_url, response)
112
- cached_entry = @cache_manager.get(final_url)
113
- realized_model = cached_entry.hal_resource if cached_entry
114
- else
115
- # Create model from response
116
- realized_model = endpoint[:model].from_json(response.to_json)
66
+ cached = cached_endpoint_model(final_url)
67
+ return cached if cached
117
68
 
118
- # Cache the realized model with metadata
119
- @cache_manager&.set(final_url, response, realized_model)
69
+ coalesce(final_url) do
70
+ cached_endpoint_model(final_url) ||
71
+ fetch_uncached(endpoint, final_url, processed_params[:headers])
120
72
  end
121
-
122
- # Store embedded data for later resolution
123
- realized_model.embedded_data = response['_embedded'] if realized_model && response && response['_embedded']
124
-
125
- mark_model_links_with_register(realized_model)
126
-
127
- realized_model
128
73
  end
129
74
 
130
75
  def resolve_and_cast(link, href)
131
76
  raise 'Client not configured' unless client
132
77
 
133
- # Check cache first
134
- if @cache_manager&.available?
135
- cached_entry = @cache_manager.get(href)
136
- if cached_entry
137
- debug_log("Cache hit for: #{href}")
138
- cached_model = cached_entry.hal_resource
139
- # A model rebuilt from a persistent cache needs to be (re)marked so
140
- # its links can be realized against this register.
141
- mark_model_links_with_register(cached_model)
142
- return cached_model
143
- end
144
- end
78
+ cached = cached_resolved_model(href)
79
+ return cached if cached
145
80
 
146
81
  debug_log("resolve_and_cast: link #{link}, href #{href}")
147
82
 
148
- # Add conditional headers if we have cached metadata
149
- conditional_headers = @cache_manager&.conditional_request_headers(href) || {}
150
-
151
- response = if conditional_headers.any?
152
- client.get_by_url_with_headers(href, conditional_headers)
153
- else
154
- client.get_by_url(href)
155
- end
156
-
157
- # Handle 304 Not Modified
158
- if response.respond_to?(:status) && response.status == 304
159
- @cache_manager&.refresh_entry(href, response)
160
- cached_entry = @cache_manager.get(href)
161
- return cached_entry.hal_resource if cached_entry
83
+ coalesce(href) do
84
+ cached_resolved_model(href) || resolve_and_cast_uncached(href)
162
85
  end
163
-
164
- # TODO: Merge full Link content into the resource?
165
- response_with_link_details = response.to_h.merge({ 'href' => href })
166
-
167
- href_path = href.sub(client.api_url, '')
168
-
169
- model_class = find_matching_model_class(href_path)
170
- raise LinkResolutionError, "Unregistered URL pattern: #{href}" unless model_class
171
-
172
- debug_log("resolve_and_cast: resolved to model_class #{model_class}")
173
- debug_log("resolve_and_cast: response: #{response.inspect}")
174
- debug_log("resolve_and_cast: amended: #{response_with_link_details}")
175
-
176
- model = model_class.from_json(response_with_link_details.to_json)
177
- mark_model_links_with_register(model)
178
-
179
- # Cache the realized model with metadata
180
- @cache_manager&.set(href, response, model)
181
-
182
- model
183
86
  end
184
87
 
185
- # Recursively mark all models in the link with the register name
186
- # This is used to ensure that all links in the model are registered
187
- # with the same register name for consistent resolution
188
88
  def mark_model_links_with_register(inspecting_model)
189
89
  return unless inspecting_model.is_a?(Lutaml::Model::Serializable)
190
90
 
191
91
  inspecting_model._global_register_id = @register_name
192
92
 
193
- # Recursively process model attributes to mark links with this register
194
93
  inspecting_model.class.attributes.each_pair do |key, config|
195
94
  attr_type = config.type
196
95
  next unless attr_type < Lutaml::Hal::Resource ||
197
96
  attr_type < Lutaml::Hal::Link ||
198
97
  attr_type < Lutaml::Hal::LinkSet
199
98
 
200
- value = inspecting_model.send(key)
99
+ value = inspecting_model.public_send(key)
201
100
  next if value.nil?
202
101
 
203
- # Handle both array and single values with the same logic
204
102
  values = value.is_a?(Array) ? value : [value]
205
103
  values.each do |item|
206
104
  mark_model_links_with_register(item)
207
105
 
208
- # If this is a Link, set the parent resource for automatic embedded content detection
209
106
  item.parent_resource = inspecting_model if item.is_a?(Lutaml::Hal::Link)
210
107
  end
211
108
  end
@@ -213,7 +110,6 @@ module Lutaml
213
110
  inspecting_model
214
111
  end
215
112
 
216
- # Cache management methods
217
113
  def cache_stats
218
114
  @cache_manager&.stats || {}
219
115
  end
@@ -226,17 +122,13 @@ module Lutaml
226
122
  @cache_manager&.info
227
123
  end
228
124
 
229
- # Find the registered model class whose URL pattern matches the given href.
230
- # Public so that Link#realize can resolve embedded resources.
231
125
  def find_matching_model_class(href)
232
- # Find all matching patterns and select the most specific one (longest pattern)
233
126
  matching_models = @models.values.select do |model_data|
234
127
  matches_url_with_params?(model_data, href)
235
128
  end
236
129
 
237
130
  return nil if matching_models.empty?
238
131
 
239
- # Sort by pattern length (descending) to get the most specific match first
240
132
  result = matching_models.max_by { |model_data| model_data[:url].length }
241
133
 
242
134
  result[:model]
@@ -245,7 +137,92 @@ module Lutaml
245
137
  private
246
138
 
247
139
  def debug_log(message)
248
- puts "[Lutaml::Hal] DEBUG: #{message}" if ENV['DEBUG_API']
140
+ Hal.debug_log(message)
141
+ end
142
+
143
+ def coalesce(key, &block)
144
+ return block.call unless @cache_manager&.available?
145
+
146
+ @single_flight.run(key, &block)
147
+ end
148
+
149
+ def cached_endpoint_model(url)
150
+ return nil unless @cache_manager&.available?
151
+
152
+ entry = @cache_manager.get(url)
153
+ return nil unless entry
154
+
155
+ debug_log("Cache hit for fetch: #{url}")
156
+ model = entry.hal_resource
157
+ mark_model_links_with_register(model)
158
+ model
159
+ end
160
+
161
+ def fetch_uncached(endpoint, final_url, headers)
162
+ request_headers = headers.dup
163
+ if @cache_manager&.available?
164
+ conditional_headers = @cache_manager.conditional_request_headers(final_url)
165
+ request_headers.merge!(conditional_headers) if conditional_headers
166
+ end
167
+
168
+ response = if request_headers.any?
169
+ client.get_with_headers(final_url, request_headers)
170
+ else
171
+ client.get(final_url)
172
+ end
173
+
174
+ realized_model = build_model_from_response(response, final_url, endpoint[:model])
175
+
176
+ realized_model.embedded_data = response['_embedded'] if realized_model && response && response['_embedded']
177
+ mark_model_links_with_register(realized_model)
178
+ realized_model
179
+ end
180
+
181
+ def cached_resolved_model(href)
182
+ return nil unless @cache_manager&.available?
183
+
184
+ entry = @cache_manager.get(href)
185
+ return nil unless entry
186
+
187
+ debug_log("Cache hit for: #{href}")
188
+ model = entry.hal_resource
189
+ mark_model_links_with_register(model)
190
+ model
191
+ end
192
+
193
+ def resolve_and_cast_uncached(href)
194
+ conditional_headers = @cache_manager&.conditional_request_headers(href) || {}
195
+
196
+ response = if conditional_headers.any?
197
+ client.get_by_url_with_headers(href, conditional_headers)
198
+ else
199
+ client.get_by_url(href)
200
+ end
201
+
202
+ href_path = href.sub(client.api_url, '')
203
+
204
+ model_class = find_matching_model_class(href_path)
205
+ raise LinkResolutionError, "Unregistered URL pattern: #{href}" unless model_class
206
+
207
+ debug_log("resolve_and_cast: resolved to model_class #{model_class}")
208
+
209
+ response_with_link_details = response.to_h.merge({ 'href' => href })
210
+ model = model_class.from_json(response_with_link_details.to_json)
211
+ mark_model_links_with_register(model)
212
+ @cache_manager&.set(href, response, model)
213
+ model
214
+ end
215
+
216
+ def build_model_from_response(response, url, model_class)
217
+ if response.is_a?(Hash) && response['status'] == 304
218
+ @cache_manager&.refresh_entry(url, response)
219
+ cached_entry = @cache_manager.get(url)
220
+ return cached_entry&.hal_resource
221
+ end
222
+
223
+ realized_model = model_class.from_json(response.to_json)
224
+ @cache_manager&.set(url, response, realized_model)
225
+ realized_model
249
226
  end
250
227
 
251
228
  def process_parameters(parameter_definitions, provided_params)
@@ -255,23 +232,18 @@ module Lutaml
255
232
  param_name = param_def.name.to_sym
256
233
  provided_value = provided_params[param_name]
257
234
 
258
- # Check required parameters
259
235
  if param_def.required && provided_value.nil?
260
236
  raise ArgumentError, "Required parameter '#{param_def.name}' is missing"
261
237
  end
262
238
 
263
- # Use default value if not provided
264
239
  value = provided_value || param_def.default_value
265
240
 
266
- # Skip if still nil and not required
267
241
  next if value.nil?
268
242
 
269
- # Validate parameter value
270
243
  unless param_def.validate_value(value)
271
244
  raise ArgumentError, "Invalid value for parameter '#{param_def.name}': #{value}"
272
245
  end
273
246
 
274
- # Store in appropriate category
275
247
  case param_def.location
276
248
  when :path
277
249
  result[:path][param_def.name] = value
@@ -288,19 +260,15 @@ module Lutaml
288
260
  end
289
261
 
290
262
  def validate_path_parameters(url, parameters)
291
- # Extract path parameter names from URL template
292
263
  url_params = url.scan(/\{([^}]+)\}/).flatten
293
264
 
294
- # Find path parameters in parameter definitions
295
265
  path_params = parameters.select(&:path_parameter?).map(&:name)
296
266
 
297
- # Check that all URL parameters have definitions
298
267
  missing_params = url_params - path_params
299
268
  unless missing_params.empty?
300
269
  raise ArgumentError, "URL contains undefined path parameters: #{missing_params.join(', ')}"
301
270
  end
302
271
 
303
- # Check that all path parameter definitions are used in URL
304
272
  unused_params = path_params - url_params
305
273
  return if unused_params.empty?
306
274
 
@@ -324,12 +292,9 @@ module Lutaml
324
292
  end
325
293
 
326
294
  def build_url_with_query_params(base_url, query_params, params = nil)
327
- # Handle both 2-argument and 3-argument calls for backward compatibility
328
295
  if params.nil?
329
- # 2-argument call: query_params is the final query parameters
330
296
  final_query_params = query_params
331
297
  else
332
- # 3-argument call: query_params is template, params contains values
333
298
  final_query_params = {}
334
299
  query_params.each do |key, template_value|
335
300
  if template_value.is_a?(String) && template_value.match?(/\{(\w+)\}/)
@@ -347,7 +312,6 @@ module Lutaml
347
312
  "#{base_url}?#{query_string}"
348
313
  end
349
314
 
350
- # Interpolate path parameters in a URL template
351
315
  def interpolate_url(url_template, params)
352
316
  params.reduce(url_template) do |url, (key, value)|
353
317
  url.gsub("{#{key}}", value.to_s)
@@ -366,7 +330,6 @@ module Lutaml
366
330
  path_match_result = path_matches?(pattern_path, uri.path)
367
331
  return false unless path_match_result
368
332
 
369
- # Check query parameters if any are defined
370
333
  query_params = parameters.select(&:query_parameter?)
371
334
  return true if query_params.empty?
372
335
 
@@ -388,21 +351,17 @@ module Lutaml
388
351
  end
389
352
 
390
353
  def query_params_match?(expected_params, actual_params)
391
- # Query parameters should be optional unless marked as required
392
354
  expected_params.all? do |param_def|
393
355
  actual_value = actual_params[param_def.name]
394
356
 
395
- # Required parameters must be present
396
357
  if param_def.required
397
358
  return false if actual_value.nil?
398
359
 
399
360
  return param_def.validate_value(actual_value)
400
361
  end
401
362
 
402
- # Optional parameters are always considered matching if not present
403
363
  return true if actual_value.nil?
404
364
 
405
- # If present, they must be valid
406
365
  param_def.validate_value(actual_value)
407
366
  end
408
367
  end
@@ -416,14 +375,10 @@ module Lutaml
416
375
  end
417
376
  end
418
377
 
419
- # Match URL pattern (supports {param} templates)
420
378
  def pattern_match?(pattern, url)
421
379
  return false unless pattern && url
422
380
 
423
- # Convert {param} to wildcards for matching
424
381
  pattern_with_wildcards = pattern.gsub(/\{[^}]+\}/, '*')
425
- # Convert * wildcards to regex pattern - use [^/]+ to match path segments, not across slashes
426
- # This ensures that {param} only matches a single path segment
427
382
  regex = Regexp.new("^#{pattern_with_wildcards.gsub('*', '[^/]+')}$")
428
383
 
429
384
  debug_log("pattern_match?: regex: #{regex.inspect}")
@@ -1,12 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'resource'
4
-
5
3
  module Lutaml
6
4
  module Hal
7
- # Models the pagination of a collection of resources
8
- # This class is used to represent the pagination information
9
- # for a collection of resources in the HAL format.
10
5
  class Page < Resource
11
6
  attribute :page, :integer
12
7
  attribute :limit, :integer
@@ -23,153 +18,100 @@ module Lutaml
23
18
  def self.inherited(subclass)
24
19
  super
25
20
 
26
- # Skip automatic link creation for anonymous classes (used in tests)
27
21
  return unless subclass.name
28
22
 
29
23
  page_links_symbols = %i[self next prev first last up]
30
24
  subclass_name = subclass.name
31
25
  subclass.class_eval do
32
- # Define common page links
33
26
  page_links_symbols.each do |link_symbol|
34
27
  hal_link link_symbol, key: link_symbol.to_s, realize_class: subclass_name
35
28
  end
36
29
  end
37
30
  end
38
31
 
39
- # Returns the next page of results, or nil if on the last page
40
- #
41
- # @return [Object, nil] The next page instance or nil
42
32
  def next
43
33
  return nil unless links.next
44
34
 
45
35
  links.next.realize
46
36
  end
47
37
 
48
- # Returns the previous page of results, or nil if on the first page
49
- #
50
- # @return [Object, nil] The previous page instance or nil
51
38
  def prev
52
- # If the API provides a prev link, use it
53
39
  return links.prev.realize if links.prev
54
40
 
55
- # If we're on page 1, there's no previous page
56
41
  return nil if page <= 1
57
42
 
58
- # Construct the previous page URL manually
59
43
  prev_page_url = construct_page_url(page - 1)
60
44
  return nil unless prev_page_url
61
45
 
62
- # Use the HAL register to fetch the previous page
63
46
  register_name = _global_register_id
64
47
  return nil unless register_name
65
48
 
66
- hal_register = Lutaml::Hal::GlobalRegister.instance.get(register_name)
49
+ hal_register = GlobalRegister.instance.get(register_name)
67
50
  return nil unless hal_register
68
51
 
69
52
  hal_register.resolve_and_cast(nil, prev_page_url)
70
53
  end
71
54
 
72
- # Returns the first page of results
73
- #
74
- # @return [Object, nil] The first page instance or nil
75
55
  def first
76
56
  return nil unless links.first
77
57
 
78
58
  links.first.realize
79
59
  end
80
60
 
81
- # Returns the last page of results
82
- #
83
- # @return [Object, nil] The last page instance or nil
84
61
  def last
85
62
  return nil unless links.last
86
63
 
87
64
  links.last.realize
88
65
  end
89
66
 
90
- # Returns the total number of pages
91
- #
92
- # @return [Integer] The total number of pages
93
67
  def total_pages
94
68
  pages
95
69
  end
96
70
 
97
- # Checks if there is a next page available
98
- #
99
- # @return [Boolean] true if next page exists, false otherwise
100
71
  def next?
101
72
  !links.next.nil?
102
73
  end
103
74
 
104
- # Checks if there is a previous page available
105
- #
106
- # @return [Boolean] true if previous page exists, false otherwise
107
75
  def prev?
108
76
  !links.prev.nil?
109
77
  end
110
78
 
111
- # Checks if there is a first page link available
112
- #
113
- # @return [Boolean] true if first page link exists, false otherwise
114
79
  def first?
115
80
  !links.first.nil?
116
81
  end
117
82
 
118
- # Checks if there is a last page link available
119
- #
120
- # @return [Boolean] true if last page link exists, false otherwise
121
83
  def last?
122
84
  !links.last.nil?
123
85
  end
124
86
 
125
- # Returns the next page link, or nil if on the last page
126
- #
127
- # @return [Object, nil] The next page link or nil
128
87
  def next_page
129
88
  links.next
130
89
  end
131
90
 
132
- # Returns the previous page link, or nil if on the first page
133
- #
134
- # @return [Object, nil] The previous page link or nil
135
91
  def prev_page
136
92
  links.prev
137
93
  end
138
94
 
139
- # Returns the first page link
140
- #
141
- # @return [Object, nil] The first page link or nil
142
95
  def first_page
143
96
  links.first
144
97
  end
145
98
 
146
- # Returns the last page link
147
- #
148
- # @return [Object, nil] The last page link or nil
149
99
  def last_page
150
100
  links.last
151
101
  end
152
102
 
153
103
  private
154
104
 
155
- # Constructs a URL for a specific page based on the current page's URL pattern
156
- #
157
- # @param target_page [Integer] The page number to construct URL for
158
- # @return [String, nil] The constructed URL or nil if unable to construct
159
105
  def construct_page_url(target_page)
160
- # Try to get a reference URL from next, first, or last links
161
106
  reference_url = links.next&.href || links.first&.href || links.last&.href
162
107
  return nil unless reference_url
163
108
 
164
- # Parse the reference URL and modify the page parameter
165
109
  uri = URI.parse(reference_url)
166
110
  query_params = URI.decode_www_form(uri.query || '')
167
111
 
168
- # Update the page parameter
169
112
  query_params = query_params.reject { |key, _| key == 'page' }
170
113
  query_params << ['page', target_page.to_s]
171
114
 
172
- # Reconstruct the URL
173
115
  uri.query = URI.encode_www_form(query_params)
174
116
  uri.to_s
175
117
  end
@@ -2,7 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Hal
5
- # Rate limiter to handle API rate limiting with exponential backoff
6
5
  class RateLimiter
7
6
  DEFAULT_MAX_RETRIES = 5
8
7
  DEFAULT_BASE_DELAY = 0.05
@@ -16,10 +15,9 @@ module Lutaml
16
15
  @base_delay = options[:base_delay] || DEFAULT_BASE_DELAY
17
16
  @max_delay = options[:max_delay] || DEFAULT_MAX_DELAY
18
17
  @backoff_factor = options[:backoff_factor] || DEFAULT_BACKOFF_FACTOR
19
- @enabled = options[:enabled] != false # Default to enabled unless explicitly disabled
18
+ @enabled = options[:enabled] != false
20
19
  end
21
20
 
22
- # Execute a block with rate limiting and retry logic
23
21
  def with_rate_limiting
24
22
  return yield unless @enabled
25
23
 
@@ -36,47 +34,27 @@ module Lutaml
36
34
  end
37
35
  end
38
36
 
39
- # Check if we should retry based on the error and attempt count
40
37
  def should_retry?(error, attempt)
41
38
  return false if attempt > @max_retries
42
39
 
43
- case error
44
- when TooManyRequestsError
45
- true
46
- when ServerError
47
- # Always retry on server errors
48
- true
49
- else
50
- false
51
- end
40
+ error.is_a?(TooManyRequestsError) || error.is_a?(ServerError)
52
41
  end
53
42
 
54
- # Calculate delay with exponential backoff
55
43
  def calculate_delay(attempt, error = nil)
56
- # Check for Retry-After header if it's a rate limit error
57
- if error.is_a?(TooManyRequestsError) && error.respond_to?(:response) && error.response
58
- retry_after = extract_retry_after(error.response)
59
- return retry_after if retry_after
60
- end
44
+ return retry_after_from_error(error) if error.is_a?(TooManyRequestsError) && retry_after_from_error(error)
61
45
 
62
- # Exponential backoff: base_delay * (backoff_factor ^ (attempt - 1))
63
46
  delay = @base_delay * (@backoff_factor**(attempt - 1))
64
-
65
- # Cap at max_delay
66
47
  [delay, @max_delay].min
67
48
  end
68
49
 
69
- # Extract Retry-After header value
70
50
  def extract_retry_after(response)
71
51
  headers = response[:headers] || {}
72
52
  retry_after = headers['retry-after'] || headers['Retry-After']
73
53
  return nil unless retry_after
74
54
 
75
- # Retry-After can be in seconds (integer) or HTTP date
76
55
  if retry_after.match?(/^\d+$/)
77
56
  retry_after.to_i
78
57
  else
79
- # Parse HTTP date and calculate seconds from now
80
58
  begin
81
59
  retry_time = Time.parse(retry_after)
82
60
  [retry_time - Time.now, 0].max
@@ -86,20 +64,26 @@ module Lutaml
86
64
  end
87
65
  end
88
66
 
89
- # Enable rate limiting
90
67
  def enable!
91
68
  @enabled = true
92
69
  end
93
70
 
94
- # Disable rate limiting
95
71
  def disable!
96
72
  @enabled = false
97
73
  end
98
74
 
99
- # Check if rate limiting is enabled
100
75
  def enabled?
101
76
  @enabled
102
77
  end
78
+
79
+ private
80
+
81
+ def retry_after_from_error(error)
82
+ return nil unless error.is_a?(TooManyRequestsError)
83
+ return nil unless error.response
84
+
85
+ extract_retry_after(error.response)
86
+ end
103
87
  end
104
88
  end
105
89
  end