Skip to content

Commit a622263

Browse files
anandswaminathandanielhochman
authored andcommitted
Allow override of settings from global configuration (#147)
1 parent 4ab34ca commit a622263

File tree

7 files changed

+192
-34
lines changed

7 files changed

+192
-34
lines changed

pynamodb/connection/base.py

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
RETURN_CONSUMED_CAPACITY_VALUES, RETURN_ITEM_COLL_METRICS_VALUES, COMPARISON_OPERATOR_VALUES,
2525
RETURN_ITEM_COLL_METRICS, RETURN_CONSUMED_CAPACITY, RETURN_VALUES_VALUES, ATTR_UPDATE_ACTIONS,
2626
COMPARISON_OPERATOR, EXCLUSIVE_START_KEY, SCAN_INDEX_FORWARD, SCAN_FILTER_VALUES, ATTR_DEFINITIONS,
27-
BATCH_WRITE_ITEM, CONSISTENT_READ, ATTR_VALUE_LIST, DESCRIBE_TABLE, DEFAULT_REGION, KEY_CONDITIONS,
27+
BATCH_WRITE_ITEM, CONSISTENT_READ, ATTR_VALUE_LIST, DESCRIBE_TABLE, KEY_CONDITIONS,
2828
BATCH_GET_ITEM, DELETE_REQUEST, SELECT_VALUES, RETURN_VALUES, REQUEST_ITEMS, ATTR_UPDATES,
2929
ATTRS_TO_GET, SERVICE_NAME, DELETE_ITEM, PUT_REQUEST, UPDATE_ITEM, SCAN_FILTER, TABLE_NAME,
3030
INDEX_NAME, KEY_SCHEMA, ATTR_NAME, ATTR_TYPE, TABLE_KEY, EXPECTED, KEY_TYPE, GET_ITEM, UPDATE,
@@ -36,14 +36,10 @@
3636
CONDITIONAL_OPERATORS, NULL, NOT_NULL, SHORT_ATTR_TYPES, DELETE,
3737
ITEMS, DEFAULT_ENCODING, BINARY_SHORT, BINARY_SET_SHORT, LAST_EVALUATED_KEY, RESPONSES, UNPROCESSED_KEYS,
3838
UNPROCESSED_ITEMS, STREAM_SPECIFICATION, STREAM_VIEW_TYPE, STREAM_ENABLED)
39+
from pynamodb.settings import get_settings_value
3940

4041
BOTOCORE_EXCEPTIONS = (BotoCoreError, ClientError)
4142

42-
# retry parameters
43-
DEFAULT_TIMEOUT = 60 # matches legacy retry timeout from botocore
44-
DEFAULT_MAX_RETRY_ATTEMPTS_EXCEPTION = 3
45-
DEFAULT_BASE_BACKOFF_MS = 25
46-
4743
log = logging.getLogger(__name__)
4844
log.addHandler(NullHandler())
4945

@@ -178,7 +174,8 @@ class Connection(object):
178174
A higher level abstraction over botocore
179175
"""
180176

181-
def __init__(self, region=None, host=None, session_cls=None):
177+
def __init__(self, region=None, host=None, session_cls=None,
178+
request_timeout_seconds=None, max_retry_attempts=None, base_backoff_ms=None):
182179
self._tables = {}
183180
self.host = host
184181
self._session = None
@@ -187,17 +184,27 @@ def __init__(self, region=None, host=None, session_cls=None):
187184
if region:
188185
self.region = region
189186
else:
190-
self.region = DEFAULT_REGION
191-
192-
# TODO: provide configurability of retry parameters via arguments
193-
self._request_timeout_seconds = DEFAULT_TIMEOUT
194-
self._max_retry_attempts_exception = DEFAULT_MAX_RETRY_ATTEMPTS_EXCEPTION
195-
self._base_backoff_ms = DEFAULT_BASE_BACKOFF_MS
187+
self.region = get_settings_value('region')
196188

197189
if session_cls:
198190
self.session_cls = session_cls
199191
else:
200-
self.session_cls = requests.Session
192+
self.session_cls = get_settings_value('session_cls')
193+
194+
if request_timeout_seconds is not None:
195+
self._request_timeout_seconds = request_timeout_seconds
196+
else:
197+
self._request_timeout_seconds = get_settings_value('request_timeout_seconds')
198+
199+
if max_retry_attempts is not None:
200+
self._max_retry_attempts_exception = max_retry_attempts
201+
else:
202+
self._max_retry_attempts_exception = get_settings_value('max_retry_attempts')
203+
204+
if base_backoff_ms is not None:
205+
self._base_backoff_ms = base_backoff_ms
206+
else:
207+
self._base_backoff_ms = get_settings_value('base_backoff_ms')
201208

202209
def __repr__(self):
203210
return six.u("Connection<{0}>".format(self.client.meta.endpoint_url))
@@ -254,8 +261,9 @@ def _make_api_call(self, operation_name, operation_kwargs):
254261
)
255262
prepared_request = self.client._endpoint.create_request(request_dict, operation_model)
256263

257-
for attempt_number in range(1, self._max_retry_attempts_exception + 1):
258-
is_last_attempt_for_exceptions = attempt_number == self._max_retry_attempts_exception
264+
for i in range(0, self._max_retry_attempts_exception + 1):
265+
attempt_number = i + 1
266+
is_last_attempt_for_exceptions = i == self._max_retry_attempts_exception
259267

260268
try:
261269
response = self.requests_session.send(
@@ -309,7 +317,7 @@ def _make_api_call(self, operation_name, operation_kwargs):
309317
else:
310318
# We use fully-jittered exponentially-backed-off retries:
311319
# https://www.awsarchitectureblog.com/2015/03/backoff.html
312-
sleep_time_ms = random.randint(0, self._base_backoff_ms * (2 ** attempt_number))
320+
sleep_time_ms = random.randint(0, self._base_backoff_ms * (2 ** i))
313321
log.debug(
314322
'Retry with backoff needed for (%s) after attempt %s,'
315323
'sleeping for %s milliseconds, retryable %s caught: %s',

pynamodb/connection/table.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,23 @@ class TableConnection(object):
1010
A higher level abstraction over botocore
1111
"""
1212

