http.rb 0.20.0 → 0.22.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: f8926fd82491288941f05e4ff3d38c0c000e31f214bd35f55462befa0ece73a3
4
- data.tar.gz: ad696135489b778872649c052fb1f23eb05e151e7a04c29237510e997dba7d71
3
+ metadata.gz: 5535114ce1c4c4612aaef5e7eb88777a4807ab671d2f474d38175e3398d37358
4
+ data.tar.gz: e72a5ce3ea83170a4bafc2d6179912d20ff88c8d945d9f8d06f40dae387b6961
5
5
  SHA512:
6
- metadata.gz: eb7f2d8945396de4151166fb3a2fc62a0bb47ac2636a9f157b04c67318f0c9c20d302aba00b8a0db21278fbeed559230b236fea674cb2cf4de3880fe22b09dce
7
- data.tar.gz: bea5d01ffdab8e4a8fd876ec1b7353776b933d5e1259ea8c57aa4cbf4a8a6b3ba35950fa7bdc907b18bff84e5e7cbc972abbd1cc71398f37934834f176e9d86c
6
+ metadata.gz: cf30ba6e2171e8a005b398cbb467f8437d0653b15f066f610e87bf5d301464695bcd751dbf4b064ded581761c1c2abad25e71418d24d2c4a3001706f1e41f8dc
7
+ data.tar.gz: f62e33d69f7e107c43d717f33bd9130538f0b4ff96a8f337145dfe11fb3ee3dac25ee83fec6777d301fa0eadeb0282760057a58da4736cd2adee0eba47c6552d
data/CHANGELOG CHANGED
@@ -1,7 +1,34 @@
1
1
  # CHANGELOG
2
2
 
3
- # 20260522
4
- # 0.20.0: Add opt-in retry logic with exponential backoff.
3
+ ## 20260522
4
+
5
+ 0.22.0: Convert specs from RSpec to Minitest.
6
+
7
+ 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.
8
+ 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).
9
+ 3. ~ Rakefile: Use Rake::TestTask instead of RSpec::Core::RakeTask.
10
+ 4. ~ http.rb.gemspec: -rspec; +minitest; +minitest-mock (minitest 6 extracted Mock/stub into a separate gem); files glob /spec/test/.
11
+ 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.
12
+ 6. ~ TODO: Annotate "Convert specs from RSpec to Minitest" as (Done as of 0.22.0).
13
+ 7. ~ HTTP::VERSION: /0.21.0/0.22.0/
14
+ 8. ~ CHANGELOG: + 0.22.0 entry
15
+ 9. ~ CHANGELOG: Reformat all entries with Markdown-style headings — `## YYYYMMDD` date headings, version line no longer `#`-prefixed.
16
+ 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.
17
+
18
+ ## 20260522
19
+
20
+ 0.21.0: Add specs for options, trace, and patch verbs.
21
+
22
+ 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.
23
+ 2. + spec/HTTP/trace_spec.rb: Specs for HTTP.trace. Owed since 0.18.0.
24
+ 3. + spec/HTTP/patch_spec.rb: Specs for HTTP.patch. Owed since 0.18.0.
25
+ 4. ~ HTTP::VERSION: /0.20.0/0.21.0/
26
+ 5. ~ CHANGELOG: + 0.21.0 entry
27
+
28
+ ## 20260522
29
+
30
+ 0.20.0: Add opt-in retry logic with exponential backoff.
31
+
5
32
  1. + lib/HTTP/RETRY.rb: Retry helpers (with_retries, backoff_delay, retry_after) and constants (HTTP::RETRY::EXCEPTIONS, STATUS_CODES, VERBS).
6
33
  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.
7
34
  3. + spec/HTTP/RETRY_spec.rb: Specs for retry behaviour and the helpers.
@@ -10,8 +37,10 @@
10
37
  6. ~ HTTP::VERSION: /0.19.0/0.20.0/
