http.rb 0.19.0 → 0.20.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: 114eef76572ba0ee1ef7efbb61fe704870a80c9bdc7d45cd5fca467de12658f6
4
- data.tar.gz: bc249e876babf5aa7a4369272675e6194fc25f1816d474ef8080ad107fa1c60c
3
+ metadata.gz: f8926fd82491288941f05e4ff3d38c0c000e31f214bd35f55462befa0ece73a3
4
+ data.tar.gz: ad696135489b778872649c052fb1f23eb05e151e7a04c29237510e997dba7d71
5
5
  SHA512:
6
- metadata.gz: 516e828dced10204ba4b850904d7c195c027166b1dc0dcd575e09403ef5b26da936b925ab7fdfd4c0fb3c7a3e3cf8c44585a562ac647ffbf4451804b35aa9b9a
7
- data.tar.gz: f3c7dc2350bd17d98abdfe9c2deb98b0923def00ea790fffbc8919280399cb5c3d5e65eaed530abb86850a54e2e790591a1c1e9f6a91a03ff2c90611c08d2760
6
+ metadata.gz: eb7f2d8945396de4151166fb3a2fc62a0bb47ac2636a9f157b04c67318f0c9c20d302aba00b8a0db21278fbeed559230b236fea674cb2cf4de3880fe22b09dce
7
+ data.tar.gz: bea5d01ffdab8e4a8fd876ec1b7353776b933d5e1259ea8c57aa4cbf4a8a6b3ba35950fa7bdc907b18bff84e5e7cbc972abbd1cc71398f37934834f176e9d86c
data/CHANGELOG CHANGED
@@ -1,5 +1,15 @@
1
1
  # CHANGELOG
2
2
 
3
+ # 20260522
4
+ # 0.20.0: Add opt-in retry logic with exponential backoff.
5
+ 1. + lib/HTTP/RETRY.rb: Retry helpers (with_retries, backoff_delay, retry_after) and constants (HTTP::RETRY::EXCEPTIONS, STATUS_CODES, VERBS).
6
+ 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
+ 3. + spec/HTTP/RETRY_spec.rb: Specs for retry behaviour and the helpers.
8
+ 4. ~ README.md: + Retries section.
9
+ 5. ~ TODO: Annotate "Add retry logic to HTTP.request" as (Done as of 0.20.0); also annotate three previously-completed items as (Done as of 0.18.2), (Done as of 0.18.3), and (Done as of 0.19.0).
10
+ 6. ~ HTTP::VERSION: /0.19.0/0.20.0/
11
+ 7. ~ CHANGELOG: + 0.20.0 entry
12
+
3
13
  # 20260522
4
14
  # 0.19.0: Change default verify_mode to VERIFY_PEER.
5
15
  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.
data/README.md CHANGED
@@ -84,6 +84,34 @@ HTTP.get('http://example.com', {}, {}, {no_redirect: true})
84
84
  # => #<Net::HTTPResponse @code=3xx>
85
85
  ```
86
86
 
87
+ ### Retries
88
+
89
+ Retries are disabled by default. Enable them by passing `retries:` in the options hash.
90
+
91
+ ```ruby
92
+ HTTP.get('http://example.com', {}, {}, {retries: 3})
93
+ ```
94
+
95
+ When enabled, transient network exceptions and retry-worthy HTTP status codes (429, 502, 503, 504) are retried with exponential backoff and jitter. If the response carries a `Retry-After` header, it is honoured in place of the calculated delay.
96
+
97
+ Only idempotent verbs (`get`, `head`, `options`, `put`, `delete`, `trace`) are retried by default. POST and PATCH are not — retrying a non-idempotent write can create duplicate resources against APIs that don't deduplicate. Opt in per-call via `retry_verbs:`.
98
+
99
+ ```ruby
100
+ HTTP.post('http://example.com', {a: 1}, {}, {retries: 3, retry_verbs: %i{get post}})
101
+ ```
102
+
103
+ Configurable options:
104
+
105
+ ```ruby
106
+ options = {
107
+ retries: 3, # max retry attempts; 0 disables
108
+ retry_delay: 1.0, # base delay (seconds) for exponential backoff
109
+ retry_status_codes: [429, 502, 503, 504], # HTTP status codes to retry
110
+ retry_exceptions: HTTP::RETRY::EXCEPTIONS, # exceptions to retry
111
+ retry_verbs: HTTP::RETRY::VERBS # verbs that retry by default
112
+ }
113
+ ```
114
+
87
115
  ### Response status predicate methods
88
116
 
89
117
  ```ruby
