Skip to content

Commit 1fe4714

Browse files
authored
Merge pull request #124 from immerrr/make-before-sleep-accept-call-state
Make before_sleep accept call_state
2 parents 8b9295e + 020e7a7 commit 1fe4714

File tree

10 files changed

+290
-37
lines changed

10 files changed

+290
-37
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,5 @@ AUTHORS
88
ChangeLog
99
.eggs/
1010
doc/_build
11+
12+
/.pytest_cache

doc/source/conf.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
project = "Tenacity"
2323

2424
extensions = [
25-
'sphinx.ext.doctest'
25+
'sphinx.ext.doctest',
26+
'sphinx.ext.autodoc',
2627
]
2728

2829
# -- Options for sphinx.ext.doctest -----------------------------------------

doc/source/index.rst

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,109 @@ In the same spirit, It's possible to execute after a call that failed:
272272
def raise_my_exception():
273273
raise MyException("Fail")
274274

275+
It's also possible to only log failures that are going to be retried. Normally
276+
retries happen after a wait interval, so the keyword argument is called
277+
``before_sleep``:
278+
279+
.. testcode::
280+
281+
logger = logging.getLogger(__name__)
282+
@retry(stop=stop_after_attempt(3),
283+
before_sleep=before_sleep_log(logger, logging.DEBUG))
284+
def raise_my_exception():
285+
raise MyException("Fail")
286+
287+
You can also define a custom ``before_sleep`` function. It should have one
288+
parameter called ``call_state`` that contains all information about current
289+
retry invocation.
290+
291+
.. testcode::
292+
293+
logger = logging.getLogger(__name__)
294+
295+
def my_before_sleep(call_state):
296+
if call_state.attempt_number < 1:
297+
loglevel = logging.INFO
298+
else:
299+
loglevel = logging.WARNING
300+
logger.log(
301+
loglevel, 'Retrying %s: attempt %s ended with: %s',
302+
call_state.fn, call_state.attempt_number, call_state.outcome)
303+
304+
@retry(stop=stop_after_attempt(3), before_sleep=my_before_sleep)
305+
def raise_my_exception():
306+
raise MyException("Fail")
307+
308+
try:
309+
raise_my_exception()
310+
except RetryError:
311+
pass
312+
313+
``call_state`` argument is an object of `RetryCallState` class:
314+
315+
.. autoclass:: tenacity.RetryCallState
316+
317+
Constant attributes:
318+
319+
.. autoinstanceattribute:: start_time(float)
320+
:annotation:
321+
322+
.. autoinstanceattribute:: retry_object(BaseRetrying)
323+
:annotation:
324+
325+
.. autoinstanceattribute:: fn(callable)
326+
:annotation:
327+
328+
.. autoinstanceattribute:: args(tuple)
329+
:annotation:
330+
331+
.. autoinstanceattribute:: kwargs(dict)
332+
:annotation:
333+
334+
Variable attributes:
335+
336+
.. autoinstanceattribute:: attempt_number(int)
337+
:annotation:
338+
339+
.. autoinstanceattribute:: outcome(tenacity.Future or None)
340+
:annotation:
341+
342+
.. autoinstanceattribute:: outcome_timestamp(float or None)
343+
:annotation:
344+
345+
.. autoinstanceattribute:: idle_for(float)
346+
:annotation:
347+
348+
.. autoinstanceattribute:: next_action(tenacity.RetryAction or None)
349+
:annotation:
350+
351+
352+
353+
Previously custom ``before_sleep`` functions could also accept three
354+
parameters. This approach is deprecated but kept for backward compatibility:
355+
356+
- ``retry_object``: the `Retrying` object that runs the retries,
357+
- ``sleep``: wait interval in seconds before the next attempt,
358+
- ``last_result``: the outcome of the current attempt.
359+
360+
.. testcode::
361+
362+
logger = logging.getLogger(__name__)
363+
364+
def my_before_sleep(retry_object, sleep, last_result):
365+
logger.warning(
366+
'Retrying %s: last_result=%s, retrying in %s seconds...',
367+
retry_object.fn, last_result, sleep)
368+
369+
@retry(stop=stop_after_attempt(3), before_sleep=my_before_sleep)
370+
def raise_my_exception():
371+
raise MyException("Fail")
372+
373+
try:
374+
raise_my_exception()
375+
except RetryError:
376+
pass
377+
275378
Similarly, you can call a custom callback function after all retries failed, without raising an exception (or you can re-raise or do anything really)
276379

