lutaml-hal 0.2.4 → 0.2.5

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: 92019434dffa3ebb7b26f536e37778edb1b56e6e9cae58e867bfc5aefa2af780
4
- data.tar.gz: bb79ba24aef2c088cadb6dca19ee324ee271910c58f819e6461ed3041be78f2b
3
+ metadata.gz: 59361d090cefb4d2158d64a1bef3f644d29de1d84c577f6744e0dd415c734be1
4
+ data.tar.gz: 4240bf652f5de8fd2a173406ecd7bd3fd139efffacc6d4e49fddcce37f3a2d24
5
5
  SHA512:
6
- metadata.gz: ede7ea442d0b57f837709be49fe02e66b426c9845a44d452ab4a756059d61bc94e682b1ca7d01fb1ac68a7abb8b5e07d874b7d52298e2fad81220b114fe8ad45
7
- data.tar.gz: 9420249a6edd7e8fa9eb17c2ca4f370053d4dee92a5810ba587ac28bddd51d355c942405b698c083a671473aee56a01b54dadfdd68030735f8effb8b5f9cdb85
6
+ metadata.gz: 92335a8f9826cc136d92611184bc1e562728eb48039c09f29c1ebaa5adfb01963520c544137e666a9c5a7d8e38a125a93728f16b4fec96eca8331ae9c69c31cb
7
+ data.tar.gz: fca046d05353c72e7d10e667028183f20fb5ce6dc3e6f705b0905efe2b2ff9bae3860a87d35eda8e563aeef1465237a9d8732828635098ea1c006c346093d0d5
@@ -8,7 +8,12 @@ require 'rainbow'
8
8
  module Lutaml
9
9
  module Hal
10
10
  class Client
11
- attr_reader :last_response, :api_url, :connection, :rate_limiter
11
+ # Thread-variable (not Thread#[], which is fiber-local) holding this
12
+ # thread's most recent [client, response] pair. One fixed key, one entry
13
+ # per thread: nothing accumulates as clients come and go.
14
+ LAST_RESPONSE_KEY = :lutaml_hal_last_response
15
+
16
+ attr_reader :api_url, :connection, :rate_limiter
12
17
 
13
18
  def initialize(options = {})
14
19
  @api_url = options[:api_url] || raise(ArgumentError, 'api_url is required')
@@ -19,6 +24,24 @@ module Lutaml
19
24
  @api_url = strip_api_url(@api_url)
20
25
  end
21
26
 
27
+ # The raw Faraday response from the most recent request issued by *this*
28
+ # thread, or nil if this thread has issued none through this client.
29
+ # Instance-wide state would be unsafe: a single Client is routinely shared
30
+ # across threads, and one thread must never observe another's response.
31
+ #
32
+ # Scoped to the thread's single most recent request rather than kept
33
+ # per-client, so a thread retains exactly one response no matter how many
34
+ # clients it uses. A thread that then issues a request through a different
35
+ # client reports nil here.
36
+ #
37
+ # Note that under SingleFlight coalescing, follower threads issue no
38
+ # request of their own, so their last_response is unchanged by the
39
+ # coalesced fetch.
40
+ def last_response
41
+ client, response = Thread.current.thread_variable_get(LAST_RESPONSE_KEY)
42
+ response if client.equal?(self)
43
+ end
44
+
22
45
  def strip_api_url(url)
23
46
  url.sub(%r{/\Z}, '')
24
47
  end
@@ -34,27 +57,28 @@ module Lutaml
34
57
  end
35
58
 
36
59
  def get(url, params = {})
37
- @rate_limiter.with_rate_limiting do
38
- @last_response = @connection.get(url, params)
39
- handle_response(@last_response, url)
60
+ with_faraday_errors do
61
+ @rate_limiter.with_rate_limiting do
62
+ handle_response(record_response(@connection.get(url, params)), url)
63
+ end
40
64
  end
41
- rescue Faraday::ConnectionFailed => e
42
- raise ConnectionError, "Connection failed: #{e.message}"
43
- rescue Faraday::TimeoutError => e
44
- raise TimeoutError, "Request timed out: #{e.message}"
45
- rescue Faraday::ParsingError => e
46
- raise ParsingError, "Response parsing error: #{e.message}"
47
- rescue Faraday::Adapter::Test::Stubs::NotFound => e
48
- raise LinkResolutionError, "Resource not found: #{e.message}"
49
65
  end
