Skip to content

Commit 93da710

Browse files
committed
Implement stricter timeout options parsing
Closes #754.
1 parent 5b59b84 commit 93da710

7 files changed

Lines changed: 184 additions & 21 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414

1515
### Changed
1616

17+
- **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)
1720
- Bumped min llhttp dependency version
1821
- **BREAKING** Handle responses in the reverse order from the requests (#776)
1922

lib/http/chainable.rb

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -168,16 +168,16 @@ def build_request(verb, uri, opts = {})
168168
# @api public
169169
def timeout(options)
170170
klass, options = case options
171-
when Numeric then [HTTP::Timeout::Global, { global: options }]
172-
when Hash then [HTTP::Timeout::PerOperation, options.dup]
173-
when :null then [HTTP::Timeout::Null, {}]
171+
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, {}]
174175
else raise ArgumentError,
175-
"Use `.timeout(global_timeout_in_seconds)` " \
176-
"or `.timeout(connect: x, write: y, read: z)`."
176+
"Use `.timeout(:null)`, " \
177+
"`.timeout(global_timeout_in_seconds)` or " \
178+
"`.timeout(connect: x, write: y, read: z)`."
177179
end
178180

179-
normalize_timeout_keys!(options)
180-
181181
branch default_options.merge(
182182
timeout_class: klass,
183183
timeout_options: options

lib/http/chainable/helpers.rb

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,5 @@ def build_proxy_hash(proxy)
2727
end
2828
result
2929
end
30-
31-
# Normalize timeout option keys to include _timeout suffix
32-
#
33-
# @param [Hash] options timeout options to normalize
34-
# @return [void]
35-
# @api private
36-
def normalize_timeout_keys!(options)
37-
%i[global read write connect].each do |k|
38-
next unless options.key? k
39-
40-
options[:"#{k}_timeout"] = options.delete k
41-
end
42-
end
4330
end
4431
end

lib/http/timeout/per_operation.rb

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,55 @@ class PerOperation < Null
1515
# Default read timeout in seconds
1616
READ_TIMEOUT = 0.25
1717

18+
# Mapping of shorthand option keys to their full forms
19+
KEYS = %i[read write connect].to_h { |k| [k, :"#{k}_timeout"] }.freeze
20+
21+
# Normalize and validate timeout options
22+
#
23+
# @example
24+
# PerOperation.normalize_options(read: 5, write: 3)
25+
#
26+
# @param [Hash] options timeout options with short or long keys
27+
# @return [Hash] normalized options with long keys
28+
# @raise [ArgumentError] if options are invalid
29+
# @api public
30+
def self.normalize_options(options)
31+
remaining = options.dup
32+
normalized = {} #: Hash[Symbol, Numeric]
33+
34+
KEYS.each do |short, long|
35+
next if !remaining.key?(short) && !remaining.key?(long)
36+
37+
normalized[long] = resolve_timeout_value!(remaining, short, long)
38+
end
39+
40+
raise ArgumentError, "unknown timeout options: #{remaining.keys.join(', ')}" unless remaining.empty?
41+
raise ArgumentError, "no timeout options given" if normalized.empty?
42+
43+
normalized
44+
end
45+
46+
# Resolve a single timeout value from the options hash
47+
#
48+
# @example
49+
# resolve_timeout_value!({read: 5}, :read, :read_timeout)
50+
#
51+
# @param [Hash] options mutable options hash (keys are deleted as consumed)
52+
# @param [Symbol] short short key name (e.g. :read)
53+
# @param [Symbol] long long key name (e.g. :read_timeout)
54+
# @return [Numeric] the timeout value
55+
# @raise [ArgumentError] if both forms given or value is not numeric
56+
# @api private
57+
private_class_method def self.resolve_timeout_value!(options, short, long)
58+
raise ArgumentError, "can't pass both #{short} and #{long}" if options.key?(short) && options.key?(long)
59+
60+
value = options.delete(options.key?(long) ? long : short)
61+
62+
raise ArgumentError, "#{long} must be numeric" unless value.is_a?(Numeric)
63+
64+
value
65+
end
66+
1867
# Initializes per-operation timeout with options
1968
#
2069
# @example

sig/http.rbs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ module HTTP
5050

5151
def branch: (Options options) -> Client
5252
def build_proxy_hash: (Array[untyped] proxy) -> Hash[Symbol, untyped]
53-
def normalize_timeout_keys!: (Hash[untyped, untyped] options) -> void
5453
end
5554

5655
class Client
@@ -975,6 +974,13 @@ module HTTP
975974
READ_TIMEOUT: Numeric
976975
WRITE_TIMEOUT: Numeric
977976
CONNECT_TIMEOUT: Numeric
977+
KEYS: Hash[Symbol, Symbol]
978+
979+
def self.normalize_options: (Hash[Symbol, untyped] options) -> Hash[Symbol, Numeric]
980+
981+
private
982+
983+
def self.resolve_timeout_value!: (Hash[Symbol, untyped] options, Symbol short, Symbol long) -> Numeric
978984

979985
attr_reader read_timeout: untyped
980986
attr_reader write_timeout: untyped

test/http/timeout/per_operation_test.rb

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,64 @@
44

55
describe HTTP::Timeout::PerOperation do
66
cover "HTTP::Timeout::PerOperation*"
7+
describe ".normalize_options" do
8+
it "normalizes short read key to long form" do
9+
assert_equal({ read_timeout: 5 }, HTTP::Timeout::PerOperation.normalize_options(read: 5))
10+
end
11+
12+
it "normalizes short write key to long form" do
13+
assert_equal({ write_timeout: 3 }, HTTP::Timeout::PerOperation.normalize_options(write: 3))
14+
end
15+
16+
it "normalizes short connect key to long form" do
17+
assert_equal({ connect_timeout: 1 }, HTTP::Timeout::PerOperation.normalize_options(connect: 1))
18+
end
19+
20+
it "passes through long form keys" do
21+
assert_equal({ read_timeout: 5 }, HTTP::Timeout::PerOperation.normalize_options(read_timeout: 5))
22+
end
23+
24+
it "normalizes all keys together" do
25+
result = HTTP::Timeout::PerOperation.normalize_options(read: 1, write: 2, connect: 3)
26+
27+
assert_equal({ read_timeout: 1, write_timeout: 2, connect_timeout: 3 }, result)
28+
end
29+
30+
it "accepts float values" do
31+
assert_equal({ read_timeout: 1.5 }, HTTP::Timeout::PerOperation.normalize_options(read: 1.5))
32+
end
33+
34+
it "handles frozen hashes" do
35+
result = HTTP::Timeout::PerOperation.normalize_options({ read: 5 }.freeze)
36+
37+
assert_equal({ read_timeout: 5 }, result)
38+
end
39+
40+
it "raises when both short and long form of same key given" do
41+
assert_raises(ArgumentError) do
42+
HTTP::Timeout::PerOperation.normalize_options(read: 1, read_timeout: 2)
43+
end
44+
end
45+
46+
it "raises for non-numeric values" do
47+
assert_raises(ArgumentError) do
48+
HTTP::Timeout::PerOperation.normalize_options(read: "5")
49+
end
50+
end
51+
52+
it "raises for unknown keys" do
53+
assert_raises(ArgumentError) do
54+
HTTP::Timeout::PerOperation.normalize_options(timeout: 5)
55+
end
56+
end
57+
58+
it "raises for empty hash" do
59+
assert_raises(ArgumentError) do
60+
HTTP::Timeout::PerOperation.normalize_options({})
61+
end
62+
end
63+
end
64+
765
let(:timeout) { HTTP::Timeout::PerOperation.new(connect_timeout: 1, read_timeout: 1, write_timeout: 1) }
866

967
let(:io) { fake(wait_readable: true, wait_writable: true) }

test/http_test.rb

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,22 @@
418418
end
419419
end
420420

421+
context "specifying per operation timeouts with long form keys" do
422+
let(:client) { HTTP.timeout read_timeout: 123 }
423+
424+
it "sets given timeout options" do
425+
assert_equal({ read_timeout: 123 }, client.default_options.timeout_options)
426+
end
427+
end
428+
429+
context "specifying all per operation timeouts" do
430+
let(:client) { HTTP.timeout read: 1, write: 2, connect: 3 }
431+
432+
it "sets all timeout options" do
433+
assert_equal({ read_timeout: 1, write_timeout: 2, connect_timeout: 3 }, client.default_options.timeout_options)
434+
end
435+
end
436+
421437
context "specifying per operation timeouts as frozen hash" do
422438
let(:frozen_options) { { read: 123 }.freeze }
423439
let(:client) { HTTP.timeout(frozen_options) }
@@ -427,6 +443,42 @@
427443
end
428444
end
429445

446+
context "with empty hash" do
447+
it "raises ArgumentError" do
448+
assert_raises(ArgumentError) { HTTP.timeout({}) }
449+
end
450+
end
451+
452+
context "with unknown timeout key" do
453+
it "raises ArgumentError" do
454+
assert_raises(ArgumentError) { HTTP.timeout(timeout: 2) }
455+
end
456+
end
457+
458+
context "with both short and long form of same key" do
459+
it "raises ArgumentError" do
460+
assert_raises(ArgumentError) { HTTP.timeout(read: 2, read_timeout: 2) }
461+
end
462+
end
463+
464+
context "with non-numeric timeout value" do
465+
it "raises ArgumentError" do
466+
assert_raises(ArgumentError) { HTTP.timeout(read: "2") }
467+
end
468+
end
469+
470+
context "with string keys" do
471+
it "raises ArgumentError" do
472+
assert_raises(ArgumentError) { HTTP.timeout("read" => 2) }
473+
end
474+
end
475+
476+
context "with global key as hash" do
477+
it "raises ArgumentError" do
478+
assert_raises(ArgumentError) { HTTP.timeout(global: 161) }
479+
end
480+
end
481+
430482
context "specifying a global timeout" do
431483
let(:client) { HTTP.timeout 123 }
432484

@@ -439,6 +491,14 @@
439491
end
440492
end
441493

494+
context "specifying a float global timeout" do
495+
let(:client) { HTTP.timeout 2.5 }
496+
497+
it "sets given timeout option" do
498+
assert_equal({ global_timeout: 2.5 }, client.default_options.timeout_options)
499+
end
500+
end
501+
442502
context "with unsupported options" do
443503
it "raises ArgumentError" do
444504
assert_raises(ArgumentError) { HTTP.timeout("invalid") }

0 commit comments

Comments
 (0)