277380
.. testcode::

tenacity/__init__.py

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import six
3535

3636
from tenacity import _utils
37+
from tenacity import before_sleep as _before_sleep
3738
from tenacity import wait as _wait
3839

3940
# Import all built-in retry strategies for easier usage.
@@ -122,6 +123,35 @@ class DoSleep(float):
122123
pass
123124

124125

126+
class BaseAction(object):
127+
"""Base class for representing actions to take by retry object.
128+
129+
Concrete implementations must define:
130+
- __init__: to initialize all necessary fields
131+
- REPR_ATTRS: class variable specifying attributes to include in repr(self)
132+
- NAME: for identification in retry object methods and callbacks
133+
"""
134+
135+
REPR_FIELDS = ()
136+
NAME = None
137+
138+
def __repr__(self):
139+
state_str = ', '.join('%s=%r' % (field, getattr(self, field))
140+
for field in self.REPR_FIELDS)
141+
return '%s(%s)' % (type(self).__name__, state_str)
142+
143+
def __str__(self):
144+
return repr(self)
145+
146+
147+
class RetryAction(BaseAction):
148+
REPR_FIELDS = ('sleep',)
149+
NAME = 'retry'
150+
151+
def __init__(self, sleep):
152+
self.sleep = float(sleep)
153+
154+
125155
_unset = object()
126156

127157