50
66
 
51
67
  def get_with_headers(url, headers = {})
52
- @rate_limiter.with_rate_limiting do
53
- @last_response = @connection.get(url) do |req|
54
- headers.each { |key, value| req.headers[key] = value }
68
+ with_faraday_errors do
69
+ @rate_limiter.with_rate_limiting do
70
+ response = @connection.get(url) do |req|
71
+ headers.each { |key, value| req.headers[key] = value }
72
+ end
73
+ handle_response(record_response(response), url)
55
74
  end
56
- handle_response(@last_response, url)
57
75
  end
76
+ end
77
+
78
+ private
79
+
80
+ def with_faraday_errors
81
+ yield
58
82
  rescue Faraday::ConnectionFailed => e
59
83
  raise ConnectionError, "Connection failed: #{e.message}"
60
84
  rescue Faraday::TimeoutError => e
@@ -65,7 +89,13 @@ module Lutaml
65
89
  raise LinkResolutionError, "Resource not found: #{e.message}"
66
90
  end
67
91
 
68
- private
92
+ # Publish the response on #last_response and return it, so callers keep
93
+ # working with their own local reference rather than re-reading shared
94
+ # state that another thread may already have replaced.
95
+ def record_response(response)
96
+ Thread.current.thread_variable_set(LAST_RESPONSE_KEY, [self, response])
97
+ response
98
+ end
69
99
 
70
100
  def create_connection
71
101
  Faraday.new(url: @api_url) do |conn|
@@ -86,23 +116,25 @@ module Lutaml
86
116
  raise BadRequestError, response_message(response)
87
117
  when 401
88
118
  raise UnauthorizedError, response_message(response)
119
+ when 403
120
+ raise ForbiddenError.new(response_message(response), response: response_context(response))
89
121
  when 404
90
122
  raise NotFoundError, response_message(response)
91
123
  when 429
92
- TooManyRequestsError.new(response_message(response)).tap do |error|
93
- error.define_singleton_method(:response) { { status: response.status, headers: response.headers } }
94
- raise error
95
- end
124
+ raise TooManyRequestsError.new(response_message(response), response: response_context(response))
96
125
  when 500..599
97
- ServerError.new(response_message(response)).tap do |error|
98
- error.define_singleton_method(:response) { { status: response.status, headers: response.headers } }
99
- raise error
100
- end
126
+ raise ServerError.new(response_message(response), response: response_context(response))
101
127
  else
102
128
  raise Error, response_message(response)
103
129
  end
104
130
  end
105
131
 
132
+ # HTTP context attached to retryable errors so callers (and RateLimiter)
133
+ # can read Retry-After without holding on to the Faraday response.
134
+ def response_context(response)
135
+ { status: response.status, headers: response.headers }
136
+ end
137
+
106
138
  def debug_api_log(response, url)
107
139
  if defined?(Rainbow)
108
140
  puts Rainbow("\n===== Lutaml::Hal DEBUG: HAL API REQUEST =====").blue
@@ -2,10 +2,21 @@
2
2
 
3
3
  module Lutaml
4
4
  module Hal
5
- class Error < StandardError; end
5
+ class Error < StandardError
6
+ # HTTP context for errors mapped from a response status, as
7
+ # `{ status: Integer, headers: Hash }`. Nil for locally raised errors.
8
+ attr_reader :response
9
+
10
+ def initialize(message = nil, response: nil)
11
+ message.nil? ? super() : super(message)
12
+ @response = response
13
+ end
14
+ end
15
+
6
16
  class NotFoundError < Error; end
7
17
  class UnauthorizedError < Error; end
8
18
  class BadRequestError < Error; end
19
+ class ForbiddenError < Error; end
9
20
  class ServerError < Error; end
10
21
  class LinkResolutionError < Error; end
11
22
  class ParsingError < Error; end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'time' # Time.parse, used by #extract_retry_after for HTTP-date values
4
+
3
5
  module Lutaml
4
6
  module Hal
5
7
  class RateLimiter
@@ -7,15 +9,25 @@ module Lutaml
7
9
  DEFAULT_BASE_DELAY = 0.05
8
10
  DEFAULT_MAX_DELAY = 5.0
9
11
  DEFAULT_BACKOFF_FACTOR = 1.5
