Skip to content

Commit 1ad97fb

Browse files
booxterOmer Katz
authored and
Omer Katz
committed
Don't send AAAA DNS request when domain resolved to IPv4 address (#153)
* Don't patch out whole _connect body in test_AbstractTransport It should raise test coverage level for the method. * tests: check that if socket.connect raises, it bubbles up * tests: cover _connect behavior when multiple entries are found _connect should continue to iterate through entries returned by getaddrinfo until it succeeds, or until it depletes the list, at which point it raises socket.error. * Don't send AAAA DNS request when domain resolved to IPv4 address There is no need to send both A and AAAA requests when connecting to a host since we are interested in a single address only. So if the host resolves into IPv4 address, we can skip request for AAAA since it will not be used anyway. This sounds like a minor optimization, but it comes as a huge win in case where DNS resolver is not configured for the requested host name, but the name is listed in /etc/hosts with a IPv4 address. In this case, resolution is very quick (the file is local, so no DNS request is sent), except for the fact that we still ask for AAAA record for the host name. A misconfigured DNS resolver can take time until it will time out the request (30 seconds is a common length for Linux), which is an unnecessary delay. This exact situation hit OpenStack TripleO CI where DNS was not configured, but resolution is implemented via /etc/hosts file which did not include IPv6 entries. OpenStack bug: https://bugs.launchpad.net/neutron/+bug/1696094 * tests: cover recover logic when getaddrinfo raises gaierror * tests: check NotImplementedError from set_cloexec in _connect If it's raised, we do nothing. * Added some more comments in _connect explaining the logic The logic became a bit hard to follow, so added a bunch of comments trying to explain the rationale behind the complexity, as well as give key for intent of specific code blocks. * _connect: made the socket error message more descriptive Suggest it's a DNS resolution issue, not a generic connectivity problem. * Replace an unused variable with _ * _connect: extracted len() results into separate variables
1 parent b59290b commit 1ad97fb

File tree

2 files changed

+171
-23
lines changed

2 files changed

+171
-23
lines changed

amqp/transport.py

+50-18
Original file line numberDiff line numberDiff line change
@@ -109,26 +109,58 @@ def having_timeout(self, timeout):
109109
sock.settimeout(prev)
110110

111111
def _connect(self, host, port, timeout):
112-
entries = socket.getaddrinfo(
113-
host, port, 0, socket.SOCK_STREAM, SOL_TCP,
114-
)
115-
for i, res in enumerate(entries):
116-
af, socktype, proto, canonname, sa = res
112+
e = None
113+
114+
# Below we are trying to avoid additional DNS requests for AAAA if A
115+
# succeeds. This helps a lot in case when a hostname has an IPv4 entry
116+
# in /etc/hosts but not IPv6. Without the (arguably somewhat twisted)
117+
# logic below, getaddrinfo would attempt to resolve the hostname for
118+
# both IP versions, which would make the resolver talk to configured
119+
# DNS servers. If those servers are for some reason not available
120+
# during resolution attempt (either because of system misconfiguration,
121+
# or network connectivity problem), resolution process locks the
122+
# _connect call for extended time.
123+
addr_types = (socket.AF_INET, socket.AF_INET6)
124+
addr_types_num = len(addr_types)
125+
for n, family in enumerate(addr_types):
126+
# first, resolve the address for a single address family
117127
try:
118-
self.sock = socket.socket(af, socktype, proto)
128+
entries = socket.getaddrinfo(
129+
host, port, family, socket.SOCK_STREAM, SOL_TCP)
130+
entries_num = len(entries)
131+
except socket.gaierror:
132+
# we may have depleted all our options
133+
if n + 1 >= addr_types_num:
134+
# if getaddrinfo succeeded before for another address
135+
# family, reraise the previous socket.error since it's more
136+
# relevant to users
137+
raise (e
138+
if e is not None
139+
else socket.error(
140+
"failed to resolve broker hostname"))
141+
continue
142+
143+
# now that we have address(es) for the hostname, connect to broker
144+
for i, res in enumerate(entries):
145+
af, socktype, proto, _, sa = res
119146
try:
120-
set_cloexec(self.sock, True)
121-
except NotImplementedError:
122-
pass
123-
self.sock.settimeout(timeout)
124-
self.sock.connect(sa)
125-
except socket.error:
126-
self.sock.close()
127-
self.sock = None
128-
if i + 1 >= len(entries):
129-
raise
130-
else:
131-
break
147+
self.sock = socket.socket(af, socktype, proto)
148+
try:
149+
set_cloexec(self.sock, True)
150+
except NotImplementedError:
151+
pass
152+
self.sock.settimeout(timeout)
153+
self.sock.connect(sa)
154+
except socket.error as e:
155+
if self.sock is not None:
156+
self.sock.close()
157+
self.sock = None
158+
# we may have depleted all our options
159+
if i + 1 >= entries_num and n + 1 >= addr_types_num:
160+
raise
161+
else:
162+
# hurray, we established connection
163+
return
132164

133165
def _init_socket(self, socket_settings, read_timeout, write_timeout):
134166
try:

t/unit/test_transport.py

+121-5
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import socket
55
import pytest
66

7-
from case import Mock, patch
7+
from case import ANY, Mock, call, patch
88

99
from amqp import transport
1010
from amqp.exceptions import UnexpectedFrame
@@ -15,6 +15,11 @@
1515
class MockSocket(object):
1616
options = {}
1717

18+
def __init__(self, *args, **kwargs):
19+
super(MockSocket, self).__init__(*args, **kwargs)
20+
self.connected = False
21+
self.sa = None
22+
1823
def setsockopt(self, family, key, value):
1924
if not isinstance(value, int):
2025
raise socket.error()
@@ -23,6 +28,20 @@ def setsockopt(self, family, key, value):
2328
def getsockopt(self, family, key):
2429
return self.options.get(key, 0)
2530

31+
def settimeout(self, timeout):
32+
self.timeout = timeout
33+
34+
def fileno(self):
35+
return 10
36+
37+
def connect(self, sa):
38+
self.connected = True
39+
self.sa = sa
40+
41+
def close(self):
42+
self.connected = False
43+
self.sa = None
44+
2645

2746
TCP_KEEPIDLE = 4
2847
TCP_KEEPINTVL = 5
@@ -188,14 +207,13 @@ class test_AbstractTransport:
188207

189208
class Transport(transport._AbstractTransport):
190209

191-
def _connect(self, *args):
192-
pass
193-
194210
def _init_socket(self, *args):
195211
pass
196212

197213
@pytest.fixture(autouse=True)
198-
def setup_transport(self):
214+
@patch('socket.socket.connect')
215+
def setup_transport(self, patching):
216+
self.connect_mock = patching
199217
self.t = self.Transport('localhost:5672', 10)
200218
self.t.connect()
201219

@@ -305,6 +323,104 @@ def test_write__EINTR(self):
305323
self.t.write('foo')
306324
assert not self.t.connected
307325

326+
def test_connect_socket_fails(self):
327+
self.t.sock = Mock()
328+
self.t.close()
329+
self.connect_mock.side_effect = socket.error
330+
with pytest.raises(socket.error):
331+
self.t.connect()
332+
333+
@patch('socket.socket', return_value=MockSocket())
334+
@patch('socket.getaddrinfo',
335+
return_value=[
336+
(socket.AF_INET, 1, socket.IPPROTO_TCP,
337+
'', ('127.0.0.1', 5672)),
338+
(socket.AF_INET, 1, socket.IPPROTO_TCP,
339+
'', ('127.0.0.2', 5672))
340+
])
341+
def test_connect_multiple_addr_entries_fails(self, getaddrinfo, sock_mock):
342+
self.t.sock = Mock()
343+
self.t.close()
344+
with patch.object(sock_mock.return_value, 'connect',
345+
side_effect=socket.error):
346+
with pytest.raises(socket.error):
347+
self.t.connect()
348+
349+
@patch('socket.socket', return_value=MockSocket())
350+
@patch('socket.getaddrinfo',
351+
return_value=[
352+
(socket.AF_INET, 1, socket.IPPROTO_TCP,
353+
'', ('127.0.0.1', 5672)),
354+
(socket.AF_INET, 1, socket.IPPROTO_TCP,
355+
'', ('127.0.0.2', 5672))
356+
])
357+
def test_connect_multiple_addr_entries_succeed(self, getaddrinfo,
358+
sock_mock):
359+
self.t.sock = Mock()
360+
self.t.close()
361+
with patch.object(sock_mock.return_value, 'connect',
362+
side_effect=(socket.error, None)):
363+
self.t.connect()
364+
365+
@patch('socket.socket', return_value=MockSocket())
366+
@patch('socket.getaddrinfo',
367+
side_effect=[
368+
[(socket.AF_INET, 1, socket.IPPROTO_TCP,
369+
'', ('127.0.0.1', 5672))],
370+
[(socket.AF_INET6, 1, socket.IPPROTO_TCP,
371+
'', ('::1', 5672))]
372+
])
373+
def test_connect_short_curcuit_on_INET_succeed(self, getaddrinfo,
374+
sock_mock):
375+
self.t.sock = Mock()
376+
self.t.close()
377+
self.t.connect()
378+
getaddrinfo.assert_called_with(
379+
'localhost', 5672, socket.AF_INET, ANY, ANY)
380+
381+
@patch('socket.socket', return_value=MockSocket())
382+
@patch('socket.getaddrinfo',
383+
side_effect=[
384+
[(socket.AF_INET, 1, socket.IPPROTO_TCP,
385+
'', ('127.0.0.1', 5672))],
386+
[(socket.AF_INET6, 1, socket.IPPROTO_TCP,
387+
'', ('::1', 5672))]
388+
])
389+
def test_connect_short_curcuit_on_INET_fails(self, getaddrinfo, sock_mock):
390+
self.t.sock = Mock()
391+
self.t.close()
392+
with patch.object(sock_mock.return_value, 'connect',
393+
side_effect=(socket.error, None)):
394+
self.t.connect()
395+
getaddrinfo.assert_has_calls(
396+
[call('localhost', 5672, addr_type, ANY, ANY)
397+
for addr_type in (socket.AF_INET, socket.AF_INET6)])
398+
399+
@patch('socket.getaddrinfo', side_effect=socket.gaierror)
400+
def test_connect_getaddrinfo_raises_gaierror(self, getaddrinfo):
401+
with pytest.raises(socket.error):
402+
self.t.connect()
403+
404+
@patch('socket.socket', return_value=MockSocket())
405+
@patch('socket.getaddrinfo',
406+
side_effect=[
407+
socket.gaierror,
408+
[(socket.AF_INET6, 1, socket.IPPROTO_TCP,
409+
'', ('::1', 5672))]
410+
])
411+
def test_connect_getaddrinfo_raises_gaierror_once_recovers(self, *mocks):
412+
self.t.connect()
413+
414+
@patch('socket.socket', return_value=MockSocket())
415+
@patch('socket.getaddrinfo',
416+
return_value=[(socket.AF_INET, 1, socket.IPPROTO_TCP,
417+
'', ('127.0.0.1', 5672))])
418+
def test_connect_survives_not_implemented_set_cloexec(self, *mocks):
419+
with patch('amqp.transport.set_cloexec',
420+
side_effect=NotImplementedError) as cloexec_mock:
421+
self.t.connect()
422+
assert cloexec_mock.called
423+
308424

309425
class test_SSLTransport:
310426

0 commit comments

Comments
 (0)