@@ -148,7 +178,7 @@ def __init__(self,
148178
retry=retry_if_exception_type(),
149179
before=before_nothing,
150180
after=after_nothing,
151-
before_sleep=before_sleep_nothing,
181+
before_sleep=None,
152182
reraise=False,
153183
retry_error_cls=RetryError,
154184
retry_error_callback=None):
@@ -158,16 +188,25 @@ def __init__(self,
158188
self.retry = retry
159189
self.before = before
160190
self.after = after
161-
self.before_sleep = before_sleep
191+
self._before_sleep = before_sleep
162192
self.reraise = reraise
163193
self._local = threading.local()
164194
self.retry_error_cls = retry_error_cls
165195
self.retry_error_callback = retry_error_callback
166196

197+
# This attribute was moved to RetryCallState and is deprecated on
198+
# Retrying objects but kept for backward compatibility.
199+
self.fn = None
200+
167201
@_utils.cached_property
168202
def wait(self):
169203
return _wait._wait_func_accept_call_state(self._wait)
170204

205+
@_utils.cached_property
206+
def before_sleep(self):
207+
return _before_sleep._before_sleep_func_accept_call_state(
208+
self._before_sleep)
209+
171210
def copy(self, sleep=_unset, stop=_unset, wait=_unset,
172211
retry=_unset, before=_unset, after=_unset, before_sleep=_unset,
173212
reraise=_unset):
@@ -244,6 +283,7 @@ def begin(self, fn):
244283
self.statistics['start_time'] = _utils.now()
245284
self.statistics['attempt_number'] = 1
246285
self.statistics['idle_for'] = 0
286+
self.fn = fn
247287

248288
def iter(self, call_state): # noqa
249289
fut = call_state.outcome
@@ -275,13 +315,14 @@ def iter(self, call_state): # noqa
275315
if self.wait:
276316
sleep = self.wait(call_state=call_state)
277317
else:
278-
sleep = 0
318+
sleep = 0.0
319+
call_state.next_action = RetryAction(sleep)
279320
call_state.idle_for += sleep
280321
self.statistics['idle_for'] += sleep
281322
self.statistics['attempt_number'] += 1
282323

283324
if self.before_sleep is not None:
284-
self.before_sleep(self, sleep=sleep, last_result=fut)
325+
self.before_sleep(call_state=call_state)
285326

286327
return DoSleep(sleep)
287328

@@ -292,7 +333,8 @@ class Retrying(BaseRetrying):
292333
def call(self, fn, *args, **kwargs):
293334
self.begin(fn)
294335

295-
call_state = RetryCallState(fn=fn, args=args, kwargs=kwargs)
336+
call_state = RetryCallState(
337+
retry_object=self, fn=fn, args=args, kwargs=kwargs)
296338
while True:
297339
do = self.iter(call_state=call_state)
298340
if isinstance(do, DoAttempt):
@@ -337,16 +379,28 @@ def construct(cls, attempt_number, value, has_exception):
337379
class RetryCallState(object):
338380
"""State related to a single call wrapped with Retrying."""
339381

340-
def __init__(self, fn, args, kwargs):
382+
def __init__(self, retry_object, fn, args, kwargs):
383+
#: Retry call start timestamp
341384
self.start_time = _utils.now()
385+
#: Retry manager object
386+
self.retry_object = retry_object
387+
#: Function wrapped by this retry call
342388
self.fn = fn
389+
#: Arguments of the function wrapped by this retry call
343390
self.args = args
391+
#: Keyword arguments of the function wrapped by this retry call
344392
self.kwargs = kwargs
345393

346-
self.idle_for = 0
394+
#: The number of the current attempt
395+
self.attempt_number = 1
396+
#: Last outcome (result or exception) produced by the function
347397
self.outcome = None
398+
#: Timestamp of the last outcome
348399
self.outcome_timestamp = None
349-
self.attempt_number = 1
400+
#: Time spent sleeping in retries
401+
self.idle_for = 0
402+
#: Next action as decided by the retry manager
403+
self.next_action = None
350404

351405
@property
352406
def seconds_since_start(self):
@@ -358,6 +412,7 @@ def prepare_for_next_attempt(self):
358412
self.outcome = None
359413
self.outcome_timestamp = None
360414
self.attempt_number += 1
415+
self.next_action = None
361416

362417
def set_result(self, val):
363418
ts = _utils.now()

tenacity/_asyncio.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ def __init__(self,
3737
def call(self, fn, *args, **kwargs):
3838
self.begin(fn)
3939

40-
call_state = RetryCallState(fn=fn, args=args, kwargs=kwargs)
40+
call_state = RetryCallState(
41+
retry_object=self, fn=fn, args=args, kwargs=kwargs)
4142
while True:
4243
do = self.iter(call_state=call_state)
4344
if isinstance(do, DoAttempt):

tenacity/_utils.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,13 @@ def __get__(self, obj, cls):
132132
return self
133133
value = obj.__dict__[self.func.__name__] = self.func(obj)
134134
return value
135+
136+
137+
def _func_takes_call_state(func):
138+
if not six.callable(func):
139+
return False
140+
if not inspect.isfunction(func):
141+
# func is a callable object rather than a function
142+
func = func.__call__
143+
func_spec = getargspec(func)
144+
return 'call_state' in func_spec.args

tenacity/before_sleep.py

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,43 @@
1414
# See the License for the specific language governing permissions and
1515
# limitations under the License.
1616

17+
import six
18+
1719
from tenacity import _utils
1820

1921

20-
def before_sleep_nothing(retry_obj, sleep, last_result):
22+
def before_sleep_nothing(call_state, sleep, last_result):
2123
"""Before call strategy that does nothing."""
2224

2325

2426
def before_sleep_log(logger, log_level):
2527
"""Before call strategy that logs to some logger the attempt."""
26-
def log_it(retry_obj, sleep, last_result):
27-
logger.log(log_level,
28-
"Retrying %s in %d seconds as it raised %s.",
29-
_utils.get_callback_name(retry_obj.fn),
30-
sleep,
31-
last_result.exception())
28+
def log_it(call_state):
29+
if call_state.outcome.failed:
30+
verb, value = 'raised', call_state.outcome.exception()
31+
else:
32+
verb, value = 'returned', call_state.outcome.result()
3233

34+
logger.log(log_level,
35+
"Retrying %s in %s seconds as it %s %s.",
36+
_utils.get_callback_name(call_state.fn),
37+
getattr(call_state.next_action, 'sleep'),
38+
verb, value)
3339
return log_it
40+
41+
42+
def _before_sleep_func_accept_call_state(fn):
43+
if not six.callable(fn):
44+
return fn
45+
46+
takes_call_state = _utils._func_takes_call_state(fn)
47+
if takes_call_state:
48+
return fn
49+
50+
@six.wraps(fn)
51+
def wrapped_before_sleep_func(call_state):
52+
return fn(
53+
call_state.retry_object,
54+
sleep=getattr(call_state.next_action, 'sleep'),
55+
last_result=call_state.outcome)
56+
return wrapped_before_sleep_func

0 commit comments

Comments
 (0)