Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add google.api.core.timeout #3858

Merged
merged 5 commits into from
Aug 24, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions core/google/api/core/retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,10 @@ def check_if_exists():
from google.api.core.helpers import datetime_helpers

_LOGGER = logging.getLogger(__name__)
_DEFAULT_INITIAL_DELAY = 1.0
_DEFAULT_MAXIMUM_DELAY = 60.0
_DEFAULT_INITIAL_DELAY = 1.0 # seconds
_DEFAULT_MAXIMUM_DELAY = 60.0 # seconds
_DEFAULT_DELAY_MULTIPLIER = 2.0
_DEFAULT_DEADLINE = 60.0 * 2.0
_DEFAULT_DEADLINE = 60.0 * 2.0 # seconds


def if_exception_type(*exception_types):
Expand Down
214 changes: 214 additions & 0 deletions core/google/api/core/timeout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
# Copyright 2017 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Decorators for applying timeout arguments to functions.

These decorators are used to wrap API methods to apply either a constant
or exponential timeout argument.

For example, imagine an API method that can take a while to return results,
such as one that might block until a resource is ready:

.. code-block:: python

def is_thing_ready(timeout=None):
response = requests.get('https://example.com/is_thing_ready')
response.raise_for_status()
return response.json()

This module allows a function like this to be wrapped so that timeouts are
automatically determined, for example:

.. code-block:: python

timeout_ = timeout.ExponentialTimeout()
is_thing_ready_with_timeout = timeout_(is_thing_ready)

for n in range(10):
try:
is_thing_ready_with_timeout({'example': 'data'})
except:
pass

In this example the first call to ``is_thing_ready`` will have a relatively
small timeout (like 1 second). If the resource is available and the request
completes quickly, the loop exits. But, if the resource isn't yet available
and the request times out, it'll be retried - this time with a larger timeout.

In the broader context these decorators are typically combined with
:mod:`google.api.core.retry` to implement API methods with a signature that
matches ``api_method(request, timeout=None, retry=None)``.
"""

from __future__ import unicode_literals

import datetime

import six

from google.api.core.helpers import datetime_helpers

_DEFAULT_INITIAL_TIMEOUT = 5.0 # seconds
_DEFAULT_MAXIMUM_TIMEOUT = 30.0 # seconds
_DEFAULT_TIMEOUT_MULTIPLIER = 2.0
# If specified, must be in seconds. If none, deadline is not used in the
# timeout calculation.
_DEFAULT_DEADLINE = None


@six.python_2_unicode_compatible
class ConstantTimeout(object):
"""A decorator that adds a constant timeout argument.

This is effectively equivalent to
``functools.partial(func, timeout=timeout)``.

Args:
timeout (Optional[float]): the timeout (in seconds) to applied to the
wrapped function. If `None`, the target function is expected to
never timeout.
"""
def __init__(self, timeout=None):
self._timeout = timeout

def __call__(self, func):
"""Apply the timeout decorator.

Args:
func (Callable): The function to apply the timeout argument to.
This function must accept a timeout keyword argument.

Returns:
Callable: The wrapped function.
"""
@six.wraps(func)
def func_with_timeout(*args, **kwargs):
"""Wrapped function that adds timeout."""
kwargs['timeout'] = self._timeout
return func(*args, **kwargs)
return func_with_timeout

def __str__(self):
return '<ConstantTimeout timeout={:.1f}>'.format(self._timeout)


def _exponential_timeout_generator(initial, maximum, multiplier, deadline):
"""A generator that yields exponential timeout values.

Args:
initial (float): The initial timeout.
maximum (float): The maximum timeout.
multiplier (float): The multiplier applied to the timeout.
deadline (float): The overall deadline across all invocations.

Yields:
float: A timeout value.
"""
if deadline is not None:

This comment was marked as spam.

This comment was marked as spam.

deadline_datetime = (
datetime_helpers.utcnow() +
datetime.timedelta(seconds=deadline))
else:
deadline_datetime = datetime.datetime.max

timeout = initial
while True:
now = datetime_helpers.utcnow()
yield min(
# The calculated timeout based on invocations.
timeout,
# The set maximum timeout.
maximum,
# The remaining time before the deadline is reached.
float((deadline_datetime - now).seconds))
timeout = timeout * multiplier


@six.python_2_unicode_compatible
class ExponentialTimeout(object):
"""A decorator that adds an exponentially increasing timeout argument.

This is useful if a function is called multiple times. Each time the
function is called this decorator will calculate a new timeout parameter
based on the the number of times the function has been called.

For example

.. code-block:: python

Args:
initial (float): The initial timeout to pass.
maximum (float): The maximum timeout for any one call.
multiplier (float): The multiplier applied to the timeout for each
invocation.
deadline (Optional[float]): The overall deadline across all
invocations. This is used to prevent a very large calculated
timeout from pushing the overall execution time over the deadline.
This is especially useful in conjuction with
:mod:`google.api.core.retry`. If ``None``, the timeouts will not
be adjusted to accomodate an overall deadline.
"""
def __init__(
self,
initial=_DEFAULT_INITIAL_TIMEOUT,
maximum=_DEFAULT_MAXIMUM_TIMEOUT,
multiplier=_DEFAULT_TIMEOUT_MULTIPLIER,
deadline=_DEFAULT_DEADLINE):
self._initial = initial
self._maximum = maximum
self._multiplier = multiplier
self._deadline = deadline

def with_deadline(self, deadline):
"""Return a copy of this teimout with the given deadline.