11
38
  7. ~ CHANGELOG: + 0.20.0 entry
12
39
 
13
- # 20260522
14
- # 0.19.0: Change default verify_mode to VERIFY_PEER.
40
+ ## 20260522
41
+
42
+ 0.19.0: Change default verify_mode to VERIFY_PEER.
43
+
15
44
  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.
16
45
  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.
17
46
  3. ~ spec/HTTP/post_spec.rb: /verify_mode: 0/verify_mode: OpenSSL::SSL::VERIFY_PEER/ in redirect specs.
@@ -21,8 +50,10 @@
21
50
  7. ~ HTTP::VERSION: /0.18.3/0.19.0/
22
51
  8. ~ CHANGELOG: + 0.19.0 entry
23
52
 
24
- # 20260521
25
- # 0.18.3: Fix verb preservation on 307/308 redirects.
53
+ ## 20260521
54
+
55
+ 0.18.3: Fix verb preservation on 307/308 redirects.
56
+
26
57
  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.
27
58
  2. ~ spec/HTTP/post_spec.rb: + specs for 307/308 verb preservation and 307 body preservation.
28
59
  3. ~ spec/HTTP/put_spec.rb: + spec for 307 verb preservation.
@@ -31,8 +62,10 @@
31
62
  6. ~ HTTP::VERSION: /0.18.2/0.18.3/
32
63
  7. ~ CHANGELOG: + 0.18.3 entry
33
64
 
34
- # 20260520
35
- # 0.18.2: Fix relative redirect URL construction.
65
+ ## 20260520
66
+
67
+ 0.18.2: Fix relative redirect URL construction.
68
+
36
69
  1. ~ HTTP.request: Use URI#merge for redirect URL construction. Preserves original scheme, elides default ports, and resolves relative paths per RFC 3986.
37
70
  2. ~ spec/HTTP/get_spec.rb: Update relative-redirect stubs to elide default port; + context for HTTPS relative redirect.
38
71
  3. ~ spec/HTTP/post_spec.rb: Update relative-redirect stubs to elide default port.
@@ -41,8 +74,10 @@
41
74
  6. ~ HTTP::VERSION: /0.18.1/0.18.2/
42
75
  7. ~ CHANGELOG: + 0.18.2 entry
43
76
 
44
- # 20260508
45
- # 0.18.1: Remove incorrectly added WebDAV verbs.
77
+ ## 20260508
78
+
79
+ 0.18.1: Remove incorrectly added WebDAV verbs.
80
+
46
81
  1. - lib/Net/HTTP/Report.rb
47
82
  2. ~ HTTP::VERBS: - require_relative '../Net/HTTP/Report'
48
83
  3. ~ HTTP::VERBS::WITH_BODY: - propfind, proppatch, mkcol, copy, move, lock, unlock, report
@@ -51,8 +86,9 @@
51
86
  6. ~ HTTP::VERSION: /0.18.0/0.18.1/
52
87
  7. ~ CHANGELOG: + 0.18.1 entry
53
88
 
54
- # 20260507
55
- # 0.18.0: Add all missing HTTP verbs; use meta-programming to define verb methods.
89
+ ## 20260507
90
+ 0.18.0: Add all missing HTTP verbs; use meta-programming to define verb methods.
91
+
56
92
  1. + HTTP/verbs.rb; including:
57
93
  + HTTP::VERBS::WITHOUT_BODY: get, delete, head, options, trace
58
94
  + HTTP::VERBS::WITH_BODY: post, put, patch, propfind, proppatch, mkcol, copy, move, lock, unlock, report
@@ -79,8 +115,10 @@
79
115
  22. ~ .gitignore: Using a common one with lots of entries.
80
116
  23. ~ README.md: /http.rb/http/; Trimmed the Description; Usage has more code blocks, making the comments Markdown sub-sections.
81
117
 