data/lib/HTTP/RETRY.rb ADDED
@@ -0,0 +1,76 @@
1
+ # HTTP/RETRY.rb
2
+ # HTTP::RETRY (retry helpers)
3
+
4
+ require 'net/http'
5
+ require 'socket'
6
+ require 'time'
7
+
8
+ module HTTP
9
+ module RETRY
10
+ EXCEPTIONS = [
11
+ Errno::ECONNREFUSED,
12
+ Errno::ECONNRESET,
13
+ Errno::ETIMEDOUT,
14
+ Errno::EHOSTUNREACH,
15
+ Errno::ENETUNREACH,
16
+ Net::OpenTimeout,
17
+ Net::ReadTimeout,
18
+ SocketError,
19
+ EOFError
20
+ ].freeze
21
+ STATUS_CODES = [429, 502, 503, 504].freeze
22
+ VERBS = %i{get head options put delete trace}.freeze
23
+
24
+ def self.sleep(seconds)
25
+ Kernel.sleep(seconds)
26
+ end
27
+ end
28
+
29
+ def retry_config(options)
30
+ {
31
+ retries: options.delete(:retries) || 0,
32
+ delay: options.delete(:retry_delay) || 1.0,
33
+ status_codes: options.delete(:retry_status_codes) || RETRY::STATUS_CODES,
34
+ exceptions: options.delete(:retry_exceptions) || RETRY::EXCEPTIONS,
35
+ verbs: options.delete(:retry_verbs) || RETRY::VERBS
36
+ }
37
+ end
38
+ module_function :retry_config
39
+
40
+ def with_retries(http, request_object, config)
41
+ attempt = 0
42
+ loop do
43
+ begin
44
+ response = http.request(request_object)
45
+ if config[:status_codes].include?(response.code.to_i) && attempt < config[:retries]
46
+ attempt += 1
47
+ RETRY.sleep(retry_after(response) || backoff_delay(config[:delay], attempt))
48
+ next
49
+ end
50
+ return response
51
+ rescue *config[:exceptions]
52
+ raise unless attempt < config[:retries]
53
+ attempt += 1
54
+ RETRY.sleep(backoff_delay(config[:delay], attempt))
55
+ end
56
+ end
57
+ end
58
+ module_function :with_retries
59
+
60
+ def backoff_delay(base, attempt)
61
+ base * (2 ** (attempt - 1)) * (1 + (rand - 0.5) * 0.4)
62
+ end
63
+ module_function :backoff_delay
64
+
65
+ def retry_after(response)
66
+ header = response['Retry-After']
67
+ return nil unless header
68
+ if header =~ /\A\d+\z/
69
+ header.to_i
70
+ else
71
+ # Malformed HTTP-date — fall through to caller's backoff.
72
+ Time.httpdate(header) - Time.now rescue nil
73
+ end
74
+ end
75
+ module_function :retry_after
76
+ end
data/lib/HTTP/VERSION.rb CHANGED
@@ -2,5 +2,5 @@
2
2
  # HTTP::VERSION
3
3
 
4
4
  module HTTP
5
- VERSION = '0.19.0'
5
+ VERSION = '0.20.0'
6
6
  end
data/lib/HTTP/request.rb CHANGED
@@ -9,18 +9,27 @@ require_relative '../Net/HTTP/set_options'
9
9
  require_relative '../Net/HTTPRequest/set_headers'
10
10
  require_relative '../Net/HTTPResponse/StatusPredicates'
11
11
  require_relative '../URI/Generic/use_sslQ'
12
+ require_relative './RETRY'
12
13
 
13
14
  module HTTP
14
15
  def request(uri, request_object, headers = {}, options = {}, &block)
15
16
  uri = uri.is_a?(URI) ? uri : URI.parse(uri)
16
17
  http = Net::HTTP.new(uri.host, uri.port)
17
18
  no_redirect = options.delete(:no_redirect)
19
+ config = retry_config(options)
18
20
  options[:use_ssl] ||= uri.use_ssl?
19
21
  options[:verify_mode] ||= OpenSSL::SSL::VERIFY_PEER
20
22
  http.options = options
21
23
  request_object.headers = headers
22
24
  request_object.basic_auth(uri.user, uri.password) if uri.user
23
- response = http.request(request_object)
25
+ verb = request_object.method.downcase.to_sym
26
+ response = (
27
+ if config[:retries] > 0 && config[:verbs].include?(verb)
28
+ with_retries(http, request_object, config)
29
+ else
30
+ http.request(request_object)
31
+ end
32
+ )
24
33
  if response.code =~ /^3/
