[ARVADOS] updated: 96a3d13e88c67f6beef17877f15d97a03c63b525
git at public.curoverse.com
git at public.curoverse.com
Wed Dec 2 09:37:53 EST 2015
Summary of changes:
sdk/python/arvados/api.py | 67 +++++++++++++++++++++++----------
sdk/python/tests/test_api.py | 88 +++++++++++++++++++++++++++++++++++++-------
2 files changed, 121 insertions(+), 34 deletions(-)
via 96a3d13e88c67f6beef17877f15d97a03c63b525 (commit)
via 8a0eb69984a93852ec888cd3e02b778b0be758ed (commit)
from fc5257c18b24ab0e28b248655dcabfafe9665bf3 (commit)
Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.
commit 96a3d13e88c67f6beef17877f15d97a03c63b525
Merge: fc5257c 8a0eb69
Author: Tom Clegg <tom at curoverse.com>
Date: Tue Dec 1 16:46:03 2015 -0500
Merge branch '7697-socket-retry' closes #7697
commit 8a0eb69984a93852ec888cd3e02b778b0be758ed
Author: Tom Clegg <tom at curoverse.com>
Date: Tue Dec 1 15:48:22 2015 -0500
7697: Avoid reusing long-idle HTTP connections. Avoid retrying non-idempotent operations.
diff --git a/sdk/python/arvados/api.py b/sdk/python/arvados/api.py
index 5ec5ac2..e2e8ba1 100644
--- a/sdk/python/arvados/api.py
+++ b/sdk/python/arvados/api.py
@@ -1,10 +1,12 @@
import collections
+import httplib
import httplib2
import json
import logging
import os
import re
import socket
+import time
import types
import apiclient
@@ -16,6 +18,11 @@ import util
_logger = logging.getLogger('arvados.api')
+MAX_IDLE_CONNECTION_DURATION = 30
+RETRY_DELAY_INITIAL = 2
+RETRY_DELAY_BACKOFF = 2
+RETRY_COUNT = 2
+
class OrderedJsonModel(apiclient.model.JsonModel):
"""Model class for JSON that preserves the contents' order.
@@ -37,8 +44,6 @@ class OrderedJsonModel(apiclient.model.JsonModel):
def _intercept_http_request(self, uri, **kwargs):
- from httplib import BadStatusLine
-
if (self.max_request_size and
kwargs.get('body') and
self.max_request_size < len(kwargs['body'])):
@@ -51,32 +56,54 @@ def _intercept_http_request(self, uri, **kwargs):
kwargs['headers']['X-External-Client'] = '1'
kwargs['headers']['Authorization'] = 'OAuth2 %s' % self.arvados_api_token
- try:
- return self.orig_http_request(uri, **kwargs)
- except BadStatusLine:
- # This is how httplib tells us that it tried to reuse an
- # existing connection but it was already closed by the
- # server. In that case, yes, we would like to retry.
- # Unfortunately, we are not absolutely certain that the
- # previous call did not succeed, so this is slightly
- # risky.
- return self.orig_http_request(uri, **kwargs)
- except socket.error:
- # This is the one case where httplib2 doesn't close the
- # underlying connection first. Close all open connections,
- # expecting this object only has the one connection to the API
- # server. This is safe because httplib2 reopens connections when
- # needed.
- _logger.debug("Retrying API request after socket error", exc_info=True)
+
+ retryable = kwargs.get('method', 'GET') in [
+ 'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PUT']
+ retry_count = self._retry_count if retryable else 0
+
+ if (not retryable and
+ time.time() - self._last_request_time > self._max_keepalive_idle):
+ # High probability of failure due to connection atrophy. Make
+ # sure this request [re]opens a new connection by closing and
+ # forgetting all cached connections first.
for conn in self.connections.itervalues():
conn.close()
- return self.orig_http_request(uri, **kwargs)
+ self.connections.clear()
+
+ delay = self._retry_delay_initial
+ for _ in range(retry_count):
+ self._last_request_time = time.time()
+ try:
+ return self.orig_http_request(uri, **kwargs)
+ except httplib.HTTPException:
+ _logger.debug("Retrying API request in %d s after HTTP error",
+ delay, exc_info=True)
+ except socket.error:
+ # This is the one case where httplib2 doesn't close the
+ # underlying connection first. Close all open
+ # connections, expecting this object only has the one
+ # connection to the API server. This is safe because
+ # httplib2 reopens connections when needed.
+ _logger.debug("Retrying API request in %d s after socket error",
+ delay, exc_info=True)
+ for conn in self.connections.itervalues():
+ conn.close()
+ time.sleep(delay)
+ delay = delay * self._retry_delay_backoff
+
+ self._last_request_time = time.time()
+ return self.orig_http_request(uri, **kwargs)
def _patch_http_request(http, api_token):
http.arvados_api_token = api_token
http.max_request_size = 0
http.orig_http_request = http.request
http.request = types.MethodType(_intercept_http_request, http)
+ http._last_request_time = 0
+ http._max_keepalive_idle = MAX_IDLE_CONNECTION_DURATION
+ http._retry_delay_initial = RETRY_DELAY_INITIAL
+ http._retry_delay_backoff = RETRY_DELAY_BACKOFF
+ http._retry_count = RETRY_COUNT
return http
# Monkey patch discovery._cast() so objects and arrays get serialized
diff --git a/sdk/python/tests/test_api.py b/sdk/python/tests/test_api.py
index 6d1e979..795a9aa 100644
--- a/sdk/python/tests/test_api.py
+++ b/sdk/python/tests/test_api.py
@@ -3,6 +3,7 @@
import arvados
import collections
import httplib2
+import itertools
import json
import mimetypes
import os
@@ -15,8 +16,8 @@ import run_test_server
from apiclient import errors as apiclient_errors
from apiclient import http as apiclient_http
-from arvados.api import OrderedJsonModel
-from arvados_testutil import fake_httplib2_response
+from arvados.api import OrderedJsonModel, RETRY_DELAY_INITIAL, RETRY_DELAY_BACKOFF, RETRY_COUNT
+from arvados_testutil import fake_httplib2_response, queue_with
if not mimetypes.inited:
mimetypes.init()
@@ -106,20 +107,79 @@ class ArvadosApiTest(run_test_server.TestCaseWithServers):
result = api.humans().get(uuid='test').execute()
self.assertEqual(string.hexdigits, ''.join(result.keys()))
- def test_socket_errors_retried(self):
- api = arvados.api('v1')
- self.assertTrue(hasattr(api._http, 'orig_http_request'),
+
+class RetryREST(unittest.TestCase):
+ def setUp(self):
+ self.api = arvados.api('v1')
+ self.assertTrue(hasattr(self.api._http, 'orig_http_request'),
"test doesn't know how to intercept HTTP requests")
- api._http.orig_http_request = mock.MagicMock()
- mock_response = {'user': 'person'}
- api._http.orig_http_request.side_effect = [
- socket.error("mock error"),
- (fake_httplib2_response(200), json.dumps(mock_response))
- ]
- actual_response = api.users().current().execute()
- self.assertEqual(mock_response, actual_response)
- self.assertGreater(api._http.orig_http_request.call_count, 1,
+ self.mock_response = {'user': 'person'}
+ self.request_success = (fake_httplib2_response(200),
+ json.dumps(self.mock_response))
+ self.api._http.orig_http_request = mock.MagicMock()
+ # All requests succeed by default. Tests override as needed.
+ self.api._http.orig_http_request.return_value = self.request_success
+
+ @mock.patch('time.sleep')
+ def test_socket_error_retry_get(self, sleep):
+ self.api._http.orig_http_request.side_effect = (
+ socket.error('mock error'),
+ self.request_success,
+ )
+ self.assertEqual(self.api.users().current().execute(),
+ self.mock_response)
+ self.assertGreater(self.api._http.orig_http_request.call_count, 1,
"client got the right response without retrying")
+ self.assertEqual(sleep.call_args_list,
+ [mock.call(RETRY_DELAY_INITIAL)])
+
+ @mock.patch('time.sleep')
+ def test_socket_error_retry_delay(self, sleep):
+ self.api._http.orig_http_request.side_effect = socket.error('mock')
+ self.api._http._retry_count = 3
+ with self.assertRaises(socket.error):
+ self.api.users().current().execute()
+ self.assertEqual(self.api._http.orig_http_request.call_count, 4)
+ self.assertEqual(sleep.call_args_list, [
+ mock.call(RETRY_DELAY_INITIAL),
+ mock.call(RETRY_DELAY_INITIAL * RETRY_DELAY_BACKOFF),
+ mock.call(RETRY_DELAY_INITIAL * RETRY_DELAY_BACKOFF**2),
+ ])
+
+ @mock.patch('time.time', side_effect=[i*2**20 for i in range(99)])
+ def test_close_old_connections_non_retryable(self, sleep):
+ self._test_connection_close(expect=1)
+
+ @mock.patch('time.time', side_effect=itertools.count())
+ def test_no_close_fresh_connections_non_retryable(self, sleep):
+ self._test_connection_close(expect=0)
+
+ @mock.patch('time.time', side_effect=itertools.count())
+ def test_override_max_idle_time(self, sleep):
+ self.api._http._max_keepalive_idle = 0
+ self._test_connection_close(expect=1)
+
+ def _test_connection_close(self, expect=0):
+ # Do two POST requests. The second one must close all
+ # connections +expect+ times.
+ self.api.users().create(body={}).execute()
+ mock_conns = {str(i): mock.MagicMock() for i in range(2)}
+ self.api._http.connections = mock_conns.copy()
+ self.api.users().create(body={}).execute()
+ for c in mock_conns.itervalues():
+ self.assertEqual(c.close.call_count, expect)
+
+ @mock.patch('time.sleep')
+ def test_socket_error_no_retry_post(self, sleep):
+ self.api._http.orig_http_request.side_effect = (
+ socket.error('mock error'),
+ self.request_success,
+ )
+ with self.assertRaises(socket.error):
+ self.api.users().create(body={}).execute()
+ self.assertEqual(self.api._http.orig_http_request.call_count, 1,
+ "client should try non-retryable method exactly once")
+ self.assertEqual(sleep.call_args_list, [])
if __name__ == '__main__':
-----------------------------------------------------------------------
hooks/post-receive
--
More information about the arvados-commits
mailing list