Skip to content

Commit 45d68fd

Browse files
authored
Merge pull request #20 from justinhoward/tighten-net-http-timeout-on-retry
Apply Net::HTTP cutoff to internal retries
2 parents e47ed00 + b671cf8 commit 45d68fd

4 files changed

Lines changed: 99 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [1.1.0] - 2026-05-12
11+
12+
### Fixed
13+
14+
- Net::HTTP patch now also re-applies on `begin_transport`, so internal retries
15+
(Net::HTTP retries idempotent requests once by default on transient errors
16+
like Net::ReadTimeout) respect the cutoff instead of silently doubling the
17+
effective deadline.
18+
1019
## [1.0.0] - 2026-05-12
1120

1221
This release marks Cutoff's API as stable. There are no behavior changes
@@ -98,7 +107,8 @@ to `Timeout::Error`. `CutoffError` changes from a class to a module.
98107
- Cutoff class
99108
- Mysql2 patch
100109

101-
[Unreleased]: https://github.com/justinhoward/cutoff/compare/v1.0.0...HEAD
110+
[Unreleased]: https://github.com/justinhoward/cutoff/compare/v1.1.0...HEAD
111+
[1.1.0]: https://github.com/justinhoward/cutoff/compare/v1.0.0...v1.1.0
102112
[1.0.0]: https://github.com/justinhoward/cutoff/compare/v0.5.2...v1.0.0
103113
[0.5.2]: https://github.com/justinhoward/cutoff/compare/v0.5.1...v0.5.2
104114
[0.5.1]: https://github.com/justinhoward/cutoff/compare/v0.5.0...v0.5.1

lib/cutoff/patch/net_http.rb

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,22 +25,43 @@ def use_write_timeout?
2525
end
2626

2727
# Same as the original start, but adds a checkpoint for starting HTTP
28-
# requests and sets network timeouts to the remaining time
28+
# requests and sets network timeouts to the remaining time.
29+
#
30+
# Also applies the same logic to begin_transport, which is called on
31+
# every HTTP attempt including internal retries. Net::HTTP#transport_request
32+
# silently retries idempotent requests (GET, HEAD, PUT, DELETE, OPTIONS,
33+
# TRACE) up to max_retries (default 1) on transient errors like
34+
# Net::ReadTimeout. Without re-applying the cutoff in begin_transport,
35+
# the retry path goes through #connect (not #start), reuses the original
36+
# read_timeout, and effectively doubles the deadline you set.
2937
#
3038
# @method start
39+
# @method begin_transport
3140
# @see Net::HTTP#start
41+
# @see Net::HTTP#begin_transport
3242
module_eval(<<~RUBY, __FILE__, __LINE__ + 1)
3343
def start
34-
if (cutoff = Cutoff.current) && cutoff.selected?(:net_http)
35-
remaining = cutoff.seconds_remaining
36-
#{gen_timeout_method('open_timeout')}
37-
#{gen_timeout_method('read_timeout')}
38-
#{gen_timeout_method('write_timeout') if use_write_timeout?}
39-
#{gen_timeout_method('continue_timeout')}
40-
Cutoff.checkpoint!(:net_http)
41-
end
44+
_cutoff_apply_to_net_http
4245
super
4346
end
47+
48+
def begin_transport(req)
49+
_cutoff_apply_to_net_http
50+
super
51+
end
52+
53+
private
54+
55+
def _cutoff_apply_to_net_http
56+
return unless (cutoff = Cutoff.current) && cutoff.selected?(:net_http)
57+
58+
remaining = cutoff.seconds_remaining
59+
#{gen_timeout_method('open_timeout')}
60+
#{gen_timeout_method('read_timeout')}
61+
#{gen_timeout_method('write_timeout') if use_write_timeout?}
62+
#{gen_timeout_method('continue_timeout')}
63+
Cutoff.checkpoint!(:net_http)
64+
end
4465
RUBY
4566
end
4667
end

lib/cutoff/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@
33
class Cutoff
44
# @return [Gem::Version] The current version of the cutoff gem
55
def self.version
6-
Gem::Version.new('1.0.0')
6+
Gem::Version.new('1.1.0')
77
end
88
end

spec/patch/net_http_spec.rb

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,61 @@
7171
expect(Net::HTTP.get_response(URI.parse('https://example.com')).code)
7272
.to eq('200')
7373
end
74+
75+
describe "Net::HTTP's internal retry of idempotent requests" do
76+
# Establishes the upstream behavior the cutoff patch is guarding against:
77+
# Net::HTTP#transport_request silently retries idempotent requests
78+
# (default max_retries: 1) on Net::ReadTimeout and other transient errors,
79+
# which can effectively double the deadline a caller set on the request.
80+
it 'normally retries the request once on Net::ReadTimeout' do
81+
uri = URI.parse('https://example.com')
82+
Net::HTTP.start(uri.host, uri.port) do |http|
83+
req = Net::HTTP::Get.new('/')
84+
attempts = 0
85+
allow(req).to receive(:exec) do
86+
attempts += 1
87+
raise Net::ReadTimeout
88+
end
89+
90+
expect { http.request(req) }.to raise_error(Net::ReadTimeout)
91+
expect(attempts).to eq(2)
92+
end
93+
end
94+
95+
it 'tightens read_timeout on each retry as the cutoff is consumed' do
96+
Timecop.freeze
97+
Cutoff.start(10)
98+
uri = URI.parse('https://example.com')
99+
Net::HTTP.start(uri.host, uri.port) do |http|
100+
req = Net::HTTP::Get.new('/')
101+
timeouts = []
102+
allow(req).to receive(:exec) do
103+
timeouts << http.read_timeout
104+
Timecop.freeze(4)
105+
raise Net::ReadTimeout
106+
end
107+
108+
expect { http.request(req) }.to raise_error(Net::ReadTimeout)
109+
expect(timeouts).to eq([10, 6])
110+
end
111+
end
112+
113+
it 'short-circuits the retry with CutoffExceededError when the cutoff is exhausted' do
114+
Timecop.freeze
115+
Cutoff.start(5)
116+
uri = URI.parse('https://example.com')
117+
Net::HTTP.start(uri.host, uri.port) do |http|
118+
req = Net::HTTP::Get.new('/')
119+
attempts = 0
120+
allow(req).to receive(:exec) do
121+
attempts += 1
122+
Timecop.freeze(10)
123+
raise Net::ReadTimeout
124+
end
125+
126+
expect { http.request(req) }.to raise_error(Cutoff::CutoffExceededError)
127+
expect(attempts).to eq(1)
128+
end
129+
end
130+
end
74131
end

0 commit comments

Comments
 (0)