http.rb 0.21.0 → 0.23.0

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: 905cbbe9829dc262c67ec3eb4dc5dd37590e377df516ee2550b57c7d2648553e
4
- data.tar.gz: 22983ec7dfd2d28060abe07f3236c183b2da16cd1f49657a1c9b1b5bd6b5c05a
3
+ metadata.gz: b1e4577389b2272eb97f9912694fc3e2951c64cf83758c9a6f05b544dab99f3f
4
+ data.tar.gz: 13574567a8fabe8ac8693cda1cd183703eb8d7f57f94da20ffe249feb4b1a52b
5
5
  SHA512:
6
- metadata.gz: 971d00bde4ac4a54010a99332df8264c54f0e737beb98eeb9e76d5ddc9202578c868f643a7153fb07063aacbcc195495ebb8a5703d5bea68f53f6a446dfb6fdc
7
- data.tar.gz: b4de8b52f8bb46a376021c78109649b45820603cb6f1954afb700ab707699157246adabb2296f40510b7e2abf54b99f7c360603b4c109a971f006687921db644
6
+ metadata.gz: f15f20fb2292d31e28da282fd138463234a2cead1e153a963c32a298b3fee441fcd50958203be30a103eb2c176da18f4beccdd6b362624e1c2769c1ee5b351f8
7
+ data.tar.gz: c8062c4c3f8ea7ffba22ac31f5e53288b8b36941651df1d087b18ec6e9bff46178e45afd421fc6218a910d61dc32e6cf410182336d058f28136b302589e1869a
data/CHANGELOG CHANGED
@@ -1,15 +1,45 @@
1
1
  # CHANGELOG
2
2
 
3
- # 20260521
4
- # 0.21.0: Add specs for options, trace, and patch verbs.
3
+ ## 20260522
4
+
5
+ 0.23.0: Fix cross-scheme redirect SSL leak; clamp negative Retry-After.
6
+
7
+ 1. ~ HTTP.request: Compute http-object SSL configuration via options.merge instead of mutating the caller's options hash with auto-derived use_ssl and verify_mode. The previous ||= writes meant an HTTPS→HTTP redirect carried use_ssl: true through to the recursive call against the HTTP host, attempting an SSL handshake on the plain-HTTP port. Cross-scheme redirects in both directions now re-derive use_ssl from each URI.
8
+ 2. ~ HTTP.retry_after: Clamp the HTTP-date branch via delta && [delta, 0].max. A past Retry-After HTTP-date previously returned a negative delta; Kernel.sleep raises ArgumentError on negatives.
9
+ 3. ~ test/HTTP/get_test.rb: + cross-scheme redirection specs (HTTPS→HTTP and HTTP→HTTPS) using a Net::HTTP.new stub to capture each Net::HTTP instance and assert use_ssl? per hop.
10
+ 4. ~ test/HTTP/RETRY_test.rb: + specs for past-date Retry-After (clamps to 0) and negative-integer Retry-After (returns nil).
11
+ 5. ~ HTTP::VERSION: /0.22.0/0.23.0/
12
+ 6. ~ CHANGELOG: + 0.23.0 entry; fix 0.13.2 date typo (202503030 → 20250330).
13
+
14
+ ## 20260522
15
+
16
+ 0.22.0: Convert specs from RSpec to Minitest.
17
+
18
+ 1. ~ spec/ → test/: All spec files moved to test/ and rewritten in Minitest spec style with `let`, double-quoted descriptions, and `_(...).must_*` expectations. On the TODO since at least 0.17.0; completed before 1.0 to ship into stability on the test framework that's here to stay.
19
+ 2. ~ spec/spec_helper.rb → test/helper.rb: Rewrite for Minitest; preserve the webmock http_rb_adapter collision workaround. + MockResponse Struct used as a header-stand-in in HTTP.retry_after specs in place of instance_double(Net::HTTPResponse).
20
+ 3. ~ Rakefile: Use Rake::TestTask instead of RSpec::Core::RakeTask.
21
+ 4. ~ http.rb.gemspec: -rspec; +minitest; +minitest-mock (minitest 6 extracted Mock/stub into a separate gem); files glob /spec/test/.
22
+ 5. ~ HTTP.request: /response.header['location']/response['location']/ — Net::HTTPResponse#header has been deprecated for years; the documented replacement is #[]. Surfaced by Minitest's default reporter showing stderr warnings inline where RSpec was hiding them.
23
+ 6. ~ TODO: Annotate "Convert specs from RSpec to Minitest" as (Done as of 0.22.0).
24
+ 7. ~ HTTP::VERSION: /0.21.0/0.22.0/
25
+ 8. ~ CHANGELOG: + 0.22.0 entry
26
+ 9. ~ CHANGELOG: Reformat all entries with Markdown-style headings — `## YYYYMMDD` date headings, version line no longer `#`-prefixed.
27
+ 10. ~ http.rb.gemspec: Pin minitest to `~> 6.0` to lock the major and avoid bundler quietly resolving to 5.x off a stale lockfile.
28
+
29
+ ## 20260522
30
+
31
+ 0.21.0: Add specs for options, trace, and patch verbs.
32
+
5
33
  1. + spec/HTTP/options_spec.rb: Specs for HTTP.options. These were owed since 0.18.0 when the verb was added via the metaprogramming refactor.