Args:
deadline (float): The overall deadline across all invocations.

Returns:
ExponentialTimeout: A new instance with the given deadline.
"""
return ExponentialTimeout(
initial=self._initial,
maximum=self._maximum,
multiplier=self._multiplier,
deadline=deadline)

def __call__(self, func):
"""Apply the timeout decorator.

Args:
func (Callable): The function to apply the timeout argument to.
This function must accept a timeout keyword argument.

Returns:
Callable: The wrapped function.
"""
timeouts = _exponential_timeout_generator(
self._initial, self._maximum, self._multiplier, self._deadline)

@six.wraps(func)
def func_with_timeout(*args, **kwargs):
"""Wrapped function that adds timeout."""
kwargs['timeout'] = next(timeouts)

This comment was marked as spam.

This comment was marked as spam.

This comment was marked as spam.

This comment was marked as spam.

This comment was marked as spam.

This comment was marked as spam.

return func(*args, **kwargs)

return func_with_timeout

def __str__(self):
return (
'<ExponentialTimeout initial={:.1f}, maximum={:.1f}, '
'multiplier={:.1f}, deadline={:.1f}>'.format(
self._initial, self._maximum, self._multiplier,
self._deadline))
132 changes: 132 additions & 0 deletions core/tests/unit/api_core/test_timeout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# Copyright 2017 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import datetime
import itertools

import mock

from google.api.core import timeout


def test__exponential_timeout_generator_base_2():
gen = timeout._exponential_timeout_generator(
1.0, 60.0, 2.0, deadline=None)

result = list(itertools.islice(gen, 8))
assert result == [1, 2, 4, 8, 16, 32, 60, 60]


@mock.patch('google.api.core.helpers.datetime_helpers.utcnow', autospec=True)
def test__exponential_timeout_generator_base_deadline(utcnow):
# Make each successive call to utcnow() advance one second.
utcnow.side_effect = [
datetime.datetime.min + datetime.timedelta(seconds=n)
for n in range(15)]

gen = timeout._exponential_timeout_generator(
1.0, 60.0, 2.0, deadline=30.0)

result = list(itertools.islice(gen, 14))
# Should grow until the cumulative time is > 30s, then start decreasing as
# the cumulative time approaches 60s.
assert result == [1, 2, 4, 8, 16, 24, 23, 22, 21, 20, 19, 18, 17, 16]


class TestConstantTimeout(object):

def test_constructor(self):
timeout_ = timeout.ConstantTimeout()
assert timeout_._timeout is None

def test_constructor_args(self):
timeout_ = timeout.ConstantTimeout(42.0)
assert timeout_._timeout == 42.0

def test___str__(self):
timeout_ = timeout.ConstantTimeout(1)
assert str(timeout_) == '<ConstantTimeout timeout=1.0>'

def test_apply(self):
target = mock.Mock(spec=['__call__', '__name__'], __name__='target')
timeout_ = timeout.ConstantTimeout(42.0)
wrapped = timeout_(target)

wrapped()

target.assert_called_once_with(timeout=42.0)

def test_apply_passthrough(self):
target = mock.Mock(spec=['__call__', '__name__'], __name__='target')
timeout_ = timeout.ConstantTimeout(42.0)
wrapped = timeout_(target)

wrapped(1, 2, meep='moop')

target.assert_called_once_with(1, 2, meep='moop', timeout=42.0)


class TestExponentialTimeout(object):

def test_constructor(self):
timeout_ = timeout.ExponentialTimeout()
assert timeout_._initial == timeout._DEFAULT_INITIAL_TIMEOUT
assert timeout_._maximum == timeout._DEFAULT_MAXIMUM_TIMEOUT
assert timeout_._multiplier == timeout._DEFAULT_TIMEOUT_MULTIPLIER
assert timeout_._deadline == timeout._DEFAULT_DEADLINE

def test_constructor_args(self):
timeout_ = timeout.ExponentialTimeout(1, 2, 3, 4)
assert timeout_._initial == 1
assert timeout_._maximum == 2
assert timeout_._multiplier == 3
assert timeout_._deadline == 4

def test_with_timeout(self):
original_timeout = timeout.ExponentialTimeout()
timeout_ = original_timeout.with_deadline(42)
assert original_timeout is not timeout_
assert timeout_._initial == timeout._DEFAULT_INITIAL_TIMEOUT
assert timeout_._maximum == timeout._DEFAULT_MAXIMUM_TIMEOUT
assert timeout_._multiplier == timeout._DEFAULT_TIMEOUT_MULTIPLIER
assert timeout_._deadline == 42

def test___str__(self):
timeout_ = timeout.ExponentialTimeout(1, 2, 3, 4)
assert str(timeout_) == (
'<ExponentialTimeout initial=1.0, maximum=2.0, multiplier=3.0, '
'deadline=4.0>')

def test_apply(self):
target = mock.Mock(spec=['__call__', '__name__'], __name__='target')
timeout_ = timeout.ExponentialTimeout(1, 10, 2)
wrapped = timeout_(target)

wrapped()
target.assert_called_with(timeout=1)

wrapped()
target.assert_called_with(timeout=2)

wrapped()
target.assert_called_with(timeout=4)

def test_apply_passthrough(self):
target = mock.Mock(spec=['__call__', '__name__'], __name__='target')
timeout_ = timeout.ExponentialTimeout(42.0, 100, 2)
wrapped = timeout_(target)

wrapped(1, 2, meep='moop')

target.assert_called_once_with(1, 2, meep='moop', timeout=42.0)