13-
def __init__(self, table_name, region=None, host=None, session_cls=None,):
13+
def __init__(self,
14+
table_name,
15+
region=None,
16+
host=None,
17+
session_cls=None,
18+
request_timeout_seconds=None,
19+
max_retry_attempts=None,
20+
base_backoff_ms=None):
1421
self._hash_keyname = None
1522
self._range_keyname = None
1623
self.table_name = table_name
17-
self.connection = Connection(region=region, host=host, session_cls=session_cls,)
24+
self.connection = Connection(region=region,
25+
host=host,
26+
session_cls=session_cls,
27+
request_timeout_seconds=request_timeout_seconds,
28+
max_retry_attempts=max_retry_attempts,
29+
base_backoff_ms=base_backoff_ms)
1830

1931
def delete_item(self, hash_key,
2032
range_key=None,

pynamodb/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,3 +239,4 @@
239239
AND = 'AND'
240240
OR = 'OR'
241241
CONDITIONAL_OPERATORS = [AND, OR]
242+

pynamodb/models.py

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from pynamodb.types import HASH, RANGE
1818
from pynamodb.compat import NullHandler
1919
from pynamodb.indexes import Index, GlobalSecondaryIndex
20+
from pynamodb.settings import get_settings_value
2021
from pynamodb.constants import (
2122
ATTR_TYPE_MAP, ATTR_DEFINITIONS, ATTR_NAME, ATTR_TYPE, KEY_SCHEMA,
2223
KEY_TYPE, ITEM, ITEMS, READ_CAPACITY_UNITS, WRITE_CAPACITY_UNITS, CAMEL_COUNT,
@@ -27,7 +28,7 @@
2728
TABLE_STATUS, ACTIVE, RETURN_VALUES, BATCH_GET_PAGE_LIMIT, UNPROCESSED_KEYS,
2829
PUT_REQUEST, DELETE_REQUEST, LAST_EVALUATED_KEY, QUERY_OPERATOR_MAP, NOT_NULL,
2930
SCAN_OPERATOR_MAP, CONSUMED_CAPACITY, BATCH_WRITE_PAGE_LIMIT, TABLE_NAME,
30-
CAPACITY_UNITS, DEFAULT_REGION, META_CLASS_NAME, REGION, HOST, EXISTS, NULL,
31+
CAPACITY_UNITS, META_CLASS_NAME, REGION, HOST, EXISTS, NULL,
3132
DELETE_FILTER_OPERATOR_MAP, UPDATE_FILTER_OPERATOR_MAP, PUT_FILTER_OPERATOR_MAP,
3233
COUNT, ITEM_COUNT, KEY, UNPROCESSED_ITEMS, STREAM_VIEW_TYPE, STREAM_SPECIFICATION,
3334
STREAM_ENABLED, EQ, NE)
@@ -137,9 +138,7 @@ def commit(self):
137138

138139

139140
class DefaultMeta(object):
140-
table_name = None
141-
region = DEFAULT_REGION
142-
host = None
141+
pass
143142

144143

145144
class ResultSet(object):
@@ -165,11 +164,17 @@ def __init__(cls, name, bases, attrs):
165164
for attr_name, attr_obj in attrs.items():
166165
if attr_name == META_CLASS_NAME:
167166
if not hasattr(attr_obj, REGION):
168-
setattr(attr_obj, REGION, DEFAULT_REGION)
167+
setattr(attr_obj, REGION, get_settings_value('region'))
169168
if not hasattr(attr_obj, HOST):
170-
setattr(attr_obj, HOST, None)
169+
setattr(attr_obj, HOST, get_settings_value('host'))
171170
if not hasattr(attr_obj, 'session_cls'):
172-
setattr(attr_obj, 'session_cls', None)
171+
setattr(attr_obj, 'session_cls', get_settings_value('session_cls'))
172+
if not hasattr(attr_obj, 'request_timeout_seconds'):
173+
setattr(attr_obj, 'request_timeout_seconds', get_settings_value('request_timeout_seconds'))
174+
if not hasattr(attr_obj, 'base_backoff_ms'):
175+
setattr(attr_obj, 'base_backoff_ms', get_settings_value('base_backoff_ms'))
176+
if not hasattr(attr_obj, 'max_retry_attempts'):
177+
setattr(attr_obj, 'max_retry_attempts', get_settings_value('max_retry_attempts'))
173178
elif issubclass(attr_obj.__class__, (Index, )):
174179
attr_obj.Meta.model = cls
175180
if not hasattr(attr_obj.Meta, "index_name"):
@@ -1167,8 +1172,13 @@ def _get_connection(cls):
11671172
See https://pynamodb.readthedocs.io/en/latest/release_notes.html"""
11681173
)
11691174
if cls._connection is None:
1170-
cls._connection = TableConnection(cls.Meta.table_name, region=cls.Meta.region, host=cls.Meta.host,
1171-
session_cls=cls.Meta.session_cls)
1175+
cls._connection = TableConnection(cls.Meta.table_name,
1176+
region=cls.Meta.region,
1177+
host=cls.Meta.host,
1178+
session_cls=cls.Meta.session_cls,
1179+
request_timeout_seconds=cls.Meta.request_timeout_seconds,
1180+
max_retry_attempts=cls.Meta.max_retry_attempts,
1181+
base_backoff_ms=cls.Meta.base_backoff_ms)
11721182
return cls._connection
11731183