6
34
  2. + spec/HTTP/trace_spec.rb: Specs for HTTP.trace. Owed since 0.18.0.
7
35
  3. + spec/HTTP/patch_spec.rb: Specs for HTTP.patch. Owed since 0.18.0.
8
36
  4. ~ HTTP::VERSION: /0.20.0/0.21.0/
9
37
  5. ~ CHANGELOG: + 0.21.0 entry
10
38
 
11
- # 20260522
12
- # 0.20.0: Add opt-in retry logic with exponential backoff.
39
+ ## 20260522
40
+
41
+ 0.20.0: Add opt-in retry logic with exponential backoff.
42
+
13
43
  1. + lib/HTTP/RETRY.rb: Retry helpers (with_retries, backoff_delay, retry_after) and constants (HTTP::RETRY::EXCEPTIONS, STATUS_CODES, VERBS).
14
44
  2. ~ HTTP.request: Retry on transient network exceptions and retry-worthy HTTP status codes (429, 502, 503, 504) when enabled. Exponential backoff with jitter. Honours Retry-After when present. Disabled by default; opt in via the retries option. Configurable via new options: retries (default 0), retry_delay (default 1.0), retry_status_codes, retry_exceptions, retry_verbs. Only idempotent verbs (get, head, options, put, delete, trace) retry by default; opt in to POST/PATCH retries via retry_verbs.
15
45
  3. + spec/HTTP/RETRY_spec.rb: Specs for retry behaviour and the helpers.
@@ -18,8 +48,10 @@
18
48
  6. ~ HTTP::VERSION: /0.19.0/0.20.0/
19
49
  7. ~ CHANGELOG: + 0.20.0 entry
20
50
 
21
- # 20260522
22
- # 0.19.0: Change default verify_mode to VERIFY_PEER.
51
+ ## 20260522
52
+
53
+ 0.19.0: Change default verify_mode to VERIFY_PEER.
54
+
23
55
  1. ~ HTTP.request: Default verify_mode changed from OpenSSL::SSL::VERIFY_NONE to OpenSSL::SSL::VERIFY_PEER. Callers needing the old behaviour can pass verify_mode: OpenSSL::SSL::VERIFY_NONE explicitly through the options hash.
24
56
  2. ~ spec/HTTP/get_spec.rb: + specs for default verify_mode and explicit VERIFY_NONE override; /verify_mode: 0/verify_mode: OpenSSL::SSL::VERIFY_PEER/ in redirect specs.
25
57
  3. ~ spec/HTTP/post_spec.rb: /verify_mode: 0/verify_mode: OpenSSL::SSL::VERIFY_PEER/ in redirect specs.
@@ -29,8 +61,10 @@
29
61
  7. ~ HTTP::VERSION: /0.18.3/0.19.0/
30
62
  8. ~ CHANGELOG: + 0.19.0 entry
31
63
 
32
- # 20260521
33
- # 0.18.3: Fix verb preservation on 307/308 redirects.
64
+ ## 20260521
65
+
66
+ 0.18.3: Fix verb preservation on 307/308 redirects.
67
+
34
68
  1. ~ HTTP.request: Use original verb when following 307 or 308 redirects, per RFC 7231 §6.4.7 and RFC 7538. 301/302/303 keep legacy GET-on-redirect behaviour.
35
69
  2. ~ spec/HTTP/post_spec.rb: + specs for 307/308 verb preservation and 307 body preservation.
36
70
  3. ~ spec/HTTP/put_spec.rb: + spec for 307 verb preservation.
