Skip to content

Commit 7cda5dd

Browse files
author
Jon Wayne Parrott
authored
Add google.api.core.timeout (#3858)
1 parent c984389 commit 7cda5dd

3 files changed

Lines changed: 349 additions & 3 deletions

File tree

core/google/api/core/retry.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,10 @@ def check_if_exists():
6868
from google.api.core.helpers import datetime_helpers
6969

7070
_LOGGER = logging.getLogger(__name__)
71-
_DEFAULT_INITIAL_DELAY = 1.0
72-
_DEFAULT_MAXIMUM_DELAY = 60.0
71+
_DEFAULT_INITIAL_DELAY = 1.0 # seconds
72+
_DEFAULT_MAXIMUM_DELAY = 60.0 # seconds
7373
_DEFAULT_DELAY_MULTIPLIER = 2.0
74-
_DEFAULT_DEADLINE = 60.0 * 2.0
74+
_DEFAULT_DEADLINE = 60.0 * 2.0 # seconds
7575

7676

7777
def if_exception_type(*exception_types):

core/google/api/core/timeout.py

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
# Copyright 2017 Google Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Decorators for applying timeout arguments to functions.
16+
17+
These decorators are used to wrap API methods to apply either a constant
18+
or exponential timeout argument.
19+
20+
For example, imagine an API method that can take a while to return results,
21+
such as one that might block until a resource is ready:
22+
23+
.. code-block:: python
24+
25+
def is_thing_ready(timeout=None):
26+
response = requests.get('https://example.com/is_thing_ready')
27+
response.raise_for_status()
28+
return response.json()
29+
30+
This module allows a function like this to be wrapped so that timeouts are
31+
automatically determined, for example:
32+
33+
.. code-block:: python
34+
35+
timeout_ = timeout.ExponentialTimeout()
36+
is_thing_ready_with_timeout = timeout_(is_thing_ready)
37+
38+
for n in range(10):
39+
try:
40+
is_thing_ready_with_timeout({'example': 'data'})
41+
except:
42+
pass
43+
44+
In this example the first call to ``is_thing_ready`` will have a relatively
45+
small timeout (like 1 second). If the resource is available and the request
46+
completes quickly, the loop exits. But, if the resource isn't yet available
47+
and the request times out, it'll be retried - this time with a larger timeout.
48+
49+
In the broader context these decorators are typically combined with
50+
:mod:`google.api.core.retry` to implement API methods with a signature that
51+
matches ``api_method(request, timeout=None, retry=None)``.
52+
"""
53+
54+
from __future__ import unicode_literals
55+
56+
import datetime
57+
58+
import six
59+
60+
from google.api.core.helpers import datetime_helpers
61+
62+
_DEFAULT_INITIAL_TIMEOUT = 5.0 # seconds
63+
_DEFAULT_MAXIMUM_TIMEOUT = 30.0 # seconds
64+
_DEFAULT_TIMEOUT_MULTIPLIER = 2.0
65+
# If specified, must be in seconds. If none, deadline is not used in the
66+
# timeout calculation.
67+
_DEFAULT_DEADLINE = None
68+
69+
70+
@six.python_2_unicode_compatible
71+
class ConstantTimeout(object):
72+
"""A decorator that adds a constant timeout argument.
73+
74+
This is effectively equivalent to
75+
``functools.partial(func, timeout=timeout)``.
76+
77+
Args:
78+
timeout (Optional[float]): the timeout (in seconds) to applied to the
79+
wrapped function. If `None`, the target function is expected to
80+
never timeout.
81+
"""
82+
def __init__(self, timeout=None):
83+
self._timeout = timeout
84+
85+
def __call__(self, func):
86+
"""Apply the timeout decorator.
87+
88+
Args:
89+
func (Callable): The function to apply the timeout argument to.
90+
This function must accept a timeout keyword argument.
91+
92+
Returns:
93+
Callable: The wrapped function.
94+
"""
95+
@six.wraps(func)
96+
def func_with_timeout(*args, **kwargs):
97+
"""Wrapped function that adds timeout."""
98+
kwargs['timeout'] = self._timeout
99+
return func(*args, **kwargs)
100+
return func_with_timeout
101+
102+
def __str__(self):
103+
return '<ConstantTimeout timeout={:.1f}>'.format(self._timeout)
104+
105+
106+
def _exponential_timeout_generator(initial, maximum, multiplier, deadline):
107+
"""A generator that yields exponential timeout values.
108+
109+
Args:
110+
initial (float): The initial timeout.
111+
maximum (float): The maximum timeout.
112+
multiplier (float): The multiplier applied to the timeout.
113+
deadline (float): The overall deadline across all invocations.
114+
115+
Yields:
116+
float: A timeout value.
117+
"""
118+
if deadline is not None:
119+
deadline_datetime = (
120+
datetime_helpers.utcnow() +
121+
datetime.timedelta(seconds=deadline))
122+
else:
123+
deadline_datetime = datetime.datetime.max
124+
125+
timeout = initial
126+
while True:
127+
now = datetime_helpers.utcnow()
128+
yield min(
129+
# The calculated timeout based on invocations.
130+
timeout,
131+
# The set maximum timeout.
132+
maximum,
133+
# The remaining time before the deadline is reached.
134+
float((deadline_datetime - now).seconds))
135+
timeout = timeout * multiplier
136+
137+
138+
@six.python_2_unicode_compatible
139+
class ExponentialTimeout(object):
140+
"""A decorator that adds an exponentially increasing timeout argument.
141+
142+
This is useful if a function is called multiple times. Each time the
143+
function is called this decorator will calculate a new timeout parameter
144+
based on the the number of times the function has been called.
145+
146+
For example
147+
148+
.. code-block:: python
149+
150+
Args:
151+
initial (float): The initial timeout to pass.
152+
maximum (float): The maximum timeout for any one call.
153+
multiplier (float): The multiplier applied to the timeout for each
154+
invocation.
155+
deadline (Optional[float]): The overall deadline across all
156+
invocations. This is used to prevent a very large calculated
157+
timeout from pushing the overall execution time over the deadline.
158+
This is especially useful in conjuction with
159+
:mod:`google.api.core.retry`. If ``None``, the timeouts will not
160+
be adjusted to accomodate an overall deadline.
161+
"""
162+
def __init__(
163+
self,
164+
initial=_DEFAULT_INITIAL_TIMEOUT,
165+
maximum=_DEFAULT_MAXIMUM_TIMEOUT,
166+
multiplier=_DEFAULT_TIMEOUT_MULTIPLIER,
167+
deadline=_DEFAULT_DEADLINE):
168+
self._initial = initial
169+
self._maximum = maximum
170+
self._multiplier = multiplier
171+
self._deadline = deadline
172+
173+
def with_deadline(self, deadline):
174+
"""Return a copy of this teimout with the given deadline.
175+
176+
Args:
177+
deadline (float): The overall deadline across all invocations.
178+
179+
Returns:
180+
ExponentialTimeout: A new instance with the given deadline.
181+
"""
182+
return ExponentialTimeout(
183+
initial=self._initial,
184+
maximum=self._maximum,
185+
multiplier=self._multiplier,
186+
deadline=deadline)
187+
188+
def __call__(self, func):
189+
"""Apply the timeout decorator.
190+
191+
Args:
192+
func (Callable): The function to apply the timeout argument to.
193+
This function must accept a timeout keyword argument.
194+
195+
Returns:
196+
Callable: The wrapped function.
197+
"""
198+
timeouts = _exponential_timeout_generator(
199+
self._initial, self._maximum, self._multiplier, self._deadline)
200+
201+
@six.wraps(func)
202+
def func_with_timeout(*args, **kwargs):
203+
"""Wrapped function that adds timeout."""
204+
kwargs['timeout'] = next(timeouts)
205+
return func(*args, **kwargs)
206+
207+
return func_with_timeout
208+
209+
def __str__(self):
210+
return (
211+
'<ExponentialTimeout initial={:.1f}, maximum={:.1f}, '
212+
'multiplier={:.1f}, deadline={:.1f}>'.format(
213+
self._initial, self._maximum, self._multiplier,
214+
self._deadline))
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# Copyright 2017 Google Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import datetime
16+
import itertools
17+
18+
import mock
19+
20+
from google.api.core import timeout
21+
22+
23+
def test__exponential_timeout_generator_base_2():
24+
gen = timeout._exponential_timeout_generator(
25+
1.0, 60.0, 2.0, deadline=None)
26+
27+
result = list(itertools.islice(gen, 8))
28+
assert result == [1, 2, 4, 8, 16, 32, 60, 60]
29+
30+
31+
@mock.patch('google.api.core.helpers.datetime_helpers.utcnow', autospec=True)
32+
def test__exponential_timeout_generator_base_deadline(utcnow):
33+
# Make each successive call to utcnow() advance one second.
34+
utcnow.side_effect = [
35+
datetime.datetime.min + datetime.timedelta(seconds=n)
36+
for n in range(15)]
37+
38+
gen = timeout._exponential_timeout_generator(
39+
1.0, 60.0, 2.0, deadline=30.0)
40+
41+
result = list(itertools.islice(gen, 14))
42+
# Should grow until the cumulative time is > 30s, then start decreasing as
43+
# the cumulative time approaches 60s.
44+
assert result == [1, 2, 4, 8, 16, 24, 23, 22, 21, 20, 19, 18, 17, 16]
45+
46+
47+
class TestConstantTimeout(object):
48+
49+
def test_constructor(self):
50+
timeout_ = timeout.ConstantTimeout()
51+
assert timeout_._timeout is None
52+
53+
def test_constructor_args(self):
54+
timeout_ = timeout.ConstantTimeout(42.0)
55+
assert timeout_._timeout == 42.0
56+
57+
def test___str__(self):
58+
timeout_ = timeout.ConstantTimeout(1)
59+
assert str(timeout_) == '<ConstantTimeout timeout=1.0>'
60+
61+
def test_apply(self):
62+
target = mock.Mock(spec=['__call__', '__name__'], __name__='target')
63+
timeout_ = timeout.ConstantTimeout(42.0)
64+
wrapped = timeout_(target)
65+
66+
wrapped()
67+
68+
target.assert_called_once_with(timeout=42.0)
69+
70+
def test_apply_passthrough(self):
71+
target = mock.Mock(spec=['__call__', '__name__'], __name__='target')
72+
timeout_ = timeout.ConstantTimeout(42.0)
73+
wrapped = timeout_(target)
74+
75+
wrapped(1, 2, meep='moop')
76+
77+
target.assert_called_once_with(1, 2, meep='moop', timeout=42.0)
78+
79+
80+
class TestExponentialTimeout(object):
81+
82+
def test_constructor(self):
83+
timeout_ = timeout.ExponentialTimeout()
84+
assert timeout_._initial == timeout._DEFAULT_INITIAL_TIMEOUT
85+
assert timeout_._maximum == timeout._DEFAULT_MAXIMUM_TIMEOUT
86+
assert timeout_._multiplier == timeout._DEFAULT_TIMEOUT_MULTIPLIER
87+
assert timeout_._deadline == timeout._DEFAULT_DEADLINE
88+
89+
def test_constructor_args(self):
90+
timeout_ = timeout.ExponentialTimeout(1, 2, 3, 4)
91+
assert timeout_._initial == 1
92+
assert timeout_._maximum == 2
93+
assert timeout_._multiplier == 3
94+
assert timeout_._deadline == 4
95+
96+
def test_with_timeout(self):
97+
original_timeout = timeout.ExponentialTimeout()
98+
timeout_ = original_timeout.with_deadline(42)
99+
assert original_timeout is not timeout_
100+
assert timeout_._initial == timeout._DEFAULT_INITIAL_TIMEOUT
101+
assert timeout_._maximum == timeout._DEFAULT_MAXIMUM_TIMEOUT
102+
assert timeout_._multiplier == timeout._DEFAULT_TIMEOUT_MULTIPLIER
103+
assert timeout_._deadline == 42
104+
105+
def test___str__(self):
106+
timeout_ = timeout.ExponentialTimeout(1, 2, 3, 4)
107+
assert str(timeout_) == (
108+
'<ExponentialTimeout initial=1.0, maximum=2.0, multiplier=3.0, '
109+
'deadline=4.0>')
110+
111+
def test_apply(self):
112+
target = mock.Mock(spec=['__call__', '__name__'], __name__='target')
113+
timeout_ = timeout.ExponentialTimeout(1, 10, 2)
114+
wrapped = timeout_(target)
115+
116+
wrapped()
117+
target.assert_called_with(timeout=1)
118+
119+
wrapped()
120+
target.assert_called_with(timeout=2)
121+
122+
wrapped()
123+
target.assert_called_with(timeout=4)
124+
125+
def test_apply_passthrough(self):
126+
target = mock.Mock(spec=['__call__', '__name__'], __name__='target')
127+
timeout_ = timeout.ExponentialTimeout(42.0, 100, 2)
128+
wrapped = timeout_(target)
129+
130+
wrapped(1, 2, meep='moop')
131+
132+
target.assert_called_once_with(1, 2, meep='moop', timeout=42.0)

0 commit comments

Comments
 (0)