Skip to content

Commit bf112a2

Browse files
author
Zach Anker
committed
Use Timeout.timeout for TCP connection due to impossibility of handling timeouts in pure Ruby
1 parent 30e37ce commit bf112a2

File tree

4 files changed

+28
-143
lines changed

4 files changed

+28
-143
lines changed

lib/http/timeout/global.rb

+13-32
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
# rubocop:disable Lint/HandleExceptions
21
module HTTP
32
module Timeout
43
class Global < PerOperation
@@ -11,24 +10,24 @@ def initialize(*args)
1110
@total_timeout = time_left
1211
end
1312

14-
# Abstracted out from the normal connect for SSL connections
15-
def connect_with_timeout(*args)
13+
def connect(socket_class, host, port)
1614
reset_timer
15+
::Timeout.timeout(time_left, TimeoutError) do
16+
@socket = socket_class.open(host, port)
17+
end
1718

18-
begin
19-
socket.connect_nonblock(*args)
19+
log_time
20+
end
21+
22+
def connect_ssl
23+
reset_timer
2024

25+
begin
26+
socket.connect_nonblock
2127
rescue IO::WaitReadable
2228
IO.select([socket], nil, nil, time_left)
2329
log_time
2430
retry
25-
26-
rescue Errno::EINPROGRESS
27-
IO.select(nil, [socket], nil, time_left)
28-
log_time
29-
retry
30-
31-
rescue Errno::EISCONN
3231
end
3332
end
3433

@@ -58,26 +57,9 @@ def write(data)
5857
end
5958
end
6059

61-
private
62-
63-
# Create a DNS resolver
64-
def resolve_address(host)
65-
addr = HostResolver.getaddress(host)
66-
return addr if addr
67-
68-
reset_timer
69-
70-
addr = Resolv::DNS.open(:timeout => time_left) do |dns|
71-
dns.getaddress
72-
end
60+
alias_method :<<, :write
7361

74-
log_time
75-
76-
addr
77-
78-
rescue Resolv::ResolvTimeout
79-
raise TimeoutError, "DNS timed out after #{total_timeout} seconds"
80-
end
62+
private
8163

8264
# Due to the run/retry nature of nonblocking I/O, it's easier to keep track of time
8365
# via method calls instead of a block to monitor.
@@ -96,4 +78,3 @@ def log_time
9678
end
9779
end
9880
end
99-
# rubocop:enable Lint/HandleExceptions

lib/http/timeout/per_operation.rb

+11-69
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
# rubocop:disable Lint/HandleExceptions
2-
require "resolv"
3-
41
module HTTP
52
module Timeout
63
class PerOperation < Null
@@ -20,40 +17,22 @@ def initialize(*args)
2017
@connect_timeout = options.fetch(:connect_timeout, CONNECT_TIMEOUT)
2118
end
2219

23-
def connect(_, host, port)
24-
# https://github.com/celluloid/celluloid-io/blob/master/lib/celluloid/io/tcp_socket.rb
25-
begin
26-
addr = Resolv::IPv4.create(host)
27-
rescue ArgumentError
28-
end
29-
30-
# Guess it's not IPv4! Is it IPv6?
31-
begin
32-
addr ||= Resolv::IPv6.create(host)
33-
rescue ArgumentError
34-
end
35-
36-
unless addr
37-
addr = resolve_address(host)
38-
fail Resolv::ResolvError, "DNS result has no information for #{host}" unless addr
20+
def connect(socket_class, host, port)
21+
::Timeout.timeout(connect_timeout, TimeoutError) do
22+
@socket = socket_class.open(host, port)
3923
end
24+
end
4025

41-
case addr
42-
when Resolv::IPv4
43-
family = Socket::AF_INET
44-
when Resolv::IPv6
45-
family = Socket::AF_INET6
46-
else fail ArgumentError, "unsupported address class: #{addr.class}"
26+
def connect_ssl
27+
socket.connect_nonblock
28+
rescue IO::WaitReadable
29+
if IO.select([socket], nil, nil, connect_timeout)
30+
retry
31+
else
32+
raise TimeoutError, "Connection timed out after #{connect_timeout} seconds"
4733
end
48-
49-
@socket = Socket.new(family, Socket::SOCK_STREAM, 0)
50-
51-
connect_with_timeout(Socket.sockaddr_in(port, addr.to_s))
5234
end
5335