@@ -39,8 +73,10 @@
39
73
  6. ~ HTTP::VERSION: /0.18.2/0.18.3/
40
74
  7. ~ CHANGELOG: + 0.18.3 entry
41
75
 
42
- # 20260520
43
- # 0.18.2: Fix relative redirect URL construction.
76
+ ## 20260520
77
+
78
+ 0.18.2: Fix relative redirect URL construction.
79
+
44
80
  1. ~ HTTP.request: Use URI#merge for redirect URL construction. Preserves original scheme, elides default ports, and resolves relative paths per RFC 3986.
45
81
  2. ~ spec/HTTP/get_spec.rb: Update relative-redirect stubs to elide default port; + context for HTTPS relative redirect.
46
82
  3. ~ spec/HTTP/post_spec.rb: Update relative-redirect stubs to elide default port.
@@ -49,8 +85,10 @@
49
85
  6. ~ HTTP::VERSION: /0.18.1/0.18.2/
50
86
  7. ~ CHANGELOG: + 0.18.2 entry
51
87
 
52
- # 20260508
53
- # 0.18.1: Remove incorrectly added WebDAV verbs.
88
+ ## 20260508
89
+
90
+ 0.18.1: Remove incorrectly added WebDAV verbs.
91
+
54
92
  1. - lib/Net/HTTP/Report.rb
55
93
  2. ~ HTTP::VERBS: - require_relative '../Net/HTTP/Report'
56
94
  3. ~ HTTP::VERBS::WITH_BODY: - propfind, proppatch, mkcol, copy, move, lock, unlock, report
@@ -59,8 +97,9 @@
59
97
  6. ~ HTTP::VERSION: /0.18.0/0.18.1/
60
98
  7. ~ CHANGELOG: + 0.18.1 entry
61
99
 
62
- # 20260507
63
- # 0.18.0: Add all missing HTTP verbs; use meta-programming to define verb methods.
100
+ ## 20260507
101
+ 0.18.0: Add all missing HTTP verbs; use meta-programming to define verb methods.
102
+
64
103
  1. + HTTP/verbs.rb; including:
65
104
  + HTTP::VERBS::WITHOUT_BODY: get, delete, head, options, trace
66
105
  + HTTP::VERBS::WITH_BODY: post, put, patch, propfind, proppatch, mkcol, copy, move, lock, unlock, report
@@ -87,8 +126,10 @@
87
126
  22. ~ .gitignore: Using a common one with lots of entries.
88
127
  23. ~ README.md: /http.rb/http/; Trimmed the Description; Usage has more code blocks, making the comments Markdown sub-sections.
89
128
 
90
- # 20260325
91
- # 0.17.0: Extract HTTP.request method; consolidate set_headers.
129
+ ## 20260325
130
+
131
+ 0.17.0: Extract HTTP.request method; consolidate set_headers.
132
+
92
133
  1. + HTTP.request: Extract common plumbing (connection, SSL, auth, redirect, response) from verb methods.
93
134
  2. ~ HTTP.get: Delegate to HTTP.request.
94
135
  3. ~ HTTP.delete: Delegate to HTTP.request.
@@ -104,8 +145,10 @@
104
145
  13. ~ CHANGELOG.txt: + 0.17.0 entry
105
146
  14. ~ http.rb.gemspec: Change date.
106
147
 
107
- # 20250908
108
- # 0.16.1: Allow any case for the content-type key.
148
+ ## 20250908
149
+
150
+ 0.16.1: Allow any case for the content-type key.
151
+
109
152
  1. ~ HTTP.post: Check for any case for content-type key.
110
153
  2. ~ HTTP.put: Check for any case for content-type key.
111
154
  3. ~ spec/HTTP/post_spec.rb: + specs for different content-type key cases.
@@ -115,8 +158,10 @@
115
158
  7. ~ CHANGELOG.txt: + 0.16.1 entry
116
159
  8. ~ http.rb.gemspec: Change date.
117
160
 
118
- # 20250809
119
- # 0.16.0: Allow passing of a raw payload for POST/PUT.
161
+ ## 20250809
162
+
163
+ 0.16.0: Allow passing of a raw payload for POST/PUT.
164
+
120
165
  1. ~ HTTP.post: Check for Content-Type and whether a string.
121
166
  2. ~ HTTP.put: Check for Content-Type and whether a string.
122
167
  3. ~ spec/HTTP/post_spec.rb: + raw form data example
