Skip to content

Commit 52f12af

Browse files
authored
feat(api_core): make the last retry happen at deadline (#9873)
* feat(api_core): allow setting Retry deadline as strict If a deadline is set as strict, Retry will shorten the last sleep period to end at the given deadline, and not possibly stretch beyond it. * feat(core): make the last retry happen at deadline This commit changes Retry instances in a way that the last sleep period is shortened so that its end matches at the given deadline. This prevents the last sleep period to last way beyond the deadline.
1 parent b6cea3c commit 52f12af

File tree

2 files changed

+136
-20
lines changed

2 files changed

+136
-20
lines changed

google/api_core/retry.py

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -155,10 +155,12 @@ def retry_target(target, predicate, sleep_generator, deadline, on_error=None):
155155
It should return True to retry or False otherwise.
156156
sleep_generator (Iterable[float]): An infinite iterator that determines
157157
how long to sleep between retries.
158-
deadline (float): How long to keep retrying the target.
159-
on_error (Callable): A function to call while processing a retryable
160-
exception. Any error raised by this function will *not* be
161-
caught.
158+
deadline (float): How long to keep retrying the target. The last sleep
159+
period is shortened as necessary, so that the last retry runs at
160+
``deadline`` (and not considerably beyond it).
161+
on_error (Callable[Exception]): A function to call while processing a
162+
retryable exception. Any error raised by this function will *not*
163+
be caught.
162164
163165
Returns:
164166
Any: the return value of the target function.
@@ -191,16 +193,21 @@ def retry_target(target, predicate, sleep_generator, deadline, on_error=None):
191193
on_error(exc)
192194

193195
now = datetime_helpers.utcnow()
194-
if deadline_datetime is not None and deadline_datetime < now:
195-
six.raise_from(
196-
exceptions.RetryError(
197-
"Deadline of {:.1f}s exceeded while calling {}".format(
198-
deadline, target
196+
197+
if deadline_datetime is not None:
198+
if deadline_datetime <= now:
199+
six.raise_from(
200+
exceptions.RetryError(
201+
"Deadline of {:.1f}s exceeded while calling {}".format(
202+
deadline, target
203+
),
204+
last_exc,
199205
),
200206
last_exc,
201-
),
202-
last_exc,
203-
)
207+
)
208+
else:
209+
time_to_deadline = (deadline_datetime - now).total_seconds()
210+
sleep = min(time_to_deadline, sleep)
204211

205212
_LOGGER.debug(
206213
"Retrying due to {}, sleeping {:.1f}s ...".format(last_exc, sleep)
@@ -227,7 +234,9 @@ class Retry(object):
227234
must be greater than 0.
228235
maximum (float): The maximum amout of time to delay in seconds.
229236
multiplier (float): The multiplier applied to the delay.
230-
deadline (float): How long to keep retrying in seconds.
237+
deadline (float): How long to keep retrying in seconds. The last sleep
238+
period is shortened as necessary, so that the last retry runs at
239+
``deadline`` (and not considerably beyond it).
231240
"""
232241

233242
def __init__(
@@ -251,8 +260,8 @@ def __call__(self, func, on_error=None):
251260
252261
Args:
253262
func (Callable): The callable to add retry behavior to.
254-
on_error (Callable): A function to call while processing a
255-
retryable exception. Any error raised by this function will
263+
on_error (Callable[Exception]): A function to call while processing
264+
a retryable exception. Any error raised by this function will
256265
*not* be caught.
257266
258267
Returns:

tests/unit/test_retry.py

Lines changed: 112 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -182,35 +182,94 @@ def test_constructor_options(self):
182182
assert retry_._on_error is _some_function
183183

184184
def test_with_deadline(self):
185-
retry_ = retry.Retry()
185+
retry_ = retry.Retry(
186+
predicate=mock.sentinel.predicate,
187+
initial=1,
188+
maximum=2,
189+
multiplier=3,
190+
deadline=4,
191+
on_error=mock.sentinel.on_error,
192+
)
186193
new_retry = retry_.with_deadline(42)
187194
assert retry_ is not new_retry
188195
assert new_retry._deadline == 42
189196

197+
# the rest of the attributes should remain the same
198+
assert new_retry._predicate is retry_._predicate
199+
assert new_retry._initial == retry_._initial
200+
assert new_retry._maximum == retry_._maximum
201+
assert new_retry._multiplier == retry_._multiplier
202+
assert new_retry._on_error is retry_._on_error
203+
190204
def test_with_predicate(self):
191-
retry_ = retry.Retry()
205+
retry_ = retry.Retry(
206+
predicate=mock.sentinel.predicate,
207+
initial=1,
208+
maximum=2,
209+
multiplier=3,
210+
deadline=4,
211+
on_error=mock.sentinel.on_error,
212+
)
192213
new_retry = retry_.with_predicate(mock.sentinel.predicate)
193214
assert retry_ is not new_retry
194215
assert new_retry._predicate == mock.sentinel.predicate
195216

217+
# the rest of the attributes should remain the same
218+
assert new_retry._deadline == retry_._deadline
219+
assert new_retry._initial == retry_._initial
220+
assert new_retry._maximum == retry_._maximum
221+
assert new_retry._multiplier == retry_._multiplier
222+
assert new_retry._on_error is retry_._on_error
223+
196224
def test_with_delay_noop(self):
197-
retry_ = retry.Retry()
225+
retry_ = retry.Retry(
226+
predicate=mock.sentinel.predicate,
227+
initial=1,
228+
maximum=2,
229+
multiplier=3,
230+
deadline=4,
231+
on_error=mock.sentinel.on_error,
232+
)
198233
new_retry = retry_.with_delay()
199234
assert retry_ is not new_retry
200235
assert new_retry._initial == retry_._initial
201236
assert new_retry._maximum == retry_._maximum
202237
assert new_retry._multiplier == retry_._multiplier
203238

204239
def test_with_delay(self):
205-
retry_ = retry.Retry()
240+
retry_ = retry.Retry(
241+
predicate=mock.sentinel.predicate,
242+
initial=1,
243+
maximum=2,
244+
multiplier=3,
245+
deadline=4,
246+
on_error=mock.sentinel.on_error,
247+
)
206248
new_retry = retry_.with_delay(initial=1, maximum=2, multiplier=3)
207249
assert retry_ is not new_retry
208250
assert new_retry._initial == 1
209251
assert new_retry._maximum == 2
210252
assert new_retry._multiplier == 3
211253

254+
# the rest of the attributes should remain the same
255+
assert new_retry._deadline == retry_._deadline
256+
assert new_retry._predicate is retry_._predicate
257+
assert new_retry._on_error is retry_._on_error
258+
212259
def test___str__(self):
213-
retry_ = retry.Retry()
260+
def if_exception_type(exc):
261+
return bool(exc) # pragma: NO COVER
262+
263+
# Explicitly set all attributes as changed Retry defaults should not
264+
# cause this test to start failing.
265+
retry_ = retry.Retry(
266+
predicate=if_exception_type,
267+
initial=1.0,
268+
maximum=60.0,
269+
multiplier=2.0,
270+
deadline=120.0,
271+
on_error=None,
272+
)
214273
assert re.match(
215274
(
216275
r"<Retry predicate=<function.*?if_exception_type.*?>, "
@@ -259,6 +318,54 @@ def test___call___and_execute_retry(self, sleep, uniform):
259318
sleep.assert_called_once_with(retry_._initial)
260319
assert on_error.call_count == 1
261320

321+
# Make uniform return half of its maximum, which is the calculated sleep time.
322+
@mock.patch("random.uniform", autospec=True, side_effect=lambda m, n: n / 2.0)
323+
@mock.patch("time.sleep", autospec=True)
324+
def test___call___and_execute_retry_hitting_deadline(self, sleep, uniform):
325+
326+
on_error = mock.Mock(spec=["__call__"], side_effect=[None] * 10)
327+
retry_ = retry.Retry(
328+
predicate=retry.if_exception_type(ValueError),
329+
initial=1.0,
330+
maximum=1024.0,
331+
multiplier=2.0,
332+
deadline=9.9,
333+
)
334+
335+
utcnow = datetime.datetime.utcnow()
336+
utcnow_patcher = mock.patch(
337+
"google.api_core.datetime_helpers.utcnow", return_value=utcnow
338+
)
339+
340+
target = mock.Mock(spec=["__call__"], side_effect=[ValueError()] * 10)
341+
# __name__ is needed by functools.partial.
342+
target.__name__ = "target"
343+
344+
decorated = retry_(target, on_error=on_error)
345+
target.assert_not_called()
346+
347+
with utcnow_patcher as patched_utcnow:
348+
# Make sure that calls to fake time.sleep() also advance the mocked
349+
# time clock.
350+
def increase_time(sleep_delay):
351+
patched_utcnow.return_value += datetime.timedelta(seconds=sleep_delay)
352+
sleep.side_effect = increase_time
353+
354+
with pytest.raises(exceptions.RetryError):
355+
decorated("meep")
356+
357+
assert target.call_count == 5
358+
target.assert_has_calls([mock.call("meep")] * 5)
359+
assert on_error.call_count == 5
360+
361+
# check the delays
362+
assert sleep.call_count == 4 # once between each successive target calls
363+
last_wait = sleep.call_args.args[0]
364+
total_wait = sum(call_args.args[0] for call_args in sleep.call_args_list)
365+
366+
assert last_wait == 2.9 # and not 8.0, because the last delay was shortened
367+
assert total_wait == 9.9 # the same as the deadline
368+
262369
@mock.patch("time.sleep", autospec=True)
263370
def test___init___without_retry_executed(self, sleep):
264371
_some_function = mock.Mock()

0 commit comments

Comments
 (0)