berkeley_library-util 0.1.7 → 0.1.9

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: 71143c349265481e222ad5c0330ad1f995697139d114f9b9a2615c8d9cb8ea4b
4
- data.tar.gz: 3ad66018de9e3ff7efe0d5637c9c79ea616da7510aff3c6f8f9bc07ed83cbb59
3
+ metadata.gz: fdc84acc6adb94fcfbbcf7d18b9a6bead8ef484ef40028f139b07964b565b4e2
4
+ data.tar.gz: 43e850e1ee541d777c07f546f789e576b45a44580734206c44cd2dce4d3293a1
5
5
  SHA512:
6
- metadata.gz: f0563b8e84cdeff86055d4423ddf57a6559c7e9a80527b643fe9cf4c10db2a62cbd8e122fc0045da56778b00ac6486ebff8ab00a5452721f385a93e9f8fc6e12
7
- data.tar.gz: c19e2f817542e98ec80b9e05faf6bdaad821ab8f6589638c3790611de8231cd7bbf2a68f3d0a520fb7b96a8b0c16ac06685ae547ad6c5b7023468bea50ec4f01
6
+ metadata.gz: dc47ba39cee501522db01aceeba7060f51ee4270ca03a2d4263e836190eddc2e953fd83eb292379ce4fcdbd5a9bac3831ec76c750cb2ecd9a375c86a60533079
7
+ data.tar.gz: 0dc4190292083d68066528c6a7efa815667ccc6ba79fb75ba0d0fa5e5f1ab354465447d1d7b53bbf825426e010bd4954b0ada17f3b935ae68095d8d0795940c1
@@ -11,7 +11,7 @@ jobs:
11
11
 
12
12
  steps:
13
13
  - name: Check out repository
14
- uses: actions/checkout@v2
14
+ uses: actions/checkout@v3
15
15
 
16
16
  - name: Set up Ruby
17
17
  uses: ruby/setup-ruby@v1
data/CHANGES.md CHANGED
@@ -1,3 +1,12 @@
1
+ # 0.1.9 (2023-06-01)
2
+
3
+ - `URIs#path_escape` now attempts to convert non-UTF-8 strings to UTF-8 rather than immediately
4
+ raising an error.
5
+
6
+ # 0.1.8 (2023-03-20)
7
+
8
+ - Add `Retry-After` support to `Requester` for `429 Too Many Requests` and `503 Service Unavailable`.
9
+
1
10
  # 0.1.7 (2023-03-15)
2
11
 