@@ -127,8 +172,10 @@
127
172
  8. ~ CHANGELOG.txt: + 0.16.0 entry; Small edit on 0.15.1.
128
173
  9. ~ http.rb.gemspec: Change date.
129
174
 
130
- # 20250721
131
- # 0.15.1: Fix PUT; require delete and put in the load file.
175
+ ## 20250721
176
+
177
+ 0.15.1: Fix PUT; require delete and put in the load file.
178
+
132
179
  1. + require 'HTTP/put'
133
180
  2. + require 'HTTP/delete'
134
181
  3. ~ HTTP/put.rb: Params in the body!
@@ -138,8 +185,10 @@
138
185
  5. ~ CHANGELOG.txt
139
186
  6. ~ http.rb.gemspec: Change date.
140
187
 
141
- # 20250716
142
- # 0.15.0: + PUT
188
+ ## 20250716
189
+
190
+ 0.15.0: + PUT
191
+
143
192
  1. + HTTP.put
144
193
  2. + Net::HTTP::Put#set_headers
145
194
  3. + spec/HTTP/put_spec.rb
@@ -147,8 +196,10 @@
147
196
  5. ~ CHANGELOG.txt
148
197
  6. ~ http.rb.gemspec: Change date.
149
198
 
150
- # 20250711
151
- # 0.14.0: + DELETE
199
+ ## 20250711
200
+
201
+ 0.14.0: + DELETE
202
+
152
203
  1. + HTTP.delete
153
204
  2. + Net::HTTP::Delete#set_headers
154
205
  3. + spec/HTTP/delete_spec.rb
@@ -158,26 +209,34 @@
158
209
  7. ~ CHANGELOG.txt
159
210
  8. ~ http.rb.gemspec: Change date.
160
211
 
161
- # 20250501
162
- # 0.13.3: Handle when there's a redirect to a URL with arguments, so as to not add an additional '?' at the end.
212
+ ## 20250501
213
+
214
+ 0.13.3: Handle when there's a redirect to a URL with arguments, so as to not add an additional '?' at the end.
215
+
163
216
  1. ~ HTTP.get: Check if the args hash is empty.
164
217
  2. ~ HTTP::VERSION: /0.13.2/0.13.3/
165
218
  3. ~ http.rb.gemspec: Change date.
166
219
 
167
- # 202503030
168
- # 0.13.2: Change repo name to match gem name (/HTTP/http.rb/); + Use HTTP::VERSION; /require/require_relative/
220
+ ## 20250330
221
+
222
+ 0.13.2: Change repo name to match gem name (/HTTP/http.rb/); + Use HTTP::VERSION; /require/require_relative/
223
+
169
224
  1. ~ README.md: /HTTP/http.rb/, /HTTP.rb/http.rb/
170
225
  2. + HTTP::VERSION
171
226
  3. ~ http.rb.gemspec: Use HTTP::VERSION.
172
227
  4. ~ HTTP.get: /require/require_relative/
173
228
  5. ~ HTTP.post: /require/require_relative/
174
229
 
175
- # 20250304
176
- # 0.13.1: /HTTP.rb.gemspec/http.rb.gemspec/
230
+ ## 20250304
231
+
232
+ 0.13.1: /HTTP.rb.gemspec/http.rb.gemspec/
233
+
177
234
  1. /HTTP.rb.gemspec/http.rb.gemspec/ (Wonder no more!)
178
235
 
179
- # 20250304
180
- # 0.13.0: Extend Net::HTTPResponse to allow use of predicate methods for statuses and optionally prevent redirections.
236
+ ## 20250304
237
+
238
+ 0.13.0: Extend Net::HTTPResponse to allow use of predicate methods for statuses and optionally prevent redirections.
239
+
181
240
  1. + lib/Net/HTTPResponse/StatusPredicates.rb
182
241
  2. ~ HTTP/get.rb: + require 'Net/HTTPResponse/StatusPredicates'
183
242
  3. ~ HTTP/post.rb: + require 'Net/HTTPResponse/StatusPredicates'
