[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