Allow requesting fixed retry delay instead of exponential

Clients like ironicclient and swiftclient use fixed delay for their
build-in retry functionality. To replace it without changing behavior
we need a similar feature.

Change-Id: I1f9de98dae5719842f03d45e5a9d724199d5718b
This commit is contained in:
Dmitry Tantsur 2019-07-26 11:38:18 +02:00
parent 3fd9ce7007
commit bca9ee7d3c
6 changed files with 150 additions and 24 deletions

View File

@ -103,6 +103,14 @@ class Adapter(object):
:param int concurrency: :param int concurrency:
How many simultaneous http requests this Adapter can be used for. How many simultaneous http requests this Adapter can be used for.
(optional, defaults to None, which means no limit). (optional, defaults to None, which means no limit).
:param float connect_retry_delay:
Delay (in seconds) between two connect retries (if enabled).
By default exponential retry starting with 0.5 seconds up to
a maximum of 60 seconds is used.
:param float status_code_retry_delay:
Delay (in seconds) between two status code retries (if enabled).
By default exponential retry starting with 0.5 seconds up to
a maximum of 60 seconds is used.
""" """
client_name = None client_name = None
@ -119,6 +127,7 @@ class Adapter(object):
default_microversion=None, status_code_retries=None, default_microversion=None, status_code_retries=None,
retriable_status_codes=None, raise_exc=None, retriable_status_codes=None, raise_exc=None,
rate_limit=None, concurrency=None, rate_limit=None, concurrency=None,
connect_retry_delay=None, status_code_retry_delay=None,
): ):
if version and (min_version or max_version): if version and (min_version or max_version):
raise TypeError( raise TypeError(
@ -148,6 +157,8 @@ class Adapter(object):
self.default_microversion = default_microversion self.default_microversion = default_microversion
self.status_code_retries = status_code_retries self.status_code_retries = status_code_retries
self.retriable_status_codes = retriable_status_codes self.retriable_status_codes = retriable_status_codes
self.connect_retry_delay = connect_retry_delay
self.status_code_retry_delay = status_code_retry_delay
self.raise_exc = raise_exc self.raise_exc = raise_exc
self.global_request_id = global_request_id self.global_request_id = global_request_id
@ -195,10 +206,10 @@ class Adapter(object):
kwargs.setdefault('auth', self.auth) kwargs.setdefault('auth', self.auth)
if self.user_agent: if self.user_agent:
kwargs.setdefault('user_agent', self.user_agent) kwargs.setdefault('user_agent', self.user_agent)
if self.connect_retries is not None: for arg in ('connect_retries', 'status_code_retries',
kwargs.setdefault('connect_retries', self.connect_retries) 'connect_retry_delay', 'status_code_retry_delay'):
if self.status_code_retries is not None: if getattr(self, arg) is not None:
kwargs.setdefault('status_code_retries', self.status_code_retries) kwargs.setdefault(arg, getattr(self, arg))
if self.retriable_status_codes: if self.retriable_status_codes:
kwargs.setdefault('retriable_status_codes', kwargs.setdefault('retriable_status_codes',
self.retriable_status_codes) self.retriable_status_codes)

View File

@ -140,11 +140,27 @@ class Adapter(base.BaseLoader):
'connect-retries'), 'connect-retries'),
help='The maximum number of retries that should be ' help='The maximum number of retries that should be '
'attempted for connection errors.'), 'attempted for connection errors.'),
cfg.FloatOpt('connect-retry-delay',
deprecated_opts=deprecated_opts.get(
'connect-retry-delay'),
help='Delay (in seconds) between two retries '
'for connection errors. If not set, '
'exponential retry starting with 0.5 '
'seconds up to a maximum of 60 seconds '
'is used.'),
cfg.IntOpt('status-code-retries', cfg.IntOpt('status-code-retries',
deprecated_opts=deprecated_opts.get( deprecated_opts=deprecated_opts.get(
'status-code-retries'), 'status-code-retries'),
help='The maximum number of retries that should be ' help='The maximum number of retries that should be '
'attempted for retriable HTTP status codes.'), 'attempted for retriable HTTP status codes.'),
cfg.FloatOpt('status-code-retry-delay',
deprecated_opts=deprecated_opts.get(
'status-code-retry-delay'),
help='Delay (in seconds) between two retries '
'for retriable status codes. If not set, '
'exponential retry starting with 0.5 '
'seconds up to a maximum of 60 seconds '
'is used.'),
] ]
if include_deprecated: if include_deprecated:
opts += [ opts += [
@ -271,7 +287,10 @@ def process_conf_options(confgrp, kwargs):
"version is mutually exclusive with min_version and" "version is mutually exclusive with min_version and"
" max_version") " max_version")
kwargs.setdefault('connect_retries', confgrp.connect_retries) kwargs.setdefault('connect_retries', confgrp.connect_retries)
kwargs.setdefault('connect_retry_delay', confgrp.connect_retry_delay)
kwargs.setdefault('status_code_retries', confgrp.status_code_retries) kwargs.setdefault('status_code_retries', confgrp.status_code_retries)
kwargs.setdefault('status_code_retry_delay',
confgrp.status_code_retry_delay)
def register_argparse_arguments(*args, **kwargs): def register_argparse_arguments(*args, **kwargs):