@@ -191,8 +250,10 @@
191
250
  11. /http.rb.gemspec/HTTP.rb.gemspec/ (I wonder if that's going to cause issues for rubygems.org...)
192
251
  12. ~ CHANGELOG.txt
193
252
 
194
- # 20250207
195
- # 0.12.1: Correctly handle POST'ing JSON data.
253
+ ## 20250207
254
+
255
+ 0.12.1: Correctly handle POST'ing JSON data.
256
+
196
257
  1. ~ HTTP.post(): Check if the Content-Type is 'application/json' and assign the body JSON data otherwise assign the supplied hash to the form data.
197
258
  2. ~ spec/HTTP/post_spec.rb: + spec for when a request is being made with JSON data
198
259
  3. ~ spec/HTTP/post_spec.rb: Assign a headers variable to make the spec more readable.
data/Rakefile CHANGED
@@ -1,9 +1,9 @@
1
1
  # Rakefile
2
2
 
3
- require 'rspec/core/rake_task'
3
+ require 'rake/testtask'
4
4
 
5
- RSpec::Core::RakeTask.new(:spec) do |t|
6
- t.verbose = false
5
+ Rake::TestTask.new do |t|
6
+ t.test_files = FileList['test/**/*_test.rb']
7
7
  end
8
8
 
9
- task default: :spec
9
+ task default: :test
data/http.rb.gemspec CHANGED
@@ -31,13 +31,14 @@ Gem::Specification.new do |spec|
31
31
  'Rakefile',
32
32
  'README.md',
33
33
  Dir['lib/**/*.rb'],
34
- Dir['spec/**/*.rb'],
34
+ Dir['test/**/*.rb'],
35
35
  ].flatten
36
36
 
37
- spec.development_dependencies = %w{
38
- pry
39
- rake
40
- rspec
41
- webmock
42
- }
37
+ spec.development_dependencies = [
38
+ ['minitest', '~> 6.0'],
39
+ 'minitest-mock',
40
+ 'pry',
41
+ 'rake',
42
+ 'webmock',
43
+ ]
43
44
  end
data/lib/HTTP/RETRY.rb CHANGED
@@ -69,7 +69,8 @@ module HTTP
69
69
  header.to_i
70
70
  else
71
71
  # Malformed HTTP-date — fall through to caller's backoff.
72
- Time.httpdate(header) - Time.now rescue nil
72
+ delta = Time.httpdate(header) - Time.now rescue nil
73
+ delta && [delta, 0].max
73
74
  end
74
75
  end
75
76
  module_function :retry_after
data/lib/HTTP/VERSION.rb CHANGED
@@ -2,5 +2,5 @@
2
2
  # HTTP::VERSION
3
3
 
4
4
  module HTTP
5
- VERSION = '0.21.0'
5
+ VERSION = '0.23.0'
6
6
  end
data/lib/HTTP/request.rb CHANGED
@@ -17,9 +17,10 @@ module HTTP
17
17
  http = Net::HTTP.new(uri.host, uri.port)
18
18
  no_redirect = options.delete(:no_redirect)
19
19
  config = retry_config(options)
20
- options[:use_ssl] ||= uri.use_ssl?
21
- options[:verify_mode] ||= OpenSSL::SSL::VERIFY_PEER
22
- http.options = options
20
+ http.options = options.merge(
21
+ use_ssl: (options[:use_ssl] || uri.use_ssl?),
22
+ verify_mode: (options[:verify_mode] || OpenSSL::SSL::VERIFY_PEER)
23
+ )
23
24
  request_object.headers = headers
24
25
  request_object.basic_auth(uri.user, uri.password) if uri.user
25
26
  verb = request_object.method.downcase.to_sym
@@ -36,7 +37,7 @@ module HTTP
36
37
  elsif no_redirect
37
38
  return response
38
39
  end
39
- redirect_uri = uri.merge(response.header['location'])
40
+ redirect_uri = uri.merge(response['location'])
40
41
  if response.code =~ /^30[78]$/
41
42
  data = VERBS::WITH_BODY.include?(verb) ? request_object.body : {}
42
43
  response = send(verb, redirect_uri.to_s, data, headers, options, &block)