3
12
  - Allow passing `log: false` to `Requester` methods (and corresponding `URIs` convenience
@@ -7,7 +7,7 @@ module BerkeleyLibrary
7
7
  SUMMARY = 'Miscellaneous Ruby utilities for the UC Berkeley Library'.freeze
8
8
  DESCRIPTION = 'A collection of miscellaneous Ruby routines for the UC Berkeley Library.'.freeze
9
9
  LICENSE = 'MIT'.freeze
10
- VERSION = '0.1.7'.freeze
10
+ VERSION = '0.1.9'.freeze
11
11
  HOMEPAGE = 'https://github.com/BerkeleyLibrary/util'.freeze
12
12
  end
13
13
  end
@@ -0,0 +1,15 @@
1
+ class RetryDelayTooLarge < RestClient::Exception
2
+ def initialize(response, delay:, max_delay:)
3
+ super(response, response.code)
4
+
5
+ self.message = 'Retry delay of %0.2gs exceeds limit of %0.2gs' % [delay, max_delay]
6
+ end
7
+ end
8
+
9
+ class RetryLimitExceeded < RestClient::Exception
10
+ def initialize(response, max_retries:)
11
+ super(response, response.code)
12
+
13
+ self.message = "Retry limit (#{max_retries}) exceeded"
14
+ end
15
+ end
@@ -0,0 +1,89 @@
1
+ module BerkeleyLibrary
2
+ module Util
3
+ module URIs
4
+ class Requester
5
+ # rubocop:disable Metrics/ParameterLists
6
+ module ClassMethods
7
+ # Performs a GET request and returns the response body as a string.
8
+ #
9
+ # @param uri [URI, String] the URI to GET
10
+ # @param params [Hash] the query parameters to add to the URI. (Note that the URI may already include query parameters.)
11
+ # @param headers [Hash] the request headers.
12
+ # @return [String] the body as a string.
13
+ # @param log [Boolean] whether to log each request URL and response code
14
+ # @param max_retries [Integer] the maximum number of times to retry after a 429 or 503 with Retry-After
15
+ # @param max_retry_delay [Integer] the maximum retry delay (in seconds) to accept in a Retry-After header
16
+ # @raise [RestClient::Exception] in the event of an unsuccessful request.
17
+ def get(uri, params: {}, headers: {}, log: true, max_retries: MAX_RETRIES, max_retry_delay: MAX_RETRY_DELAY_SECONDS)
18
+ resp = make_request(:get, uri, params, headers, log, max_retries, max_retry_delay)
19
+ resp.body
20
+ end
21
+
22
+ # Performs a HEAD request and returns the response status as an integer.
23
+ # Note that unlike {Requester#get}, this does not raise an error in the
24
+ # event of an unsuccessful request.
25
+ #
26
+ # @param uri [URI, String] the URI to HEAD
27
+ # @param params [Hash] the query parameters to add to the URI. (Note that the URI may already include query parameters.)
28
+ # @param headers [Hash] the request headers.
29
+ # @param log [Boolean] whether to log each request URL and response code
30
+ # @return [Integer] the response code as an integer.
31
+ def head(uri, params: {}, headers: {}, log: true, max_retries: MAX_RETRIES, max_retry_delay: MAX_RETRY_DELAY_SECONDS)
32
+ head_response(uri, params: params, headers: headers, log: log, max_retries: max_retries, max_retry_delay: max_retry_delay).code
33
+ end
34
+
35
+ # Performs a GET request and returns the response, even in the event of
36
+ # a failed request.
37
+ #
38
+ # @param uri [URI, String] the URI to GET
39
+ # @param params [Hash] the query parameters to add to the URI. (Note that the URI may already include query parameters.)
40
+ # @param headers [Hash] the request headers.
41
+ # @param log [Boolean] whether to log each request URL and response code
42
+ # @return [RestClient::Response] the response
43
+ def get_response(uri, params: {}, headers: {}, log: true, max_retries: MAX_RETRIES, max_retry_delay: MAX_RETRY_DELAY_SECONDS)
44
+ make_request(:get, uri, params, headers, log, max_retries, max_retry_delay)
45
+ rescue RestClient::Exception => e
46
+ e.response
47
+ end
48
+
49
+ # Performs a HEAD request and returns the response, even in the event of
50
+ # a failed request.
51
+ #
52
+ # @param uri [URI, String] the URI to HEAD
53
+ # @param params [Hash] the query parameters to add to the URI. (Note that the URI may already include query parameters.)
54
+ # @param headers [Hash] the request headers.
55
+ # @param log [Boolean] whether to log each request URL and response code
56
+ # @return [RestClient::Response] the response
57
+ def head_response(uri, params: {}, headers: {}, log: true, max_retries: MAX_RETRIES, max_retry_delay: MAX_RETRY_DELAY_SECONDS)
58
+ make_request(:head, uri, params, headers, log, max_retries, max_retry_delay)
59
+ rescue RestClient::Exception => e
60
+ e.response
61
+ end
62
+
63
+ private
64
+
65
+ def make_request(method, url, params, headers, log, max_retries, max_retry_delay)
66
+ Requester.new(
67
+ method,
68
+ url,
69
+ params: params,
70
+ headers: headers,
71
+ log: log,
72
+ max_retries: max_retries,
73
+ max_retry_delay: max_retry_delay
74
+ ).make_request
75
+ end
76
+
77
+ end
78
+ # rubocop:enable Metrics/ParameterLists
79
+
80
+ # ------------------------------------------------------------
81
+ # Class methods
82
+
83
+ class << self
84
+ include ClassMethods
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -1,6 +1,9 @@
1
+ require 'time'
1
2
  require 'rest-client'
2
3
  require 'berkeley_library/util/uris/appender'
4
+ require 'berkeley_library/util/uris/exceptions'
3
5
  require 'berkeley_library/util/uris/validator'
6
+ require 'berkeley_library/util/uris/requester/class_methods'
4
7
  require 'berkeley_library/logging'
5
8
 
6
9
  module BerkeleyLibrary
@@ -9,80 +12,19 @@ module BerkeleyLibrary
9
12
  class Requester
10
13
  include BerkeleyLibrary::Logging
11
14
 
12
- # ------------------------------------------------------------
13
- # Class methods
14
-
15
- class << self
16
- # Performs a GET request and returns the response body as a string.
17
- #
18
- # @param uri [URI, String] the URI to GET
19
- # @param params [Hash] the query parameters to add to the URI. (Note that the URI may already include query parameters.)
20
- # @param headers [Hash] the request headers.
21
- # @return [String] the body as a string.
22
- # @param log [Boolean] whether to log each request URL and response code
23
- # @raise [RestClient::Exception] in the event of an unsuccessful request.
24
- def get(uri, params: {}, headers: {}, log: true)
25
- resp = make_request(:get, uri, params, headers, log)
26
- resp.body
27
- end
28
-
29
- # Performs a HEAD request and returns the response status as an integer.
30
- # Note that unlike {Requester#get}, this does not raise an error in the
31
- # event of an unsuccessful request.
32
- #
33
- # @param uri [URI, String] the URI to HEAD
34
- # @param params [Hash] the query parameters to add to the URI. (Note that the URI may already include query parameters.)
35
- # @param headers [Hash] the request headers.
36
- # @param log [Boolean] whether to log each request URL and response code
37
- # @return [Integer] the response code as an integer.
38
- def head(uri, params: {}, headers: {}, log: true)
39
- head_response(uri, params: params, headers: headers, log: log).code
40
- end
41
-
42
- # Performs a GET request and returns the response, even in the event of
43
- # a failed request.
44
- #
45
- # @param uri [URI, String] the URI to GET
46
- # @param params [Hash] the query parameters to add to the URI. (Note that the URI may already include query parameters.)
47
- # @param headers [Hash] the request headers.
48
- # @param log [Boolean] whether to log each request URL and response code
49
- # @return [RestClient::Response] the body as a string.
50
- def get_response(uri, params: {}, headers: {}, log: true)
51
- make_request(:get, uri, params, headers, log)
52
- rescue RestClient::Exception => e
53
- e.response
54
- end
55
-
56
- # Performs a HEAD request and returns the response, even in the event of
57
- # a failed request.
58
- #
59
- # @param uri [URI, String] the URI to HEAD
60
- # @param params [Hash] the query parameters to add to the URI. (Note that the URI may already include query parameters.)
61
- # @param headers [Hash] the request headers.
62
- # @param log [Boolean] whether to log each request URL and response code
63
- # @return [RestClient::Response] the response
64
- def head_response(uri, params: {}, headers: {}, log: true)
65
- make_request(:head, uri, params, headers, log)
66
- rescue RestClient::Exception => e
67
- e.response
68
- end
69
-
70
- private
71
-
72
- def make_request(method, url, params, headers, log)
73
- Requester.new(method, url, params: params, headers: headers, log: log).make_request
74
- end
75
- end
76
-
77
15
  # ------------------------------------------------------------
78
16
  # Constants
79
17
 
80
18
  SUPPORTED_METHODS = %i[get head].freeze
19
+ RETRY_HEADER = :retry_after
20
+ RETRY_STATUSES = [429, 503].freeze
21
+ MAX_RETRY_DELAY_SECONDS = 10
22
+ MAX_RETRIES = 3
81
23
 
82
24
  # ------------------------------------------------------------
83
25
  # Attributes
84
26
 
85
- attr_reader :method, :url_str, :headers, :log
27
+ attr_reader :method, :url_str, :headers, :log, :max_retries, :max_retry_delay
86
28
 
87
29
  # ------------------------------------------------------------
88
30
  # Initializer
@@ -94,8 +36,11 @@ module BerkeleyLibrary
94
36
  # @param params [Hash] the query parameters to add to the URI. (Note that the URI may already include query parameters.)
95
37
  # @param headers [Hash] the request headers.
96
38
  # @param log [Boolean] whether to log each request URL and response code
39
+ # @param max_retries [Integer] the maximum number of times to retry after a 429 or 503 with Retry-After
40
+ # @param max_retry_delay [Integer] the maximum retry delay (in seconds) to accept in a Retry-After header
97
41
  # @raise URI::InvalidURIError if the specified URL is invalid
98
- def initialize(method, url, params: {}, headers: {}, log: true)
42
+ # rubocop:disable Metrics/ParameterLists
43
+ def initialize(method, url, params: {}, headers: {}, log: true, max_retries: MAX_RETRIES, max_retry_delay: MAX_RETRY_DELAY_SECONDS)
99
44
  raise ArgumentError, "#{method} not supported" unless SUPPORTED_METHODS.include?(method)
100
45
  raise ArgumentError, 'url cannot be nil' unless (uri = Validator.uri_or_nil(url))
101
46
 
@@ -103,8 +48,12 @@ module BerkeleyLibrary
103
48
  @url_str = url_str_with_params(uri, params)
104
49
  @headers = headers
105
50
  @log = log
51
+ @max_retries = max_retries
52
+ @max_retry_delay = max_retry_delay
106
53
  end
107
54
 
55
+ # rubocop:enable Metrics/ParameterLists
56
+
108
57
  # ------------------------------------------------------------
109
58
  # Public instance methods
110
59
 
@@ -139,13 +88,30 @@ module BerkeleyLibrary
139
88
  Appender.new(*elements).to_url_str
140
89
  end
141
90
 
142
- def execute_request
91
+ def execute_request(retries_remaining = max_retries)
92
+ try_execute_request
93
+ rescue RestClient::Exception => e
94
+ response = e.response
95
+ raise unless (retry_delay = retry_delay_from(response))
96
+
97
+ wait_for_retry(response, retry_delay, retries_remaining)
98
+ execute_request(retries_remaining - 1)
99
+ end
100
+
101
+ def try_execute_request
143
102
  RestClient::Request.execute(method: method, url: url_str, headers: headers).tap do |response|
144
103
  # Not all failed RestClient requests throw exceptions
145
104
  raise(exception_for(response)) unless response.code == 200
146
105
  end
147
106
  end
148
107
 
108
+ def wait_for_retry(response, retry_delay, retries_remaining)
109
+ raise RetryLimitExceeded.new(response, max_retries: max_retries) unless retries_remaining > 0
110
+ raise RetryDelayTooLarge.new(response, delay: retry_delay, max_delay: max_retry_delay) if retry_delay > max_retry_delay
111
+
112
+ sleep(retry_delay)
113
+ end
114
+
149
115
  def exception_for(resp)
150
116
  status = resp.code
151
117
  ex_class_for(status).new(resp, status).tap do |ex|
@@ -158,6 +124,43 @@ module BerkeleyLibrary
158
124
  RestClient::Exceptions::EXCEPTIONS_MAP[status] || RestClient::RequestFailed
159
125
  end
160
126
 
127
+ # Returns the retry interval for the specified exception, or `nil`
128
+ # if the response does not allow a retry.
129
+ #
130
+ # @param resp [RestClient::Response] the response
131
+ # @return [Integer, nil] the retry delay in seconds, or `nil` if the response
132
+ # does not allow a retry
133
+ def retry_delay_from(resp)
134
+ return unless RETRY_STATUSES.include?(resp.code)
135
+ return unless (retry_header_value = resp.headers[RETRY_HEADER])
136
+ return unless (retry_delay_seconds = parse_retry_header_value(retry_header_value))
137
+
138
+ [1, retry_delay_seconds.ceil].max
139
+ end
140
+
141
+ # @return [Float, nil] the retry delay in seconds, or `nil` if the delay cannot be parsed
142
+ def parse_retry_header_value(v)
143
+ # start by assuming it's a delay in seconds
144
+ Float(v) # should be an integer but let's not count on it
145
+ rescue ArgumentError
146
+ # assume it's an HTTP-date
147
+ parse_retry_after_date(v)
148
+ end
149
+
150
+ # Parses the specified RFC2822 datetime string and returns the interval between that
151
+ # datetime and the current time in seconds
152
+ #
153
+ # @param date_str [String] an RFC2822 datetime string
154
+ # @return [Float, nil] the interval between the current time and the specified datetime,
155
+ # or nil if `date_str` cannot be parsed
156
+ def parse_retry_after_date(date_str)
157
+ retry_after = DateTime.rfc2822(date_str).to_time
158
+ retry_after - Time.now
159
+ rescue ArgumentError
160
+ logger.warn("Can't parse #{RETRY_HEADER} value #{date_str}")
161
+ nil
162
+ end
163
+
161
164
  end
162
165
  end
163
166
  end
@@ -88,8 +88,8 @@ module BerkeleyLibrary
88
88
  # replacing disallowed characters (including /) with percent-encodings as needed.
89
89
  def path_escape(s)
90
90
  raise ArgumentError, "Can't escape #{s.inspect}: not a string" unless s.respond_to?(:encoding)
91
- raise ArgumentError, "Can't escape #{s.inspect}: expected #{UTF_8}, was #{s.encoding}" unless s.encoding == UTF_8
92
91
 
92
+ s = s.encode(UTF_8) unless s.encoding == UTF_8
93
93
  ''.tap do |escaped|
94
94
  s.bytes.each do |b|
95
95
  escaped << (should_escape?(b, :path_segment) ? '%%%02X' % b : b.chr)
@@ -65,6 +65,250 @@ module BerkeleyLibrary
65
65
  result = Requester.get(url1)
66
66
  expect(result).to eq(expected_body)
67
67
  end
68
+
69
+ describe 'retries' do
70
+ let(:url) { 'https://example.org/' }
71
+ let(:expected_body) { 'Help! I am trapped in a unit test' }
72
+
73
+ context 'handling 429 Too Many Requests' do
74
+ context 'with Retry-After' do
75
+ context 'in seconds' do
76
+ it 'retries after the specified delay' do
77
+ retry_after_seconds = 1
78
+
79
+ stub_request(:get, url)
80
+ .to_return(status: 429, headers: { 'Retry-After' => retry_after_seconds.to_s })
81
+ .to_return(status: 200, body: expected_body)
82
+
83
+ requester = Requester.new(:get, url)
84
+ expect(requester).to receive(:sleep).with(1).once
85
+
86
+ result = requester.make_request
87
+ expect(result).to eq(expected_body)
88
+ end
89
+
90
+ it 'handles a non-integer retry delay' do
91
+ retry_after_seconds = 1.5
92
+ stub_request(:get, url)
93
+ .to_return(status: 429, headers: { 'Retry-After' => retry_after_seconds.to_s })
94
+ .to_return(status: 200, body: expected_body)
95
+
96
+ requester = Requester.new(:get, url)
97
+ expect(requester).to receive(:sleep).with(2).once
98
+
99
+ result = requester.make_request
100
+ expect(result).to eq(expected_body)
101
+ end
102
+
103
+ it 'raises RetryDelayTooLarge if the delay is too large' do
104
+ retry_after_seconds = 10 + BerkeleyLibrary::Util::URIs::Requester::MAX_RETRY_DELAY_SECONDS
105
+
106
+ stub_request(:get, url)
107
+ .to_return(status: 429, headers: { 'Retry-After' => retry_after_seconds.to_s })
108
+
109
+ requester = Requester.new(:get, url)
110
+ expect(requester).not_to receive(:sleep)
111
+
112
+ expect { requester.make_request }.to raise_error(RetryDelayTooLarge) do |ex|
113
+ expect(ex.cause).to be_a(RestClient::TooManyRequests)
114
+ end
115
+ end
116
+
117
+ it 'raises RetryLimitExceeded if there are too many retries' do
118
+ retry_after_seconds = 1
119
+
120
+ stub_request(:get, url)
121
+ .to_return(status: 429, headers: { 'Retry-After' => retry_after_seconds.to_s })
122
+ .to_return(status: 429, headers: { 'Retry-After' => retry_after_seconds.to_s })
123
+
124
+ requester = Requester.new(:get, url, max_retries: 1)
125
+ expect(requester).to receive(:sleep).with(1).once
126
+
127
+ expect { requester.make_request }.to raise_error(RetryLimitExceeded) do |ex|
128
+ expect(ex.cause).to be_a(RestClient::TooManyRequests)
129
+ end
130
+ end
131
+ end
132
+
133
+ context 'as RFC2822 datetime' do
134
+ it 'retries after the specified delay' do
135
+ retry_after_seconds = 1
136
+ retry_after_datetime = (Time.now + retry_after_seconds)
137
+
138
+ stub_request(:get, url)
139
+ .to_return(status: 429, headers: { 'Retry-After' => retry_after_datetime.rfc2822 })
140
+ .to_return(status: 200, body: expected_body)
141
+
142
+ requester = Requester.new(:get, url)
143
+ expect(requester).to receive(:sleep).with(1).once
144
+
145
+ result = requester.make_request
146
+ expect(result).to eq(expected_body)
147
+ end
148
+
149
+ it 'handles a non-integer retry delay' do
150
+ retry_after_seconds = 2.75
151
+ retry_after_datetime = (Time.now + retry_after_seconds)
152
+
153
+ stub_request(:get, url)
154
+ .to_return(status: 429, headers: { 'Retry-After' => retry_after_datetime.rfc2822 })
155
+ .to_return(status: 200, body: expected_body)
156
+
157
+ requester = Requester.new(:get, url)
158
+ expected_value = a_value_within(1).of(retry_after_seconds)
159
+ expect(requester).to receive(:sleep).with(expected_value).once
160
+
161
+ result = requester.make_request
162
+ expect(result).to eq(expected_body)
163
+ end
164
+
165
+ it 'raises RetryDelayTooLarge if the delay is too large' do
166
+ retry_after_seconds = 10 + BerkeleyLibrary::Util::URIs::Requester::MAX_RETRY_DELAY_SECONDS
167
+ retry_after_datetime = (Time.now + retry_after_seconds)
168
+
169
+ stub_request(:get, url)
170
+ .to_return(status: 429, headers: { 'Retry-After' => retry_after_datetime.rfc2822 })
171
+
172
+ requester = Requester.new(:get, url)
173
+ expect(requester).not_to receive(:sleep)
174
+
175
+ expect { requester.make_request }.to raise_error(RetryDelayTooLarge) do |ex|
176
+ expect(ex.cause).to be_a(RestClient::TooManyRequests)
177
+ end
178
+ end
179
+
180
+ it 'raises RetryLimitExceeded if there are too many retries' do
181
+ retry_after_seconds = 1
182
+ retry_after_datetime = (Time.now + retry_after_seconds)
183
+
184
+ stub_request(:get, url)
185
+ .to_return(status: 429, headers: { 'Retry-After' => retry_after_datetime.rfc2822 })
186
+ .to_return(status: 429, headers: { 'Retry-After' => retry_after_datetime.rfc2822 })
187
+
188
+ requester = Requester.new(:get, url, max_retries: 1)
189
+ expect(requester).to receive(:sleep).with(1).once
190
+
191
+ expect { requester.make_request }.to raise_error(RetryLimitExceeded) do |ex|
192
+ expect(ex.cause).to be_a(RestClient::TooManyRequests)
193
+ end
194
+ end
195
+
196
+ end
197
+
198
+ it 'ignores an invalid Retry-After' do
199
+ stub_request(:get, url)
200
+ .to_return(status: 429, headers: { 'Retry-After' => 'the end of the world' })
201
+
202
+ requester = Requester.new(:get, url)
203
+ expect { requester.make_request }.to raise_error(RestClient::TooManyRequests)
204
+ end
205
+ end
206
+ end
207
+
208
+ context 'handling 503 Service Unavailable' do
209
+ context 'with Retry-After' do
210
+ context 'in seconds' do
211
+ it 'retries after the specified delay' do
212
+ retry_after_seconds = 1
213
+
214
+ stub_request(:get, url)
215
+ .to_return(status: 503, headers: { 'Retry-After' => retry_after_seconds.to_s })
216
+ .to_return(status: 200, body: expected_body)
217
+
218
+ requester = Requester.new(:get, url)
219
+ expect(requester).to receive(:sleep).with(1).once
220
+
221
+ result = requester.make_request
222
+ expect(result).to eq(expected_body)
223
+ end
224
+
225
+ it 'handles a non-integer retry delay' do
226
+ retry_after_seconds = 0.75
227
+ stub_request(:get, url)
228
+ .to_return(status: 503, headers: { 'Retry-After' => retry_after_seconds.to_s })
229
+ .to_return(status: 200, body: expected_body)
230
+
231
+ requester = Requester.new(:get, url)
232
+ expect(requester).to receive(:sleep).with(1).once
233
+
234
+ result = requester.make_request
235
+ expect(result).to eq(expected_body)
236
+ end
237
+
238
+ it 'raises RetryDelayTooLarge if the delay is too large' do
239
+ retry_after_seconds = 10 + BerkeleyLibrary::Util::URIs::Requester::MAX_RETRY_DELAY_SECONDS
240
+
241
+ stub_request(:get, url)
242
+ .to_return(status: 503, headers: { 'Retry-After' => retry_after_seconds.to_s })
243
+
244
+ requester = Requester.new(:get, url)
245
+ expect(requester).not_to receive(:sleep)
246
+
247
+ expect { requester.make_request }.to raise_error(RetryDelayTooLarge) do |ex|
248
+ expect(ex.cause).to be_a(RestClient::ServiceUnavailable)
249
+ end
250
+ end
251
+ end
252
+
253
+ context 'as RFC2822 datetime' do
254
+ it 'retries after the specified delay' do
255
+ retry_after_seconds = 1
256
+ retry_after_datetime = (Time.now + retry_after_seconds)
257
+
258
+ stub_request(:get, url)
259
+ .to_return(status: 503, headers: { 'Retry-After' => retry_after_datetime.rfc2822 })
260
+ .to_return(status: 200, body: expected_body)
261
+
262
+ requester = Requester.new(:get, url)
263
+ expect(requester).to receive(:sleep).with(1).once
264
+
265
+ result = requester.make_request
266
+ expect(result).to eq(expected_body)
267
+ end
268
+
269
+ it 'handles a non-integer retry delay' do
270
+ retry_after_seconds = 1.75
271
+ retry_after_datetime = (Time.now + retry_after_seconds)
272
+
273
+ stub_request(:get, url)
274
+ .to_return(status: 503, headers: { 'Retry-After' => retry_after_datetime.rfc2822 })
275
+ .to_return(status: 200, body: expected_body)
276
+
277
+ requester = Requester.new(:get, url)
278
+ expected_value = a_value_within(1).of(retry_after_seconds)
279
+ expect(requester).to receive(:sleep).with(expected_value).once
280
+
281
+ result = requester.make_request
282
+ expect(result).to eq(expected_body)
283
+ end
284
+
285
+ it 'raises RetryDelayTooLarge if the delay is too large' do
286
+ retry_after_seconds = 10 + BerkeleyLibrary::Util::URIs::Requester::MAX_RETRY_DELAY_SECONDS
287
+ retry_after_datetime = (Time.now + retry_after_seconds)
288
+
289
+ stub_request(:get, url)
290
+ .to_return(status: 503, headers: { 'Retry-After' => retry_after_datetime.rfc2822 })
291
+
292
+ requester = Requester.new(:get, url)
293
+ expect(requester).not_to receive(:sleep)
294
+
295
+ expect { requester.make_request }.to raise_error(RetryDelayTooLarge) do |ex|
296
+ expect(ex.cause).to be_a(RestClient::ServiceUnavailable)
297
+ end
298
+ end
299
+ end
300
+
301
+ it 'ignores an invalid Retry-After' do
302
+ stub_request(:get, url)
303
+ .to_return(status: 503, headers: { 'Retry-After' => 'the end of the world' })
304
+
305
+ requester = Requester.new(:get, url)
306
+ expect { requester.make_request }.to raise_error(RestClient::ServiceUnavailable)
307
+ end
308
+ end
309
+ end
310
+ end
311
+
68
312
  end
69
313
 
70
314
  describe :head do
@@ -337,20 +337,14 @@ module BerkeleyLibrary::Util
337
337
  expect { URIs.path_escape(str.bytes) }.to raise_error(ArgumentError)
338
338
  end
339
339
 
340
- it 'rejects non-UTF-8 strings' do
341
- str = in_out.keys.last
342
- expect { URIs.path_escape(str.encode(Encoding::Shift_JIS)) }.to raise_error(ArgumentError)
343
- end
344
-
345
- it 'accepts non-UTF-8 strings converted to UTF-8' do
346
- in_str = in_out.keys.last
347
- out_str = in_out[in_str]
348
-
349
- # OK, we're really just testing String#encode here, but
350
- # it's useful for documentation
351
- in_str_sjis = in_str.encode(Encoding::Shift_JIS)
352
- in_str_utf8 = in_str_sjis.encode(Encoding::UTF_8)
353
- expect(URIs.path_escape(in_str_utf8)).to eq(out_str)
340
+ it 'converts non-UTF-8 strings to UTF-8' do
341
+ utf_16_be = Encoding.find('UTF-16BE')
342
+ aggregate_failures do
343
+ in_out.each do |in_str, out_str|
344
+ encoded = in_str.encode(utf_16_be)
345
+ expect(URIs.path_escape(encoded)).to eq(out_str)
346
+ end
347
+ end
354
348
  end
355
349
  end
356
350
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: berkeley_library-util
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.7
4
+ version: 0.1.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Moles
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-03-15 00:00:00.000000000 Z
11
+ date: 2023-06-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: berkeley_library-logging
@@ -241,7 +241,9 @@ files:
241
241
  - lib/berkeley_library/util/times.rb
242
242
  - lib/berkeley_library/util/uris.rb
243
243
  - lib/berkeley_library/util/uris/appender.rb
244
+ - lib/berkeley_library/util/uris/exceptions.rb
244
245
  - lib/berkeley_library/util/uris/requester.rb
246
+ - lib/berkeley_library/util/uris/requester/class_methods.rb
245
247
  - lib/berkeley_library/util/uris/validator.rb
246
248
  - rakelib/.rubocop.yml
247
249
  - rakelib/coverage.rake