11741184
def _deserialize(self, attrs):

pynamodb/settings.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import imp
2+
import logging
3+
import os
4+
from os import getenv
5+
6+
from botocore.vendored import requests
7+
8+
log = logging.getLogger(__name__)
9+
10+
default_settings_dict = {
11+
'request_timeout_seconds': 60,
12+
'max_retry_attempts': 3,
13+
'base_backoff_ms': 25,
14+
'region': 'us-east-1',
15+
'session_cls': requests.Session
16+
}
17+
18+
OVERRIDE_SETTINGS_PATH = getenv('PYNAMODB_CONFIG', '/etc/pynamodb/global_default_settings.py')
19+
20+
override_settings = {}
21+
if os.path.isfile(OVERRIDE_SETTINGS_PATH):
22+
override_settings = imp.load_source(OVERRIDE_SETTINGS_PATH, OVERRIDE_SETTINGS_PATH)
23+
log.info('Override settings for pynamo available {0}'.format(OVERRIDE_SETTINGS_PATH))
24+
else:
25+
log.info('Override settings for pynamo not available {0}'.format(OVERRIDE_SETTINGS_PATH))
26+
log.info('Using Default settings value')
27+
28+
29+
def get_settings_value(key):
30+
"""
31+
Fetches the value from the override file.
32+
If the value is not present, then tries to fetch the values from constants.py
33+
"""
34+
if hasattr(override_settings, key):
35+
return getattr(override_settings, key)
36+
37+
if key in default_settings_dict:
38+
return default_settings_dict[key]
39+
40+
return None

pynamodb/tests/test_base_connection.py

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
from pynamodb.tests.deep_eq import deep_eq
1515
from botocore.exceptions import BotoCoreError
1616
from botocore.client import ClientError
17-
1817
if six.PY3:
1918
from unittest.mock import patch
2019
from unittest import mock
@@ -1664,9 +1663,10 @@ def test_make_api_call_throws_verbose_error_after_backoff(self, requests_session
16641663
)
16651664
raise
16661665