12
+ # Separate, far larger cap for server-instructed waits. max_delay bounds
13
+ # our own guesswork; when the server states how long to wait, honouring it
14
+ # is the point -- capping a `Retry-After: 60` at a 5s backoff cap would
15
+ # hammer an API that explicitly asked us to back off. This cap exists only
16
+ # to stop an absurd value (`Retry-After: 3600`) parking a worker thread.
17
+ DEFAULT_MAX_RETRY_AFTER = 300.0
10
18
 
11
- attr_reader :max_retries, :base_delay, :max_delay, :backoff_factor
19
+ attr_reader :max_retries, :base_delay, :max_delay, :backoff_factor, :max_retry_after
12
20
 
13
21
  def initialize(options = {})
14
22
  @max_retries = options[:max_retries] || DEFAULT_MAX_RETRIES
15
23
  @base_delay = options[:base_delay] || DEFAULT_BASE_DELAY
16
24
  @max_delay = options[:max_delay] || DEFAULT_MAX_DELAY
17
25
  @backoff_factor = options[:backoff_factor] || DEFAULT_BACKOFF_FACTOR
26
+ @max_retry_after = options[:max_retry_after] || DEFAULT_MAX_RETRY_AFTER
18
27
  @enabled = options[:enabled] != false
28
+ # Off by default: 403 usually means "not allowed", not "slow down".
29
+ # Some APIs (historically the W3C API) use it as a rate-limit signal.
30
+ @retry_on_forbidden = options[:retry_on_forbidden] ? true : false
19
31
  end
20
32
 
21
33
  def with_rate_limiting
@@ -25,7 +37,7 @@ module Lutaml
25
37
  begin
26
38
  attempt += 1
27
39
  yield
28
- rescue TooManyRequestsError, ServerError => e
40
+ rescue TooManyRequestsError, ServerError, ForbiddenError => e
29
41
  raise unless should_retry?(e, attempt)
30
42
 
31
43
  delay = calculate_delay(attempt, e)
@@ -36,12 +48,22 @@ module Lutaml
36
48
 
37
49
  def should_retry?(error, attempt)
38
50
  return false if attempt > @max_retries
51
+ return @retry_on_forbidden if error.is_a?(ForbiddenError)
39
52
 
40
53
  error.is_a?(TooManyRequestsError) || error.is_a?(ServerError)
41
54
  end
42
55
 
56
+ def retry_on_forbidden?
57
+ @retry_on_forbidden
58
+ end
59
+
43
60
  def calculate_delay(attempt, error = nil)
44
- return retry_after_from_error(error) if error.is_a?(TooManyRequestsError) && retry_after_from_error(error)
61
+ # Retry-After is bounded at both ends: capped at max_retry_after so an
62
+ # absurd value can't park the thread, floored at base_delay because a
63
+ # `Retry-After: 0` (or a date already in the past) would otherwise
64
+ # sleep(0) and burn the whole retry budget in a busy loop.
65
+ retry_after = retry_after_from_error(error)
66
+ return retry_after.clamp(@base_delay, @max_retry_after) if retry_after
45
67
 
46
68
  delay = @base_delay * (@backoff_factor**(attempt - 1))
47
69
  [delay, @max_delay].min
@@ -79,7 +101,7 @@ module Lutaml
79
101
  private
80
102
 
81
103
  def retry_after_from_error(error)
82
- return nil unless error.is_a?(TooManyRequestsError)
104
+ return nil unless error.is_a?(TooManyRequestsError) || error.is_a?(ForbiddenError)
83
105
  return nil unless error.response
84
106
 
85
107
  extract_retry_after(error.response)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Hal
5
- VERSION = '0.2.4'
5
+ VERSION = '0.2.5'
6
6
  end
7
7
  end
data/lib/lutaml/hal.rb CHANGED
@@ -16,6 +16,7 @@ module Lutaml
16
16
  autoload :NotFoundError, 'lutaml/hal/errors'
17
17
  autoload :UnauthorizedError, 'lutaml/hal/errors'
18
18
  autoload :BadRequestError, 'lutaml/hal/errors'
19
+ autoload :ForbiddenError, 'lutaml/hal/errors'
19
20
  autoload :ServerError, 'lutaml/hal/errors'
20
21
  autoload :LinkResolutionError, 'lutaml/hal/errors'
21
22
  autoload :ParsingError, 'lutaml/hal/errors'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-hal
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.4
4
+ version: 0.2.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-11 00:00:00.000000000 Z
11
+ date: 2026-08-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday