Skip to content

Commit 65cf7d2

Browse files
Implement better DNS timeout mitigation
This commit implements two new strategies: - Uses the `RES_OPTIONS` to set the DNS resolution timeout of `getaddrinfo` (used by requests internally) - Ignore failing DNS for some time
1 parent ad3e281 commit 65cf7d2

File tree

3 files changed

+81
-9
lines changed

3 files changed

+81
-9
lines changed

algoliasearch/helpers.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,14 @@ def urlify(e):
8888
return encode(e)
8989

9090

91+
def rotate(l, n=1):
92+
"""
93+
Return the list rotated n times.
94+
rotate([1, 2, 3], 2) => [3, 1, 2]
95+
"""
96+
return l[n:] + l[:n]
97+
98+
9199
class CustomJSONEncoder(json.JSONEncoder):
92100
def default(self, obj):
93101
if isinstance(obj, decimal.Decimal):

algoliasearch/transport.py

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1-
import os
1+
import copy
22
import json
3+
import os
4+
import time
35

46
from requests import Session
7+
from requests.adapters import HTTPAdapter
8+
from requests.packages.urllib3.util import Retry
59

6-
from .helpers import urlify, CustomJSONEncoder, AlgoliaException
10+
from .helpers import AlgoliaException, CustomJSONEncoder, rotate, urlify
711

812
try:
913
from urllib import urlencode
@@ -12,6 +16,7 @@
1216

1317
APPENGINE = 'APPENGINE_RUNTIME' in os.environ
1418
SSL_CERTIFICATE_DOMAIN = 'algolia.net'
19+
DNS_TIMER_DELAY = 5 * 60 # 5 minutes
1520

1621
if APPENGINE:
1722
from google.appengine.api import urlfetch
@@ -23,18 +28,46 @@
2328
}
2429

2530

26-
class Transport():
31+
# urllib ultimately uses `/etc/resolv.conf` on linux to get its DNS resolution
32+
# timeout, this is the settings allowing to change it.
33+
if 'RES_OPTIONS' not in os.environ:
34+
os.environ['RES_OPTIONS'] = 'timeout:2 attempts:1'
35+
36+
37+
class Transport(object):
2738
def __init__(self):
2839
self.headers = {}
2940
self.read_hosts = []
3041
self.write_hosts = []
3142
self.timeout = (2, 30)
3243
self.search_timeout = (2, 5)
44+
self.dns_timer = time.time()
3345

3446
self.session = Session()
47+
# Ask urllib not to make retries on its own.
48+
self.session.mount('https://', HTTPAdapter(max_retries=Retry(connect=0)))
49+
3550
self.session.verify = os.path.join(os.path.dirname(__file__),
3651
'resources/ca-bundle.crt')
3752

53+
@property
54+
def read_hosts(self):
55+
return self._read_hosts
56+
57+
@read_hosts.setter
58+
def read_hosts(self, value):
59+
self._read_hosts = value
60+
self._original_read_hosts = value
61+
62+
@property
63+
def write_hosts(self):
64+
return self._write_hosts
65+
66+
@write_hosts.setter
67+
def write_hosts(self, value):
68+
self._write_hosts = value
69+
self._original_write_hosts = value
70+
3871
def _app_req(self, host, path, meth, timeout, params, data):
3972
"""
4073
Perform an HTTPS request with AppEngine's urlfetch. SSL certificate
@@ -91,6 +124,25 @@ def _session_req(self, host, path, meth, timeout, params, data):
91124

92125
res.raise_for_status()
93126

127+
def _rotate_hosts(self, is_search):
128+
if is_search:
129+
self._read_hosts = rotate(self._read_hosts)
130+
else:
131+
self._write_hosts = rotate(self._write_hosts)
132+
133+
def _get_hosts(self, is_search):
134+
secs_since_rotate = time.time() - self.dns_timer
135+
if is_search:
136+
if secs_since_rotate < DNS_TIMER_DELAY:
137+
return self.read_hosts
138+
else:
139+
return self._original_read_hosts
140+
else:
141+
if secs_since_rotate < DNS_TIMER_DELAY:
142+
return self.write_hosts
143+
else:
144+
return self._original_write_hosts
145+
94146
def req(self, is_search, path, meth, params=None, data=None):
95147
"""Perform an HTTPS request with retry logic."""
96148
if params is not None:
@@ -99,12 +151,8 @@ def req(self, is_search, path, meth, params=None, data=None):
99151
if data is not None:
100152
data = json.dumps(data, cls=CustomJSONEncoder)
101153

102-
if is_search:
103-
hosts = self.read_hosts
104-
timeout = self.search_timeout
105-
else:
106-
hosts = self.write_hosts
107-
timeout = self.timeout
154+
hosts = self._get_hosts(is_search)
155+
timeout = self.search_timeout if is_search else self.timeout
108156

109157
exceptions = {}
110158
for i, host in enumerate(hosts):
@@ -120,6 +168,8 @@ def req(self, is_search, path, meth, params=None, data=None):
120168
except AlgoliaException as e:
121169
raise e
122170
except Exception as e:
171+
self._rotate_hosts(is_search)
172+
self.dns_timer = time.time()
123173
exceptions[host] = "%s: %s" % (e.__class__.__name__, str(e))
124174

125175
raise AlgoliaException('Unreachable hosts: %s' % exceptions)

tests/test_client.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,20 @@ def test_dns_timeout(self):
135135
pass
136136
self.assertLess(time.time(), now + 6)
137137

138+
def test_dns_timeout_hard(self):
139+
app_id = os.environ['ALGOLIA_APPLICATION_ID']
140+
141+
hosts = ['algolia.biz', '%s-dsn.algolia.net' % app_id]
142+
client = Client(app_id, os.environ['ALGOLIA_API_KEY'], hosts)
143+
144+
now = time.time()
145+
for i in range(10):
146+
indices = client.list_indexes()
147+
148+
self.assertLess(time.time(), now + 10)
149+
150+
151+
138152

139153
class ClientWithDataTest(ClientTest):
140154
"""Tests that use two index with initial data."""

0 commit comments

Comments
 (0)