berkeley_library-util 0.1.7 → 0.1.9

Sign up to get free protection for your applications and to get access to all the features.
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