54-
# No changes need to be made for the SSL connection
55-
alias_method :connect_with_timeout, :connect_ssl
56-
5736
# Read data from the socket
5837
def readpartial(size)
5938
socket.read_nonblock(size)
@@ -75,43 +54,6 @@ def write(data)
7554
raise TimeoutError, "Read timed out after #{write_timeout} seconds"
7655
end
7756
end
78-
79-
private
80-
81-
# Actually do the connect after we're setup
82-
def connect_with_timeout(*args)
83-
socket.connect_nonblock(*args)
84-
85-
rescue IO::WaitReadable
86-
if IO.select([socket], nil, nil, connect_timeout)
87-
retry
88-
else
89-
raise TimeoutError, "Connection timed out after #{connect_timeout} seconds"
90-
end
91-
92-
rescue Errno::EINPROGRESS
93-
if IO.select(nil, [socket], nil, connect_timeout)
94-
retry
95-
else
96-
raise TimeoutError, "Connection timed out after #{connect_timeout} seconds"
97-
end
98-
99-
rescue Errno::EISCONN
100-
end
101-
102-
# Create a DNS resolver
103-
def resolve_address(host)
104-
addr = HostResolver.getaddress(host)
105-
return addr if addr
106-
107-
Resolv::DNS.open(:timeout => connect_timeout) do |dns|
108-
dns.getaddress
109-
end
110-
111-
rescue Resolv::ResolvTimeout
112-
raise TimeoutError, "DNS timed out after #{connect_timeout} seconds"
113-
end
11457
end
11558
end
11659
end
117-
# rubocop:enable Lint/HandleExceptions

spec/lib/http/client_spec.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ def simple_response(body, status = 200)
181181
described_class.new options.merge :ssl_context => SSLHelper.client_context
182182
end
183183

184-
include_context "HTTP handling", true do
184+
include_context "HTTP handling" do
185185
let(:server) { dummy_ssl }
186186
end
187187

spec/support/http_handling_shared.rb

+3-41
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
RSpec.shared_context "HTTP handling" do |ssl = false|
1+
RSpec.shared_context "HTTP handling" do
22
describe "timeouts" do
33
let(:conn_timeout) { 1 }
44
let(:read_timeout) { 1 }
@@ -73,56 +73,18 @@
7373

7474
let(:response) { client.get(server.endpoint).body.to_s }
7575

76-
context "with localhost" do
77-
let(:endpoint) { server.endpoint.sub("127.0.0.1", "localhost") }
78-
79-
it "errors if DNS takes too long" do
80-
# Block the localhost lookup
81-
expect(timeout_class::HostResolver)
82-
.to receive(:getaddress).with("localhost").and_return(nil)
83-
84-
# Request
85-
expect(Resolv::DNS).to receive(:open).with(:timeout => 1) do |_|
86-
sleep 1.25
87-
"127.0.0.1"
88-
end
89-
90-
expect { client.get(server.endpoint.sub("127.0.0.1", "localhost")) }
91-
.to raise_error(HTTP::TimeoutError, /Timed out/)
92-
end
93-
end
94-
9576
it "errors if connecting takes too long" do
96-
socket = Socket.new(Socket::AF_INET, Socket::SOCK_STREAM, 0)
97-
98-
fake_socket_id = double(:to_io => socket)
99-
expect(fake_socket_id).to receive(:connect_nonblock) do |*args|
77+
expect(TCPSocket).to receive(:open) do
10078
sleep 1.25
101-
socket.connect_nonblock(*args)
10279
end
10380

104-
allow_any_instance_of(timeout_class).to receive(:socket).and_return(fake_socket_id)
105-
106-
expect { response }.to raise_error(HTTP::TimeoutError, /Timed out/)
81+
expect { response }.to raise_error(HTTP::TimeoutError, /execution/)
10782
end
10883

10984
it "errors if reading takes too long" do
11085
expect { client.get("#{server.endpoint}/sleep").body.to_s }
11186
.to raise_error(HTTP::TimeoutError, /Timed out/)
11287
end
113-
114-
unless ssl
115-
it "errors if writing takes too long" do
116-
socket = Socket.new(Socket::AF_INET, Socket::SOCK_STREAM, 0)
117-
allow_any_instance_of(timeout_class).to receive(:socket).and_return(socket)
118-
119-
expect(socket).to receive(:<<) do |*|
120-
sleep 1.25
121-
end
122-
123-
expect { response }.to raise_error(HTTP::TimeoutError, /Timed out/)
124-
end
125-
end
12688
end
12789
end
12890

0 commit comments

Comments
 (0)