Skip to content

Commit 67961da

Browse files
committed
Allow combining global and per-operation timeouts
Global and per-operation timeouts are no longer mutually exclusive. `HTTP.timeout(global: 60, read: 30, write: 30, connect: 5)` now sets a global request timeout while also enforcing individual operation limits. The effective timeout for each wait is the minimum of the per-operation value and the remaining global time. Closes #773.
1 parent 93da710 commit 67961da

11 files changed

Lines changed: 323 additions & 32 deletions

File tree

.rubocop/metrics.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,5 @@ Metrics/ModuleLength:
3232
- array
3333
- heredoc
3434
- method_call
35+
Exclude:
36+
- 'test/support/**/*.rb'

CHANGELOG.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- Pattern matching support (`deconstruct_keys`) for Response, Response::Status,
1313
Headers, ContentType, and URI (#642)
14+
- Combined global and per-operation timeouts: global and per-operation timeouts
15+
are no longer mutually exclusive. Use
16+
`HTTP.timeout(global: 60, read: 30, write: 30, connect: 5)` to set both a
17+
global request timeout and individual operation limits (#773)
1418

1519
### Changed
1620

1721
- **BREAKING** Stricter timeout options parsing: `.timeout()` with a Hash now
18-
rejects unknown keys, non-numeric values, string keys, and empty hashes.
19-
`HTTP.timeout(global: N)` is no longer accepted; use `HTTP.timeout(N)` (#754)
22+
rejects unknown keys, non-numeric values, string keys, and empty hashes (#754)
2023
- Bumped min llhttp dependency version
2124
- **BREAKING** Handle responses in the reverse order from the requests (#776)
2225

lib/http/chainable.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ def build_request(verb, uri, opts = {})
161161
# @option options [Float] :read Read timeout
162162
# @option options [Float] :write Write timeout
163163
# @option options [Float] :connect Connect timeout
164+
# @option options [Float] :global Global timeout (combines with per-operation)
164165
# @overload timeout(global_timeout)
165166
# Adds a global timeout to the full request
166167
# @param [Numeric] global_timeout
@@ -169,9 +170,8 @@ def build_request(verb, uri, opts = {})
169170
def timeout(options)
170171
klass, options = case options
171172
when Numeric then [HTTP::Timeout::Global, { global_timeout: options }]
172-
when Hash
173-
[HTTP::Timeout::PerOperation, HTTP::Timeout::PerOperation.normalize_options(options)]
174-
when :null then [HTTP::Timeout::Null, {}]
173+
when Hash then resolve_timeout_hash(options)
174+
when :null then [HTTP::Timeout::Null, {}]
175175
else raise ArgumentError,
176176
"Use `.timeout(:null)`, " \
177177
"`.timeout(global_timeout_in_seconds)` or " \

lib/http/chainable/helpers.rb

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,36 @@ def build_proxy_hash(proxy)
2727
end
2828
result
2929
end
30+
31+
# Resolve a timeout hash into a timeout class and normalized options
32+
#
33+
# @example
34+
# resolve_timeout_hash(global: 60, read: 30)
35+
#
36+
# @param [Hash] options timeout options
37+
# @return [Array(Class, Hash)] timeout class and normalized options
38+
# @raise [ArgumentError] if options are invalid
39+
# @api private
40+
def resolve_timeout_hash(options)
41+
remaining = options.dup
42+
global = HTTP::Timeout::PerOperation.send(:extract_global_timeout!, remaining)
43+
44+
return resolve_global_only(global) if remaining.empty?
45+
46+
per_op = HTTP::Timeout::PerOperation.normalize_options(remaining)
47+
global ? [HTTP::Timeout::Global, per_op.merge(global_timeout: global)] : [HTTP::Timeout::PerOperation, per_op]
48+
end
49+
50+
# Build options for a global-only timeout from a hash
51+
#
52+
# @param [Numeric, nil] global the global timeout value
53+
# @return [Array(Class, Hash)] timeout class and options
54+
# @raise [ArgumentError] if no global timeout given
55+
# @api private
56+
def resolve_global_only(global)
57+
raise ArgumentError, "no timeout options given" unless global
58+
59+
[HTTP::Timeout::Global, { global_timeout: global }]
60+
end
3061
end
3162
end

lib/http/timeout/global.rb

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ def initialize(*args)
2323
super
2424

2525
@timeout = @time_left = options.fetch(:global_timeout)
26+
@read_timeout = options[:read_timeout]
27+
@write_timeout = options[:write_timeout]
28+
@connect_timeout = options[:connect_timeout]
2629
end
2730

2831
# Resets the time left counter to initial timeout
@@ -49,7 +52,7 @@ def reset_counter
4952
# @return [void]
5053
def connect(socket_class, host, port, nodelay: false)
5154
reset_timer
52-
::Timeout.timeout(@time_left, ConnectTimeoutError) do
55+
::Timeout.timeout(effective_timeout(@connect_timeout), ConnectTimeoutError) do
5356
@socket = socket_class.open(host, port)
5457
@socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay
5558
end
@@ -70,10 +73,10 @@ def connect_ssl
7073
begin
7174
@socket.connect_nonblock
7275
rescue IO::WaitReadable
73-
wait_readable_or_timeout
76+
wait_readable_or_timeout(@connect_timeout)
7477
retry
7578
rescue IO::WaitWritable
76-
wait_writable_or_timeout
79+
wait_writable_or_timeout(@connect_timeout)
7780
retry
7881
end
7982
end
@@ -88,7 +91,7 @@ def connect_ssl
8891
# @api public
8992
# @return [String, :eof]
9093
def readpartial(size, buffer = nil)
91-
perform_io { read_nonblock(size, buffer) }
94+
perform_io(@read_timeout) { read_nonblock(size, buffer) }
9295
end
9396

9497
# Write to the socket
@@ -100,7 +103,7 @@ def readpartial(size, buffer = nil)
100103
# @api public
101104
# @return [Integer, :eof]
102105
def write(data)
103-
perform_io { write_nonblock(data) }
106+
perform_io(@write_timeout) { write_nonblock(data) }
104107
end
105108

106109
alias << write
@@ -125,18 +128,19 @@ def write_nonblock(data)
125128

126129
# Performs I/O operation with timeout tracking
127130
#
131+
# @param [Numeric, nil] per_op_timeout per-operation timeout limit
128132
# @api private
129133
# @return [Object]
130-
def perform_io
134+
def perform_io(per_op_timeout = nil)
131135
reset_timer
132136

133137
loop do
134138
result = yield
135139
return handle_io_result(result) unless WAIT_RESULTS.include?(result)
136140

137-
wait_for_io(result)
138-
rescue IO::WaitReadable then wait_readable_or_timeout
139-
rescue IO::WaitWritable then wait_writable_or_timeout
141+
wait_for_io(result, per_op_timeout)
142+
rescue IO::WaitReadable then wait_readable_or_timeout(per_op_timeout)
143+
rescue IO::WaitWritable then wait_writable_or_timeout(per_op_timeout)
140144
end
141145
rescue EOFError
142146
:eof
@@ -152,32 +156,53 @@ def handle_io_result(result)
152156

153157
# Waits for an I/O readiness based on the result type
154158
#
159+
# @param [Symbol] result the I/O wait type
160+
# @param [Numeric, nil] per_op_timeout per-operation timeout limit
155161
# @api private
156162
# @return [void]
157-
def wait_for_io(result)
163+
def wait_for_io(result, per_op_timeout = nil)
158164
if result == :wait_readable
159-
wait_readable_or_timeout
165+
wait_readable_or_timeout(per_op_timeout)
160166
else
161-
wait_writable_or_timeout
167+
wait_writable_or_timeout(per_op_timeout)
162168
end
163169
end
164170

165171
# Waits for a socket to become readable
166172
#
173+
# @param [Numeric, nil] per_op per-operation timeout limit
167174
# @api private
168175
# @return [void]
169-
def wait_readable_or_timeout
170-
@socket.to_io.wait_readable(@time_left)
176+
def wait_readable_or_timeout(per_op = nil)
177+
timeout = effective_timeout(per_op)
178+
result = @socket.to_io.wait_readable(timeout)
171179
log_time
180+
181+
raise TimeoutError, "Read timed out after #{per_op} seconds" if per_op && result.nil?
172182
end
173183

174184
# Waits for a socket to become writable
175185
#
186+
# @param [Numeric, nil] per_op per-operation timeout limit
176187
# @api private
177188
# @return [void]
178-
def wait_writable_or_timeout
179-
@socket.to_io.wait_writable(@time_left)
189+
def wait_writable_or_timeout(per_op = nil)
190+
timeout = effective_timeout(per_op)
191+
result = @socket.to_io.wait_writable(timeout)
180192
log_time
193+
194+
raise TimeoutError, "Write timed out after #{per_op} seconds" if per_op && result.nil?
195+
end
196+
197+
# Computes the effective timeout as the minimum of global and per-operation
198+
#
199+
# @param [Numeric, nil] per_op_timeout per-operation timeout limit
200+
# @api private
201+
# @return [Numeric]
202+
def effective_timeout(per_op_timeout)
203+
return @time_left unless per_op_timeout
204+
205+
[per_op_timeout, @time_left].min
181206
end
182207

183208
# Resets the I/O timer to current time

lib/http/timeout/per_operation.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,21 @@ def self.normalize_options(options)
4343
normalized
4444
end
4545

46+
# Extract and validate global timeout from options hash
47+
#
48+
# @example
49+
# extract_global_timeout!({global: 60, read: 5})
50+
#
51+
# @param [Hash] options mutable options hash (global key is deleted if found)
52+
# @return [Numeric, nil] the global timeout value, or nil if not present
53+
# @raise [ArgumentError] if both forms given or value is not numeric
54+
# @api private
55+
private_class_method def self.extract_global_timeout!(options)
56+
return unless options.key?(:global) || options.key?(:global_timeout)
57+
58+
resolve_timeout_value!(options, :global, :global_timeout)
59+
end
60+
4661
# Resolve a single timeout value from the options hash
4762
#
4863
# @example

sig/http.rbs

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ module HTTP
5050

5151
def branch: (Options options) -> Client
5252
def build_proxy_hash: (Array[untyped] proxy) -> Hash[Symbol, untyped]
53+
def resolve_timeout_hash: (Hash[Symbol, untyped] options) -> [Class, Hash[Symbol, untyped]]
54+
def resolve_global_only: (Numeric? global) -> [Class, Hash[Symbol, untyped]]
5355
end
5456

5557
class Client
@@ -980,6 +982,7 @@ module HTTP
980982

981983
private
982984

985+
def self.extract_global_timeout!: (Hash[Symbol, untyped] options) -> Numeric?
983986
def self.resolve_timeout_value!: (Hash[Symbol, untyped] options, Symbol short, Symbol long) -> Numeric
984987

985988
attr_reader read_timeout: untyped
@@ -998,10 +1001,9 @@ module HTTP
9981001
@timeout: Numeric
9991002
@time_left: Numeric
10001003
@started: Time
1001-
1002-
attr_reader read_timeout: untyped
1003-
attr_reader write_timeout: untyped
1004-
attr_reader connect_timeout: untyped
1004+
@read_timeout: Numeric?
1005+
@write_timeout: Numeric?
1006+
@connect_timeout: Numeric?
10051007

10061008
def initialize: (*untyped args) -> void
10071009
def reset_counter: () -> Numeric
@@ -1013,11 +1015,12 @@ module HTTP
10131015

10141016
def read_nonblock: (Integer size, ?String? buffer) -> untyped
10151017
def write_nonblock: (String data) -> untyped
1016-
def perform_io: () { () -> untyped } -> untyped
1018+
def perform_io: (?Numeric? per_op_timeout) { () -> untyped } -> untyped
10171019
def handle_io_result: (untyped result) -> untyped
1018-
def wait_for_io: (untyped result) -> void
1019-
def wait_readable_or_timeout: () -> void
1020-
def wait_writable_or_timeout: () -> void
1020+
def wait_for_io: (untyped result, ?Numeric? per_op_timeout) -> void
1021+
def wait_readable_or_timeout: (?Numeric? per_op) -> void
1022+
def wait_writable_or_timeout: (?Numeric? per_op) -> void
1023+
def effective_timeout: (Numeric? per_op_timeout) -> Numeric
10211024
def reset_timer: () -> void
10221025
def log_time: () -> void
10231026
end

test/http/timeout/global_test.rb

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,4 +183,91 @@
183183
end
184184
end
185185
end
186+
187+
context "with per-operation timeouts" do
188+
let(:timeout) do
189+
HTTP::Timeout::Global.new(global_timeout: 100, read_timeout: 100, write_timeout: 100, connect_timeout: 100)
190+
end
191+
192+
describe "#readpartial when global is shorter than per-op" do
193+
it "uses global time_left as effective timeout" do
194+
call_count = 0
195+
socket = fake(
196+
to_io: io,
197+
closed?: false,
198+
read_nonblock: proc { |*|
199+
call_count += 1
200+
call_count == 1 ? :wait_readable : "data"
201+
}
202+
)
203+
timeout.instance_variable_set(:@socket, socket)
204+
205+
assert_equal "data", timeout.readpartial(10)
206+
end
207+
end
208+
209+
context "with tight per-op timeouts" do
210+
let(:timeout) do
211+
HTTP::Timeout::Global.new(global_timeout: 100, read_timeout: 0.01, write_timeout: 0.01, connect_timeout: 0.01)
212+
end
213+
214+
describe "#readpartial" do
215+
it "raises when per-op read timeout fires before global" do
216+
io_nil = fake(wait_readable: nil, wait_writable: true)
217+
socket = fake(
218+
to_io: io_nil,
219+
closed?: false,
220+
read_nonblock: :wait_readable
221+
)
222+
timeout.instance_variable_set(:@socket, socket)
223+
224+
err = assert_raises(HTTP::TimeoutError) { timeout.readpartial(10) }
225+
226+
assert_match(/Read timed out/, err.message)
227+
end
228+
end
229+
230+
describe "#write" do
231+
it "raises when per-op write timeout fires before global" do
232+
io_nil = fake(wait_readable: true, wait_writable: nil)
233+
socket = fake(
234+
to_io: io_nil,
235+
closed?: false,
236+
write_nonblock: :wait_writable
237+
)
238+
timeout.instance_variable_set(:@socket, socket)
239+
240+
err = assert_raises(HTTP::TimeoutError) { timeout.write("data") }
241+
242+
assert_match(/Write timed out/, err.message)
243+
end
244+
end
245+
246+
describe "#connect_ssl" do
247+
it "uses connect_timeout for SSL handshake wait_readable" do
248+
io_nil = fake(wait_readable: nil, wait_writable: true)
249+
socket = fake(
250+
to_io: io_nil,
251+
closed?: false,
252+
connect_nonblock: ->(*) { raise IO::EAGAINWaitReadable }
253+
)
254+
timeout.instance_variable_set(:@socket, socket)
255+
256+
assert_raises(HTTP::TimeoutError) { timeout.connect_ssl }
257+
end
258+
259+
it "uses connect_timeout for SSL handshake wait_writable" do
260+
io_nil = fake(wait_readable: true, wait_writable: nil)
261+
socket = fake(
262+
to_io: io_nil,
263+
closed?: false,
264+
connect_nonblock: ->(*) { raise IO::EAGAINWaitWritable }
265+
)
266+
timeout.instance_variable_set(:@socket, socket)
267+
268+
assert_raises(HTTP::TimeoutError) { timeout.connect_ssl }
269+
end
270+
end
271+
end
272+
end
186273
end

0 commit comments

Comments
 (0)