View File

@ -51,6 +51,7 @@ DEFAULT_USER_AGENT = 'keystoneauth1/%s %s %s/%s' % (
_LOG_CONTENT_TYPES = set(['application/json']) _LOG_CONTENT_TYPES = set(['application/json'])
_MAX_RETRY_INTERVAL = 60.0 _MAX_RETRY_INTERVAL = 60.0
_EXPONENTIAL_DELAY_START = 0.5
# NOTE(efried): This is defined in oslo_middleware.request_id.INBOUND_HEADER, # NOTE(efried): This is defined in oslo_middleware.request_id.INBOUND_HEADER,
# but it didn't seem worth adding oslo_middleware to requirements just for that # but it didn't seem worth adding oslo_middleware to requirements just for that
@ -239,6 +240,29 @@ class RequestTiming(object):
self.elapsed = elapsed self.elapsed = elapsed
class _Retries(object):
__slots__ = ('_fixed_delay', '_current')
def __init__(self, fixed_delay=None):
self._fixed_delay = fixed_delay
self.reset()
def __next__(self):
value = self._current
if not self._fixed_delay:
self._current = min(value * 2, _MAX_RETRY_INTERVAL)
return value
def reset(self):
if self._fixed_delay:
self._current = self._fixed_delay
else:
self._current = _EXPONENTIAL_DELAY_START
# Python 2 compatibility
next = __next__
class Session(object): class Session(object):
"""Maintains client communication state and common functionality. """Maintains client communication state and common functionality.
@ -583,7 +607,9 @@ class Session(object):
allow=None, client_name=None, client_version=None, allow=None, client_name=None, client_version=None,
microversion=None, microversion_service_type=None, microversion=None, microversion_service_type=None,
status_code_retries=0, retriable_status_codes=None, status_code_retries=0, retriable_status_codes=None,
rate_semaphore=None, global_request_id=None, **kwargs): rate_semaphore=None, global_request_id=None,
connect_retry_delay=None, status_code_retry_delay=None,
**kwargs):
"""Send an HTTP request with the specified characteristics. """Send an HTTP request with the specified characteristics.
Wrapper around `requests.Session.request` to handle tasks such as Wrapper around `requests.Session.request` to handle tasks such as
@ -673,6 +699,16 @@ class Session(object):
and rate limiting of requests. (optional, and rate limiting of requests. (optional,
defaults to no concurrency or rate control) defaults to no concurrency or rate control)
:param global_request_id: Value for the X-Openstack-Request-Id header. :param global_request_id: Value for the X-Openstack-Request-Id header.
:param float connect_retry_delay: Delay (in seconds) between two
connect retries (if enabled).
By default exponential retry starting
with 0.5 seconds up to a maximum of
60 seconds is used.
:param float status_code_retry_delay: Delay (in seconds) between two
status code retries (if enabled).
By default exponential retry
starting with 0.5 seconds up to
a maximum of 60 seconds is used.
:param kwargs: any other parameter that can be passed to :param kwargs: any other parameter that can be passed to
:meth:`requests.Session.request` (such as `headers`). :meth:`requests.Session.request` (such as `headers`).
Except: Except:
@ -827,11 +863,15 @@ class Session(object):
if redirect is None: if redirect is None:
redirect = self.redirect redirect = self.redirect
connect_retry_delays = _Retries(connect_retry_delay)
status_code_retry_delays = _Retries(status_code_retry_delay)
send = functools.partial(self._send_request, send = functools.partial(self._send_request,
url, method, redirect, log, logger, url, method, redirect, log, logger,
split_loggers, connect_retries, split_loggers, connect_retries,
status_code_retries, retriable_status_codes, status_code_retries, retriable_status_codes,
rate_semaphore) rate_semaphore, connect_retry_delays,
status_code_retry_delays)
try: try:
connection_params = self.get_auth_connection_params(auth=auth) connection_params = self.get_auth_connection_params(auth=auth)
@ -920,7 +960,7 @@ class Session(object):
def _send_request(self, url, method, redirect, log, logger, split_loggers, def _send_request(self, url, method, redirect, log, logger, split_loggers,
connect_retries, status_code_retries, connect_retries, status_code_retries,
retriable_status_codes, rate_semaphore, retriable_status_codes, rate_semaphore,
connect_retry_delay=0.5, status_code_retry_delay=0.5, connect_retry_delays, status_code_retry_delays,
**kwargs): **kwargs):
# NOTE(jamielennox): We handle redirection manually because the # NOTE(jamielennox): We handle redirection manually because the
# requests lib follows some browser patterns where it will redirect # requests lib follows some browser patterns where it will redirect
@ -962,12 +1002,10 @@ class Session(object):
if connect_retries <= 0: if connect_retries <= 0:
raise raise
delay = next(connect_retry_delays)
logger.info('Failure: %(e)s. Retrying in %(delay).1fs.', logger.info('Failure: %(e)s. Retrying in %(delay).1fs.',
{'e': e, 'delay': connect_retry_delay}) {'e': e, 'delay': delay})
time.sleep(connect_retry_delay) time.sleep(delay)
connect_retry_delay = min(connect_retry_delay * 2,
_MAX_RETRY_INTERVAL)
return self._send_request( return self._send_request(
url, method, redirect, log, logger, split_loggers, url, method, redirect, log, logger, split_loggers,
@ -975,7 +1013,8 @@ class Session(object):
retriable_status_codes=retriable_status_codes, retriable_status_codes=retriable_status_codes,
rate_semaphore=rate_semaphore, rate_semaphore=rate_semaphore,
connect_retries=connect_retries - 1, connect_retries=connect_retries - 1,
connect_retry_delay=connect_retry_delay, connect_retry_delays=connect_retry_delays,
status_code_retry_delays=status_code_retry_delays,
**kwargs) **kwargs)
if log: if log:
@ -1000,14 +1039,18 @@ class Session(object):
logger.warning("Failed to redirect request to %s as new " logger.warning("Failed to redirect request to %s as new "
"location was not provided.", resp.url) "location was not provided.", resp.url)
else: else:
# NOTE(jamielennox): We don't pass through connect_retry_delay. # NOTE(jamielennox): We don't keep increasing delays.
# This request actually worked so we can reset the delay count. # This request actually worked so we can reset the delay count.
connect_retry_delays.reset()
status_code_retry_delays.reset()
new_resp = self._send_request( new_resp = self._send_request(
location, method, redirect, log, logger, split_loggers, location, method, redirect, log, logger, split_loggers,
rate_semaphore=rate_semaphore, rate_semaphore=rate_semaphore,
connect_retries=connect_retries, connect_retries=connect_retries,
status_code_retries=status_code_retries, status_code_retries=status_code_retries,
retriable_status_codes=retriable_status_codes, retriable_status_codes=retriable_status_codes,
connect_retry_delays=connect_retry_delays,
status_code_retry_delays=status_code_retry_delays,
**kwargs) **kwargs)
if not isinstance(new_resp.history, list): if not isinstance(new_resp.history, list):
@ -1017,24 +1060,23 @@ class Session(object):
elif (resp.status_code in retriable_status_codes and elif (resp.status_code in retriable_status_codes and
status_code_retries > 0): status_code_retries > 0):
delay = next(status_code_retry_delays)
logger.info('Retriable status code %(code)s. Retrying in ' logger.info('Retriable status code %(code)s. Retrying in '
'%(delay).1fs.', '%(delay).1fs.',
{'code': resp.status_code, {'code': resp.status_code, 'delay': delay})
'delay': status_code_retry_delay}) time.sleep(delay)
time.sleep(status_code_retry_delay)
status_code_retry_delay = min(status_code_retry_delay * 2, # NOTE(jamielennox): We don't keep increasing connection delays.
_MAX_RETRY_INTERVAL)
# NOTE(jamielennox): We don't pass through connect_retry_delay.
# This request actually worked so we can reset the delay count. # This request actually worked so we can reset the delay count.
connect_retry_delays.reset()
return self._send_request( return self._send_request(
url, method, redirect, log, logger, split_loggers, url, method, redirect, log, logger, split_loggers,
connect_retries=connect_retries, connect_retries=connect_retries,
status_code_retries=status_code_retries - 1, status_code_retries=status_code_retries - 1,
retriable_status_codes=retriable_status_codes, retriable_status_codes=retriable_status_codes,
rate_semaphore=rate_semaphore, rate_semaphore=rate_semaphore,
status_code_retry_delay=status_code_retry_delay, connect_retry_delays=connect_retry_delays,
status_code_retry_delays=status_code_retry_delays,
**kwargs) **kwargs)
return resp return resp

