Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.1.0] - 2026-05-12

### Fixed

- Net::HTTP patch now also re-applies on `begin_transport`, so internal retries
(Net::HTTP retries idempotent requests once by default on transient errors
like Net::ReadTimeout) respect the cutoff instead of silently doubling the
effective deadline.

## [1.0.0] - 2026-05-12

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

[Unreleased]: https://github.com/justinhoward/cutoff/compare/v1.0.0...HEAD
[Unreleased]: https://github.com/justinhoward/cutoff/compare/v1.1.0...HEAD
[1.1.0]: https://github.com/justinhoward/cutoff/compare/v1.0.0...v1.1.0
[1.0.0]: https://github.com/justinhoward/cutoff/compare/v0.5.2...v1.0.0
[0.5.2]: https://github.com/justinhoward/cutoff/compare/v0.5.1...v0.5.2
[0.5.1]: https://github.com/justinhoward/cutoff/compare/v0.5.0...v0.5.1
Expand Down
39 changes: 30 additions & 9 deletions lib/cutoff/patch/net_http.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,43 @@ def use_write_timeout?
end

# Same as the original start, but adds a checkpoint for starting HTTP
# requests and sets network timeouts to the remaining time
# requests and sets network timeouts to the remaining time.
#
# Also applies the same logic to begin_transport, which is called on
# every HTTP attempt including internal retries. Net::HTTP#transport_request
# silently retries idempotent requests (GET, HEAD, PUT, DELETE, OPTIONS,
# TRACE) up to max_retries (default 1) on transient errors like
# Net::ReadTimeout. Without re-applying the cutoff in begin_transport,
# the retry path goes through #connect (not #start), reuses the original
# read_timeout, and effectively doubles the deadline you set.
#
# @method start
# @method begin_transport
# @see Net::HTTP#start
# @see Net::HTTP#begin_transport
module_eval(<<~RUBY, __FILE__, __LINE__ + 1)
def start
if (cutoff = Cutoff.current) && cutoff.selected?(:net_http)
remaining = cutoff.seconds_remaining
#{gen_timeout_method('open_timeout')}
#{gen_timeout_method('read_timeout')}
#{gen_timeout_method('write_timeout') if use_write_timeout?}
#{gen_timeout_method('continue_timeout')}
Cutoff.checkpoint!(:net_http)
end
_cutoff_apply_to_net_http
super
end

def begin_transport(req)
_cutoff_apply_to_net_http
super
end

private

def _cutoff_apply_to_net_http
return unless (cutoff = Cutoff.current) && cutoff.selected?(:net_http)

remaining = cutoff.seconds_remaining
#{gen_timeout_method('open_timeout')}
#{gen_timeout_method('read_timeout')}
#{gen_timeout_method('write_timeout') if use_write_timeout?}
#{gen_timeout_method('continue_timeout')}
Cutoff.checkpoint!(:net_http)
end
RUBY
end
end
Expand Down
2 changes: 1 addition & 1 deletion lib/cutoff/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
class Cutoff
# @return [Gem::Version] The current version of the cutoff gem
def self.version
Gem::Version.new('1.0.0')
Gem::Version.new('1.1.0')
end
end
57 changes: 57 additions & 0 deletions spec/patch/net_http_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,61 @@
expect(Net::HTTP.get_response(URI.parse('https://example.com')).code)
.to eq('200')
end

describe "Net::HTTP's internal retry of idempotent requests" do
# Establishes the upstream behavior the cutoff patch is guarding against:
# Net::HTTP#transport_request silently retries idempotent requests
# (default max_retries: 1) on Net::ReadTimeout and other transient errors,
# which can effectively double the deadline a caller set on the request.
it 'normally retries the request once on Net::ReadTimeout' do
uri = URI.parse('https://example.com')
Net::HTTP.start(uri.host, uri.port) do |http|
req = Net::HTTP::Get.new('/')
attempts = 0
allow(req).to receive(:exec) do
attempts += 1
raise Net::ReadTimeout
end

expect { http.request(req) }.to raise_error(Net::ReadTimeout)
expect(attempts).to eq(2)
end
end

it 'tightens read_timeout on each retry as the cutoff is consumed' do
Timecop.freeze
Cutoff.start(10)
uri = URI.parse('https://example.com')
Net::HTTP.start(uri.host, uri.port) do |http|
req = Net::HTTP::Get.new('/')
timeouts = []
allow(req).to receive(:exec) do
timeouts << http.read_timeout
Timecop.freeze(4)
raise Net::ReadTimeout
end

expect { http.request(req) }.to raise_error(Net::ReadTimeout)
expect(timeouts).to eq([10, 6])
end
end

it 'short-circuits the retry with CutoffExceededError when the cutoff is exhausted' do
Timecop.freeze
Cutoff.start(5)
uri = URI.parse('https://example.com')
Net::HTTP.start(uri.host, uri.port) do |http|
req = Net::HTTP::Get.new('/')
attempts = 0
allow(req).to receive(:exec) do
attempts += 1
Timecop.freeze(10)
raise Net::ReadTimeout
end

expect { http.request(req) }.to raise_error(Cutoff::CutoffExceededError)
expect(attempts).to eq(1)
end
end
end
end
Loading