25
34
  if block_given? && no_redirect
26
35
  yield response
@@ -29,7 +38,6 @@ module HTTP
29
38
  end
30
39
  redirect_uri = uri.merge(response.header['location'])
31
40
  if response.code =~ /^30[78]$/
32
- verb = request_object.method.downcase.to_sym
33
41
  data = VERBS::WITH_BODY.include?(verb) ? request_object.body : {}
34
42
  response = send(verb, redirect_uri.to_s, data, headers, options, &block)
35
43
  else
@@ -0,0 +1,251 @@
1
+ # spec/HTTP/RETRY_spec.rb
2
+
3
+ require_relative '../spec_helper'
4
+ require 'http'
5
+
6
+ describe "retry behaviour" do
7
+ let(:uri){'http://example.com/path'}
8
+
9
+ before do
10
+ allow(HTTP::RETRY).to receive(:sleep)
11
+ end
12
+
13
+ describe "defaults" do
14
+ it "does not retry by default" do
15
+ stub_request(:get, uri).to_return(status: 503)
16
+ response = HTTP.get(uri)
17
+ expect(response.code.to_i).to eq(503)
18
+ expect(WebMock).to have_requested(:get, uri).times(1)
19
+ end
20
+
21
+ it "does not retry on a transient exception by default" do
22
+ stub_request(:get, uri).to_raise(Errno::ECONNRESET)
23
+ expect{HTTP.get(uri)}.to raise_error(Errno::ECONNRESET)
24
+ expect(WebMock).to have_requested(:get, uri).times(1)
25
+ end
26
+ end
27
+
28
+ describe "retry on transient exception" do
29
+ it "retries and succeeds when the failure is transient" do
30
+ stub_request(:get, uri).
31
+ to_raise(Errno::ECONNRESET).then.
32
+ to_raise(Errno::ECONNRESET).then.
33
+ to_return(status: 200, body: '')
34
+ response = HTTP.get(uri, {}, {}, {retries: 3})
35
+ expect(response.success?).to eq(true)
36
+ expect(WebMock).to have_requested(:get, uri).times(3)
37
+ end
38
+
39
+ it "re-raises the exception after retries are exhausted" do
40
+ stub_request(:get, uri).to_raise(Errno::ECONNRESET)
41
+ expect{HTTP.get(uri, {}, {}, {retries: 2})}.to raise_error(Errno::ECONNRESET)
42
+ expect(WebMock).to have_requested(:get, uri).times(3)
43
+ end
44
+
45
+ it "retries on SocketError (DNS failure)" 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
+ expect(response.success?).to eq(true)
51
+ expect(WebMock).to have_requested(:get, uri).times(2)
52
+ end
53
+
54
+ it "does not retry on a non-listed exception" do
55
+ stub_request(:get, uri).to_raise(OpenSSL::SSL::SSLError)
56
+ expect{HTTP.get(uri, {}, {}, {retries: 3})}.to raise_error(OpenSSL::SSL::SSLError)
57
+ expect(WebMock).to have_requested(:get, uri).times(1)
58
+ end
59
+ end
60
+
61
+ describe "retry on status code" do
62
+ it "retries on 503 then succeeds" do
63
+ stub_request(:get, uri).
64
+ to_return({status: 503}, {status: 503}, {status: 200, body: ''})
65
+ response = HTTP.get(uri, {}, {}, {retries: 3})
66
+ expect(response.success?).to eq(true)
67
+ expect(WebMock).to have_requested(:get, uri).times(3)
68
+ end
69
+
70
+ it "retries on 502" do
71
+ stub_request(:get, uri).to_return({status: 502}, {status: 200, body: ''})
72
+ response = HTTP.get(uri, {}, {}, {retries: 3})
73
+ expect(response.success?).to eq(true)
74
+ expect(WebMock).to have_requested(:get, uri).times(2)
75
+ end
76
+
77
+ it "retries on 504" do
78
+ stub_request(:get, uri).to_return({status: 504}, {status: 200, body: ''})
79
+ response = HTTP.get(uri, {}, {}, {retries: 3})
80
+ expect(response.success?).to eq(true)
81
+ expect(WebMock).to have_requested(:get, uri).times(2)
82
+ end
83
+
84
+ it "does not retry on 500 by default" do
85
+ stub_request(:get, uri).to_return(status: 500)
86
+ response = HTTP.get(uri, {}, {}, {retries: 3})
87
+ expect(response.code.to_i).to eq(500)
88
+ expect(WebMock).to have_requested(:get, uri).times(1)
89
+ end
90
+
91
+ it "does not retry on 404" do
92
+ stub_request(:get, uri).to_return(status: 404)
93
+ response = HTTP.get(uri, {}, {}, {retries: 3})
94
+ expect(response.code.to_i).to eq(404)
95
+ expect(WebMock).to have_requested(:get, uri).times(1)
96
+ end
97
+
98
+ it "returns the last response when retries are exhausted" do
99
+ stub_request(:get, uri).to_return(status: 503)
100
+ response = HTTP.get(uri, {}, {}, {retries: 2})
101
+ expect(response.code.to_i).to eq(503)
102
+ expect(WebMock).to have_requested(:get, uri).times(3)
103
+ end
104
+ end
105
+
106
+ describe "Retry-After header" do
107
+ it "honours integer Retry-After on 429" do
108
+ stub_request(:get, uri).
109
+ to_return({status: 429, headers: {'Retry-After' => '2'}}, {status: 200, body: ''})
110
+ expect(HTTP::RETRY).to receive(:sleep).with(2)
111
+ response = HTTP.get(uri, {}, {}, {retries: 3})
112
+ expect(response.success?).to eq(true)
113
+ end
114
+
115
+ it "honours integer Retry-After on 503" do
116
+ stub_request(:get, uri).
117
+ to_return({status: 503, headers: {'Retry-After' => '5'}}, {status: 200, body: ''})
118
+ expect(HTTP::RETRY).to receive(:sleep).with(5)
119
+ response = HTTP.get(uri, {}, {}, {retries: 3})
120
+ expect(response.success?).to eq(true)
121
+ end
122
+ end
123
+
124
+ describe "configuration" do
125
+ it "treats retries: 0 as no retries" do
126
+ stub_request(:get, uri).to_return(status: 503)
127
+ response = HTTP.get(uri, {}, {}, {retries: 0})
128
+ expect(response.code.to_i).to eq(503)
129
+ expect(WebMock).to have_requested(:get, uri).times(1)
130
+ end
131
+
132
+ it "respects a custom retry_status_codes list" do
133
+ stub_request(:get, uri).to_return({status: 500}, {status: 200, body: ''})
134
+ response = HTTP.get(uri, {}, {}, {retries: 3, retry_status_codes: [500]})
135
+ expect(response.success?).to eq(true)
136
+ expect(WebMock).to have_requested(:get, uri).times(2)
137
+ end
138
+
139
+ it "respects a custom retry_exceptions list" do
140
+ stub_request(:get, uri).
141
+ to_raise(OpenSSL::SSL::SSLError).then.
142
+ to_return(status: 200, body: '')
143
+ response = HTTP.get(uri, {}, {}, {retries: 3, retry_exceptions: [OpenSSL::SSL::SSLError]})
144
+ expect(response.success?).to eq(true)
145
+ expect(WebMock).to have_requested(:get, uri).times(2)
146
+ end
147
+
148
+ it "does not pass retry options through to Net::HTTP" do
149
+ stub_request(:get, uri).to_return(status: 200, body: '')
150
+ net_http_object = Net::HTTP.new(URI.parse(uri).host, URI.parse(uri).port)
151
+ allow(Net::HTTP).to receive(:new).and_return(net_http_object)
152
+ expect(net_http_object).to receive(:options=) do |opts|
153
+ expect(opts).not_to include(:retries, :retry_delay, :retry_status_codes, :retry_exceptions, :retry_verbs)
154
+ end
155
+ HTTP.get(uri, {}, {}, {
156
+ retries: 3,
157
+ retry_delay: 0.1,
158
+ retry_status_codes: [500],
159
+ retry_exceptions: [Errno::ECONNRESET],
160
+ retry_verbs: %i{get}
161
+ })
162
+ end
163
+ end
164
+
165
+ describe "backoff timing" do
166
+ it "increases the delay between successive retries" do
167
+ delays = []
168
+ allow(HTTP::RETRY).to receive(:sleep){|d| delays << d}
169
+ stub_request(:get, uri).to_return(status: 503)
170
+ HTTP.get(uri, {}, {}, {retries: 3, retry_delay: 1.0})
171
+ expect(delays.length).to eq(3)
172
+ expect(delays[1]).to be > delays[0] * 0.8
173
+ expect(delays[2]).to be > delays[1] * 0.8
174
+ end
175
+ end
176
+
177
+ describe "verb-based retry default" do
178
+ it "does not retry POST by default even when retries are enabled" do
179
+ stub_request(:post, uri).to_return(status: 503)
180
+ HTTP.post(uri, {}, {}, {retries: 3})
181
+ expect(WebMock).to have_requested(:post, uri).times(1)
182
+ end
183
+
184
+ it "does not retry PATCH by default" do
185
+ stub_request(:patch, uri).to_return(status: 503)
186
+ HTTP.patch(uri, {}, {}, {retries: 3})
187
+ expect(WebMock).to have_requested(:patch, uri).times(1)
188
+ end
189
+
190
+ it "retries PUT by default (idempotent)" do
191
+ stub_request(:put, uri).to_return({status: 503}, {status: 200, body: ''})
192
+ response = HTTP.put(uri, {}, {}, {retries: 3})
193
+ expect(response.success?).to eq(true)
194
+ expect(WebMock).to have_requested(:put, uri).times(2)
195
+ end
196
+
197
+ it "retries DELETE by default (idempotent)" do
198
+ stub_request(:delete, uri).to_return({status: 503}, {status: 200, body: ''})
199
+ response = HTTP.delete(uri, {}, {}, {retries: 3})
200
+ expect(response.success?).to eq(true)
201
+ expect(WebMock).to have_requested(:delete, uri).times(2)
202
+ end
203
+
204
+ it "retries POST when opted in via retry_verbs" do
205
+ stub_request(:post, uri).to_return({status: 503}, {status: 200, body: ''})
206
+ response = HTTP.post(uri, {}, {}, {retries: 3, retry_verbs: %i{get post}})
207
+ expect(response.success?).to eq(true)
208
+ expect(WebMock).to have_requested(:post, uri).times(2)
209
+ end
210
+ end
211
+ end
212
+
213
+ describe HTTP, ".retry_after" do
214
+ it "returns integer seconds for a delta-seconds Retry-After header" do
215
+ response = instance_double(Net::HTTPResponse)
216
+ allow(response).to receive(:[]).with('Retry-After').and_return('5')
217
+ expect(HTTP.retry_after(response)).to eq(5)
218
+ end
219
+
220
+ it "parses an HTTP-date Retry-After header" do
221
+ base = Time.utc(2026, 5, 22, 12, 0, 0)
222
+ retry_at_header = (base + 5).httpdate
223
+ response = instance_double(Net::HTTPResponse)
224
+ allow(response).to receive(:[]).with('Retry-After').and_return(retry_at_header)
225
+ allow(Time).to receive(:now).and_return(base)
226
+ expect(HTTP.retry_after(response)).to be_within(0.001).of(5.0)
227
+ end
228
+
229
+ it "returns nil when Retry-After is absent" do
230
+ response = instance_double(Net::HTTPResponse)
231
+ allow(response).to receive(:[]).with('Retry-After').and_return(nil)
232
+ expect(HTTP.retry_after(response)).to be_nil
233
+ end
234
+
235
+ it "returns nil when Retry-After is malformed" do
236
+ response = instance_double(Net::HTTPResponse)
237
+ allow(response).to receive(:[]).with('Retry-After').and_return('not a date')
238
+ expect(HTTP.retry_after(response)).to be_nil
239
+ end
240
+ end
241
+
242
+ describe HTTP, ".backoff_delay" do
243
+ it "grows exponentially with attempt number" do
244
+ base = 1.0
245
+ delays = (1..4).map{|attempt| HTTP.backoff_delay(base, attempt)}
246
+ expect(delays[0]).to be_within(0.2).of(1.0)
247
+ expect(delays[1]).to be_within(0.4).of(2.0)
248
+ expect(delays[2]).to be_within(0.8).of(4.0)
249
+ expect(delays[3]).to be_within(1.6).of(8.0)
250
+ end
251
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: http.rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.19.0
4
+ version: 0.20.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - thoran
@@ -80,6 +80,7 @@ files:
80
80
  - Rakefile
81
81
  - http.rb.gemspec
82
82
  - lib/HTTP.rb
83
+ - lib/HTTP/RETRY.rb
83
84
  - lib/HTTP/VERSION.rb
84
85
  - lib/HTTP/request.rb
85
86
  - lib/HTTP/verbs.rb
@@ -93,6 +94,7 @@ files:
93
94
  - lib/Thoran/Array/FirstX/firstX.rb
94
95
  - lib/Thoran/String/ToConst/to_const.rb
95
96
  - lib/URI/Generic/use_sslQ.rb
97
+ - spec/HTTP/RETRY_spec.rb
96
98
  - spec/HTTP/delete_spec.rb
97
99
  - spec/HTTP/get_spec.rb
98
100
  - spec/HTTP/head_spec.rb