View File

@ -158,19 +158,24 @@ class ConfLoadingTests(utils.TestCase):
self.conf_fx.config( self.conf_fx.config(
service_type='type', service_name='name', service_type='type', service_name='name',
connect_retries=3, status_code_retries=5, connect_retries=3, status_code_retries=5,
connect_retry_delay=0.5, status_code_retry_delay=2.0,
group=self.GROUP) group=self.GROUP)
adap = loading.load_adapter_from_conf_options( adap = loading.load_adapter_from_conf_options(
self.conf_fx.conf, self.GROUP, session='session', auth='auth') self.conf_fx.conf, self.GROUP, session='session', auth='auth')
self.assertEqual('type', adap.service_type) self.assertEqual('type', adap.service_type)
self.assertEqual('name', adap.service_name) self.assertEqual('name', adap.service_name)
self.assertEqual(3, adap.connect_retries) self.assertEqual(3, adap.connect_retries)
self.assertEqual(0.5, adap.connect_retry_delay)
self.assertEqual(5, adap.status_code_retries) self.assertEqual(5, adap.status_code_retries)
self.assertEqual(2.0, adap.status_code_retry_delay)
def test_get_conf_options(self): def test_get_conf_options(self):
opts = loading.get_adapter_conf_options() opts = loading.get_adapter_conf_options()
for opt in opts: for opt in opts:
if opt.name.endswith('-retries'): if opt.name.endswith('-retries'):
self.assertIsInstance(opt, cfg.IntOpt) self.assertIsInstance(opt, cfg.IntOpt)
elif opt.name.endswith('-retry-delay'):
self.assertIsInstance(opt, cfg.FloatOpt)
elif opt.name != 'valid-interfaces': elif opt.name != 'valid-interfaces':
self.assertIsInstance(opt, cfg.StrOpt) self.assertIsInstance(opt, cfg.StrOpt)
else: else:
@ -179,7 +184,8 @@ class ConfLoadingTests(utils.TestCase):
'interface', 'valid-interfaces', 'interface', 'valid-interfaces',
'region-name', 'endpoint-override', 'version', 'region-name', 'endpoint-override', 'version',
'min-version', 'max-version', 'connect-retries', 'min-version', 'max-version', 'connect-retries',
'status-code-retries'}, 'status-code-retries', 'connect-retry-delay',
'status-code-retry-delay'},
{opt.name for opt in opts}) {opt.name for opt in opts})
def test_get_conf_options_undeprecated(self): def test_get_conf_options_undeprecated(self):
@ -187,6 +193,8 @@ class ConfLoadingTests(utils.TestCase):
for opt in opts: for opt in opts:
if opt.name.endswith('-retries'): if opt.name.endswith('-retries'):
self.assertIsInstance(opt, cfg.IntOpt) self.assertIsInstance(opt, cfg.IntOpt)
elif opt.name.endswith('-retry-delay'):
self.assertIsInstance(opt, cfg.FloatOpt)
elif opt.name != 'valid-interfaces': elif opt.name != 'valid-interfaces':
self.assertIsInstance(opt, cfg.StrOpt) self.assertIsInstance(opt, cfg.StrOpt)
else: else:
@ -194,7 +202,8 @@ class ConfLoadingTests(utils.TestCase):
self.assertEqual({'service-type', 'service-name', 'valid-interfaces', self.assertEqual({'service-type', 'service-name', 'valid-interfaces',
'region-name', 'endpoint-override', 'version', 'region-name', 'endpoint-override', 'version',
'min-version', 'max-version', 'connect-retries', 'min-version', 'max-version', 'connect-retries',
'status-code-retries'}, 'status-code-retries', 'connect-retry-delay',
'status-code-retry-delay'},
{opt.name for opt in opts}) {opt.name for opt in opts})
def test_deprecated(self): def test_deprecated(self):

