Skip to content

Commit 71c33e8

Browse files
authored
Stop using requests in favour of botocore & urllib3 (#580)
1 parent 356eddd commit 71c33e8

20 files changed

+453
-388
lines changed

.travis.yml

-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ python:
44
- "3.6"
55
- "3.5"
66
- "2.7"
7-
- "2.6"
87
- "pypy"
98

109

README.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ Want to backup and restore a table? No problem.
228228
Features
229229
========
230230

231-
* Python 3.3, 3.4, 2.6, and 2.7 support
231+
* Python >= 3.3, and 2.7 support
232232
* An ORM-like interface with query and scan filters
233233
* Compatible with DynamoDB Local
234234
* Supports the entire DynamoDB API

docs/release_notes.rst

+28-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,29 @@
11
Release Notes
22
=============
33

4+
v4.0.0a1
5+
--------
6+
7+
:date: 2019-04-10
8+
9+
NB: This is an alpha release and these notes are subject to change.
10+
11+
This is major release and contains breaking changes. Please read the notes below carefully.
12+
13+
Given that ``botocore`` has moved to using ``urllib3`` directly for making HTTP requests, we'll be doing the same (via ``botocore``). This means the following:
14+
15+
* The ``session_cls`` option is no longer supported. S
16+
* The ``request_timeout_seconds`` parameter is no longer supported. ``connect_timeout_seconds`` and ``read_timeout_seconds`` are now available instead.
17+
* Note that the timeout for connection and read are now ``15`` and ``30`` seconds respectively. This represents a change from the previous ``60`` second combined ``requests`` timeout.
18+
* *Wrapped* exceptions (i.e ``exc.cause``) that were from ``requests.exceptions`` will now be comparable ones from ``botocore.exceptions`` instead.
19+
20+
Other changes in this release:
21+
22+
* Python 2.6 is no longer supported. 4.x.x will likely be the last major release to support Python 2.7, given the upcoming EOL.
23+
* Added the ``max_pool_connection`` and ``extra_headers`` settings to replace common use cases for ``session_cls``
24+
* Added support for `moto <https://github.com/spulec/moto>`_ through implementing the botocore "before-send" hook. Other botocore hooks remain unimplemented.
25+
26+
427
v3.3.3
528
------
629

@@ -10,12 +33,12 @@ This is a backwards compatible, minor release.
1033

1134
Fixes in this release:
1235

13-
* Legacy boolean attribute migration fix. (#538)
14-
* Correctly package type stubs. (#585)
36+
* Legacy boolean attribute migration fix. (#538)
37+
* Correctly package type stubs. (#585)
1538

1639
Contributors to this release:
1740

18-
* @vo-va
41+
* @vo-va
1942

2043

2144
v3.3.2
@@ -27,7 +50,7 @@ This is a backwards compatible, minor release.
2750

2851
Changes in this release:
2952

30-
* Built-in support for mypy type stubs, superseding those in python/typeshed. (#537)
53+
* Built-in support for mypy type stubs, superseding those in python/typeshed. (#537)
3154

3255

3356
v3.3.1
@@ -89,7 +112,7 @@ Contributors to this release:
89112
* @nicysneiros
90113
* @jcomo
91114
* @kevgliss
92-
* @asottile
115+
* @asottile
93116
* @harleyk
94117
* @betamoo
95118

docs/settings.rst

+25-22
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,20 @@ Settings reference
99

1010
Here is a complete list of settings which control default PynamoDB behavior.
1111

12+
connect_timeout_seconds
13+
-----------------------
14+
15+
Default: ``15``
16+
17+
The time in seconds till a ``ConnectTimeoutError`` is thrown when attempting to make a connection.
18+
1219

13-
request_timeout_seconds
20+
read_timeout_seconds
1421
-----------------------
1522

16-
Default: ``60``
23+
Default: ``30``
1724

18-
The default timeout for HTTP requests in seconds.
25+
The time in seconds till a ``ReadTimeoutError`` is thrown when attempting to read from a connection.
1926

2027

2128
max_retry_attempts
@@ -44,15 +51,24 @@ Default: ``"us-east-1"``
4451
The default AWS region to connect to.
4552

4653

47-
session_cls
48-
-----------
54+
max_pool_connections
55+
--------------------
56+
57+
Default: ``10``
58+
59+
The maximum number of connections to keep in a connection pool.
60+
4961

50-
Default: ``botocore.vendored.requests.Session``
62+
extra_headers
63+
--------------------
5164

52-
A class which implements the Session_ interface from requests, used for making API requests
53-
to DynamoDB.
65+
Default: ``None``
66+
67+
A dictionary of headers that should be added to every request. This is only useful
68+
when interfacing with DynamoDB through a proxy, where headers are stripped by the
69+
proxy before forwarding along. Failure to strip these headers before sending to AWS
70+
will result in an ``InvalidSignatureException`` due to request signing.
5471

55-
.. _Session: http://docs.python-requests.org/en/master/api/#request-sessions
5672

5773
allow_rate_limited_scan_without_consumed_capacity
5874
-------------------------------------------------
@@ -71,16 +87,3 @@ Overriding settings
7187
Default settings may be overridden by providing a Python module which exports the desired new values.
7288
Set the ``PYNAMODB_CONFIG`` environment variable to an absolute path to this module or write it to
7389
``/etc/pynamodb/global_default_settings.py`` to have it automatically discovered.
74-
75-
See an example of specifying a custom ``session_cls`` to configure the connection pool below.
76-
77-
.. code-block:: python
78-
79-
from botocore.vendored import requests
80-
from botocore.vendored.requests import adapters
81-
82-
class CustomPynamoSession(requests.Session):
83-
super(CustomPynamoSession, self).__init__()
84-
self.mount('http://', adapters.HTTPAdapter(pool_maxsize=100))
85-
86-
session_cls = CustomPynamoSession

pynamodb/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@
77
"""
88
__author__ = 'Jharrod LaFon'
99
__license__ = 'MIT'
10-
__version__ = '3.3.3'
10+
__version__ = '4.0.0a1'

pynamodb/connection/base.py

+72-57
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,22 @@
33
"""
44
from __future__ import division
55

6+
import json
67
import logging
78
import math
89
import random
10+
import sys
911
import time
1012
import uuid
1113
import warnings
1214
from base64 import b64decode
1315
from threading import local
1416

1517
import six
18+
import botocore.client
19+
import botocore.exceptions
1620
from botocore.client import ClientError
21+
from botocore.hooks import first_non_none_response
1722
from botocore.exceptions import BotoCoreError
1823
from botocore.session import get_session
1924
from botocore.vendored import requests
@@ -222,27 +227,28 @@ class Connection(object):
222227
A higher level abstraction over botocore
223228
"""
224229

225-
def __init__(self, region=None, host=None, session_cls=None,
226-
request_timeout_seconds=None, max_retry_attempts=None, base_backoff_ms=None):
230+
def __init__(self, region=None, host=None,
231+
read_timeout_seconds=None, connect_timeout_seconds=None,
232+
max_retry_attempts=None, base_backoff_ms=None,
233+
max_pool_connections=None, extra_headers=None):
227234
self._tables = {}
228235
self.host = host
229236
self._local = local()
230-
self._requests_session = None
231237
self._client = None
232238
if region:
233239
self.region = region
234240
else:
235241
self.region = get_settings_value('region')
236242

237-
if session_cls:
238-
self.session_cls = session_cls
243+
if connect_timeout_seconds is not None:
244+
self._connect_timeout_seconds = connect_timeout_seconds
239245
else:
240-
self.session_cls = get_settings_value('session_cls')
246+
self._connect_timeout_seconds = get_settings_value('connect_timeout_seconds')
241247

242-
if request_timeout_seconds is not None:
243-
self._request_timeout_seconds = request_timeout_seconds
248+
if read_timeout_seconds is not None:
249+
self._read_timeout_seconds = read_timeout_seconds
244250
else:
245-
self._request_timeout_seconds = get_settings_value('request_timeout_seconds')
251+
self._read_timeout_seconds = get_settings_value('read_timeout_seconds')
246252

247253
if max_retry_attempts is not None:
248254
self._max_retry_attempts_exception = max_retry_attempts
@@ -254,6 +260,16 @@ def __init__(self, region=None, host=None, session_cls=None,
254260
else:
255261
self._base_backoff_ms = get_settings_value('base_backoff_ms')
256262

263+
if max_pool_connections is not None:
264+
self._max_pool_connections = max_pool_connections
265+
else:
266+
self._max_pool_connections = get_settings_value('max_pool_connections')
267+
268+
if extra_headers is not None:
269+
self._extra_headers = extra_headers
270+
else:
271+
self._extra_headers = get_settings_value('extra_headers')
272+
257273
def __repr__(self):
258274
return six.u("Connection<{0}>".format(self.client.meta.endpoint_url))
259275

@@ -276,24 +292,11 @@ def _log_error(self, operation, response):
276292
log.error("%s failed with status: %s, message: %s",
277293
operation, response.status_code,response.content)
278294

279-
def _create_prepared_request(self, request_dict, operation_model):
280-
"""
281-
Create a prepared request object from request_dict, and operation_model
282-
"""
283-
boto_prepared_request = self.client._endpoint.create_request(request_dict, operation_model)
284-
285-
# The call requests_session.send(final_prepared_request) ignores the headers which are
286-
# part of the request session. In order to include the requests session headers inside
287-
# the request, we create a new request object, and call prepare_request with the newly
288-
# created request object
289-
raw_request_with_params = Request(
290-
boto_prepared_request.method,
291-
boto_prepared_request.url,
292-
data=boto_prepared_request.body,
293-
headers=boto_prepared_request.headers
294-
)
295-
296-
return self.requests_session.prepare_request(raw_request_with_params)
295+
def _create_prepared_request(self, params, operation_model):
296+
prepared_request = self.client._endpoint.create_request(params, operation_model)
297+
if self._extra_headers is not None:
298+
prepared_request.headers.update(self._extra_headers)
299+
return prepared_request
297300

298301
def dispatch(self, operation_name, operation_kwargs):
299302
"""
@@ -341,32 +344,47 @@ def _make_api_call(self, operation_name, operation_kwargs):
341344
operation_model = self.client._service_model.operation_model(operation_name)
342345
request_dict = self.client._convert_to_request_dict(
343346
operation_kwargs,
344-
operation_model
347+
operation_model,
345348
)
346-
prepared_request = self._create_prepared_request(request_dict, operation_model)
347349

348350
for i in range(0, self._max_retry_attempts_exception + 1):
349351
attempt_number = i + 1
350352
is_last_attempt_for_exceptions = i == self._max_retry_attempts_exception
351353

352-
response = None
354+
http_response = None
355+
prepared_request = None
353356
try:
354-
proxies = getattr(self.client._endpoint, "proxies", None)
355-
# After the version 1.11.0 of botocore this field is no longer available here
356-
if proxies is None:
357-
proxies = self.client._endpoint.http_session._proxy_config._proxies
358-
359-
response = self.requests_session.send(
360-
prepared_request,
361-
timeout=self._request_timeout_seconds,
362-
proxies=proxies,
363-
)
364-
data = response.json()
365-
except (requests.RequestException, ValueError) as e:
357+
if prepared_request is not None:
358+
# If there is a stream associated with the request, we need
359+
# to reset it before attempting to send the request again.
360+
# This will ensure that we resend the entire contents of the
361+
# body.
362+
prepared_request.reset_stream()
363+
364+
# Create a new request for each retry (including a new signature).
365+
prepared_request = self._create_prepared_request(request_dict, operation_model)
366+
367+
# Implement the before-send event from botocore
368+
event_name = 'before-send.dynamodb.{}'.format(operation_model.name)
369+
event_responses = self.client._endpoint._event_emitter.emit(event_name, request=prepared_request)
370+
event_response = first_non_none_response(event_responses)
371+
372+
if event_response is None:
373+
http_response = self.client._endpoint.http_session.send(prepared_request)
374+
else:
375+
http_response = event_response
376+
is_last_attempt_for_exceptions = True # don't retry if we have an event response
377+
378+
# json.loads accepts bytes in >= 3.6.0
379+
if sys.version_info < (3, 6, 0):
380+
data = json.loads(http_response.text)
381+
else:
382+
data = json.loads(http_response.content)
383+
except (ValueError, botocore.exceptions.HTTPClientError, botocore.exceptions.ConnectionError) as e:
366384
if is_last_attempt_for_exceptions:
367385
log.debug('Reached the maximum number of retry attempts: %s', attempt_number)
368-
if response:
369-
e.args += (str(response.content),)
386+
if http_response:
387+
e.args += (http_response.text,)
370388
raise
371389
else:
372390
# No backoff for fast-fail exceptions that likely failed at the frontend
@@ -379,14 +397,16 @@ def _make_api_call(self, operation_name, operation_kwargs):
379397
)
380398
continue
381399

382-
if response.status_code >= 300:
400+
status_code = http_response.status_code
401+
headers = http_response.headers
402+
if status_code >= 300:
383403
# Extract error code from __type
384404
code = data.get('__type', '')
385405
if '#' in code:
386406
code = code.rsplit('#', 1)[1]
387407
botocore_expected_format = {'Error': {'Message': data.get('message', ''), 'Code': code}}
388408
verbose_properties = {
389-
'request_id': response.headers.get('x-amzn-RequestId')
409+
'request_id': headers.get('x-amzn-RequestId')
390410
}
391411

392412
if 'RequestItems' in operation_kwargs:
@@ -401,7 +421,7 @@ def _make_api_call(self, operation_name, operation_kwargs):
401421
if is_last_attempt_for_exceptions:
402422
log.debug('Reached the maximum number of retry attempts: %s', attempt_number)
403423
raise
404-
elif response.status_code < 500 and code != 'ProvisionedThroughputExceededException':
424+
elif status_code < 500 and code != 'ProvisionedThroughputExceededException':
405425
# We don't retry on a ConditionalCheckFailedException or other 4xx (except for
406426
# throughput related errors) because we assume they will fail in perpetuity.
407427
# Retrying when there is already contention could cause other problems
@@ -470,15 +490,6 @@ def session(self):
470490
self._local.session = get_session()
471491
return self._local.session
472492

473-
@property
474-
def requests_session(self):
475-
"""
476-
Return a requests session to execute prepared requests using the same pool
477-
"""
478-
if self._requests_session is None:
479-
self._requests_session = self.session_cls()
480-
return self._requests_session
481-
482493
@property
483494
def client(self):
484495
"""
@@ -489,7 +500,11 @@ def client(self):
489500
# if the client does not have credentials, we create a new client
490501
# otherwise the client is permanently poisoned in the case of metadata service flakiness when using IAM roles
491502
if not self._client or (self._client._request_signer and not self._client._request_signer._credentials):
492-
self._client = self.session.create_client(SERVICE_NAME, self.region, endpoint_url=self.host)
503+
config = botocore.client.Config(
504+
connect_timeout=self._connect_timeout_seconds,
505+
read_timeout=self._read_timeout_seconds,
506+
max_pool_connections=self._max_pool_connections)
507+
self._client = self.session.create_client(SERVICE_NAME, self.region, endpoint_url=self.host, config=config)
493508
return self._client
494509

495510
def get_meta_table(self, table_name, refresh=False):

0 commit comments

Comments
 (0)