82
- # 20260325
83
- # 0.17.0: Extract HTTP.request method; consolidate set_headers.
118
+ ## 20260325
119
+
120
+ 0.17.0: Extract HTTP.request method; consolidate set_headers.
121
+
84
122
  1. + HTTP.request: Extract common plumbing (connection, SSL, auth, redirect, response) from verb methods.
85
123
  2. ~ HTTP.get: Delegate to HTTP.request.
86
124
  3. ~ HTTP.delete: Delegate to HTTP.request.
@@ -96,8 +134,10 @@
96
134
  13. ~ CHANGELOG.txt: + 0.17.0 entry
97
135
  14. ~ http.rb.gemspec: Change date.
98
136
 
99
- # 20250908
100
- # 0.16.1: Allow any case for the content-type key.
137
+ ## 20250908
138
+
139
+ 0.16.1: Allow any case for the content-type key.
140
+
101
141
  1. ~ HTTP.post: Check for any case for content-type key.
102
142
  2. ~ HTTP.put: Check for any case for content-type key.
103
143
  3. ~ spec/HTTP/post_spec.rb: + specs for different content-type key cases.
@@ -107,8 +147,10 @@
107
147
  7. ~ CHANGELOG.txt: + 0.16.1 entry
108
148
  8. ~ http.rb.gemspec: Change date.
109
149
 
110
- # 20250809
111
- # 0.16.0: Allow passing of a raw payload for POST/PUT.
150
+ ## 20250809
151
+
152
+ 0.16.0: Allow passing of a raw payload for POST/PUT.
153
+
112
154
  1. ~ HTTP.post: Check for Content-Type and whether a string.
113
155
  2. ~ HTTP.put: Check for Content-Type and whether a string.
114
156
  3. ~ spec/HTTP/post_spec.rb: + raw form data example
@@ -119,8 +161,10 @@
119
161
  8. ~ CHANGELOG.txt: + 0.16.0 entry; Small edit on 0.15.1.
120
162
  9. ~ http.rb.gemspec: Change date.
121
163
 
122
- # 20250721
123
- # 0.15.1: Fix PUT; require delete and put in the load file.
164
+ ## 20250721
165
+
166
+ 0.15.1: Fix PUT; require delete and put in the load file.
167
+
124
168
  1. + require 'HTTP/put'
125
169
  2. + require 'HTTP/delete'
126
170
  3. ~ HTTP/put.rb: Params in the body!
@@ -130,8 +174,10 @@
130
174
  5. ~ CHANGELOG.txt
131
175
  6. ~ http.rb.gemspec: Change date.
132
176
 
133
- # 20250716
134
- # 0.15.0: + PUT
177
+ ## 20250716
178
+
179
+ 0.15.0: + PUT
180
+
135
181
  1. + HTTP.put
136
182
  2. + Net::HTTP::Put#set_headers
137
183
  3. + spec/HTTP/put_spec.rb
@@ -139,8 +185,10 @@
139
185
  5. ~ CHANGELOG.txt
140
186
  6. ~ http.rb.gemspec: Change date.
141
187
 
142
- # 20250711
143
- # 0.14.0: + DELETE
188
+ ## 20250711
189
+
190
+ 0.14.0: + DELETE
191
+
144
192
  1. + HTTP.delete
145
193
  2. + Net::HTTP::Delete#set_headers
146
194
  3. + spec/HTTP/delete_spec.rb
@@ -150,26 +198,34 @@
150
198
  7. ~ CHANGELOG.txt
151
199
  8. ~ http.rb.gemspec: Change date.
152
200
 
153
- # 20250501
154
- # 0.13.3: Handle when there's a redirect to a URL with arguments, so as to not add an additional '?' at the end.
201
+ ## 20250501
202
+
203
+ 0.13.3: Handle when there's a redirect to a URL with arguments, so as to not add an additional '?' at the end.
204
+
155
205
  1. ~ HTTP.get: Check if the args hash is empty.