@@ -0,0 +1,295 @@
1
+ # test/HTTP/RETRY_test.rb
2
+
3
+ require_relative '../helper'
4
+
5
+ describe "retry behaviour" do
6
+ let(:uri){'http://example.com/path'}
7
+
8
+ describe "defaults" do
9
+ it "does not retry by default" do
10
+ stub_request(:get, uri).to_return(status: 503)
11
+ response = HTTP.get(uri)
12
+ _(response.code.to_i).must_equal(503)
13
+ assert_requested(:get, uri, times: 1)
14
+ end
15
+
16
+ it "does not retry on a transient exception by default" do
17
+ stub_request(:get, uri).to_raise(Errno::ECONNRESET)
18
+ _(->{HTTP.get(uri)}).must_raise(Errno::ECONNRESET)
19
+ assert_requested(:get, uri, times: 1)
20
+ end
21
+ end
22
+
23
+ describe "retry on transient exception" do
24
+ it "retries and succeeds when the failure is transient" do
25
+ HTTP::RETRY.stub(:sleep, nil) do
26
+ stub_request(:get, uri).
27
+ to_raise(Errno::ECONNRESET).then.
28
+ to_raise(Errno::ECONNRESET).then.
29
+ to_return(status: 200, body: '')
30
+ response = HTTP.get(uri, {}, {}, {retries: 3})
31
+ _(response.success?).must_equal(true)
32
+ assert_requested(:get, uri, times: 3)
33
+ end
34
+ end
35
+
36
+ it "re-raises the exception after retries are exhausted" do
37
+ HTTP::RETRY.stub(:sleep, nil) do
38
+ stub_request(:get, uri).to_raise(Errno::ECONNRESET)
39
+ _(->{HTTP.get(uri, {}, {}, {retries: 2})}).must_raise(Errno::ECONNRESET)
40
+ assert_requested(:get, uri, times: 3)
41
+ end
42
+ end
43
+
44
+ it "retries on SocketError (DNS failure)" do
45
+ HTTP::RETRY.stub(:sleep, nil) do
46
+ stub_request(:get, uri).
47
+ to_raise(SocketError).then.
48
+ to_return(status: 200, body: '')
49
+ response = HTTP.get(uri, {}, {}, {retries: 3})
50
+ _(response.success?).must_equal(true)
51
+ assert_requested(:get, uri, times: 2)
52
+ end
53
+ end
54
+
55
+ it "does not retry on a non-listed exception" do
56
+ HTTP::RETRY.stub(:sleep, nil) do
57
+ stub_request(:get, uri).to_raise(OpenSSL::SSL::SSLError)
58
+ _(->{HTTP.get(uri, {}, {}, {retries: 3})}).must_raise(OpenSSL::SSL::SSLError)
59
+ assert_requested(:get, uri, times: 1)
60
+ end
61
+ end
62
+ end
63
+
64
+ describe "retry on status code" do
65
+ it "retries on 503 then succeeds" do
66
+ HTTP::RETRY.stub(:sleep, nil) do
67
+ stub_request(:get, uri).
68
+ to_return({status: 503}, {status: 503}, {status: 200, body: ''})
69
+ response = HTTP.get(uri, {}, {}, {retries: 3})
70
+ _(response.success?).must_equal(true)
71
+ assert_requested(:get, uri, times: 3)
72
+ end
73
+ end
74
+
75
+ it "retries on 502" do
76
+ HTTP::RETRY.stub(:sleep, nil) do
77
+ stub_request(:get, uri).to_return({status: 502}, {status: 200, body: ''})
78
+ response = HTTP.get(uri, {}, {}, {retries: 3})
79
+ _(response.success?).must_equal(true)
80
+ assert_requested(:get, uri, times: 2)
81
+ end
82
+ end
83
+
84
+ it "retries on 504" do
85
+ HTTP::RETRY.stub(:sleep, nil) do
86
+ stub_request(:get, uri).to_return({status: 504}, {status: 200, body: ''})
87
+ response = HTTP.get(uri, {}, {}, {retries: 3})
88
+ _(response.success?).must_equal(true)
89
+ assert_requested(:get, uri, times: 2)
90
+ end
91
+ end
92
+
93
+ it "does not retry on 500 by default" do
94
+ stub_request(:get, uri).to_return(status: 500)
95
+ response = HTTP.get(uri, {}, {}, {retries: 3})
96
+ _(response.code.to_i).must_equal(500)
97
+ assert_requested(:get, uri, times: 1)
98
+ end
99
+
100
+ it "does not retry on 404" do
101
+ stub_request(:get, uri).to_return(status: 404)
102
+ response = HTTP.get(uri, {}, {}, {retries: 3})
103
+ _(response.code.to_i).must_equal(404)
104
+ assert_requested(:get, uri, times: 1)
105
+ end
106
+
107
+ it "returns the last response when retries are exhausted" do
108
+ HTTP::RETRY.stub(:sleep, nil) do
109
+ stub_request(:get, uri).to_return(status: 503)
110
+ response = HTTP.get(uri, {}, {}, {retries: 2})
111
+ _(response.code.to_i).must_equal(503)
112
+ assert_requested(:get, uri, times: 3)
113
+ end
114
+ end
115
+ end
116
+
117
+ describe "Retry-After header" do
118
+ it "honours integer Retry-After on 429" do
119
+ received_seconds = nil
120
+ HTTP::RETRY.stub(:sleep, ->(n){received_seconds = n}) do
121
+ stub_request(:get, uri).
122
+ to_return({status: 429, headers: {'Retry-After' => '2'}}, {status: 200, body: ''})
123
+ response = HTTP.get(uri, {}, {}, {retries: 3})
124
+ _(response.success?).must_equal(true)
125
+ _(received_seconds).must_equal(2)
126
+ end
127
+ end
128
+
129
+ it "honours integer Retry-After on 503" do
130
+ received_seconds = nil
131
+ HTTP::RETRY.stub(:sleep, ->(n){received_seconds = n}) do
132
+ stub_request(:get, uri).
133
+ to_return({status: 503, headers: {'Retry-After' => '5'}}, {status: 200, body: ''})
134
+ response = HTTP.get(uri, {}, {}, {retries: 3})
135
+ _(response.success?).must_equal(true)
136
+ _(received_seconds).must_equal(5)
137
+ end
138
+ end
139
+ end
140
+
141
+ describe "configuration" do
142
+ it "treats retries: 0 as no retries" do
143
+ stub_request(:get, uri).to_return(status: 503)
144
+ response = HTTP.get(uri, {}, {}, {retries: 0})
145
+ _(response.code.to_i).must_equal(503)
146
+ assert_requested(:get, uri, times: 1)
147
+ end
148
+
149
+ it "respects a custom retry_status_codes list" do
150
+ HTTP::RETRY.stub(:sleep, nil) do
151
+ stub_request(:get, uri).to_return({status: 500}, {status: 200, body: ''})
152
+ response = HTTP.get(uri, {}, {}, {retries: 3, retry_status_codes: [500]})
153
+ _(response.success?).must_equal(true)
154
+ assert_requested(:get, uri, times: 2)
155
+ end
156
+ end
157
+
158
+ it "respects a custom retry_exceptions list" do
159
+ HTTP::RETRY.stub(:sleep, nil) do
160
+ stub_request(:get, uri).
161
+ to_raise(OpenSSL::SSL::SSLError).then.
162
+ to_return(status: 200, body: '')
163
+ response = HTTP.get(uri, {}, {}, {retries: 3, retry_exceptions: [OpenSSL::SSL::SSLError]})
164
+ _(response.success?).must_equal(true)
165
+ assert_requested(:get, uri, times: 2)
166
+ end
167
+ end
168
+
169
+ it "does not pass retry options through to Net::HTTP" do
170
+ stub_request(:get, uri).to_return(status: 200, body: '')
171
+ net_http_object = Net::HTTP.new(URI.parse(uri).host, URI.parse(uri).port)
172
+ received_opts = nil
173
+ net_http_object.define_singleton_method(:options=){|opts| received_opts = opts}
174
+ Net::HTTP.stub(:new, net_http_object) do
175
+ HTTP.get(uri, {}, {}, {
176
+ retries: 3,
177
+ retry_delay: 0.1,
178
+ retry_status_codes: [500],
179
+ retry_exceptions: [Errno::ECONNRESET],
180
+ retry_verbs: %i{get}
181
+ })
182
+ end
183
+ _(received_opts).wont_include(:retries)
184
+ _(received_opts).wont_include(:retry_delay)
185
+ _(received_opts).wont_include(:retry_status_codes)
186
+ _(received_opts).wont_include(:retry_exceptions)
187
+ _(received_opts).wont_include(:retry_verbs)
188
+ end
189
+ end
190
+
191
+ describe "backoff timing" do
192
+ it "increases the delay between successive retries" do
193
+ delays = []
194
+ HTTP::RETRY.stub(:sleep, ->(d){delays << d}) do
195
+ stub_request(:get, uri).to_return(status: 503)
196
+ HTTP.get(uri, {}, {}, {retries: 3, retry_delay: 1.0})
197
+ end
198
+ _(delays.length).must_equal(3)
199
+ _(delays[1]).must_be(:>, delays[0] * 0.8)
200
+ _(delays[2]).must_be(:>, delays[1] * 0.8)
201
+ end
202
+ end
203
+
204
+ describe "verb-based retry default" do
205
+ it "does not retry POST by default even when retries are enabled" do
206
+ stub_request(:post, uri).to_return(status: 503)
207
+ HTTP.post(uri, {}, {}, {retries: 3})
208
+ assert_requested(:post, uri, times: 1)
209
+ end
210
+
211
+ it "does not retry PATCH by default" do
212
+ stub_request(:patch, uri).to_return(status: 503)
213
+ HTTP.patch(uri, {}, {}, {retries: 3})
214
+ assert_requested(:patch, uri, times: 1)
215
+ end
216
+
217
+ it "retries PUT by default (idempotent)" do
218
+ HTTP::RETRY.stub(:sleep, nil) do
219
+ stub_request(:put, uri).to_return({status: 503}, {status: 200, body: ''})
220
+ response = HTTP.put(uri, {}, {}, {retries: 3})
221
+ _(response.success?).must_equal(true)
222
+ assert_requested(:put, uri, times: 2)
223
+ end
224
+ end
225
+
226
+ it "retries DELETE by default (idempotent)" do
227
+ HTTP::RETRY.stub(:sleep, nil) do
228
+ stub_request(:delete, uri).to_return({status: 503}, {status: 200, body: ''})
229
+ response = HTTP.delete(uri, {}, {}, {retries: 3})
230
+ _(response.success?).must_equal(true)
231
+ assert_requested(:delete, uri, times: 2)
232
+ end
233
+ end
234
+
235
+ it "retries POST when opted in via retry_verbs" do
236
+ HTTP::RETRY.stub(:sleep, nil) do
237
+ stub_request(:post, uri).to_return({status: 503}, {status: 200, body: ''})
238
+ response = HTTP.post(uri, {}, {}, {retries: 3, retry_verbs: %i{get post}})
239
+ _(response.success?).must_equal(true)
240
+ assert_requested(:post, uri, times: 2)
241
+ end
242
+ end
243
+ end
244
+ end
245
+
246
+ describe HTTP, ".retry_after" do
247
+ it "returns integer seconds for a delta-seconds Retry-After header" do
248
+ response = MockResponse.new(headers_hash: {'Retry-After' => '5'})
249
+ _(HTTP.retry_after(response)).must_equal(5)
250
+ end
251
+
252
+ it "parses an HTTP-date Retry-After header" do
253
+ base = Time.utc(2026, 5, 22, 12, 0, 0)
254
+ retry_at_header = (base + 5).httpdate
255
+ response = MockResponse.new(headers_hash: {'Retry-After' => retry_at_header})
256
+ Time.stub(:now, base) do
257
+ _(HTTP.retry_after(response)).must_be_close_to(5.0, 0.001)
258
+ end
259
+ end
260
+
261
+ it "returns nil when Retry-After is absent" do
262
+ response = MockResponse.new(headers_hash: {})
263
+ _(HTTP.retry_after(response)).must_be_nil
264
+ end
265
+
266
+ it "returns nil when Retry-After is malformed" do
267
+ response = MockResponse.new(headers_hash: {'Retry-After' => 'not a date'})
268
+ _(HTTP.retry_after(response)).must_be_nil
269
+ end
270
+
271
+ it "clamps to 0 when the Retry-After HTTP-date is in the past" do
272
+ base = Time.utc(2026, 5, 22, 12, 0, 0)
273
+ retry_at_header = (base - 60).httpdate
274
+ response = MockResponse.new(headers_hash: {'Retry-After' => retry_at_header})
275
+ Time.stub(:now, base) do
276
+ _(HTTP.retry_after(response)).must_equal(0)
277
+ end
278
+ end
279
+
280
+ it "returns nil for a negative integer Retry-After" do
281
+ response = MockResponse.new(headers_hash: {'Retry-After' => '-5'})
282
+ _(HTTP.retry_after(response)).must_be_nil
283
+ end
284
+ end
285
+
286
+ describe HTTP, ".backoff_delay" do
287
+ it "grows exponentially with attempt number" do
288
+ base = 1.0
289
+ delays = (1..4).map{|attempt| HTTP.backoff_delay(base, attempt)}
290
+ _(delays[0]).must_be_close_to(1.0, 0.2)
291
+ _(delays[1]).must_be_close_to(2.0, 0.4)
292
+ _(delays[2]).must_be_close_to(4.0, 0.8)
293
+ _(delays[3]).must_be_close_to(8.0, 1.6)
294
+ end
295
+ end