lutaml-hal 0.2.3 → 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 +4 -4
- data/lib/lutaml/hal/client.rb +57 -25
- data/lib/lutaml/hal/errors.rb +12 -1
- data/lib/lutaml/hal/rate_limiter.rb +26 -4
- data/lib/lutaml/hal/version.rb +1 -1
- data/lib/lutaml/hal.rb +1 -0
- metadata +4 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 59361d090cefb4d2158d64a1bef3f644d29de1d84c577f6744e0dd415c734be1
|
|
4
|
+
data.tar.gz: 4240bf652f5de8fd2a173406ecd7bd3fd139efffacc6d4e49fddcce37f3a2d24
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 92335a8f9826cc136d92611184bc1e562728eb48039c09f29c1ebaa5adfb01963520c544137e666a9c5a7d8e38a125a93728f16b4fec96eca8331ae9c69c31cb
|
|
7
|
+
data.tar.gz: fca046d05353c72e7d10e667028183f20fb5ce6dc3e6f705b0905efe2b2ff9bae3860a87d35eda8e563aeef1465237a9d8732828635098ea1c006c346093d0d5
|
data/lib/lutaml/hal/client.rb
CHANGED
|
@@ -8,7 +8,12 @@ require 'rainbow'
|
|
|
8
8
|
module Lutaml
|
|
9
9
|
module Hal
|
|
10
10
|
class Client
|
|
11
|
-
|
|
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
|
-
|
|
38
|
-
@
|
|
39
|
-
|
|
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
|
-
|
|
53
|
-
@
|
|
54
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
data/lib/lutaml/hal/errors.rb
CHANGED
|
@@ -2,10 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
module Lutaml
|
|
4
4
|
module Hal
|
|
5
|
-
class Error < StandardError
|
|
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
|
-
|
|
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)
|
data/lib/lutaml/hal/version.rb
CHANGED
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
|
+
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-
|
|
11
|
+
date: 2026-08-20 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: faraday
|
|
@@ -58,14 +58,14 @@ dependencies:
|
|
|
58
58
|
requirements:
|
|
59
59
|
- - "~>"
|
|
60
60
|
- !ruby/object:Gem::Version
|
|
61
|
-
version: 0.
|
|
61
|
+
version: '0.2'
|
|
62
62
|
type: :runtime
|
|
63
63
|
prerelease: false
|
|
64
64
|
version_requirements: !ruby/object:Gem::Requirement
|
|
65
65
|
requirements:
|
|
66
66
|
- - "~>"
|
|
67
67
|
- !ruby/object:Gem::Version
|
|
68
|
-
version: 0.
|
|
68
|
+
version: '0.2'
|
|
69
69
|
- !ruby/object:Gem::Dependency
|
|
70
70
|
name: rainbow
|
|
71
71
|
requirement: !ruby/object:Gem::Requirement
|