156
206
  2. ~ HTTP::VERSION: /0.13.2/0.13.3/
157
207
  3. ~ http.rb.gemspec: Change date.
158
208
 
159
- # 202503030
160
- # 0.13.2: Change repo name to match gem name (/HTTP/http.rb/); + Use HTTP::VERSION; /require/require_relative/
209
+ ## 202503030
210
+
211
+ 0.13.2: Change repo name to match gem name (/HTTP/http.rb/); + Use HTTP::VERSION; /require/require_relative/
212
+
161
213
  1. ~ README.md: /HTTP/http.rb/, /HTTP.rb/http.rb/
162
214
  2. + HTTP::VERSION
163
215
  3. ~ http.rb.gemspec: Use HTTP::VERSION.
164
216
  4. ~ HTTP.get: /require/require_relative/
165
217
  5. ~ HTTP.post: /require/require_relative/
166
218
 
167
- # 20250304
168
- # 0.13.1: /HTTP.rb.gemspec/http.rb.gemspec/
219
+ ## 20250304
220
+
221
+ 0.13.1: /HTTP.rb.gemspec/http.rb.gemspec/
222
+
169
223
  1. /HTTP.rb.gemspec/http.rb.gemspec/ (Wonder no more!)
170
224
 
171
- # 20250304
172
- # 0.13.0: Extend Net::HTTPResponse to allow use of predicate methods for statuses and optionally prevent redirections.
225
+ ## 20250304
226
+
227
+ 0.13.0: Extend Net::HTTPResponse to allow use of predicate methods for statuses and optionally prevent redirections.
228
+
173
229
  1. + lib/Net/HTTPResponse/StatusPredicates.rb
174
230
  2. ~ HTTP/get.rb: + require 'Net/HTTPResponse/StatusPredicates'
175
231
  3. ~ HTTP/post.rb: + require 'Net/HTTPResponse/StatusPredicates'
@@ -183,8 +239,10 @@
183
239
  11. /http.rb.gemspec/HTTP.rb.gemspec/ (I wonder if that's going to cause issues for rubygems.org...)
184
240
  12. ~ CHANGELOG.txt
185
241
 
186
- # 20250207
187
- # 0.12.1: Correctly handle POST'ing JSON data.
242
+ ## 20250207
243
+
244
+ 0.12.1: Correctly handle POST'ing JSON data.
245
+
188
246
  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.
189
247
  2. ~ spec/HTTP/post_spec.rb: + spec for when a request is being made with JSON data
190
248
  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/VERSION.rb CHANGED
@@ -2,5 +2,5 @@
2
2
  # HTTP::VERSION
3
3
 
4
4
  module HTTP
5
- VERSION = '0.20.0'
5
+ VERSION = '0.22.0'
6
6
  end
data/lib/HTTP/request.rb CHANGED
@@ -36,7 +36,7 @@ module HTTP
36
36
  elsif no_redirect
37
37
  return response
38
38
  end
39
- redirect_uri = uri.merge(response.header['location'])
39
+ redirect_uri = uri.merge(response['location'])
40
40
  if response.code =~ /^30[78]$/
41
41
  data = VERBS::WITH_BODY.include?(verb) ? request_object.body : {}
42
42
  response = send(verb, redirect_uri.to_s, data, headers, options, &block)
@@ -0,0 +1,281 @@
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
+ end
271
+
272
+ describe HTTP, ".backoff_delay" do
273
+ it "grows exponentially with attempt number" do
274
+ base = 1.0
275
+ delays = (1..4).map{|attempt| HTTP.backoff_delay(base, attempt)}
276
+ _(delays[0]).must_be_close_to(1.0, 0.2)
277
+ _(delays[1]).must_be_close_to(2.0, 0.4)
278
+ _(delays[2]).must_be_close_to(4.0, 0.8)
279
+ _(delays[3]).must_be_close_to(8.0, 1.6)
280
+ end
281
+ end