View File

@ -458,6 +458,25 @@ class SessionTests(utils.TestCase):
self.assertThat(self.requests_mock.request_history, self.assertThat(self.requests_mock.request_history,
matchers.HasLength(retries + 1)) matchers.HasLength(retries + 1))
def test_connect_retries_fixed_delay(self):
self.stub_url('GET', exc=requests.exceptions.Timeout())
session = client_session.Session()
retries = 3
with mock.patch('time.sleep') as m:
self.assertRaises(exceptions.ConnectTimeout,
session.get,
self.TEST_URL, connect_retries=retries,
connect_retry_delay=0.5)
self.assertEqual(retries, m.call_count)
m.assert_has_calls([mock.call(0.5)] * retries)
# we count retries so there will be one initial request + 3 retries
self.assertThat(self.requests_mock.request_history,
matchers.HasLength(retries + 1))
def test_http_503_retries(self): def test_http_503_retries(self):
self.stub_url('GET', status_code=503) self.stub_url('GET', status_code=503)
@ -514,6 +533,26 @@ class SessionTests(utils.TestCase):
self.assertThat(self.requests_mock.request_history, self.assertThat(self.requests_mock.request_history,
matchers.HasLength(1)) matchers.HasLength(1))
def test_http_status_retries_fixed_delay(self):
self.stub_url('GET', status_code=409)
session = client_session.Session()
retries = 3
with mock.patch('time.sleep') as m:
self.assertRaises(exceptions.Conflict,
session.get,
self.TEST_URL, status_code_retries=retries,
status_code_retry_delay=0.5,
retriable_status_codes=[503, 409])
self.assertEqual(retries, m.call_count)
m.assert_has_calls([mock.call(0.5)] * retries)
# we count retries so there will be one initial request + 3 retries
self.assertThat(self.requests_mock.request_history,
matchers.HasLength(retries + 1))
def test_http_status_retries_inverval_limit(self): def test_http_status_retries_inverval_limit(self):
self.stub_url('GET', status_code=409) self.stub_url('GET', status_code=409)

View File

@ -0,0 +1,6 @@
---
features:
- |
Allows configuring fixed retry delay for connection and status code retries
via the new parameters ``connect_retry_delay`` and
``status_code_retry_delay`` accordingly.