1666+
@mock.patch('random.randint')
16671667
@mock.patch('pynamodb.connection.Connection.session')
16681668
@mock.patch('pynamodb.connection.Connection.requests_session')
1669-
def test_make_api_call_throws_verbose_error_after_backoff_later_succeeds(self, requests_session_mock, session_mock):
1669+
def test_make_api_call_throws_verbose_error_after_backoff_later_succeeds(self, requests_session_mock, session_mock, rand_int_mock):
16701670

16711671
# mock response
16721672
bad_response = requests.Response()
@@ -1681,14 +1681,19 @@ def test_make_api_call_throws_verbose_error_after_backoff_later_succeeds(self, r
16811681
good_response._content = json.dumps(good_response_content).encode('utf-8')
16821682

16831683
requests_session_mock.send.side_effect = [
1684+
bad_response,
16841685
bad_response,
16851686
good_response,
16861687
]
16871688

1689+
rand_int_mock.return_value = 1
1690+
16881691
c = Connection()
16891692

16901693
self.assertEqual(good_response_content, c._make_api_call('CreateTable', {'TableName': 'MyTable'}))
1691-
self.assertEqual(len(requests_session_mock.send.mock_calls), 2)
1694+
self.assertEqual(len(requests_session_mock.send.mock_calls), 3)
1695+
1696+
assert rand_int_mock.call_args_list == [mock.call(0, 25), mock.call(0, 50)]
16921697

16931698
@mock.patch('pynamodb.connection.Connection.session')
16941699
@mock.patch('pynamodb.connection.Connection.requests_session')
@@ -1705,13 +1710,13 @@ def test_make_api_call_retries_properly(self, requests_session_mock, session_moc
17051710
session_mock.create_client.return_value._endpoint.create_request.return_value = prepared_request
17061711

17071712
requests_session_mock.send.side_effect = [
1708-
requests.ConnectionError('problems!'),
1713+
bad_response,
17091714
requests.Timeout('problems!'),
17101715
bad_response,
17111716
deserializable_response
17121717
]
17131718
c = Connection()
1714-
c._max_retry_attempts_exception = 4
1719+
c._max_retry_attempts_exception = 3
17151720

17161721
c._make_api_call('DescribeTable', {'TableName': 'MyTable'})
17171722
self.assertEqual(len(requests_session_mock.mock_calls), 4)
@@ -1732,15 +1737,40 @@ def test_make_api_call_throws_when_retries_exhausted(self, requests_session_mock
17321737
requests.Timeout('problems!'),
17331738
]
17341739
c = Connection()
1735-
c._max_retry_attempts_exception = 4
1740+
c._max_retry_attempts_exception = 3
17361741

17371742
with self.assertRaises(requests.Timeout):
17381743
c._make_api_call('DescribeTable', {'TableName': 'MyTable'})
17391744

17401745
self.assertEqual(len(requests_session_mock.mock_calls), 4)
1746+
assert requests_session_mock.send.call_args[1]['timeout'] == 60
17411747
for call in requests_session_mock.mock_calls:
17421748
self.assertEqual(call[:2], ('send', (prepared_request,)))
17431749

1750+
1751+
@mock.patch('random.randint')
1752+
@mock.patch('pynamodb.connection.Connection.session')
1753+
@mock.patch('pynamodb.connection.Connection.requests_session')
1754+
def test_make_api_call_throws_retry_disabled(self, requests_session_mock, session_mock, rand_int_mock):
1755+
prepared_request = requests.Request('GET', 'http://lyft.com').prepare()
1756+
session_mock.create_client.return_value._endpoint.create_request.return_value = prepared_request
1757+
1758+
requests_session_mock.send.side_effect = [
1759+
requests.Timeout('problems!'),
1760+
]
1761+
c = Connection(request_timeout_seconds=11, base_backoff_ms=3, max_retry_attempts=0)
1762+
assert c._base_backoff_ms == 3
1763+
with self.assertRaises(requests.Timeout):
1764+
c._make_api_call('DescribeTable', {'TableName': 'MyTable'})
1765+
1766+
self.assertEqual(len(requests_session_mock.mock_calls), 1)
1767+
rand_int_mock.assert_not_called()
1768+
1769+
assert requests_session_mock.send.call_args[1]['timeout'] == 11
1770+
for call in requests_session_mock.mock_calls:
1771+
self.assertEqual(call[:2], ('send', (prepared_request,)))
1772+
1773+
17441774
def test_handle_binary_attributes_for_unprocessed_items(self):
17451775
binary_blob = six.b('\x00\xFF\x00\xFF')
17461776

0 commit comments

Comments
 (0)